diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 6837820..6e93bce 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.1.31 +current_version = 0.1.39 commit = True tag = True parse = (?P\d+)\.(?P\d+)\.(?P\d+) diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6a9a5c4 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,27 @@ +# http://editorconfig.org + +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{py,rst,ini}] +indent_style = space +indent_size = 4 + +[*.{html,css,scss,json,yml,xml}] +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab + +[nginx.conf] +indent_style = space +indent_size = 2 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ff8862c..bbd0cad 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -13,7 +13,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: 3.6 + python-version: 3.9 - name: Install Flit run: pip install flit - name: Install Dependencies diff --git a/.github/workflows/test_full.yml b/.github/workflows/test_full.yml index 46c8336..12707b9 100644 --- a/.github/workflows/test_full.yml +++ b/.github/workflows/test_full.yml @@ -11,8 +11,8 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.6', '3.7', '3.8', '3.9', '3.10'] - django-version: ['>3.1', '<3.2', '<3.3', '<4.1'] + python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] + django-version: ['>3.1', '<3.2', '<3.3', '<4.3'] steps: - uses: actions/checkout@v3 diff --git a/.gitignore b/.gitignore index c3c0e3b..f326ab0 100644 --- a/.gitignore +++ b/.gitignore @@ -389,3 +389,4 @@ easy/media/ /.idea/modules.xml /.idea/vcs.xml /.idea/django-api-framework.iml +/.idea/shelf/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8fea904..db33b6e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -23,7 +23,7 @@ repos: args: [--allow-missing-credentials] - repo: https://github.com/psf/black - rev: 22.8.0 + rev: 23.10.1 hooks: - id: black diff --git a/Makefile b/Makefile index 5750709..8b2f2a3 100644 --- a/Makefile +++ b/Makefile @@ -5,12 +5,13 @@ help: @fgrep -h "##" $(MAKEFILE_LIST) | fgrep -v fgrep | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' clean: ## Removing cached python compiled files - find . -name \*pyc | xargs rm -fv + find . -name \*pyc | xargs rm -rfv find . -name \*pyo | xargs rm -fv find . -name \*~ | xargs rm -fv find . -name __pycache__ | xargs rm -rfv install: ## Install dependencies + pip install flit make clean flit install --deps develop --symlink pre-commit install diff --git a/README.md b/README.md index 447537f..3cde0b0 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,27 @@ -![Test](https://github.com/freemindcore/django-api-framework/actions/workflows/test_full.yml/badge.svg) [![PyPI version](https://badge.fury.io/py/django-api-framework.svg)](https://badge.fury.io/py/django-api-framework) [![PyPI version](https://img.shields.io/pypi/v/django-api-framework.svg)](https://pypi.python.org/pypi/django-api-framework) -[![PyPI version](https://img.shields.io/pypi/pyversions/django-api-framework.svg)](https://pypi.python.org/pypi/django-api-framework) -[![PyPI version](https://img.shields.io/pypi/djversions/django-api-framework.svg)](https://pypi.python.org/pypi/django-api-framework) + +![Test](https://github.com/freemindcore/django-api-framework/actions/workflows/test_full.yml/badge.svg) [![Codecov](https://img.shields.io/codecov/c/gh/freemindcore/django-api-framework)](https://codecov.io/gh/freemindcore/django-api-framework) [![Downloads](https://pepy.tech/badge/django-api-framework/month)](https://pepy.tech/project/django-api-framework) -# Django Easy API Framework +[![PyPI version](https://img.shields.io/pypi/pyversions/django-api-framework.svg)](https://pypi.python.org/pypi/django-api-framework) +[![PyPI version](https://img.shields.io/pypi/djversions/django-api-framework.svg)](https://pypi.python.org/pypi/django-api-framework) -### Easy and Fast Django REST framework based on Django-ninja-extra -- Auto CRUD Async API generation for all django models, configurable via Meta class -- Domain/Service/Controller base structure for better code organization -- Base Permission/Response/Exception classes and more to come -- Pure class based [Django-Ninja](https://github.com/vitalik/django-ninja) APIs, based on [Django-Ninja-extra](https://github.com/eadwinCode/django-ninja-extra) +# Easy CRUD API Framework + +- Zero code for a full CRUD API: Automatic and configurable, inspired by [NextJs-Crud](https://github.com/nestjsx/crud). + - Async CRUD API with Django RBAC security protection + - Prefetch and retrieve all m2m fields if configured + - Recursively retrieve all FK/OneToOne fields if configured + - Excluding fields you do not want, or define a list of sensitive fields of your choice +- Pure class based [Django-Ninja](https://github.com/vitalik/django-ninja) APIs: thanks to [Django-Ninja-Extra](https://github.com/eadwinCode/django-ninja-extra) +- Domain/Service/Controller Base Structure: for better code organization. +- Base Permission/Response/Exception Classes: and some handy features to help your API coding easier. ``` -Django-Ninja features : +Django-Ninja features: Easy: Designed to be easy to use and intuitive. FAST execution: Very high performance thanks to Pydantic and async support. @@ -30,13 +35,7 @@ Plus Extra: 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 ``` -### Requirements -- Python >= 3.6 -- Django >= 3.1 -- pydantic >= 1.6 -- Django-Ninja-extra >= 0.15.0 - -### Install +## Install `pip install django-api-framework` Then add "easy" to your django INSTALLED_APPS: @@ -49,9 +48,12 @@ Then add "easy" to your django INSTALLED_APPS: ] ``` - -Get your admin api up and running: +## Usage +### Get all your Django app CRUD APIs up and running in < 1 min +In your Django project next to urls.py create new apis.py file: ``` +from easy.main import EasyAPI + api_admin_v1 = EasyAPI( urls_namespace="admin_api", version="v1.0.0", @@ -60,16 +62,83 @@ api_admin_v1 = EasyAPI( # Automatic Admin API generation api_admin_v1.auto_create_admin_controllers() ``` +Go to urls.py and add the following: +``` +from django.urls import path +from .apis import api_admin_v1 + +urlpatterns = [ + path("admin/", admin.site.urls), + path("api_admin/v1/", api_admin_v1.urls), # <---------- ! +] +``` +Now go to http://127.0.0.1:8000/api_admin/v1/docs -Please check tests/demo_app for more. +You will see the automatic interactive API documentation (provided by Swagger UI). +![Auto generated APIs List](https://github.com/freemindcore/django-api-framework/blob/fae8209a8d08c55daf75ac3a4619fe62b8ef3af6/docs/images/admin_apis_list.png) ### Boilerplate Django project -A boilerplate Django project for quickly getting started: +A boilerplate Django project for quickly getting started, and get production ready easy-apis with 100% test coverage UP and running: https://github.com/freemindcore/django-easy-api -![Auto generated APIs List](https://github.com/freemindcore/django-api-framework/blob/fae8209a8d08c55daf75ac3a4619fe62b8ef3af6/docs/images/admin_apis_list.png) ![Auto generated APIs - Users](https://github.com/freemindcore/django-api-framework/blob/9aa26e92b6fd79f4d9db422ec450fe62d4cd97b9/docs/images/user_admin_api.png) -![Auto generated APIs - Schema](https://github.com/freemindcore/django-api-framework/blob/9aa26e92b6fd79f4d9db422ec450fe62d4cd97b9/docs/images/auto_api_demo_2.png) -_Note: this project is still in early stage, comments and advices are highly appreciated._ + +## Thanks to your help +**_If you find this project useful, please give your stars to support this open-source project. :) Thank you !_** + + + + + +## Advanced Usage +If `CRUD_API_ENABLED_ALL_APPS` is set to True (default), all app models CRUD apis will be generated. +Apps in the `CRUD_API_EXCLUDE_APPS` list, will always be excluded. + +If `CRUD_API_ENABLED_ALL_APPS` is set to False, only apps in the `CRUD_API_INCLUDE_APPS` list will have CRUD apis generated. + +Also, configuration is possible for each model, via APIMeta class: +- `generate_crud`: whether to create crud api, default to True +- `model_exclude`: fields to be excluded in Schema +- `model_fields`: fields to be included in Schema, default to `"__all__"` +- `model_join`: prefetch and retrieve all m2m fields, default to False +- `model_recursive`: recursively retrieve FK/OneToOne fields, default to False +- `sensitive_fields`: fields to be ignored + +Example: +``` +class Category(TestBaseModel): + title = models.CharField(max_length=100) + status = models.PositiveSmallIntegerField(default=1, null=True) + + class APIMeta: + generate_crud = True + model_fields = ["field_1", "field_2",] # if not configured default to "__all__" + model_join = True + model_recursive = True + sensitive_fields = ["password", "sensitive_info"] +``` + +### Adding CRUD APIs to a specific API Controller +By inheriting `CrudAPIController` class, CRUD APIs can be added to any API controller. +Configuration is available via `APIMeta` inner class in your Controller, same as the above `APIMeta` inner class defined in your Django models. + +Example: + +``` +@api_controller("event_api", permissions=[AdminSitePermission]) +class EventAPIController(CrudAPIController): + def __init__(self, service: EventService): + super().__init__(service) + + class APIMeta: + model = Event # django model + generate_crud = True # whether to create crud api, default to True + model_fields = ["field_1", "field_2",] # if not configured default to "__all__" + model_join = True + model_recursive = True + sensitive_fields = ["password", "sensitive_info"] + +``` +Please check tests/demo_app for more examples. diff --git a/easy/__init__.py b/easy/__init__.py index 83173ed..55c1888 100644 --- a/easy/__init__.py +++ b/easy/__init__.py @@ -1,6 +1,6 @@ """Django Easy API - Easy and Fast Django REST framework based on Django-ninja-extra""" -__version__ = "0.1.31" +__version__ = "0.2.0" from easy.main import EasyAPI diff --git a/easy/conf/settings.py b/easy/conf/settings.py index 4024caf..6ac6c22 100644 --- a/easy/conf/settings.py +++ b/easy/conf/settings.py @@ -5,13 +5,11 @@ # AUTO ADMIN API settings # If not all -AUTO_ADMIN_ENABLED_ALL_APPS = getattr( - django_settings, "AUTO_ADMIN_ENABLED_ALL_APPS", True -) +CRUD_API_ENABLED_ALL_APPS = getattr(django_settings, "CRUD_API_ENABLED_ALL_APPS", True) # Only generate for included apps -AUTO_ADMIN_INCLUDE_APPS = getattr(django_settings, "AUTO_ADMIN_INCLUDE_APPS", []) +CRUD_API_INCLUDE_APPS = getattr(django_settings, "CRUD_API_INCLUDE_APPS", []) # Exclude apps always got excluded -AUTO_ADMIN_EXCLUDE_APPS = getattr(django_settings, "AUTO_ADMIN_EXCLUDE_APPS", []) +CRUD_API_EXCLUDE_APPS = getattr(django_settings, "CRUD_API_EXCLUDE_APPS", []) def reload_settings(*args: Any, **kwargs: Any) -> None: # pragma: no cover diff --git a/easy/controller/admin_auto_api.py b/easy/controller/auto_api.py similarity index 74% rename from easy/controller/admin_auto_api.py rename to easy/controller/auto_api.py index 1c050d2..dd1d67b 100644 --- a/easy/controller/admin_auto_api.py +++ b/easy/controller/auto_api.py @@ -8,15 +8,12 @@ from easy.controller.base import CrudAPIController from easy.controller.meta_conf import ( GENERATE_CRUD_ATTR, - GENERATE_CRUD_ATTR_DEFAULT, + MODEL_EXCLUDE_ATTR, MODEL_FIELDS_ATTR, - MODEL_FIELDS_ATTR_DEFAULT, MODEL_JOIN_ATTR, - MODEL_JOIN_ATTR_DEFAULT, MODEL_RECURSIVE_ATTR, - MODEL_RECURSIVE_ATTR_DEFAULT, SENSITIVE_FIELDS_ATTR, - SENSITIVE_FIELDS_ATTR_DEFAULT, + ModelOptions, ) from easy.permissions import AdminSitePermission, BaseApiPermission @@ -31,16 +28,22 @@ def create_api_controller( ) -> Union[Type[ControllerBase], Type]: """Create APIController class dynamically, with specified permission class""" model_name = model.__name__ # type:ignore - Meta = type( - "Meta", + + model_opts: ModelOptions = ModelOptions.get_model_options( + getattr(model, "APIMeta", None) + ) + + APIMeta = type( + "APIMeta", (object,), { "model": model, - GENERATE_CRUD_ATTR: GENERATE_CRUD_ATTR_DEFAULT, - MODEL_FIELDS_ATTR: MODEL_FIELDS_ATTR_DEFAULT, - MODEL_RECURSIVE_ATTR: MODEL_RECURSIVE_ATTR_DEFAULT, - MODEL_JOIN_ATTR: MODEL_JOIN_ATTR_DEFAULT, - SENSITIVE_FIELDS_ATTR: SENSITIVE_FIELDS_ATTR_DEFAULT, + GENERATE_CRUD_ATTR: model_opts.generate_crud, + MODEL_EXCLUDE_ATTR: model_opts.model_exclude, + MODEL_FIELDS_ATTR: model_opts.model_fields, + MODEL_RECURSIVE_ATTR: model_opts.model_recursive, + MODEL_JOIN_ATTR: model_opts.model_join, + SENSITIVE_FIELDS_ATTR: model_opts.model_fields, }, ) @@ -51,7 +54,7 @@ def create_api_controller( class_name, (CrudAPIController,), { - "Meta": Meta, + "APIMeta": APIMeta, }, ) diff --git a/easy/controller/base.py b/easy/controller/base.py index a577396..d5809f4 100644 --- a/easy/controller/base.py +++ b/easy/controller/base.py @@ -1,13 +1,13 @@ import logging -from easy.controller.meta import CrudApiMetaclass +from easy.controller.meta import CrudAPIMetaclass logger = logging.getLogger(__name__) -class CrudAPIController(metaclass=CrudApiMetaclass): +class CrudAPIController(metaclass=CrudAPIMetaclass): """ - Base APIController for auto creating CRUD APIs, configurable via Meta class + Base APIController for auto creating CRUD APIs, configurable via APIMeta class APIs auto generated: Creat PUT /{id} - Create a single Object @@ -27,12 +27,12 @@ class CrudAPIController(metaclass=CrudApiMetaclass): generate_crud: whether to create crud api, default to True model_exclude: fields to be excluded in Schema, it will ignore model_fields model_fields: fields to be included in Schema, default to "__all__" - model_join: retrieve all m2m fields, default to True - model_recursive: recursively retrieve FK/OneToOne models, default to False + model_join: prefetch and retrieve all m2m fields, default to False + model_recursive: recursively retrieve FK/OneToOne fields, default to False sensitive_fields: fields to be ignored Example: - class Meta + class APIMeta model = Event generate_crud = False model_exclude = ["field1", "field2"] diff --git a/easy/controller/meta.py b/easy/controller/meta.py index 1729b47..e793eeb 100644 --- a/easy/controller/meta.py +++ b/easy/controller/meta.py @@ -2,33 +2,32 @@ import logging import re import uuid -from abc import ABCMeta +from abc import ABC, ABCMeta from collections import ChainMap -from typing import Any, Match, Optional, Tuple, Type +from typing import Any, List, Match, Optional, Tuple, Type from django.http import HttpRequest from ninja import ModelSchema from ninja_extra import ControllerBase, http_delete, http_get, http_patch, http_put +from ninja_extra.exceptions import ValidationError from ninja_extra.pagination import paginate from easy.controller.meta_conf import MODEL_FIELDS_ATTR_DEFAULT, ModelOptions -from easy.domain.orm import CrudModel -from easy.response import BaseApiResponse +from easy.domain.meta import CrudModel +from easy.response import BaseAPIResponse from easy.services import BaseService from easy.utils import copy_func logger = logging.getLogger(__name__) -class CrudAPI(CrudModel): +class CrudAPI(CrudModel, ABC): # Never add type note to service, it will cause injection error def __init__(self, service=None): # type: ignore # Critical to set __Meta self.service = service - if self.service: - self.model = self.service.model - _model_opts: ModelOptions = ModelOptions.get_model_options(self.__class__) + _model_opts: ModelOptions = ModelOptions.get_model_options(self.APIMeta) if self.model and _model_opts: ModelOptions.set_model_meta(self.model, _model_opts) @@ -37,8 +36,13 @@ def __init__(self, service=None): # type: ignore super().__init__(model=self.model) -class CrudApiMetaclass(ABCMeta): +class CrudAPIMetaclass(ABCMeta): def __new__(mcs, name: str, bases: Tuple[Type[Any], ...], attrs: dict) -> Any: + # Get configs from APIMeta + attrs_meta = attrs.get("APIMeta", None) + model_opts: ModelOptions = ModelOptions.get_model_options(attrs_meta) + + # Get all attrs from parents excluding private ones def is_private_attrs(attr_name: str) -> Optional[Match[str]]: return re.match(r"^__[^\d\W]\w*\Z__$", attr_name, re.UNICODE) @@ -52,10 +56,6 @@ def is_private_attrs(attr_name: str) -> Optional[Match[str]]: base_cls_attrs: dict = {} base_cls_attrs.update(parent_attrs) - # Get configs from Meta - _temp_cls: Type = super().__new__(mcs, name, (object,), base_cls_attrs) - model_opts: ModelOptions = ModelOptions.get_model_options(_temp_cls) - # Define Controller APIs for auto generation async def get_obj(self, request: HttpRequest, id: int) -> Any: # type: ignore """ @@ -66,11 +66,11 @@ async def get_obj(self, request: HttpRequest, id: int) -> Any: # type: ignore qs = await self.service.get_obj(id) except Exception as e: # pragma: no cover logger.error(f"Get Error - {e}", exc_info=True) - return BaseApiResponse(str(e), message="Get Failed", errno=500) + return BaseAPIResponse(str(e), message="Get Failed", code=500) if qs: return qs else: - return BaseApiResponse(message="Not Found", errno=404) + return BaseAPIResponse(message="Not Found", code=404) async def del_obj(self, request: HttpRequest, id: int) -> Any: # type: ignore """ @@ -78,18 +78,25 @@ async def del_obj(self, request: HttpRequest, id: int) -> Any: # type: ignore Delete a single Object """ if await self.service.del_obj(id): - return BaseApiResponse("Deleted.", errno=204) + return BaseAPIResponse("Deleted.", code=204) else: - return BaseApiResponse("Not Found.", errno=404) + return BaseAPIResponse("Not Found.", code=404) @paginate - async def get_objs(self, request: HttpRequest, filters: str = None) -> Any: # type: ignore + async def get_objs(self, request: HttpRequest, filters: Optional[str] = None) -> Any: # type: ignore """ - GET /?maximum={int}&filters={filters_dict} - Retrieve multiple Object (optional: maximum # and filters) + GET /?filters={filters_dict} + Retrieve multiple Object (optional: django filters) """ if filters: - return await self.service.get_objs(**json.loads(filters)) + try: + _filters = json.loads(filters) + except Exception as exc: # pragma: no cover + raise ValidationError( + detail=f"Bad filter, please check carefully. {exc}", + code=402, + ) + return await self.service.get_objs(**_filters) return await self.service.get_objs() if model_opts.generate_crud and model_opts.model: @@ -110,14 +117,18 @@ async def get_objs(self, request: HttpRequest, filters: str = None) -> Any: # t class DataSchema(ModelSchema): class Config: model = model_opts.model + model_exclude: List = [] if model_opts.model_exclude: - model_exclude = model_opts.model_exclude + model_exclude.extend(model_opts.model_exclude) + # Remove pk(id) from Create/Update Schema + model_exclude.extend([model._meta.pk.name]) # type: ignore else: if model_opts.model_fields == MODEL_FIELDS_ATTR_DEFAULT: - model_fields = MODEL_FIELDS_ATTR_DEFAULT + # Remove pk(id) from Create/Update Schema + model_exclude.extend([model._meta.pk.name]) # type: ignore else: model_fields = ( - model_opts.model_fields # type: ignore + model_opts.model_fields if model_opts.model_fields else MODEL_FIELDS_ATTR_DEFAULT ) @@ -131,9 +142,11 @@ async def add_obj( # type: ignore """ obj_id = await self.service.add_obj(**data.dict()) if obj_id: - return BaseApiResponse({"id": obj_id}, errno=201) + return BaseAPIResponse({"id": obj_id}, code=201, message="Created.") else: - return BaseApiResponse("Add failed", errno=204) # pragma: no cover + return BaseAPIResponse( + code=204, message="Add failed." + ) # pragma: no cover async def patch_obj( # type: ignore self, request: HttpRequest, id: int, data: DataSchema @@ -143,9 +156,9 @@ async def patch_obj( # type: ignore Update a single object """ if await self.service.patch_obj(id=id, payload=data.dict()): - return BaseApiResponse("Updated.") + return BaseAPIResponse(message="Updated.") else: - return BaseApiResponse("Update Failed", errno=400) + return BaseAPIResponse(code=400, message="Updated Failed") DataSchema.__name__ = ( f"{model_opts.model.__name__}__AutoSchema({str(uuid.uuid4())[:4]})" diff --git a/easy/controller/meta_conf.py b/easy/controller/meta_conf.py index e259eaa..3902ea7 100644 --- a/easy/controller/meta_conf.py +++ b/easy/controller/meta_conf.py @@ -2,7 +2,7 @@ from django.db import models -META_ATTRIBUTE_NAME: str = "__EASY_API_META__" +META_ATTRIBUTE_NAME: str = "_easy_api_meta_" GENERATE_CRUD_ATTR: str = "generate_crud" GENERATE_CRUD_ATTR_DEFAULT = True @@ -24,7 +24,7 @@ class ModelOptions: - def __init__(self, options: object = None): + def __init__(self, options: Optional[object] = None): """ Configuration reader """ @@ -49,8 +49,8 @@ def __init__(self, options: object = None): ) @classmethod - def get_model_options(cls, klass: Type) -> Any: - return ModelOptions(getattr(klass, "Meta", None)) + def get_model_options(cls, meta: Optional[Any]) -> "ModelOptions": + return ModelOptions(meta) @classmethod def set_model_meta( diff --git a/easy/decorators.py b/easy/decorators.py index 9510adb..a56b06a 100644 --- a/easy/decorators.py +++ b/easy/decorators.py @@ -1,6 +1,6 @@ from functools import wraps from types import FunctionType -from typing import Any, Callable +from typing import Any, Callable, Optional from urllib.parse import urlparse from django.conf import settings @@ -11,7 +11,7 @@ def request_passes_test( test_func: Callable[[Any], Any], - login_url: str = None, + login_url: Optional[str] = None, redirect_field_name: str = REDIRECT_FIELD_NAME, ) -> Callable[[FunctionType], Callable[[HttpRequest, Any], Any]]: """ @@ -45,7 +45,7 @@ def _wrapped_view(request: HttpRequest, *args: Any, **kwargs: Any) -> Any: def docs_permission_required( - view_func: FunctionType = None, + view_func: Optional[FunctionType] = None, redirect_field_name: str = REDIRECT_FIELD_NAME, login_url: str = "admin:login", ) -> Any: @@ -54,7 +54,7 @@ def docs_permission_required( member, redirecting to the login page if necessary. """ actual_decorator = request_passes_test( - lambda r: ((r.user.is_active and r.user.is_staff)), + lambda r: (r.user.is_active and r.user.is_staff), login_url=login_url, redirect_field_name=redirect_field_name, ) diff --git a/easy/domain/__init__.py b/easy/domain/__init__.py index 93c2868..9d4b1ad 100644 --- a/easy/domain/__init__.py +++ b/easy/domain/__init__.py @@ -1,3 +1,7 @@ from .base import BaseDomain +from .meta import CrudModel -__all__ = ["BaseDomain"] +__all__ = [ + "BaseDomain", + "CrudModel", +] diff --git a/easy/domain/base.py b/easy/domain/base.py index e4f9069..ec76dc6 100644 --- a/easy/domain/base.py +++ b/easy/domain/base.py @@ -1,5 +1,5 @@ -from easy.domain.orm import BaseOrm +from easy.domain.orm import DjangoOrmModel -class BaseDomain(BaseOrm): +class BaseDomain(DjangoOrmModel): pass diff --git a/easy/domain/meta.py b/easy/domain/meta.py new file mode 100644 index 0000000..246bf8d --- /dev/null +++ b/easy/domain/meta.py @@ -0,0 +1,37 @@ +from abc import abstractmethod +from typing import Any, Dict, Optional + + +class CrudModel(object): + APIMeta: Dict = {} + + def __init__(self, model: Any): + self.model = model + + @abstractmethod + def crud_add_obj(self, **payload: Dict) -> Any: + raise NotImplementedError + + @abstractmethod + def crud_del_obj(self, pk: int) -> bool: + raise NotImplementedError + + @abstractmethod + def crud_update_obj(self, pk: int, payload: Dict) -> bool: + raise NotImplementedError + + @abstractmethod + def crud_get_obj(self, pk: int) -> Any: + raise NotImplementedError + + @abstractmethod + def crud_get_objs_all(self, maximum: Optional[int] = None, **filters: Any) -> Any: + raise NotImplementedError + + @abstractmethod + def crud_filter(self, **kwargs: Any) -> Any: + raise NotImplementedError + + @abstractmethod + def crud_filter_exclude(self, **kwargs: Any) -> Any: + raise NotImplementedError diff --git a/easy/domain/orm.py b/easy/domain/orm.py index 23c76d8..740e427 100644 --- a/easy/domain/orm.py +++ b/easy/domain/orm.py @@ -1,23 +1,31 @@ import logging -from typing import Any, Dict, List, Tuple, Type +from typing import Any, Dict, List, Optional, Tuple, Type from django.db import models, transaction -from django.db.models.query import QuerySet from ninja_extra.shortcuts import get_object_or_none +from easy.controller.meta_conf import ModelMetaConfig +from easy.domain.meta import CrudModel from easy.exception import BaseAPIException logger = logging.getLogger(__name__) -class CrudModel(object): - def __init__(self, model: Type[models.Model]): +class DjangoOrmModel(CrudModel): + def __init__(self, model: Optional[Type[models.Model]] = None) -> None: self.model = model - self.m2m_fields_list: List = list( - _field - for _field in self.model._meta.get_fields(include_hidden=True) - if isinstance(_field, models.ManyToManyField) - ) + if self.model: + config = ModelMetaConfig() + exclude_list = config.get_final_excluded_list(self.model()) + self.m2m_fields_list: List = list( + _field + for _field in self.model._meta.get_fields(include_hidden=True) + if ( + isinstance(_field, models.ManyToManyField) + and ((_field not in exclude_list) if exclude_list else True) + ) + ) + super().__init__(self.model) def _separate_payload(self, payload: Dict) -> Tuple[Dict, Dict]: m2m_fields = {} @@ -45,7 +53,7 @@ def _crud_set_m2m_obj(obj: models.Model, m2m_fields: Dict) -> None: # Define BASE CRUD @transaction.atomic() - def _crud_add_obj(self, **payload: Dict) -> Any: + def crud_add_obj(self, **payload: Dict) -> Any: local_f_payload, m2m_f_payload = self._separate_payload(payload) try: @@ -58,7 +66,7 @@ def _crud_add_obj(self, **payload: Dict) -> Any: if obj: return obj.id - def _crud_del_obj(self, pk: int) -> bool: + def crud_del_obj(self, pk: int) -> bool: obj = get_object_or_none(self.model, pk=pk) if obj: self.model.objects.filter(pk=pk).delete() @@ -67,7 +75,7 @@ def _crud_del_obj(self, pk: int) -> bool: return False @transaction.atomic() - def _crud_update_obj(self, pk: int, payload: Dict) -> bool: + def crud_update_obj(self, pk: int, payload: Dict) -> bool: local_fields, m2m_fields = self._separate_payload(payload) if not self.model.objects.filter(pk=pk).exists(): return False @@ -78,7 +86,7 @@ def _crud_update_obj(self, pk: int, payload: Dict) -> bool: raise BaseAPIException(f"Update Error - {e}") return bool(obj) - def _crud_get_obj(self, pk: int) -> Any: + def crud_get_obj(self, pk: int) -> Any: if self.m2m_fields_list: qs = self.model.objects.filter(pk=pk).prefetch_related( self.m2m_fields_list[0].name @@ -90,10 +98,11 @@ def _crud_get_obj(self, pk: int) -> Any: if qs: return qs.first() - def _crud_get_objs_all(self, **filters: Any) -> Any: + def crud_get_objs_all(self, maximum: Optional[int] = None, **filters: Any) -> Any: """ CRUD: get multiple objects, with django orm filters support Args: + maximum: {int} filters: {"field_name__lte", 1} Returns: qs @@ -104,6 +113,8 @@ def _crud_get_objs_all(self, **filters: Any) -> Any: qs = self.model.objects.filter(**filters) except Exception as e: # pragma: no cover logger.error(e) + elif maximum: + qs = self.model.objects.all()[:maximum] else: qs = self.model.objects.all() # If there are 2m2_fields @@ -113,12 +124,133 @@ def _crud_get_objs_all(self, **filters: Any) -> Any: qs = qs.prefetch_related(f.name) return qs - def _crud_filter(self, **kwargs: Any) -> QuerySet: + def crud_filter(self, **kwargs: Any) -> Any: return self.model.objects.filter(**kwargs) # pragma: no cover - def _crud_filter_exclude(self, **kwargs: Any) -> QuerySet: + def crud_filter_exclude(self, **kwargs: Any) -> Any: return self.model.objects.all().exclude(**kwargs) -class BaseOrm(CrudModel): - ... +class DjangoSerializer(ModelMetaConfig): + @staticmethod + def is_model_instance(data: Any) -> bool: + return isinstance(data, models.Model) + + @staticmethod + def is_queryset(data: Any) -> bool: + return isinstance(data, models.query.QuerySet) + + @staticmethod + def is_one_relationship(data: Any) -> bool: + return isinstance(data, models.ForeignKey) or isinstance( + data, models.OneToOneRel + ) + + @staticmethod + def is_many_relationship(data: Any) -> bool: + return ( + isinstance(data, models.ManyToManyRel) + or isinstance(data, models.ManyToManyField) + or isinstance(data, models.ManyToOneRel) + ) + + @staticmethod + def is_paginated(data: Any) -> bool: + return isinstance(data, dict) and isinstance( + data.get("items", None), models.query.QuerySet + ) + + def serialize_model_instance( + self, obj: models.Model, referrers: Any = tuple() + ) -> Dict[Any, Any]: + """Serializes Django model instance to dictionary""" + out = {} + for field in obj._meta.get_fields(): + if self.show_field(obj, field.name): + if self.is_one_relationship(field): + out.update( + self.serialize_foreign_key(obj, field, referrers + (obj,)) + ) + + elif self.is_many_relationship(field): + out.update( + self.serialize_many_relationship(obj, referrers + (obj,)) + ) + + else: + out.update(self.serialize_value_field(obj, field)) + return out + + def serialize_queryset( + self, data: models.query.QuerySet, referrers: Tuple[Any, ...] = tuple() + ) -> List[Dict[Any, Any]]: + """Serializes Django Queryset to dictionary""" + return [self.serialize_model_instance(obj, referrers) for obj in data] + + def serialize_foreign_key( + self, obj: models.Model, field: Any, referrers: Any = tuple() + ) -> Dict[Any, Any]: + """Serializes foreign key field of Django model instance""" + try: + if not hasattr(obj, field.name): + return {field.name: None} # pragma: no cover + related_instance = getattr(obj, field.name) + if related_instance is None: + return {field.name: None} + if related_instance in referrers: + return {} # pragma: no cover + field_value = getattr(related_instance, "pk") + except Exception as exc: # pragma: no cover + logger.error(f"serialize_foreign_key error - {obj}", exc_info=exc) + return {field.name: None} + + if self.get_model_recursive(obj): + return { + field.name: self.serialize_model_instance(related_instance, referrers) + } + return {field.name: field_value} + + def serialize_many_relationship( + self, obj: models.Model, referrers: Any = tuple() + ) -> Dict[Any, Any]: + """ + Serializes many relationship (ManyToMany, ManyToOne) of Django model instance + """ + if not hasattr(obj, "_prefetched_objects_cache"): + return {} + out = {} + try: + for k, v in obj._prefetched_objects_cache.items(): + field_name = k if hasattr(obj, k) else k + "_set" + if v: + if self.get_model_join(obj): + out[field_name] = self.serialize_queryset(v, referrers + (obj,)) + else: + out[field_name] = [o.pk for o in v] + else: + out[field_name] = [] + except Exception as exc: # pragma: no cover + logger.error(f"serialize_many_relationship error - {obj}", exc_info=exc) + return out + + def serialize_value_field(self, obj: models.Model, field: Any) -> Dict[Any, Any]: + """ + Serializes regular 'jsonable' field (Char, Int, etc.) of Django model instance + """ + return {field.name: getattr(obj, field.name)} + + def serialize_data(self, data: Any) -> Any: + out = data + # Queryset + if self.is_queryset(data): + out = self.serialize_queryset(data) + # Model + elif self.is_model_instance(data): + out = self.serialize_model_instance(data) + # Add limit_off pagination support + elif self.is_paginated(data): + out = self.serialize_queryset(data.get("items")) + return out + + +django_serializer = DjangoSerializer() diff --git a/easy/domain/serializers.py b/easy/domain/serializers.py deleted file mode 100644 index 86a8748..0000000 --- a/easy/domain/serializers.py +++ /dev/null @@ -1,133 +0,0 @@ -import logging -from typing import Any, Dict, List, Tuple - -from django.db import models - -from easy.controller.meta_conf import ModelMetaConfig - -logger = logging.getLogger(__name__) - - -class DjangoSerializer(ModelMetaConfig): - @staticmethod - def is_model_instance(data: Any) -> bool: - return isinstance(data, models.Model) - - @staticmethod - def is_queryset(data: Any) -> bool: - return isinstance(data, models.query.QuerySet) - - @staticmethod - def is_one_relationship(data: Any) -> bool: - return isinstance(data, models.ForeignKey) or isinstance( - data, models.OneToOneRel - ) - - @staticmethod - def is_many_relationship(data: Any) -> bool: - return ( - isinstance(data, models.ManyToManyRel) - or isinstance(data, models.ManyToManyField) - or isinstance(data, models.ManyToOneRel) - ) - - @staticmethod - def is_paginated(data: Any) -> bool: - return isinstance(data, dict) and isinstance( - data.get("items", None), models.query.QuerySet - ) - - def serialize_model_instance( - self, obj: models.Model, referrers: Any = tuple() - ) -> Dict[Any, Any]: - """Serializes Django model instance to dictionary""" - out = {} - for field in obj._meta.get_fields(): - if self.show_field(obj, field.name): - if self.is_one_relationship(field): - out.update( - self.serialize_foreign_key(obj, field, referrers + (obj,)) - ) - - elif self.is_many_relationship(field): - out.update( - self.serialize_many_relationship(obj, referrers + (obj,)) - ) - - else: - out.update(self.serialize_value_field(obj, field)) - return out - - def serialize_queryset( - self, data: models.query.QuerySet, referrers: Tuple[Any, ...] = tuple() - ) -> List[Dict[Any, Any]]: - """Serializes Django Queryset to dictionary""" - return [self.serialize_model_instance(obj, referrers) for obj in data] - - def serialize_foreign_key( - self, obj: models.Model, field: Any, referrers: Any = tuple() - ) -> Dict[Any, Any]: - """Serializes foreign key field of Django model instance""" - try: - if not hasattr(obj, field.name): - return {field.name: None} # pragma: no cover - related_instance = getattr(obj, field.name) - if related_instance is None: - return {field.name: None} - if related_instance in referrers: - return {} # pragma: no cover - field_value = getattr(related_instance, "pk") - except Exception as exc: # pragma: no cover - logger.error(f"serialize_foreign_key error - {obj}", exc_info=exc) - return {field.name: None} - - if self.get_model_recursive(obj): - return { - field.name: self.serialize_model_instance(related_instance, referrers) - } - return {field.name: field_value} - - def serialize_many_relationship( - self, obj: models.Model, referrers: Any = tuple() - ) -> Dict[Any, Any]: - """ - Serializes many relationship (ManyToMany, ManyToOne) of Django model instance - """ - if not hasattr(obj, "_prefetched_objects_cache"): - return {} - out = {} - try: - for k, v in obj._prefetched_objects_cache.items(): # type: ignore - field_name = k if hasattr(obj, k) else k + "_set" - if v: - if self.get_model_join(obj): - out[field_name] = self.serialize_queryset(v, referrers + (obj,)) - else: - out[field_name] = [o.pk for o in v] - else: - out[field_name] = [] - except Exception as exc: # pragma: no cover - logger.error(f"serialize_many_relationship error - {obj}", exc_info=exc) - return out - - def serialize_value_field(self, obj: models.Model, field: Any) -> Dict[Any, Any]: - """ - Serializes regular 'jsonable' field (Char, Int, etc.) of Django model instance - """ - return {field.name: getattr(obj, field.name)} - - def serialize_data(self, data: Any) -> Any: - out = data - # Queryset - if self.is_queryset(data): - out = self.serialize_queryset(data) - # Model - elif self.is_model_instance(data): - out = self.serialize_model_instance(data) - # Add limit_off pagination support - elif self.is_paginated(data): - out = self.serialize_queryset(data.get("items")) - return out - - -django_serializer = DjangoSerializer() diff --git a/easy/exception.py b/easy/exception.py index f140f12..a842024 100644 --- a/easy/exception.py +++ b/easy/exception.py @@ -1,3 +1,4 @@ +from django.utils.translation import gettext_lazy as _ from ninja_extra import status from ninja_extra.exceptions import APIException @@ -8,7 +9,7 @@ class BaseAPIException(APIException): """ status_code = status.HTTP_500_INTERNAL_SERVER_ERROR - default_detail = ( + default_detail = _( "There is an unexpected error, please try again later, if the problem " "persists, please contact customer support team for further support." ) @@ -20,4 +21,4 @@ class APIAuthException(BaseAPIException): """ status_code = status.HTTP_401_UNAUTHORIZED - default_detail = "Unauthorized" + default_detail = _("Unauthorized") diff --git a/easy/main.py b/easy/main.py index 3f20921..8bf45ca 100644 --- a/easy/main.py +++ b/easy/main.py @@ -11,10 +11,10 @@ from ninja.types import TCallable from ninja_extra import NinjaExtraAPI -from easy.controller.admin_auto_api import create_admin_controller -from easy.domain.serializers import django_serializer +from easy.controller.auto_api import create_admin_controller +from easy.domain.orm import django_serializer from easy.renderer.json import EasyJSONRenderer -from easy.response import BaseApiResponse +from easy.response import BaseAPIResponse logger = logging.getLogger(__name__) @@ -29,9 +29,9 @@ class EasyAPI(NinjaExtraAPI): If True, will be encapsulated in BaseAPIResponse -renderer, default to EasyJSONRenderer -Auto generate AdminAPIs, it will read the following settings: - AUTO_ADMIN_ENABLED_ALL_APPS - AUTO_ADMIN_EXCLUDE_APPS - AUTO_ADMIN_INCLUDE_APPS + CRUD_API_ENABLED_ALL_APPS + CRUD_API_EXCLUDE_APPS + CRUD_API_INCLUDE_APPS """ def __init__( @@ -75,17 +75,17 @@ def __init__( def auto_create_admin_controllers(self, version: str = None) -> None: for app_module in self.get_installed_apps(): # If not all - if not settings.AUTO_ADMIN_ENABLED_ALL_APPS: # type:ignore + if not settings.CRUD_API_ENABLED_ALL_APPS: # type:ignore # Only generate for this included apps - if settings.AUTO_ADMIN_INCLUDE_APPS is not None: # type:ignore + if settings.CRUD_API_INCLUDE_APPS is not None: # type:ignore if ( app_module.name - not in settings.AUTO_ADMIN_INCLUDE_APPS # type:ignore + not in settings.CRUD_API_INCLUDE_APPS # type:ignore ): continue # Exclude list - if app_module.name in settings.AUTO_ADMIN_EXCLUDE_APPS: # type:ignore + if app_module.name in settings.CRUD_API_EXCLUDE_APPS: # type:ignore continue try: @@ -126,14 +126,14 @@ def create_response( data = django_serializer.serialize_data(data) except Exception as e: # pragma: no cover logger.error(f"Creat Response Error - {e}", exc_info=True) - return BaseApiResponse(str(e), errno=500) + return BaseAPIResponse(str(e), code=500) if self.easy_output: if temporal_response: status = temporal_response.status_code assert status - _temp = BaseApiResponse( + _temp = BaseAPIResponse( data, status=status, content_type=self.get_content_type() ) @@ -154,6 +154,6 @@ def create_response( def create_temporal_response(self, request: HttpRequest) -> HttpResponse: if self.easy_output: - return BaseApiResponse("", content_type=self.get_content_type()) + return BaseAPIResponse("", content_type=self.get_content_type()) else: return super().create_temporal_response(request) diff --git a/easy/permissions/adminsite.py b/easy/permissions/adminsite.py index 07c0a86..0f91608 100644 --- a/easy/permissions/adminsite.py +++ b/easy/permissions/adminsite.py @@ -10,7 +10,7 @@ class AdminSitePermission(IsAdminUser): """ - Allows delete only to super user, and change to only staff/super users. + Only staff users with the right permission can modify objects. """ def has_permission( @@ -24,16 +24,22 @@ def has_permission( model: models.Model = cast(models.Model, getattr(controller, "model", None)) if model: app: str = model._meta.app_label + model_name = model._meta.model_name if request.method in ("GET", "OPTIONS"): - has_perm = user.has_perm(f"{app}.view{model.__name__}") # type: ignore + has_perm = user.has_perm(f"{app}.view_{model_name}") # type: ignore + elif request.method in ("PUT", "POST"): - has_perm = user.has_perm(f"{app}.add_{model.__name__}") # type: ignore + has_perm = user.has_perm(f"{app}.add_{model_name}") # type: ignore + elif request.method in ("PUT", "PATCH", "POST"): - has_perm = user.has_perm(f"{app}.change_{model.__name__}") # type: ignore + has_perm = user.has_perm(f"{app}.change_{model_name}") # type: ignore + elif request.method in ("DELETE",): - has_perm = user.has_perm(f"{app}.delete_{model.__name__}") # type: ignore + has_perm = user.has_perm(f"{app}.delete_{model_name}") # type: ignore + if user.is_superuser: # type: ignore has_perm = True + return bool( user and user.is_authenticated diff --git a/easy/permissions/base.py b/easy/permissions/base.py index 0c59a7f..76cb28d 100644 --- a/easy/permissions/base.py +++ b/easy/permissions/base.py @@ -21,7 +21,7 @@ def has_permission( """ has_perm: bool = True if hasattr(controller, "service"): - has_perm = controller.service.check_permission(request, controller) # type: ignore + has_perm = controller.service.check_permission(request, controller) return has_perm def has_object_permission( @@ -32,7 +32,7 @@ def has_object_permission( """ has_perm: bool = True if hasattr(controller, "service"): - has_perm = controller.service.check_object_permission( # type: ignore + has_perm = controller.service.check_object_permission( request, controller, obj ) return has_perm diff --git a/easy/response.py b/easy/response.py index e141d53..e6ff2d9 100644 --- a/easy/response.py +++ b/easy/response.py @@ -1,35 +1,35 @@ import json -from typing import Any, Dict, Union +from typing import Any, Dict, List, Union +from django.db.models import QuerySet from django.http.response import JsonResponse from easy.renderer.json import EasyJSONEncoder -ERRNO_SUCCESS = 0 +CODE_SUCCESS = 0 SUCCESS_MESSAGE = "success" -UNKNOWN_ERROR_MSG = "system error" -class BaseApiResponse(JsonResponse): +class BaseAPIResponse(JsonResponse): """ Base for all API responses """ def __init__( self, - data: Union[Dict, str] = None, - errno: int = None, + data: Union[Dict, str, bool, List[Any], QuerySet] = None, + code: int = None, message: str = None, **kwargs: Any ): - if errno: - message = message or UNKNOWN_ERROR_MSG + if code: + message = message or str(code) else: - message = SUCCESS_MESSAGE - errno = ERRNO_SUCCESS + message = message or SUCCESS_MESSAGE + code = CODE_SUCCESS _data: Union[Dict, str] = { - "code": errno, + "code": code, "message": message, "data": data if data is not None else {}, } diff --git a/easy/services/base.py b/easy/services/base.py index d677986..6ccc573 100644 --- a/easy/services/base.py +++ b/easy/services/base.py @@ -1,5 +1,5 @@ import logging -from typing import Type +from typing import Optional, Type from django.db import models @@ -10,9 +10,6 @@ class BaseService(CrudService, PermissionService): - def __init__( - self, - model: Type[models.Model], - ): + def __init__(self, model: Optional[Type[models.Model]] = None): self.model = model super().__init__(model=self.model) diff --git a/easy/services/crud.py b/easy/services/crud.py index 469df5f..d28ac50 100644 --- a/easy/services/crud.py +++ b/easy/services/crud.py @@ -1,39 +1,39 @@ import logging -from typing import Any, Type +from typing import Any, Optional, Type from asgiref.sync import sync_to_async from django.db import models -from easy.domain.orm import CrudModel +from easy.domain.orm import DjangoOrmModel logger = logging.getLogger(__name__) -class CrudService(CrudModel): - def __init__(self, model: Type[models.Model]): +class CrudService(DjangoOrmModel): + def __init__(self, model: Optional[Type[models.Model]] = None): super().__init__(model) self.model = model async def get_obj(self, id: int) -> Any: - return await sync_to_async(self._crud_get_obj)(id) + return await sync_to_async(self.crud_get_obj)(id) async def get_objs(self, **filters: Any) -> Any: - return await sync_to_async(self._crud_get_objs_all)(**filters) + return await sync_to_async(self.crud_get_objs_all)(**filters) async def patch_obj(self, id: int, payload: Any) -> Any: - return await sync_to_async(self._crud_update_obj)(id, payload) + return await sync_to_async(self.crud_update_obj)(id, payload) async def del_obj(self, id: int) -> Any: - return await sync_to_async(self._crud_del_obj)(id) + return await sync_to_async(self.crud_del_obj)(id) async def add_obj(self, **payload: Any) -> Any: - return await sync_to_async(self._crud_add_obj)(**payload) + return await sync_to_async(self.crud_add_obj)(**payload) async def filter_objs(self, **payload: Any) -> Any: - return await sync_to_async(self._crud_filter)(**payload) # pragma: no cover + return await sync_to_async(self.crud_filter)(**payload) # pragma: no cover async def filter_exclude_objs(self, **payload: Any) -> Any: - return await sync_to_async(self._crud_filter_exclude)(**payload) + return await sync_to_async(self.crud_filter_exclude)(**payload) # async def bulk_create_objs(self): # ... diff --git a/easy/testing/client.py b/easy/testing/client.py index 4c93613..3ad0d15 100644 --- a/easy/testing/client.py +++ b/easy/testing/client.py @@ -1,5 +1,5 @@ from json import dumps as json_dumps -from typing import Any, Callable, Dict, List, Sequence, Type, Union, cast +from typing import Any, Callable, Dict, List, Optional, Sequence, Type, Union, cast from unittest.mock import Mock from urllib.parse import urlencode @@ -22,7 +22,7 @@ def __init__( ) -> None: if hasattr(router_or_app, "get_api_controller"): api = api_cls(auth=auth) - controller_ninja_api_controller = router_or_app.get_api_controller() # type: ignore + controller_ninja_api_controller = router_or_app.get_api_controller() assert controller_ninja_api_controller controller_ninja_api_controller.set_api_instance(api) self._urls_cache = list(controller_ninja_api_controller.urls_paths("")) @@ -33,7 +33,7 @@ def request( self, method: str, path: str, - data: Dict = {}, + data: Optional[Dict] = None, json: Any = None, **request_params: Any, ) -> "NinjaResponse": @@ -43,7 +43,7 @@ def request( query = request_params.pop("query") url_encode = urlencode(query) path = f"{path}?{url_encode}" - func, request, kwargs = self._resolve(method, path, data, request_params) + func, request, kwargs = self._resolve(method, path, data, request_params) # type: ignore return self._call(func, request, kwargs) # type: ignore @property diff --git a/pyproject.toml b/pyproject.toml index 797b401..4cc0513 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,12 +27,14 @@ classifiers = [ "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3 :: Only", "Framework :: Django", "Framework :: Django :: 3.1", "Framework :: Django :: 3.2", "Framework :: Django :: 4.0", "Framework :: Django :: 4.1", + "Framework :: Django :: 4.2", "Framework :: AsyncIO", "Topic :: Internet :: WWW/HTTP :: HTTP Servers", "Topic :: Internet :: WWW/HTTP", @@ -40,7 +42,7 @@ classifiers = [ requires = [ "Django >= 3.1", - "django-ninja-extra-easy >= 0.15.0", + "django-ninja-extra >= 0.16.0", ] description-file = "README.md" requires-python = ">=3.6" @@ -55,16 +57,16 @@ test = [ "pytest-cov", "pytest-django", "pytest-asyncio", - "black", + "black==23.10.1", + "mypy==1.6.1", "isort", - "injector == 0.19.0", + "injector>= 0.19.0", "flake8", - "mypy==0.931", "django-stubs", "factory-boy==3.2.1", "django_coverage_plugin", - "django-ninja-extra-easy >= 0.15.0", - "django-ninja-jwt", + "django-ninja-extra >= 0.20.0", + "django-ninja-jwt>=5.2.9", "Django >= 3.1", ] dev = [ diff --git a/setup.cfg b/setup.cfg index f05c35a..ff77453 100644 --- a/setup.cfg +++ b/setup.cfg @@ -62,7 +62,7 @@ max-line-length = 88 exclude = .tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules,venv [mypy] -python_version = 3.8 +python_version = 3.11 ignore_missing_imports = True warn_unused_configs = True plugins = mypy_django_plugin.main @@ -82,6 +82,7 @@ warn_unused_ignores = True disallow_untyped_defs = True check_untyped_defs = True no_implicit_reexport = True +no_implicit_optional = False [mypy.plugins.django-stubs] django_settings_module = "tests.easy_app" diff --git a/tests/config/settings.py b/tests/config/settings.py index 88d9932..324b33f 100644 --- a/tests/config/settings.py +++ b/tests/config/settings.py @@ -3,9 +3,9 @@ """ # Admin API auto-generation settings -AUTO_ADMIN_ENABLED_ALL_APPS = True -AUTO_ADMIN_INCLUDE_APPS = [] -AUTO_ADMIN_EXCLUDE_APPS = [] +CRUD_API_ENABLED_ALL_APPS = True +CRUD_API_INCLUDE_APPS = [] +CRUD_API_EXCLUDE_APPS = [] # Django Settings INSTALLED_APPS = ( diff --git a/tests/conftest.py b/tests/conftest.py index dd5a702..6e5f7e4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,9 +1,11 @@ import copy +from typing import Callable, Type, Union import pytest from django.contrib.auth import get_user_model +from ninja_extra import ControllerBase, Router -from easy.controller.base import CrudAPIController +from easy import EasyAPI from easy.testing import EasyTestClient from .easy_app.auth import JWTAuthAsync, jwt_auth_async @@ -23,40 +25,41 @@ def user(db) -> User: @pytest.fixture -def easy_api_client(user) -> EasyTestClient: +def easy_api_client(user) -> Callable: orig_func = copy.deepcopy(JWTAuthAsync.__call__) - orig_has_perm_fuc = copy.deepcopy(user.has_perm) + def create_client( + api: Union[EasyAPI, Router, Type[ControllerBase]], + api_user=None, # type: ignore + is_staff: bool = False, + is_superuser: bool = False, + has_perm: bool = False, + ) -> "EasyTestClient": + if api_user is None: + api_user = user + setattr(api_user, "is_staff", is_staff) + setattr(api_user, "is_superuser", is_superuser) - def mock_has_perm_true(*args, **kwargs): - return True + def mock_has_perm_true(*args, **kwargs): + return True - def mock_has_perm_false(*args, **kwargs): - return False + def mock_has_perm_false(*args, **kwargs): + return False - async def mock_func(self, request): - setattr(request, "user", user) - return True + async def mock_func(self, request): + setattr(request, "user", api_user) + return True - setattr(JWTAuthAsync, "__call__", mock_func) + setattr(JWTAuthAsync, "__call__", mock_func) - def create_client( - api: CrudAPIController, - is_staff: bool = False, - is_superuser: bool = False, - has_perm: bool = False, - ): - setattr(user, "is_staff", is_staff) - setattr(user, "is_superuser", is_superuser) if is_superuser: - setattr(user, "is_staff", True) + setattr(api_user, "is_staff", True) if has_perm: - setattr(user, "has_perm", mock_has_perm_true) + setattr(api_user, "has_perm", mock_has_perm_true) else: - setattr(user, "has_perm", mock_has_perm_false) + setattr(api_user, "has_perm", mock_has_perm_false) client = EasyTestClient(api, auth=jwt_auth_async) return client yield create_client setattr(JWTAuthAsync, "__call__", orig_func) - setattr(user, "has_perm", orig_has_perm_fuc) diff --git a/tests/easy_app/controllers.py b/tests/easy_app/controllers.py index cc1a2bf..29e6dd2 100644 --- a/tests/easy_app/controllers.py +++ b/tests/easy_app/controllers.py @@ -11,7 +11,7 @@ IsAuthenticated, IsSuperUser, ) -from easy.response import BaseApiResponse +from easy.response import BaseAPIResponse from .models import Client, Event from .schema import EventSchema @@ -22,13 +22,13 @@ class AutoGenCrudAPIController(CrudAPIController): """ For unit testings of the following auto generated APIs: - get/create/patch/delete/filter/filter_exclude + get/create/patch/delete """ def __init__(self, service: EventService): super().__init__(service) - class Meta: + class APIMeta: model = Event model_join = True @@ -36,13 +36,13 @@ class Meta: @api_controller("unittest", permissions=[BaseApiPermission]) class RecursiveAPIController(CrudAPIController): """ - For unit testings of no recursive configuration + For unit testings of recursive configuration """ def __init__(self, service: EventService): super().__init__(service) - class Meta: + class APIMeta: model = Event model_fields = "__all__" model_join = True @@ -52,13 +52,13 @@ class Meta: @api_controller("unittest", permissions=[BaseApiPermission]) class InheritedRecursiveAPIController(AutoGenCrudAPIController): """ - For unit testings of no recursive configuration + For unit testings of inherited recursive configuration """ def __init__(self, service: EventService): super().__init__(service) - class Meta: + class APIMeta: model = Event model_fields = "__all__" model_join = True @@ -74,7 +74,7 @@ class AutoGenCrudNoJoinAPIController(CrudAPIController): def __init__(self, service: EventService): super().__init__(service) - class Meta: + class APIMeta: model = Event model_fields = "__all__" model_join = False @@ -88,7 +88,7 @@ class AutoGenCrudSomeFieldsAPIController(CrudAPIController): For unit testings of the no-m2m-fields model """ - class Meta: + class APIMeta: model = Client model_fields = [ "key", @@ -105,7 +105,7 @@ class EasyCrudAPIController(CrudAPIController): def __init__(self, service: EventService): super().__init__(service) - class Meta: + class APIMeta: model = Event model_exclude = [ "category", @@ -113,7 +113,7 @@ class Meta: @http_get("/base_response/") async def generate_base_response(self, request): - return BaseApiResponse({"data": "This is a BaseApiResponse."}) + return BaseAPIResponse({"data": "This is a BaseAPIResponse."}) @http_get("/qs_paginated/", auth=None) @paginate @@ -132,11 +132,11 @@ async def get_objs_list_with_filter_exclude(self, request): "/qs/", ) async def list_events(self): - qs = await sync_to_async(self.model.objects.all)() + qs = await sync_to_async(self.service.crud_get_objs_all)(maximum=10) await sync_to_async(list)(qs) if qs: return qs - return BaseApiResponse() + return BaseAPIResponse() @api_controller("unittest") @@ -149,7 +149,7 @@ def __init__(self, service: EventService): super().__init__(service) self.service = service - class Meta: + class APIMeta: model = Event @http_get("/must_be_authenticated/", permissions=[IsAuthenticated]) @@ -181,20 +181,46 @@ async def test_perm_admin_site(self, request, word: str): @api_controller("unittest", permissions=[AdminSitePermission]) class AdminSitePermissionAPIController(CrudAPIController): + """ + For unit testings of AdminSite permissions class + """ + def __init__(self, service: EventService): super().__init__(service) self.service = service - class Meta: + class APIMeta: model = Event @api_controller("unittest", permissions=[AdminSitePermission]) class NoCrudAPIController(CrudAPIController): + """ + For unit testings of no crud configuration + """ + + def __init__(self, service: EventService): + super().__init__(service) + self.service = service + + class APIMeta: + model = Event + generate_crud = False + + +@api_controller("unittest", permissions=[AdminSitePermission]) +class NoCrudInheritedAPIController(AdminSitePermissionAPIController): + """ + For unit testings of no crud configuration (Inherited Class) + """ + def __init__(self, service: EventService): super().__init__(service) self.service = service - class Meta: + class APIMeta: model = Event generate_crud = False + model_exclude = [ + "start_date", + ] diff --git a/tests/easy_app/factories.py b/tests/easy_app/factories.py index 82ff897..e64708a 100644 --- a/tests/easy_app/factories.py +++ b/tests/easy_app/factories.py @@ -4,7 +4,6 @@ class UserFactory(DjangoModelFactory): - username = Faker("user_name") email = Faker("email") diff --git a/tests/easy_app/models.py b/tests/easy_app/models.py index b1fc578..2c2c96c 100644 --- a/tests/easy_app/models.py +++ b/tests/easy_app/models.py @@ -10,6 +10,9 @@ class Category(TestBaseModel): title = models.CharField(max_length=100) status = models.PositiveSmallIntegerField(default=1, null=True) + class APIMeta: + generate_crud = False + class Client(TestBaseModel): key = models.CharField(max_length=20, unique=True) diff --git a/tests/easy_app/urls.py b/tests/easy_app/urls.py index 6213292..3c753df 100644 --- a/tests/easy_app/urls.py +++ b/tests/easy_app/urls.py @@ -1,20 +1,9 @@ from django.contrib import admin from django.urls import path -from ninja_extra import NinjaExtraAPI - -from .controllers import ( - AutoGenCrudAPIController, - EasyCrudAPIController, - PermissionAPIController, -) - -api = NinjaExtraAPI() -api.register_controllers(EasyCrudAPIController) -api.register_controllers(PermissionAPIController) -api.register_controllers(AutoGenCrudAPIController) +from .apis import api_unittest urlpatterns = [ path("admin/", admin.site.urls), - path("api/", api.urls), + path("api/", api_unittest.urls), ] diff --git a/tests/test_api_base_response.py b/tests/test_api_base_response.py index 3fcc633..894ac7d 100644 --- a/tests/test_api_base_response.py +++ b/tests/test_api_base_response.py @@ -2,50 +2,48 @@ import pytest -from easy.response import BaseApiResponse +from easy.response import BaseAPIResponse def test_base_api_result_base(): + assert BaseAPIResponse("").json_data["data"] == "" - assert BaseApiResponse("").json_data["data"] == "" + assert BaseAPIResponse("1").json_data["data"] == "1" - assert BaseApiResponse("1").json_data["data"] == "1" - - assert BaseApiResponse("0").json_data["data"] == "0" - assert BaseApiResponse().json_data["data"] == {} - assert BaseApiResponse([]).json_data["data"] == [] - assert BaseApiResponse(True).json_data["data"] is True - assert BaseApiResponse(False).json_data["data"] is False - assert BaseApiResponse([1, 2, 3]).json_data["data"] == [1, 2, 3] + assert BaseAPIResponse("0").json_data["data"] == "0" + assert BaseAPIResponse().json_data["data"] == {} + assert BaseAPIResponse([]).json_data["data"] == [] + assert BaseAPIResponse(True).json_data["data"] is True + assert BaseAPIResponse(False).json_data["data"] is False + assert BaseAPIResponse([1, 2, 3]).json_data["data"] == [1, 2, 3] def test_base_api_result_dict(): - - assert BaseApiResponse({"a": 1, "b": 2}).json_data["data"] == { + assert BaseAPIResponse({"a": 1, "b": 2}).json_data["data"] == { "a": 1, "b": 2, } - assert (BaseApiResponse({"code": 2, "im": 14})).json_data["data"]["im"] == 14 - assert (BaseApiResponse({"code": 2, "im": 14})).json_data["data"]["code"] == 2 + assert (BaseAPIResponse({"code": 2, "im": 14})).json_data["data"]["im"] == 14 + assert (BaseAPIResponse({"code": 2, "im": 14})).json_data["data"]["code"] == 2 def test_base_api_result_message(): assert ( - BaseApiResponse(errno=-1, message="error test").json_data["message"] + BaseAPIResponse(code=-1, message="error test").json_data["message"] == "error test" ) - assert BaseApiResponse().json_data["message"] + assert BaseAPIResponse().json_data["message"] def test_base_api_edit(): - orig_resp = BaseApiResponse( + orig_resp = BaseAPIResponse( {"item_id": 2, "im": 14}, - errno=0, + code=0, ) with pytest.raises(KeyError): - orig_resp.json_data["detail"] + print(orig_resp.json_data["detail"]) data = orig_resp.json_data diff --git a/tests/test_async_api_permissions.py b/tests/test_async_api_permissions.py index c4d2156..afd1893 100644 --- a/tests/test_async_api_permissions.py +++ b/tests/test_async_api_permissions.py @@ -192,7 +192,7 @@ async def test_perm_auto_apis_patch(self, transactional_db, easy_api_client): response = await client.patch( f"/{event.id}", json=new_data, content_type="application/json" ) - assert response.json().get("data") + assert response.json().get("message") == "Updated." response = await client.get( f"/{event.id}", diff --git a/tests/test_async_auto_crud_apis.py b/tests/test_async_auto_crud_apis.py index d401165..e10ad07 100644 --- a/tests/test_async_auto_crud_apis.py +++ b/tests/test_async_auto_crud_apis.py @@ -12,6 +12,7 @@ EventSchema, InheritedRecursiveAPIController, NoCrudAPIController, + NoCrudInheritedAPIController, RecursiveAPIController, ) from .easy_app.models import Category, Client, Event, Type @@ -39,6 +40,20 @@ async def test_crud_generate_or_not(self, transactional_db, easy_api_client): f"/{event.id}", ) + client = easy_api_client(NoCrudInheritedAPIController, is_superuser=True) + + object_data = dummy_data.copy() + object_data.update(title=f"{object_data['title']}_get") + + event = await sync_to_async(Event.objects.create)(**object_data) + + response = await client.get( + f"/{event.id}", + ) + assert response.status_code == 200 + with pytest.raises(Exception): + print(response.json()["data"]["start_date"]) + async def test_crud_default_get_all(self, transactional_db, easy_api_client): client = easy_api_client(AutoGenCrudAPIController) @@ -178,8 +193,9 @@ async def test_crud_default_create(self, transactional_db, easy_api_client): "/", json=object_data, content_type="application/json" ) assert response.status_code == 200 - assert response.json().get("code") == 201 + assert response.json().get("message") == "Created." + event_id = response.json().get("data")["id"] response = await client.get( @@ -213,6 +229,7 @@ async def test_crud_default_create_some_fields( assert response.json()["data"]["key"] == "Type" with pytest.raises(KeyError): print(response.json()["data"]["password"]) + with pytest.raises(KeyError): print(response.json()["data"]["category"]) async def test_crud_default_patch(self, transactional_db, easy_api_client): @@ -260,7 +277,7 @@ async def test_crud_default_patch(self, transactional_db, easy_api_client): ) assert response.status_code == 200 - assert response.json().get("data") + assert response.json().get("message") == "Updated." response = await client.get( f"/{event.pk}", diff --git a/tests/test_async_other_apis.py b/tests/test_async_other_apis.py index 6ad8eca..5047fdd 100644 --- a/tests/test_async_other_apis.py +++ b/tests/test_async_other_apis.py @@ -50,7 +50,7 @@ async def test_base_response(self, transactional_db, easy_api_client): "/base_response/", ) assert response.status_code == 200 - assert response.json().get("data")["data"] == "This is a BaseApiResponse." + assert response.json().get("data")["data"] == "This is a BaseAPIResponse." async def test_qs_paginated(self, transactional_db, easy_api_client): client = easy_api_client(EasyCrudAPIController) diff --git a/tests/test_auto_api_creation.py b/tests/test_auto_api_creation.py index 5dcc525..1f17471 100644 --- a/tests/test_auto_api_creation.py +++ b/tests/test_auto_api_creation.py @@ -1,3 +1,4 @@ +import pytest from ninja_extra.operation import AsyncOperation from easy import EasyAPI @@ -32,32 +33,47 @@ def test_auto_generate_admin_api(): assert "TypeAdminAPIController" in controller_names -async def test_auto_apis(transactional_db, easy_api_client): +async def test_auto_apis(transactional_db, user, easy_api_client): for controller_class in controllers: - if not str(controller_class).endswith("ClientAdminAPIController"): - continue + if str(controller_class).endswith("ClientAdminAPIController"): + client = easy_api_client(controller_class, api_user=user, has_perm=True) + response = await client.get("/", data={}, json={}, user=user) + assert response.status_code == 403 - client = easy_api_client(controller_class) - response = await client.get("/") - # TODO: figure out why user.is_authenticated is False in auto created API + client = easy_api_client( + controller_class, api_user=user, has_perm=True, is_staff=True + ) + response = await client.get("/", data={}, json={}, user=user) + assert response.status_code == 200 + assert response.json()["data"] == [] - assert response.status_code == 403 - # assert response.json()["data"] == [] + client = easy_api_client( + controller_class, api_user=user, has_perm=True, is_staff=True + ) + response = await client.delete("/20000") + assert response.status_code == 403 - response = await client.delete("/20000") - assert response.status_code == 403 - # assert response.json()["code"] == 404 + client = easy_api_client( + controller_class, api_user=user, has_perm=True, is_staff=True + ) + response = await client.delete("/20000", data={}, json={}, user=user) + assert response.status_code == 200 + assert response.json()["code"] == 404 + elif str(controller_class).endswith("CategoryAdminAPIController"): + client = easy_api_client(controller_class, api_user=user, has_perm=True) + with pytest.raises(Exception): + await client.get("/", data={}, json={}, user=user) async def test_auto_generation_settings(settings): - settings.AUTO_ADMIN_EXCLUDE_APPS = ["tests.easy_app"] + settings.CRUD_API_EXCLUDE_APPS = ["tests.easy_app"] api_admin_v2 = EasyAPI() api_admin_v2.auto_create_admin_controllers() assert len(api_admin_v2._routers) == 1 - settings.AUTO_ADMIN_ENABLED_ALL_APPS = False - settings.AUTO_ADMIN_EXCLUDE_APPS = [] - settings.AUTO_ADMIN_INCLUDE_APPS = ["tests.none_existing_app"] + settings.CRUD_API_ENABLED_ALL_APPS = False + settings.CRUD_API_EXCLUDE_APPS = [] + settings.CRUD_API_INCLUDE_APPS = ["tests.none_existing_app"] api_admin_v3 = EasyAPI() api_admin_v3.auto_create_admin_controllers() assert len(api_admin_v3._routers) == 1 diff --git a/tests/test_settings.py b/tests/test_settings.py index 0c50e2e..f75aeff 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1,11 +1,11 @@ from easy.conf.settings import ( - AUTO_ADMIN_ENABLED_ALL_APPS, - AUTO_ADMIN_EXCLUDE_APPS, - AUTO_ADMIN_INCLUDE_APPS, + CRUD_API_ENABLED_ALL_APPS, + CRUD_API_EXCLUDE_APPS, + CRUD_API_INCLUDE_APPS, ) def test_change_django_settings(settings): - assert settings.AUTO_ADMIN_ENABLED_ALL_APPS == AUTO_ADMIN_ENABLED_ALL_APPS - assert settings.AUTO_ADMIN_INCLUDE_APPS == AUTO_ADMIN_INCLUDE_APPS - assert settings.AUTO_ADMIN_EXCLUDE_APPS == AUTO_ADMIN_EXCLUDE_APPS + assert settings.CRUD_API_ENABLED_ALL_APPS == CRUD_API_ENABLED_ALL_APPS + assert settings.CRUD_API_INCLUDE_APPS == CRUD_API_INCLUDE_APPS + assert settings.CRUD_API_EXCLUDE_APPS == CRUD_API_EXCLUDE_APPS