diff --git a/.circleci/python-versions.txt b/.circleci/python-versions.txt index 07576882..970b343d 100644 --- a/.circleci/python-versions.txt +++ b/.circleci/python-versions.txt @@ -1 +1 @@ -2.7.17 3.5.8 3.6.9 3.7.5 3.8.0 +3.6.9 3.7.5 3.8.0 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1ad430ee..83ea3e66 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,9 +12,9 @@ jobs: strategy: fail-fast: false matrix: - python-version: [3.5, 3.6, 3.7] - dj-version: ["1.11.*", "2.0.*", "2.1.*", "2.2.*"] - drf-version: ["3.8.*", "3.9.*", "3.10.*", "3.11.*"] + python-version: [3.6, 3.7, 3.8] + dj-version: ["2.2.*", "3.0.*", "3.1.*", "3.2.*"] + drf-version: ["3.11.*", "3.12.*"] steps: - uses: actions/checkout@v2 diff --git a/Makefile b/Makefile index 4933755f..d2066a63 100644 --- a/Makefile +++ b/Makefile @@ -115,7 +115,7 @@ start: install # Lint the project lint: clean_working_directory $(call header,"Linting code") - @find . -type f -name '*.py' -not -path '$(INSTALL_DIR)/*' -not -path './docs/*' -not -path '$(INSTALL_DIR)/*' | xargs $(INSTALL_DIR)/bin/flake8 + @find . -type f -name '*.py' -not -path '$(INSTALL_DIR)/*' -not -path './docs/*' -not -path './env/*' -not -path '$(INSTALL_DIR)/*' | xargs $(INSTALL_DIR)/bin/flake8 # Auto-format the project format: clean_working_directory diff --git a/README.md b/README.md index 7a98509f..1d9d0561 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,4 @@ -# Dynamic REST - -[![Join the chat at https://gitter.im/dynamic-rest/Lobby](https://badges.gitter.im/dynamic-rest/Lobby.svg)](https://gitter.im/dynamic-rest/Lobby?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) +# Django Dynamic REST [![Circle CI](https://circleci.com/gh/AltSchool/dynamic-rest.svg?style=svg)](https://circleci.com/gh/AltSchool/dynamic-rest) [![PyPi](https://img.shields.io/pypi/v/dynamic-rest.svg)](https://pypi.python.org/pypi/dynamic-rest) @@ -11,27 +9,28 @@ See http://dynamic-rest.readthedocs.org for full documentation. + # Table of Contents -- [Overview](#overview) -- [Maintainers](#maintainers) -- [Requirements](#requirements) -- [Installation](#installation) -- [Demo](#demo) -- [Features](#features) - - [Linked relationships](#linked-relationships) - - [Sideloaded relationships](#sideloaded-relationships) - - [Embedded relationships](#embedded-relationships) - - [Inclusions](#inclusions) - - [Exclusions](#exclusions) - - [Filtering](#filtering) - - [Ordering](#ordering) - - [Directory panel](#directory-panel) - - [Optimizations](#optimizations) -- [Settings](#settings) -- [Compatibility](#compatibility) -- [Contributing](#contributing) -- [License](#license) +- [Overview](#overview) +- [Maintainers](#maintainers) +- [Requirements](#requirements) +- [Installation](#installation) +- [Demo](#demo) +- [Features](#features) + - [Linked relationships](#linked-relationships) + - [Sideloaded relationships](#sideloaded-relationships) + - [Embedded relationships](#embedded-relationships) + - [Inclusions](#inclusions) + - [Exclusions](#exclusions) + - [Filtering](#filtering) + - [Ordering](#ordering) + - [Directory panel](#directory-panel) + - [Optimizations](#optimizations) +- [Settings](#settings) +- [Compatibility](#compatibility) +- [Contributing](#contributing) +- [License](#license) @@ -42,34 +41,39 @@ empower simple RESTful APIs with the flexibility of a graph query language. DREST classes can be used as a drop-in replacement for DRF classes, which offer the following features on top of the standard DRF kit: -* Linked relationships -* Sideloaded relationships -* Embedded relationships -* Inclusions -* Exclusions -* Filtering -* Sorting -* Directory panel for your Browsable API -* Optimizations +- Linked relationships +- Sideloaded relationships +- Embedded relationships +- Inclusions +- Exclusions +- Filtering +- Sorting +- Directory panel for your Browsable API +- Optimizations -DREST was initially written to complement [Ember Data](https://github.com/emberjs/data), -but it can be used to provide fast and flexible CRUD operations to any consumer that supports JSON over HTTP. +DREST was originally written to complement [Ember](https://github.com/emberjs/data)\_\_, but it can be used to provide +fast and flexible CRUD operations to any consumer that supports JSON +over HTTP. ## Maintainers -* [Anthony Leontiev](mailto:ant@altitudelearning.com) -* [Ryo Chijiiwa](mailto:ryo@altitudelearning.com) -* [Savinay Nangalia](mailto:sav@altitudelearning.com) +- [Anthony Leontiev](mailto:aleontiev@tohigherground.com>) +- [Savinay Nangalia](mailto:snangalia@tohigherground.com) +- [Christina D'Astolfo](mailto:cdastolfo@tohigherground.com) + +## Contributors + +- [Ernesto González](mailto:ernesto@hunchat.com) # Requirements -* Python (3.5, 3.6, 3.7) -* Django (1.11, 2.0, 2.1, 2.2) -* Django REST Framework (3.8, 3.9, 3.10, 3.11) +- Python (3.6, 3.7, 3.8) +- Django (2.2, 3.1, 3.2) +- Django REST Framework (3.11, 3.12) # Installation -1) Install using `pip`: +1. Install using `pip`: ```bash pip install dynamic-rest @@ -77,7 +81,7 @@ but it can be used to provide fast and flexible CRUD operations to any consumer (or add `dynamic-rest` to `requirements.txt` or `setup.py`) -2) Add `rest_framework` and `dynamic_rest` to `INSTALLED_APPS` in `settings.py`: +2. Add `rest_framework` and `dynamic_rest` to `INSTALLED_APPS` in `settings.py`: ```python INSTALLED_APPS = ( @@ -88,16 +92,16 @@ but it can be used to provide fast and flexible CRUD operations to any consumer ``` -3) If you want to use the [Directory panel](#directory-panel), replace DRF's browsable API renderer with DREST's -in your settings: - +3. If you want to use the [Directory panel](#directory-panel), replace DRF's browsable API renderer with DREST's + in your settings: + ```python REST_FRAMEWORK = { 'DEFAULT_RENDERER_CLASSES': [ 'rest_framework.renderers.JSONRenderer', 'dynamic_rest.renderers.DynamicBrowsableAPIRenderer', ], -} +} ``` # Demo @@ -105,22 +109,22 @@ REST_FRAMEWORK = { This repository comes with a `tests` package that also serves as a demo application. This application is hosted at https://dynamic-rest.herokuapp.com but can also be run locally: -1) Clone this repository: +1. Clone this repository: ```bash git clone git@github.com:AltSchool/dynamic-rest.git cd dynamic-rest ``` -2) From within the repository root, start the demo server: +2. From within the repository root, start the demo server: ```bash make serve ``` -3) Visit `localhost:9002` in your browser. +3. Visit `localhost:9002` in your browser. -4) To load sample fixture data, run `make fixtures` and restart the server. +4. To load sample fixture data, run `make fixtures` and restart the server. # Features @@ -215,7 +219,7 @@ In DREST, the requirement to eagerly load (or "sideload") relationships can be e For example, in order to fetch a user and sideload their groups: ``` ---> +--> GET /users/1/?include[]=groups.* <-- 200 OK @@ -247,7 +251,7 @@ With DREST, it is possible to sideload as many relationships as you'd like, as d For example, to obtain the user with groups, locations, and groups' locations all sideloaded in the same response: ``` ---> +--> GET /users/1/?include[]=groups.location.*&include[]=location.* <-- 200 OK @@ -282,7 +286,7 @@ For example, to obtain the user with groups, locations, and groups' locations al ## Embedded relationships -If you want your relationships loaded eagerly but don't want them sideloaded in the top-level, you can instruct your serializer to embed relationships instead. +If you want your relationships loaded eagerly but don't want them sideloaded in the top-level, you can instruct your serializer to embed relationships instead. In that case, the demo serializer above would look like this: @@ -303,7 +307,7 @@ class UserSerializer(DynamicModelSerializer): ... and the call above would return a response with relationships embedded in place of the usual ID representation: ``` ---> +--> GET /users/1/?include[]=groups.* <-- 200 OK @@ -329,7 +333,7 @@ In DREST, sideloading is the default because it can produce much smaller payload For example, if you requested a list of 10 users along with their groups, and those users all happened to be in the same groups, the embedded variant would represent each group 10 times. The sideloaded variant would only represent a particular group once, regardless of the number of times that group is referenced. -## Inclusions +## Inclusions You can use the `include[]` feature not only to sideload relationships, but also to load basic fields that are marked "deferred". @@ -346,7 +350,7 @@ class UserSerializer(DynamicModelSerializer): name = 'user' fields = ("id", "name", "location", "groups", "personal_statement") deferred_fields = ("personal_statement", ) - + location = DynamicRelationField('LocationSerializer') groups = DynamicRelationField('GroupSerializer', many=True) @@ -373,7 +377,7 @@ This field will only be returned if requested: } ``` -Note that `include[]=personal_statement` does not have a `.` following the field name as in the previous examples for embedding and sideloading relationships. This allows us to differentiate between cases where we have a deferred relationship and want to include the relationship IDs as opposed to including and also sideloading the relationship. +Note that `include[]=personal_statement` does not have a `.` following the field name as in the previous examples for embedding and sideloading relationships. This allows us to differentiate between cases where we have a deferred relationship and want to include the relationship IDs as opposed to including and also sideloading the relationship. For example, if the user had a deferred "events" relationship, passing `include[]=events` would return an "events" field populated by event IDs, passing `include[]=events.` would sideload or embed the events themselves, and by default, only a link to the events would be returned. This can be useful for large has-many relationships. @@ -453,7 +457,7 @@ You can filter a user by his name (exact match): ... or a partial match: ``` ---> +--> GET /users/?filter{name.icontains}=jo <-- 200 OK @@ -516,13 +520,13 @@ You can filter a user by his name (exact match): The sky is the limit! DREST supports just about every basic filtering scenario and operator that you can use in Django: -* in -* icontains -* istartswith -* range -* lt -* gt -... +- in +- icontains +- istartswith +- range +- lt +- gt + ... See the [full list here](dynamic_rest/filters.py#L153-L176). @@ -558,8 +562,8 @@ DREST adds that in: ## Optimizations Supporting nested sideloading and filtering is expensive and can lead to very poor query performance if implemented naively. -DREST uses Django's [Prefetch](https://docs.djangoproject.com/en/1.9/ref/models/querysets/#django.db.models.Prefetch) object to prevent N+1 query situations and guarantee that your API is performant. -We also optimize the serializer layer to ensure that the conversion of model objects into JSON is as fast as possible. +DREST uses Django's [Prefetch](https://docs.djangoproject.com/en/1.9/ref/models/querysets/#django.db.models.Prefetch) object to prevent N+1 query situations and guarantee that your API is performant. +We also optimize the serializer layer to ensure that the conversion of model objects into JSON is as fast as possible. How fast is it? Here are some [benchmarks](benchmarks) that compare DREST response time to DRF response time. DREST out-performs DRF on every benchmark: @@ -595,7 +599,7 @@ DYNAMIC_REST = { # ENABLE_SERIALIZER_OPTIMIZATIONS: enable/disable representation speedups 'ENABLE_SERIALIZER_OPTIMIZATIONS': True, - # DEFER_MANY_RELATIONS: automatically defer many-relations, unless + # DEFER_MANY_RELATIONS: automatically defer many-relations, unless # `deferred=False` is explicitly set on the field. 'DEFER_MANY_RELATIONS': False, @@ -626,15 +630,15 @@ DYNAMIC_REST = { } ``` -# Compatibility +# Compatibility We actively support the following: -* Python: 2.7, 3.5, 3.6, 3.7 -* Django: 1.11, 2.0, 2.2 -* Django Rest Framework: 3.4 ~ 3.10 +- Python: 3.6, 3.7, 3.8 +- Django: 2.2, 3.1, 3.2 +- Django Rest Framework: 3.11, 3.12 -**Note:** Some combinations are not supported. For up-to-date information on actively supported/tested combinations, see the tox.ini file. +**Note:** Some combinations are not supported. For up-to-date information on actively supported/tested combinations, see the `tox.ini` file. # Contributing diff --git a/README.rst b/README.rst index 3c1e485b..7255c936 100644 --- a/README.rst +++ b/README.rst @@ -1,5 +1,5 @@ Dynamic REST -============ +=================== **Dynamic API extensions for Django REST Framework** @@ -34,12 +34,13 @@ over HTTP. Maintainers ----------- -- `Anthony Leontiev `__ -- `Ryo Chijiiwa `__ +- `Anthony Leontiev `__ +- `Savinay Nangalia `__ +- `Christina D'Astolfo `__ Requirements ============ -- Python (2.7, 3.5, 3.6, 3.7) -- Django (1.11, 2.0, 2.1, 2.2) -- Django REST Framework (3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 3.8, 3.9, 3.10) +- Python (3.6, 3.7, 3.8) +- Django (2.2, 3.1, 3.2) +- Django REST Framework (3.10, 3.11, 3.12) diff --git a/benchmarks.html b/benchmarks.html index 977f0870..7df63af0 100644 --- a/benchmarks.html +++ b/benchmarks.html @@ -36,7 +36,7 @@ verticalAlign: 'middle', borderWidth: 0 }, - series: [{"data": [[3, 0.010598], [14, 0.0129435], [39, 0.0169945], [84, 0.0209405], [155, 0.029427500000000002], [258, 0.0405075], [399, 0.048523], [584, 0.0625845], [819, 0.084913], [1110, 0.100662], [1463, 0.12601800000000002], [1884, 0.156378], [2379, 0.19036199999999998], [2954, 0.223039], [3615, 0.29465399999999997], [4368, 0.356249]], "name": "DREST 1.3.9"}, {"data": [[3, 0.004224], [14, 0.0083045], [39, 0.013636], [84, 0.0233625], [155, 0.034529000000000004], [258, 0.052696], [399, 0.06697], [584, 0.091702], [819, 0.127831], [1110, 0.1734345], [1463, 0.21316000000000002], [1884, 0.2423265], [2379, 0.2996175], [2954, 0.39503699999999997], [3615, 0.4300615], [4368, 0.579607]], "name": "DRF 3.3.0"}] + series: [{"name": "DREST 2.0.0", "data": [[3, 0.0037530000000000003], [14, 0.004215], [39, 0.0047805], [84, 0.005736], [155, 0.006875], [258, 0.0083385], [399, 0.010683000000000002], [584, 0.0135645], [819, 0.016668000000000002], [1110, 0.020622], [1463, 0.025916500000000002], [1884, 0.031551499999999996], [2379, 0.0381205], [2954, 0.0471535], [3615, 0.0551515], [4368, 0.0671345]]}, {"name": "DRF 3.12.4", "data": [[3, 0.0016465], [14, 0.0033355], [39, 0.005826], [84, 0.0093875], [155, 0.014127], [258, 0.0199585], [399, 0.027033], [584, 0.035491499999999995], [819, 0.044836], [1110, 0.056142], [1463, 0.0692645], [1884, 0.085964], [2379, 0.10098850000000001], [2954, 0.122303], [3615, 0.1445205], [4368, 0.16253800000000002]]}] }); }); @@ -75,7 +75,7 @@ verticalAlign: 'middle', borderWidth: 0 }, - series: [{"data": [[256, 0.013802499999999999], [512, 0.022781000000000003], [768, 0.0313405], [1024, 0.043452], [1280, 0.053339], [1536, 0.060793], [1792, 0.07044500000000001], [2048, 0.0799765], [2304, 0.09236649999999999], [2560, 0.09833549999999999], [2816, 0.10974600000000001], [3072, 0.1534385], [3328, 0.1260365], [3584, 0.14711249999999998], [3840, 0.15910649999999998], [4096, 0.1562075]], "name": "DREST 1.3.9"}, {"data": [[256, 0.185573], [512, 0.37659200000000004], [768, 0.5544685], [1024, 0.762219], [1280, 0.9522345], [1536, 1.1424555], [1792, 1.3354335], [2048, 1.4902134999999999], [2304, 1.6737704999999998], [2560, 1.9133445], [2816, 1.9982449999999998], [3072, 2.3125815000000003], [3328, 2.449006], [3584, 2.68817], [3840, 2.7430269999999997], [4096, 2.9553125]], "name": "DRF 3.3.0"}] + series: [{"name": "DREST 2.0.0", "data": [[256, 0.004253], [512, 0.007494499999999999], [768, 0.010120500000000001], [1024, 0.0127455], [1280, 0.0157595], [1536, 0.018013], [1792, 0.0212245], [2048, 0.0240905], [2304, 0.02581], [2560, 0.0287565], [2816, 0.0314165], [3072, 0.0506805], [3328, 0.0382505], [3584, 0.042971], [3840, 0.0428355], [4096, 0.0469565]]}, {"name": "DRF 3.12.4", "data": [[256, 0.08546100000000001], [512, 0.171128], [768, 0.2598395], [1024, 0.33919750000000004], [1280, 0.426328], [1536, 0.5128635], [1792, 0.5989595], [2048, 0.684221], [2304, 0.762429], [2560, 0.8562815], [2816, 0.9428665], [3072, 1.0435575], [3328, 1.1402725], [3584, 1.208104], [3840, 1.29737], [4096, 1.403646]]}] }); }); @@ -114,7 +114,7 @@ verticalAlign: 'middle', borderWidth: 0 }, - series: [{"data": [[20, 0.008997], [72, 0.013781999999999999], [156, 0.018541500000000002], [272, 0.0260965], [420, 0.034469], [600, 0.041933], [812, 0.053789000000000003], [1056, 0.069213], [1332, 0.081873], [1640, 0.097342], [1980, 0.135785], [2352, 0.1346085], [2756, 0.18510549999999998], [3192, 0.175554], [3660, 0.2170925], [4160, 0.2353975]], "name": "DREST 1.3.9"}, {"data": [[20, 0.007074], [72, 0.0140755], [156, 0.0215725], [272, 0.032983], [420, 0.0472835], [600, 0.062189], [812, 0.0772765], [1056, 0.105318], [1332, 0.12905250000000001], [1640, 0.1433465], [1980, 0.1805545], [2352, 0.222216], [2756, 0.25613400000000003], [3192, 0.3107965], [3660, 0.34135099999999996], [4160, 0.38530699999999996]], "name": "DRF 3.3.0"}] + series: [{"name": "DREST 2.0.0", "data": [[20, 0.003006], [72, 0.004069], [156, 0.005076], [272, 0.006527], [420, 0.0086305], [600, 0.010717], [812, 0.013677], [1056, 0.016252], [1332, 0.020575], [1640, 0.024105500000000002], [1980, 0.0269815], [2352, 0.032959], [2756, 0.036424], [3192, 0.043724], [3660, 0.047021], [4160, 0.0538335]]}, {"name": "DRF 3.12.4", "data": [[20, 0.0027085], [72, 0.004940999999999999], [156, 0.0076405], [272, 0.0109555], [420, 0.0148125], [600, 0.018860000000000002], [812, 0.0231945], [1056, 0.0287305], [1332, 0.0355045], [1640, 0.041485], [1980, 0.047808], [2352, 0.056248], [2756, 0.062773], [3192, 0.071774], [3660, 0.07946], [4160, 0.0885375]]}] }); }); diff --git a/benchmarks/drest.py b/benchmarks/drest.py index f421cbbb..608bd0cb 100644 --- a/benchmarks/drest.py +++ b/benchmarks/drest.py @@ -55,6 +55,7 @@ class UserViewSet(viewsets.DynamicModelViewSet): queryset = User.objects.all() serializer_class = UserSerializer + # DREST router router = routers.DynamicRouter() diff --git a/benchmarks/drf.py b/benchmarks/drf.py index 05f11218..634c2baf 100644 --- a/benchmarks/drf.py +++ b/benchmarks/drf.py @@ -71,6 +71,7 @@ class UserWithAllViewSet(viewsets.ModelViewSet): queryset = User.objects.all() serializer_class = UserWithAllSerializer + # DRF routing router = routers.DefaultRouter() diff --git a/benchmarks/test_bench.py b/benchmarks/test_bench.py index 57281cf2..b3fdea2d 100644 --- a/benchmarks/test_bench.py +++ b/benchmarks/test_bench.py @@ -3,6 +3,7 @@ import json import pkg_resources import random +import statistics import string from collections import defaultdict from datetime import datetime @@ -113,21 +114,12 @@ def get_average(values): - l = len(values) - if l == 0: + if len(values) == 0: return 0 elif AVERAGE_TYPE == 'mean': - return sum(values) / l + return statistics.mean(values) elif AVERAGE_TYPE == 'median': - values = sorted(values) - if len(values) % 2 == 1: - return values[((l + 1) / 2) - 1] - else: - return float( - sum( - values[(l / 2) - 1:(l / 2) + 1] - ) - ) / 2.0 + return statistics.median(values) class BenchmarkTest(APITestCase): @@ -257,6 +249,7 @@ def get_random_string(size): for _ in xrange(size) ) + # generate test methods for benchmark in BENCHMARKS: name = benchmark['name'] diff --git a/benchmarks/urls.py b/benchmarks/urls.py index ba38d937..5059c6da 100644 --- a/benchmarks/urls.py +++ b/benchmarks/urls.py @@ -1,10 +1,9 @@ -from django.conf.urls import include, patterns, url +from django.urls import include, path from .drest import router as drest_router from .drf import router as drf_router -urlpatterns = patterns( - '', - url(r'^', include(drf_router.urls)), - url(r'^', include(drest_router.urls)), -) +urlpatterns = [ + path('', include(drf_router.urls)), + path('', include(drest_router.urls)), +] diff --git a/dynamic_rest/__init__.py b/dynamic_rest/__init__.py index 6d445397..4366be93 100644 --- a/dynamic_rest/__init__.py +++ b/dynamic_rest/__init__.py @@ -8,3 +8,5 @@ - Directory panel for the browsable API - Optimizations """ + +default_app_config = "dynamic_rest.apps.DynamicRestConfig" diff --git a/dynamic_rest/apps.py b/dynamic_rest/apps.py new file mode 100644 index 00000000..eeb6d707 --- /dev/null +++ b/dynamic_rest/apps.py @@ -0,0 +1,19 @@ +from django.apps import AppConfig +from django.core.exceptions import ImproperlyConfigured + +from dynamic_rest.conf import settings + + +class DynamicRestConfig(AppConfig): + name = "dynamic_rest" + verbose_name = "Django Dynamic Rest" + + def ready(self): + + if hasattr(settings, + "ENABLE_HASHID_FIELDS") and settings.ENABLE_HASHID_FIELDS: + if not hasattr( + settings, "HASHIDS_SALT") or settings.HASHIDS_SALT is None: + raise ImproperlyConfigured( + "ENABLED_HASHID_FIELDS is True in your settings," + "but no HASHIDS_SALT string was set!") diff --git a/dynamic_rest/bases.py b/dynamic_rest/bases.py index d30eef52..4e51cf6a 100644 --- a/dynamic_rest/bases.py +++ b/dynamic_rest/bases.py @@ -1,5 +1,7 @@ """This module contains base classes for DREST.""" +from dynamic_rest.utils import model_from_definition + class DynamicSerializerBase(object): @@ -57,3 +59,47 @@ def root(self): @resettable_cached_property def context(self): return getattr(self.root, '_context', {}) + + +class GetModelMixin(object): + """ + Mixin to retrieve model hashid + + Implementation from + https://github.com/evenicoulddoit/django-rest-framework-serializer-extensions + """ + + def __init__(self, *args, **kwargs): + self.model = kwargs.pop('model', None) + super(GetModelMixin, self).__init__(*args, **kwargs) + + def get_model(self): + """ + Return the model to generate the HashId for. + + By default, this will equal the model defined within the Meta of the + ModelSerializer, but can be redefined either during initialisation + of the Field, or by providing a get__model method on the + parent serializer. + + The Meta can either explicitly define a model, or provide a + dot-delimited string path to it. + """ + if self.model is None: + custom_fn_name = 'get_{0}_model'.format(self.field_name) + + if hasattr(self.parent, custom_fn_name): + return getattr(self.parent, custom_fn_name)() + else: + try: + return self.parent.Meta.model + except AttributeError: + raise AssertionError( + 'No "model" value passed to field "{0}"'.format( + type(self).__name__ + ) + ) + elif isinstance(self.model, str): + return model_from_definition(self.model) + else: + return self.model diff --git a/dynamic_rest/conf.py b/dynamic_rest/conf.py index 39cad400..a4d483d0 100644 --- a/dynamic_rest/conf.py +++ b/dynamic_rest/conf.py @@ -72,6 +72,13 @@ # Enables caching of serializer fields to speed up serializer usage # Needs to also be configured on a per-serializer basis 'ENABLE_FIELDS_CACHE': False, + + # Enables use of hashid fields + 'ENABLE_HASHID_FIELDS': False, + + # Salt value to salt hash ids. + # Needs to be non-nullable if 'ENABLE_HASHID_FIELDS' is set to True + 'HASHIDS_SALT': None, } @@ -82,7 +89,6 @@ class Settings(object): - def __init__(self, name, defaults, settings, class_attrs=None): self.name = name self.defaults = defaults diff --git a/dynamic_rest/fields/fields.py b/dynamic_rest/fields/fields.py index 71821b01..182adfb4 100644 --- a/dynamic_rest/fields/fields.py +++ b/dynamic_rest/fields/fields.py @@ -4,6 +4,7 @@ import pickle import six +from django.core.exceptions import ObjectDoesNotExist from django.utils.functional import cached_property from rest_framework import fields from rest_framework.exceptions import ValidationError, ParseError @@ -12,15 +13,19 @@ from dynamic_rest.bases import ( CacheableFieldMixin, DynamicSerializerBase, - resettable_cached_property + resettable_cached_property, + GetModelMixin, ) from dynamic_rest.conf import settings from dynamic_rest.fields.common import WithRelationalFieldMixin from dynamic_rest.meta import is_field_remote, get_model_field +from dynamic_rest.utils import ( + external_id_from_model_and_internal_id, + internal_id_from_model_and_external_id, +) class DynamicField(CacheableFieldMixin, fields.Field): - """ Generic field base to capture additional custom field attributes. """ @@ -61,7 +66,6 @@ class DynamicComputedField(DynamicField): class DynamicMethodField(SerializerMethodField, DynamicField): - def reset(self): super(DynamicMethodField, self).reset() if self.method_name == 'get_' + self.field_name: @@ -85,14 +89,14 @@ class DynamicRelationField(WithRelationalFieldMixin, DynamicField): SERIALIZER_KWARGS = set(('many', 'source')) def __init__( - self, - serializer_class, - many=False, - queryset=None, - embed=False, - sideloading=None, - debug=False, - **kwargs + self, + serializer_class, + many=False, + queryset=None, + embed=False, + sideloading=None, + debug=False, + **kwargs ): """ Arguments: @@ -136,18 +140,18 @@ def bind(self, *args, **kwargs): try: model_field = get_model_field(parent_model, self.source) - except: + except BaseException: # model field may not be available for m2o fields with no # related_name model_field = None # Infer `required` and `allow_null` if 'required' not in self.kwargs and ( - remote or ( - model_field and ( - model_field.has_default() or model_field.null - ) + remote or ( + model_field and ( + model_field.has_default() or model_field.null ) + ) ): self.required = False if 'allow_null' not in self.kwargs and getattr( @@ -194,7 +198,7 @@ def _get_cached_serializer(self, args, init_args): 'parent': self.parent.__class__.__name__, 'field': self.field_name, 'args': args, - 'init_args': init_args + 'init_args': init_args, } cache_key = hash(pickle.dumps(key_dict)) @@ -421,3 +425,30 @@ def get_attribute(self, obj): pass return len(data) + + +class DynamicHashIdField(GetModelMixin, DynamicField): + """ + Represents an external ID (computed with hashids). + + Requires the source of the field to be an internal ID, and to provide + a "model" keyword argument. Together these will produce the external ID. + + Based on + https://github.com/evenicoulddoit/django-rest-framework-serializer-extensions + implementation of HashIdField. + """ + + default_error_messages = { + 'malformed_hash_id': 'That is not a valid HashId', + } + + def to_representation(self, value): + return external_id_from_model_and_internal_id(self.get_model(), value) + + def to_internal_value(self, value): + model = self.get_model() + try: + return internal_id_from_model_and_external_id(model, value) + except ObjectDoesNotExist: + self.fail('malformed_hash_id') diff --git a/dynamic_rest/fields/generic.py b/dynamic_rest/fields/generic.py index 7d1e0d90..c1f934aa 100644 --- a/dynamic_rest/fields/generic.py +++ b/dynamic_rest/fields/generic.py @@ -107,7 +107,7 @@ def to_representation(self, instance): if isinstance(r, TaggedDict): r.pk_value = pk_value return r - except: + except BaseException: # This feature should be considered to be in Beta so don't break # if anything unexpected happens. # TODO: Remove once we have more confidence. diff --git a/dynamic_rest/meta.py b/dynamic_rest/meta.py index 94828539..ba405c58 100644 --- a/dynamic_rest/meta.py +++ b/dynamic_rest/meta.py @@ -1,7 +1,6 @@ """Module containing Django meta helpers.""" from itertools import chain -from django import VERSION from django.db.models import ManyToOneRel # tested in 1.9 from django.db.models import OneToOneRel # tested in 1.9 from django.db.models import ( @@ -13,8 +12,6 @@ from dynamic_rest.related import RelatedObject -DJANGO19 = VERSION >= (1, 9) - def is_model_field(model, field_name): """Check whether a given field exists on a model. @@ -46,25 +43,17 @@ def get_model_field(model, field_name): """ meta = model._meta try: - if DJANGO19: - field = meta.get_field(field_name) - else: - field = meta.get_field_by_name(field_name)[0] - return field - except: - if DJANGO19: - related_objs = ( - f for f in meta.get_fields() - if (f.one_to_many or f.one_to_one) - and f.auto_created and not f.concrete - ) - related_m2m_objs = ( - f for f in meta.get_fields(include_hidden=True) - if f.many_to_many and f.auto_created - ) - else: - related_objs = meta.get_all_related_objects() - related_m2m_objs = meta.get_all_related_many_to_many_objects() + return meta.get_field(field_name) + except BaseException: + related_objs = ( + f for f in meta.get_fields() + if (f.one_to_many or f.one_to_one) + and f.auto_created and not f.concrete + ) + related_m2m_objs = ( + f for f in meta.get_fields(include_hidden=True) + if f.many_to_many and f.auto_created + ) related_objects = { o.get_accessor_name(): o @@ -100,10 +89,10 @@ def get_model_field_and_type(model, field_name): # Django 1.9 type_map = [ - (OneToOneField, 'o2o'), - (OneToOneRel, 'o2or'), # is subclass of m2o so check first - (ManyToManyField, 'm2m'), - (ManyToOneRel, 'm2o'), + (OneToOneField, 'o2o'), + (OneToOneRel, 'o2or'), # is subclass of m2o so check first + (ManyToManyField, 'm2m'), + (ManyToOneRel, 'm2o'), (ManyToManyRel, 'm2m'), (ForeignKey, 'fk'), # check last ] @@ -155,7 +144,7 @@ def reverse_m2m_field_name(m2m_field): try: # Django 1.9 return m2m_field.remote_field.name - except: + except BaseException: # Django 1.7 if hasattr(m2m_field, 'rel'): return m2m_field.rel.related_name @@ -171,7 +160,7 @@ def reverse_o2o_field_name(o2or_field): try: # Django 1.9 return o2or_field.remote_field.attname - except: + except BaseException: # Django 1.7 return o2or_field.field.attname @@ -180,7 +169,7 @@ def get_remote_model(field): try: # Django 1.9 return field.remote_field.model - except: + except BaseException: # Django 1.7 if hasattr(field, 'field'): return field.field.model @@ -195,5 +184,5 @@ def get_remote_model(field): def get_model_table(model): try: return model._meta.db_table - except: + except BaseException: return None diff --git a/dynamic_rest/routers.py b/dynamic_rest/routers.py index ec1f994f..8bcf81fa 100644 --- a/dynamic_rest/routers.py +++ b/dynamic_rest/routers.py @@ -190,7 +190,7 @@ def register_resource(self, viewset, namespace=None): resource_key = serializer.get_resource_key() resource_name = serializer.get_name() path_name = serializer.get_plural_name() - except: + except BaseException: import traceback traceback.print_exc() raise Exception( diff --git a/dynamic_rest/serializers.py b/dynamic_rest/serializers.py index 7041c945..ec6f8b00 100644 --- a/dynamic_rest/serializers.py +++ b/dynamic_rest/serializers.py @@ -17,7 +17,7 @@ from dynamic_rest.bases import ( CacheableFieldMixin, DynamicSerializerBase, - resettable_cached_property + resettable_cached_property, ) from dynamic_rest.conf import settings from dynamic_rest.fields import ( @@ -28,6 +28,7 @@ from dynamic_rest.meta import get_model_table from dynamic_rest.processors import SideloadingProcessor, post_process from dynamic_rest.tagged import tag_dict +from dynamic_rest.utils import external_id_from_model_and_internal_id OPTS = { 'ENABLE_FIELDS_CACHE': os.environ.get('ENABLE_FIELDS_CACHE', False) @@ -88,12 +89,10 @@ def id_only(self): def data(self): """Get the data, after performing post-processing if necessary.""" data = super(DynamicListSerializer, self).data - processed_data = ReturnDict( - SideloadingProcessor(self, data).data, - serializer=self - ) if self.child.envelope else ReturnList( - data, - serializer=self + processed_data = ( + ReturnDict(SideloadingProcessor(self, data).data, serializer=self) + if self.child.envelope + else ReturnList(data, serializer=self) ) processed_data = post_process(processed_data) return processed_data @@ -102,8 +101,7 @@ def update(self, queryset, validated_data): lookup_attr = getattr(self.child.Meta, 'update_lookup_field', 'id') lookup_objects = { - str(entry.pop(lookup_attr)): entry - for entry in validated_data + str(entry.pop(lookup_attr)): entry for entry in validated_data } lookup_keys = lookup_objects.keys() @@ -125,8 +123,9 @@ def update(self, queryset, validated_data): if len(lookup_keys) != objects_to_update.count(): raise exceptions.ValidationError( - 'Could not find all objects to update: {} != {}.' - .format(len(lookup_keys), objects_to_update.count()) + 'Could not find all objects to update: {} != {}.'.format( + len(lookup_keys), objects_to_update.count() + ) ) updated_objects = [] @@ -155,6 +154,7 @@ class WithDynamicSerializerMixin( - name - string - plural_name - string - defer_many_relations - bool + - hash_ids - bool - fields - list of strings - deferred_fields - list of strings - immutable_fields - list of strings @@ -180,7 +180,7 @@ def __new__(cls, *args, **kwargs): list_serializer_class = getattr( meta, 'list_serializer_class', - settings.LIST_SERIALIZER_CLASS or DynamicListSerializer + settings.LIST_SERIALIZER_CLASS or DynamicListSerializer, ) if not issubclass(list_serializer_class, DynamicListSerializer): list_serializer_class = DynamicListSerializer @@ -192,19 +192,19 @@ def __new__(cls, *args, **kwargs): ) def __init__( - self, - instance=None, - data=fields.empty, - only_fields=None, - include_fields=None, - exclude_fields=None, - request_fields=None, - sideloading=None, - debug=False, - dynamic=True, - embed=False, - envelope=False, - **kwargs + self, + instance=None, + data=fields.empty, + only_fields=None, + include_fields=None, + exclude_fields=None, + request_fields=None, + sideloading=None, + debug=False, + dynamic=True, + embed=False, + envelope=False, + **kwargs ): """ Custom initializer that builds `request_fields`. @@ -238,8 +238,10 @@ def __init__( # undefined resource fields as null on POST/PUT for field_name, field in six.iteritems(self.get_all_fields()): if ( - field.allow_null is False and field.required is False and - field_name in data and data[field_name] is None + field.allow_null is False and + field.required is False and + field_name in data and + data[field_name] is None ): data.pop(field_name) @@ -284,8 +286,10 @@ def _dynamic_init(self, only_fields, include_fields, exclude_fields): if not self.dynamic: return - if (isinstance(self.request_fields, dict) and - self.request_fields.pop('*', None) is False): + if ( + isinstance(self.request_fields, dict) + and self.request_fields.pop('*', None) is False + ): exclude_fields = '*' only_fields = set(only_fields or []) @@ -338,7 +342,7 @@ def get_name(cls): setattr( cls.Meta, 'name', - inflection.underscore(class_name) if class_name else None + inflection.underscore(class_name) if class_name else None, ) return cls.Meta.name @@ -367,10 +371,7 @@ def get_request_attribute(self, attribute, default=None): ) def get_request_method(self): - return self.get_request_attribute( - 'method', - '' - ).upper() + return self.get_request_attribute('method', '').upper() @resettable_cached_property def _all_fields(self): @@ -388,10 +389,7 @@ def _all_fields(self): self ).get_fields() - if ( - settings.ENABLE_FIELDS_CACHE and - self.ENABLE_FIELDS_CACHE - ): + if settings.ENABLE_FIELDS_CACHE and self.ENABLE_FIELDS_CACHE: FIELDS_CACHE[self.__class__] = all_fields else: all_fields = copy.copy(FIELDS_CACHE[self.__class__]) @@ -419,10 +417,7 @@ def _get_flagged_field_names(self, fields, attr, meta_attr=None): } def _get_deferred_field_names(self, fields): - deferred_fields = self._get_flagged_field_names( - fields, - 'deferred' - ) + deferred_fields = self._get_flagged_field_names(fields, 'deferred') defer_many_relations = ( settings.DEFER_MANY_RELATIONS if not hasattr(self.Meta, 'defer_many_relations') @@ -503,7 +498,7 @@ def get_fields(self): serializer_fields, immutable_field_names, 'read_only', - value=False if self.get_request_method() == 'POST' else True + value=False if self.get_request_method() == 'POST' else True, ) return serializer_fields @@ -564,6 +559,22 @@ def _readable_id_fields(self): ) } + def _get_hash_ids(self): + """ + Check whether ids should be hashed or not. + + Determined by the hash_ids boolean Meta field. + Defaults to False. + + Returns: + Boolean. + """ + + if hasattr(self.Meta, 'hash_ids'): + return self.Meta.hash_ids + else: + return False + def _faster_to_representation(self, instance): """Modified to_representation with optimizations. @@ -589,12 +600,9 @@ def _faster_to_representation(self, instance): # we exclude dynamic fields here because the proper fastquery # dereferencing happens in the `get_attribute` method now - if ( - is_fast and - not isinstance( - field, - (DynamicGenericRelationField, DynamicRelationField) - ) + if is_fast and not isinstance( + field, + (DynamicGenericRelationField, DynamicRelationField) ): if field in id_fields and field.source not in instance: # TODO - make better. @@ -660,7 +668,7 @@ def _to_representation(self, instance): if self.debug: representation['_meta'] = { 'id': instance.pk, - 'type': self.get_plural_name() + 'type': self.get_plural_name(), } # tag the representation with the serializer and instance @@ -681,9 +689,14 @@ def to_representation(self, instance): Otherwise, a tagged data dict representation. """ if self.id_only(): + if self._get_hash_ids(): + return external_id_from_model_and_internal_id( + self.get_model(), instance.pk + ) return instance.pk pk = getattr(instance, 'pk', None) + if not settings.ENABLE_SERIALIZER_OBJECT_CACHE or pk is None: return self._to_representation(instance) else: @@ -699,8 +712,13 @@ def to_internal_value(self, data): # Add update_lookup_field field back to validated data # since super by default strips out read-only fields # hence id will no longer be present in validated_data. - if all((isinstance(self.root, DynamicListSerializer), - id_attr, request_method in ('PUT', 'PATCH'))): + if all( + ( + isinstance(self.root, DynamicListSerializer), + id_attr, + request_method in ('PUT', 'PATCH'), + ) + ): id_field = self.fields[id_attr] id_value = id_field.get_value(data) value[id_attr] = id_value @@ -741,10 +759,7 @@ def data(self): data = SideloadingProcessor( self, data ).data if self.envelope else data - processed_data = ReturnDict( - data, - serializer=self - ) + processed_data = ReturnDict(data, serializer=self) self._processed_data = post_process(processed_data) return self._processed_data diff --git a/dynamic_rest/utils.py b/dynamic_rest/utils.py index 674e17d6..cab18022 100644 --- a/dynamic_rest/utils.py +++ b/dynamic_rest/utils.py @@ -1,5 +1,14 @@ +from django.db import models +from django.contrib.contenttypes.models import ContentType +from django.utils.module_loading import import_string + +from hashids import Hashids + from six import string_types +from dynamic_rest.conf import settings + + FALSEY_STRINGS = ( '0', 'false', @@ -21,3 +30,77 @@ def unpack(content): keys = [k for k in content.keys() if k != 'meta'] unpacked = content[keys[0]] return unpacked + + +def external_id_from_model_and_internal_id(model, internal_id): + """ + Return a hash for the model and internal ID combination. + """ + hashids = Hashids(salt=settings.HASHIDS_SALT) + + if hashids is None: + raise AssertionError( + "To use hashids features you must set " + "ENABLE_HASHID_FIELDS to true " + "and provide a HASHIDS_SALT in your dynamic_rest settings.") + return hashids.encode( + ContentType.objects.get_for_model(model).id, internal_id) + + +def internal_id_from_model_and_external_id(model, external_id): + """ + Return the internal ID from the external ID and model combination. + + Because the HashId is a combination of the model's content type and the + internal ID, we validate here that the external ID decodes as expected, + and that the content type corresponds to the model we're expecting. + """ + hashids = Hashids(salt=settings.HASHIDS_SALT) + + if hashids is None: + raise AssertionError( + "To use hashids features you must set " + "ENABLE_HASHID_FIELDS to true " + "and provide a HASHIDS_SALT in your dynamic_rest settings.") + + try: + content_type_id, instance_id = hashids.decode(external_id) + except (TypeError, ValueError): + raise model.DoesNotExist + + content_type = ContentType.objects.get_for_id(content_type_id) + + if content_type.model_class() != model: + raise model.DoesNotExist + + return instance_id + + +def model_from_definition(model_definition): + """ + Return a Django model corresponding to model_definition. + + Model definition can either be a string defining how to import the model, + or a model class. + + Arguments: + model_definition: (str|django.db.models.Model) + + Returns: + (django.db.models.Model) + + Implementation from + https://github.com/evenicoulddoit/django-rest-framework-serializer-extensions + """ + if isinstance(model_definition, str): + model = import_string(model_definition) + else: + model = model_definition + + try: + assert issubclass(model, models.Model) + except (AssertionError, TypeError): + raise AssertionError( + '"{0}"" is not a Django model'.format(model_definition)) + + return model diff --git a/dynamic_rest/viewsets.py b/dynamic_rest/viewsets.py index af8f85af..7fbdb169 100644 --- a/dynamic_rest/viewsets.py +++ b/dynamic_rest/viewsets.py @@ -138,7 +138,7 @@ def handle_encodings(request): for d in request.data.dicts[1:]: data_as_dict.update(d) request._full_data = data_as_dict - except: + except BaseException: pass return request @@ -230,7 +230,7 @@ def get_request_fields(self): include_fields = self.get_request_feature(self.INCLUDE) exclude_fields = self.get_request_feature(self.EXCLUDE) request_fields = {} - for fields, include in( + for fields, include in ( (include_fields, True), (exclude_fields, False)): if fields is None: diff --git a/install_requires.txt b/install_requires.txt index b64788d5..17013703 100644 --- a/install_requires.txt +++ b/install_requires.txt @@ -1,4 +1,5 @@ -Django>=1.11,<3.1.2 -djangorestframework>=3.8.0,<3.12.0 +Django>=2.2,<3.2 +djangorestframework>=3.11.0,<=3.12.4 inflection>=0.4.0 requests +hashids>=1.3.1 diff --git a/requirements.benchmark.txt b/requirements.benchmark.txt index e6cf3575..9075007d 100644 --- a/requirements.benchmark.txt +++ b/requirements.benchmark.txt @@ -1,13 +1,14 @@ Sphinx==1.3.4 -Django>=1.11.15<2.0.0 +Django>=2.2.12<=3.2.7 +djangorestframework>=3.11.0,<=3.12.4 dj-database-url==0.3.0 django-debug-toolbar==1.7 -flake8==2.4.0 -pytest-cov==1.8.1 +flake8==3.9.2 +pytest-cov==2.5.1 pytest-django==2.8.0 pytest-sugar==0.5.1 -pytest==2.7.2 -psycopg2==2.5.1 +pytest==3.7.1 +psycopg2-binary==2.8.6 tox-pyenv==1.0.2 tox==2.3.1 djay==0.0.8 diff --git a/requirements.txt b/requirements.txt index 236ca7b8..ede372dd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,13 +1,14 @@ dj-database-url==0.5.0 djay>=0.0.8 -flake8==3.5.0 +flake8==3.9.2 +hashids==1.3.1 mock==2.0.0 -psycopg2==2.7.5 +psycopg2-binary==2.8.6 pytest-cov==2.5.1 pytest-django==3.4.1 pytest-sugar==0.9.1 pytest==3.7.1 +six==1.16.0 Sphinx==1.7.5 tox-pyenv==1.1.0 tox==3.14.6 -six diff --git a/setup.py b/setup.py index 3b3e7acd..17ea2e30 100644 --- a/setup.py +++ b/setup.py @@ -1,9 +1,9 @@ from setuptools import find_packages, setup NAME = 'dynamic-rest' -DESCRIPTION = 'Adds Dynamic API support to Django REST Framework.' +DESCRIPTION = 'Dynamic API support to Django REST Framework.' URL = 'http://github.com/AltSchool/dynamic-rest' -VERSION = '2.0.0' +VERSION = '2.1.0' SCRIPTS = ['manage.py'] setup( @@ -22,9 +22,9 @@ 'License :: OSI Approved :: BSD License', 'Programming Language :: Python', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', 'Topic :: Software Development :: Libraries :: Python Modules', ], ) diff --git a/tests/migrations/0006_auto_20210921_1026.py b/tests/migrations/0006_auto_20210921_1026.py new file mode 100644 index 00000000..a45e7484 --- /dev/null +++ b/tests/migrations/0006_auto_20210921_1026.py @@ -0,0 +1,34 @@ +# Generated by Django 3.2.7 on 2021-09-21 10:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tests', '0005_auto_20170712_0759'), + ] + + operations = [ + migrations.AlterField( + model_name='cat', + name='hunting_grounds', + field=models.ManyToManyField( + related_name='annoying_cats', + related_query_name='getoffmylawn', + to='tests.Location'), + ), + migrations.AlterField( + model_name='event', + name='status', + field=models.TextField( + default='current'), + ), + migrations.AlterField( + model_name='user', + name='is_dead', + field=models.BooleanField( + default=False, + null=True), + ), + ] diff --git a/tests/models.py b/tests/models.py index f2b495f5..b3d4d076 100644 --- a/tests/models.py +++ b/tests/models.py @@ -27,7 +27,7 @@ class User(models.Model): 'favorite_pet_type', 'favorite_pet_id', ) - is_dead = models.NullBooleanField(default=False) + is_dead = models.BooleanField(null=True, default=False) class Profile(models.Model): @@ -94,8 +94,9 @@ class Event(models.Model): Event model -- Intentionally missing serializer and viewset, so they can be added as part of a codelab. """ + name = models.TextField() - status = models.TextField(default="current") + status = models.TextField(default='current') location = models.ForeignKey( 'Location', null=True, diff --git a/tests/settings.py b/tests/settings.py index 6df206f8..7b6d9fba 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -16,6 +16,7 @@ if os.environ.get('DATABASE_URL'): # remote database import dj_database_url + DATABASES['default'] = dj_database_url.config() else: # local sqlite database file @@ -25,7 +26,7 @@ 'USER': '', 'PASSWORD': '', 'HOST': '', - 'PORT': '' + 'PORT': '', } INSTALLED_APPS = ( @@ -38,30 +39,41 @@ 'tests', ) +DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' + REST_FRAMEWORK = { + 'DEFAULT_PAGINATION_CLASS': + 'rest_framework.pagination.PageNumberPagination', 'PAGE_SIZE': 50, 'DEFAULT_RENDERER_CLASSES': ( 'rest_framework.renderers.JSONRenderer', - 'dynamic_rest.renderers.DynamicBrowsableAPIRenderer' - ) + 'dynamic_rest.renderers.DynamicBrowsableAPIRenderer', + ), } ROOT_URLCONF = 'tests.urls' STATICFILES_DIRS = ( - os.path.abspath(os.path.join(BASE_DIR, '../dynamic_rest/static')), + os.path.abspath( + os.path.join( + BASE_DIR, '../dynamic_rest/static' + ) + ), ) TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': os.path.abspath(os.path.join(BASE_DIR, - '../dynamic_rest/templates')), + 'DIRS': os.path.abspath( + os.path.join(BASE_DIR, '../dynamic_rest/templates') + ), 'APP_DIRS': True, } ] DYNAMIC_REST = { 'ENABLE_LINKS': True, - 'DEBUG': os.environ.get('DYNAMIC_REST_DEBUG', 'false').lower() == 'true' + 'DEBUG': os.environ.get('DYNAMIC_REST_DEBUG', 'false').lower() == 'true', + 'ENABLE_HASHID_FIELDS': True, + 'HASHIDS_SALT': "It's your kids, Marty!", } diff --git a/tests/test_api.py b/tests/test_api.py index 5eca4663..6553fb33 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,6 +1,7 @@ import datetime import json +import django from django.db import connection from django.test import override_settings import six @@ -618,11 +619,24 @@ def test_get_with_filter_invalid_data(self): url = '/users/?filter{date_of_birth.gt}=0&filter{date_of_birth.lt}=0' response = self.client.get(url) self.assertEqual(400, response.status_code) - self.assertEqual( - ["'0' value has an invalid date format. " - "It must be in YYYY-MM-DD format."], - response.data - ) + if django.VERSION[0] > 2: + from rest_framework.exceptions import ErrorDetail + self.assertEqual( + [ + ErrorDetail( + string='“0” value has an invalid date format. ' + 'It must be in YYYY-MM-DD format.', + code='invalid' + ) + ], + response.data + ) + else: + self.assertEqual( + ["'0' value has an invalid date format. " + "It must be in YYYY-MM-DD format."], + response.data + ) def test_get_with_filter_deferred(self): # Filtering deferred field should work diff --git a/tests/test_fields.py b/tests/test_fields.py new file mode 100644 index 00000000..bd49da46 --- /dev/null +++ b/tests/test_fields.py @@ -0,0 +1,64 @@ +from django.test import TestCase, override_settings + +from rest_framework import serializers + +from dynamic_rest.fields import DynamicHashIdField +from dynamic_rest.utils import ( + external_id_from_model_and_internal_id, +) +from tests.models import Dog + + +@override_settings( + ENABLE_HASHID_FIELDS=True, + HASHIDS_SALT="I guess you guys aren’t ready for that yet, " + "but your kids are gonna love it.", +) +class FieldsTestCase(TestCase): + def test_dynamic_hash_id_field_with_model_parameter(self): + class DogModelTestSerializer(serializers.ModelSerializer): + """ + A custom model serializer simply for testing purposes. + """ + + id = DynamicHashIdField(model=Dog) + + class Meta: + model = Dog + fields = ["id", "name", "fur_color", "origin"] + + dog = Dog.objects.create( + name="Kazan", + fur_color="brown", + origin="Abuelos") + serializer = DogModelTestSerializer(dog) + + self.assertEqual( + serializer.data["id"], + external_id_from_model_and_internal_id( + Dog, + dog.id)) + + def test_dynamic_hash_id_field_without_model_parameter(self): + class DogModelTestSerializer(serializers.ModelSerializer): + """ + A custom model serializer simply for testing purposes. + """ + + id = DynamicHashIdField() + + class Meta: + model = Dog + fields = ["id", "name", "fur_color", "origin"] + + dog = Dog.objects.create( + name="Kazan", + fur_color="brown", + origin="Abuelos") + serializer = DogModelTestSerializer(dog) + + self.assertEqual( + serializer.data["id"], + external_id_from_model_and_internal_id( + Dog, + dog.id)) diff --git a/tests/test_router.py b/tests/test_router.py index e2b3c29e..83e45313 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -1,8 +1,4 @@ -# Backwards compatability for django < 1.10.x -try: - from django.urls import set_script_prefix, clear_script_prefix -except ImportError: - from django.core.urlresolvers import set_script_prefix, clear_script_prefix +from django.urls import set_script_prefix, clear_script_prefix from rest_framework.test import APITestCase diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 00000000..4f5c4f02 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,67 @@ +from django.test import TestCase, override_settings + +from dynamic_rest.utils import ( + is_truthy, + unpack, + internal_id_from_model_and_external_id, + model_from_definition +) +from tests.models import User + + +class UtilsTestCase(TestCase): + def setUp(self): + User.objects.create(name="Marie") + User.objects.create(name="Rosalind") + + def test_is_truthy(self): + self.assertTrue(is_truthy("faux")) + self.assertTrue(is_truthy(1)) + self.assertFalse(is_truthy("0")) + self.assertFalse(is_truthy("False")) + self.assertFalse(is_truthy("false")) + self.assertFalse(is_truthy("")) + + def test_unpack_empty_value(self): + self.assertIsNone(unpack(None)) + + def test_unpack_non_empty_value(self): + content = {"hello": "world", "meta": "worldpeace", "missed": "a 't'"} + self.assertIsNotNone(unpack(content)) + + def test_unpack_meta_first_key(self): + content = {"meta": "worldpeace", "missed": "a 't'"} + self.assertEqual(unpack(content), "a 't'") + + def test_unpack_meta_not_first_key(self): + content = {"hello": "world", "meta": "worldpeace", "missed": "a 't'"} + self.assertEqual(unpack(content), "world") + + @override_settings( + ENABLE_HASHID_FIELDS=True, + HASHIDS_SALT="If my calculations are correct, " + "when this vaby hits 88 miles per hour, " + "you're gonna see some serious s***.", + ) + def test_int_id_from_model_ext_id_obj_does_not_exits( + self): + self.assertRaises( + User.DoesNotExist, + internal_id_from_model_and_external_id, + model=User, + external_id="skdkahh", + ) + + def test_model_from_definition(self): + self.assertEqual(model_from_definition('tests.models.User'), User) + self.assertEqual(model_from_definition(User), User) + self.assertRaises( + AssertionError, + model_from_definition, + model_definition='django.test.override_settings' + ) + self.assertRaises( + AssertionError, + model_from_definition, + model_definition=User() + ) diff --git a/tests/urls.py b/tests/urls.py index 0a31c53f..2c711d1a 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -1,4 +1,4 @@ -from django.conf.urls import include, url +from django.urls import include, path from dynamic_rest.routers import DynamicRouter from tests import viewsets @@ -23,5 +23,5 @@ router.register(r'v1/user_locations', viewsets.UserLocationViewSet) urlpatterns = [ - url(r'^', include(router.urls)) + path('', include(router.urls)) ] diff --git a/tests/viewsets.py b/tests/viewsets.py index 167e0edf..cd04c0dc 100644 --- a/tests/viewsets.py +++ b/tests/viewsets.py @@ -26,7 +26,7 @@ UserLocationSerializer, UserSerializer, ZebraSerializer - ) +) class UserViewSet(DynamicModelViewSet): @@ -77,7 +77,7 @@ def create(self, request, *args, **kwargs): raise exceptions.ValidationError( "request.data is not a dict" ) - except: + except BaseException: pass return response diff --git a/tox.ini b/tox.ini index 9b62fb1a..8cb1648d 100644 --- a/tox.ini +++ b/tox.ini @@ -4,31 +4,27 @@ addopts=--tb=short [tox] envlist = py37-lint, - {py35,py36,py37}-django{111,20,21,22}-drf{38,39,310,311}, + {py36,py37,py38}-django{22,31,32}-drf{311,312}, [testenv] commands = ./runtests.py --fast {posargs} --coverage -rw setenv = PYTHONDONTWRITEBYTECODE=1 deps = - django111: Django==1.11.29 - django20: Django==2.0.13 - django21: Django==2.1.15 django22: Django==2.2.12 - drf38: djangorestframework==3.8.2 - drf39: djangorestframework==3.9.4 - drf310: djangorestframework==3.10.3 + django31: Django==3.1.13 + django32: Django==3.2.7 drf311: djangorestframework==3.11.0 + drf312: djangorestframework==3.12.4 -rrequirements.txt [testenv:py37-lint] commands = ./runtests.py --lintonly -deps = - -rrequirements.txt +deps = -rrequirements.txt -[testenv:py37-drf311-benchmarks] +[testenv:py38-drf312-benchmarks] commands = ./runtests.py --benchmarks deps = - Django==2.2.12 - djangorestframework==3.11.0 + Django==3.2.7 + djangorestframework==3.12.4 -rrequirements.benchmark.txt