From f1d9e731fcde1864eb70a58aea3a83c85f2ef575 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ernesto=20F=2E=20Gonz=C3=A1lez?= Date: Thu, 16 Sep 2021 00:03:07 +0100 Subject: [PATCH 1/4] Add `hashids-1.3.1` to `requirements.txt` --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 365ea3e4..be178dd6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,4 +10,5 @@ pytest==3.7.1 Sphinx==1.7.5 tox-pyenv==1.1.0 tox==3.14.6 -six==1.16.0 +six==1.16.0 +hashids==1.3.1 From 0e55e3255f99155b212dd8f549482f7cd5d8a496 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ernesto=20F=2E=20Gonz=C3=A1lez?= Date: Thu, 16 Sep 2021 02:05:07 +0100 Subject: [PATCH 2/4] Add `ENABLE_HASHID_FIELDS` and `HASHIDS_SALT` settings --- dynamic_rest/__init__.py | 2 ++ dynamic_rest/apps.py | 18 +++++++++++ dynamic_rest/conf.py | 69 ++++++++++++++++------------------------ 3 files changed, 48 insertions(+), 41 deletions(-) create mode 100644 dynamic_rest/apps.py 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..00e8c38b --- /dev/null +++ b/dynamic_rest/apps.py @@ -0,0 +1,18 @@ +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( + "You have set ENABLE_HASHID_FIELDS to True in your dynamic_rest setting. " + "Then, you must set a HASHIDS_SALT string as well." + ) diff --git a/dynamic_rest/conf.py b/dynamic_rest/conf.py index 39cad400..9dce1a9c 100644 --- a/dynamic_rest/conf.py +++ b/dynamic_rest/conf.py @@ -5,84 +5,71 @@ DYNAMIC_REST = { # DEBUG: enable/disable internal debugging - 'DEBUG': False, - + "DEBUG": False, # ENABLE_BROWSABLE_API: enable/disable the browsable API. # It can be useful to disable it in production. - 'ENABLE_BROWSABLE_API': True, - + "ENABLE_BROWSABLE_API": True, # ENABLE_LINKS: enable/disable relationship links - 'ENABLE_LINKS': True, - + "ENABLE_LINKS": True, # ENABLE_SERIALIZER_CACHE: enable/disable caching of related serializers - 'ENABLE_SERIALIZER_CACHE': True, - + "ENABLE_SERIALIZER_CACHE": True, # ENABLE_SERIALIZER_OBJECT_CACHE: enable/disable caching of serialized # objects within a serializer instance/context. This can yield # significant performance improvements in cases where the same objects # are sideloaded repeatedly. - 'ENABLE_SERIALIZER_OBJECT_CACHE': True, - + "ENABLE_SERIALIZER_OBJECT_CACHE": True, # ENABLE_SERIALIZER_OPTIMIZATIONS: enable/disable representation speedups - 'ENABLE_SERIALIZER_OPTIMIZATIONS': True, - + "ENABLE_SERIALIZER_OPTIMIZATIONS": True, # ENABLE_BULK_PARTIAL_CREATION: enable/disable partial creation in bulk - 'ENABLE_BULK_PARTIAL_CREATION': False, - + "ENABLE_BULK_PARTIAL_CREATION": False, # ENABLE_BULK_UPDATE: enable/disable update in bulk - 'ENABLE_BULK_UPDATE': True, - + "ENABLE_BULK_UPDATE": True, # ENABLE_PATCH_ALL: enable/disable patch by queryset - 'ENABLE_PATCH_ALL': False, - + "ENABLE_PATCH_ALL": False, # DEFER_MANY_RELATIONS: automatically defer many-relations, unless # `deferred=False` is explicitly set on the field. - 'DEFER_MANY_RELATIONS': False, - + "DEFER_MANY_RELATIONS": False, # LIST_SERIALIZER_CLASS: Globally override the list serializer class. # Default is `DynamicListSerializer` and also can be overridden for # each serializer class by setting `Meta.list_serializer_class`. - 'LIST_SERIALIZER_CLASS': None, - + "LIST_SERIALIZER_CLASS": None, # MAX_PAGE_SIZE: global setting for max page size. # Can be overriden at the viewset level. - 'MAX_PAGE_SIZE': None, - + "MAX_PAGE_SIZE": None, # PAGE_QUERY_PARAM: global setting for the pagination query parameter. # Can be overriden at the viewset level. - 'PAGE_QUERY_PARAM': 'page', - + "PAGE_QUERY_PARAM": "page", # PAGE_SIZE: global setting for page size. # Can be overriden at the viewset level. - 'PAGE_SIZE': None, - + "PAGE_SIZE": None, # PAGE_SIZE_QUERY_PARAM: global setting for the page size query parameter. # Can be overriden at the viewset level. - 'PAGE_SIZE_QUERY_PARAM': 'per_page', - + "PAGE_SIZE_QUERY_PARAM": "per_page", # ADDITIONAL_PRIMARY_RESOURCE_PREFIX: String to prefix additional # instances of the primary resource when sideloading. - 'ADDITIONAL_PRIMARY_RESOURCE_PREFIX': '+', - + "ADDITIONAL_PRIMARY_RESOURCE_PREFIX": "+", # Enables host-relative links. Only compatible with resources registered # through the dynamic router. If a resource doesn't have a canonical # path registered, links will default back to being resource-relative urls - 'ENABLE_HOST_RELATIVE_LINKS': True, - + "ENABLE_HOST_RELATIVE_LINKS": True, # Enables caching of serializer fields to speed up serializer usage # Needs to also be configured on a per-serializer basis - 'ENABLE_FIELDS_CACHE': False, + "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, } # Attributes where the value should be a class (or path to a class) CLASS_ATTRS = [ - 'LIST_SERIALIZER_CLASS', + "LIST_SERIALIZER_CLASS", ] class Settings(object): - def __init__(self, name, defaults, settings, class_attrs=None): self.name = name self.defaults = defaults @@ -103,8 +90,8 @@ def _load_class(self, attr, val): if inspect.isclass(val): return val elif isinstance(val, str): - parts = val.split('.') - module_path = '.'.join(parts[:-1]) + parts = val.split(".") + module_path = ".".join(parts[:-1]) class_name = parts[-1] mod = __import__(module_path, fromlist=[class_name]) return getattr(mod, class_name) @@ -133,9 +120,9 @@ def __getattr__(self, attr): def _settings_changed(self, *args, **kwargs): """Handle changes to core settings.""" - setting, value = kwargs['setting'], kwargs['value'] + setting, value = kwargs["setting"], kwargs["value"] if setting == self.name: self._reload(value) -settings = Settings('DYNAMIC_REST', DYNAMIC_REST, django_settings, CLASS_ATTRS) +settings = Settings("DYNAMIC_REST", DYNAMIC_REST, django_settings, CLASS_ATTRS) From 2902ca26158c9a654a05af743ebdaf20441746a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ernesto=20F=2E=20Gonz=C3=A1lez?= Date: Thu, 16 Sep 2021 03:14:06 +0100 Subject: [PATCH 3/4] Lint code --- dynamic_rest/bases.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/dynamic_rest/bases.py b/dynamic_rest/bases.py index d30eef52..3779dceb 100644 --- a/dynamic_rest/bases.py +++ b/dynamic_rest/bases.py @@ -4,6 +4,7 @@ class DynamicSerializerBase(object): """Base class for all DREST serializers.""" + pass @@ -15,7 +16,7 @@ def resettable_cached_property(func): """ def wrapper(self): - if not hasattr(self, '_resettable_cached_properties'): + if not hasattr(self, "_resettable_cached_properties"): self._resettable_cached_properties = {} if func.__name__ not in self._resettable_cached_properties: self._resettable_cached_properties[func.__name__] = func(self) @@ -32,7 +33,7 @@ def cacheable_object(cls): """ def reset(self): - if hasattr(self, '_resettable_cached_properties'): + if hasattr(self, "_resettable_cached_properties"): self._resettable_cached_properties = {} cls.reset = reset @@ -56,4 +57,4 @@ def root(self): @resettable_cached_property def context(self): - return getattr(self.root, '_context', {}) + return getattr(self.root, "_context", {}) From 7ef683eac53326062a836ca57725303321a50450 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ernesto=20F=2E=20Gonz=C3=A1lez?= Date: Thu, 16 Sep 2021 04:33:17 +0100 Subject: [PATCH 4/4] Add hashids utils --- dynamic_rest/utils.py | 86 +++++++++++++++++++++++++++++++++++++++++-- tests/test_utils.py | 51 +++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 tests/test_utils.py diff --git a/dynamic_rest/utils.py b/dynamic_rest/utils.py index 674e17d6..bd075666 100644 --- a/dynamic_rest/utils.py +++ b/dynamic_rest/utils.py @@ -1,9 +1,16 @@ +from django.conf import settings +from django.contrib.contenttypes.models import ContentType +from django.utils.module_loading import import_string + +from hashids import Hashids + from six import string_types + FALSEY_STRINGS = ( - '0', - 'false', - '', + "0", + "false", + "", ) @@ -18,6 +25,77 @@ def unpack(content): # empty values pass through return content - keys = [k for k in content.keys() if k != 'meta'] + 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/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 00000000..00564eef --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,51 @@ +from django.contrib.contenttypes.models import ContentType +from django.test import TestCase, override_settings + +from dynamic_rest.utils import ( + is_truthy, + unpack, + external_id_from_model_and_internal_id, + internal_id_from_model_and_external_id, +) +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_internal_id_from_model_and_external_id_model_object_does_not_exits(self): + self.assertRaises( + User.DoesNotExist, + internal_id_from_model_and_external_id, + model=User, + external_id="skdkahh", + )