Skip to content

Commit

Permalink
Add utils for hashids
Browse files Browse the repository at this point in the history
- Add `utils` to encode and decode `hashids`
- Add tests for `utils`
  • Loading branch information
Ernesto F. González authored Sep 18, 2021
2 parents 0a5169f + 7ef683e commit e5dd176
Show file tree
Hide file tree
Showing 7 changed files with 187 additions and 49 deletions.
2 changes: 2 additions & 0 deletions dynamic_rest/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,5 @@
- Directory panel for the browsable API
- Optimizations
"""

default_app_config = "dynamic_rest.apps.DynamicRestConfig"
18 changes: 18 additions & 0 deletions dynamic_rest/apps.py
Original file line number Diff line number Diff line change
@@ -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."
)
7 changes: 4 additions & 3 deletions dynamic_rest/bases.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
class DynamicSerializerBase(object):

"""Base class for all DREST serializers."""

pass


Expand All @@ -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)
Expand All @@ -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
Expand All @@ -56,4 +57,4 @@ def root(self):

@resettable_cached_property
def context(self):
return getattr(self.root, '_context', {})
return getattr(self.root, "_context", {})
69 changes: 28 additions & 41 deletions dynamic_rest/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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)
86 changes: 82 additions & 4 deletions dynamic_rest/utils.py
Original file line number Diff line number Diff line change
@@ -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",
"",
)


Expand All @@ -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
3 changes: 2 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
51 changes: 51 additions & 0 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -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",
)

0 comments on commit e5dd176

Please sign in to comment.