diff --git a/AUTHORS.rst b/AUTHORS.rst index 3445921..7a4391a 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -99,3 +99,4 @@ | zyegfryed | Éric Araujo | Őry Máté +| Nafees Anwar diff --git a/CHANGES.rst b/CHANGES.rst index 047eaf8..06e005b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -6,6 +6,8 @@ Unreleased - Add support for `Django 3.2` - Drop support for `Django 3.0` +- Added urlsafe token field. + 4.1.1 (2020-12-01) ------------------ - Applied `isort` to codebase (Refs GH-#402) diff --git a/docs/fields.rst b/docs/fields.rst index cdf2a0c..15cc3df 100644 --- a/docs/fields.rst +++ b/docs/fields.rst @@ -180,3 +180,46 @@ or any other ModelForm, default is False. class MyAppModel(models.Model): uuid = UUIDField(primary_key=True, version=4, editable=False) + +UrlsafeTokenField +----------------- + +A ``CharField`` subclass that provides random token generating using +python's ``secrets.token_urlsafe`` as default value. + +If ``editable`` is set to false the field will not be displayed in the admin +or any other ModelForm, default is False. + +``max_length`` specifies the maximum length of the token. The default value is 128. + + +.. code-block:: python + + from django.db import models + from model_utils.fields import UrlsafeTokenField + + + class MyAppModel(models.Model): + uuid = UrlsafeTokenField(editable=False, max_length=128) + + +You can provide your custom token generator using the ``factory`` argument. +``factory`` should be callable. It will raise ``TypeError`` if it is not callable. +``factory`` is called with ``max_length`` argument to generate the token, and should +return a string of specified maximum length. + + +.. code-block:: python + + import uuid + + from django.db import models + from model_utils.fields import UrlsafeTokenField + + + def _token_factory(max_length): + return uuid.uuid4().hex + + + class MyAppModel(models.Model): + uuid = UrlsafeTokenField(max_length=32, factory=_token_factory) diff --git a/model_utils/fields.py b/model_utils/fields.py index c390cce..116b524 100644 --- a/model_utils/fields.py +++ b/model_utils/fields.py @@ -1,4 +1,6 @@ +import secrets import uuid +from collections import Callable from django.conf import settings from django.core.exceptions import ValidationError @@ -309,3 +311,41 @@ class UUIDField(models.UUIDField): kwargs.setdefault('editable', editable) kwargs.setdefault('default', default) super().__init__(*args, **kwargs) + + +class UrlsafeTokenField(models.CharField): + """ + A field for storing a unique token in database. + """ + + def __init__(self, editable=False, max_length=128, factory=None, **kwargs): + """ + Parameters + ---------- + editable: bool + If true token is editable. + max_length: int + Maximum length of the token. + factory: callable + If provided, called with max_length of the field instance to generate token. + + Raises + ------ + TypeError + non-callable value for factory is not supported. + """ + + if factory is not None and not isinstance(factory, Callable): + raise TypeError("'factory' should either be a callable not 'None'") + self._factory = factory + + kwargs.pop('default', None) # passing default value has not effect. + + super().__init__(editable=editable, max_length=max_length, **kwargs) + + def get_default(self): + if self._factory is not None: + return self._factory(self.max_length) + # generate a token of length x1.33 approx. trim up to max length + token = secrets.token_urlsafe(self.max_length)[:self.max_length] + return token diff --git a/tests/test_fields/test_urlsafe_token_field.py b/tests/test_fields/test_urlsafe_token_field.py new file mode 100644 index 0000000..5daaddb --- /dev/null +++ b/tests/test_fields/test_urlsafe_token_field.py @@ -0,0 +1,55 @@ +from unittest.mock import Mock + +from django.db.models import NOT_PROVIDED +from django.test import TestCase + +from model_utils.fields import UrlsafeTokenField + + +class UrlsaftTokenFieldTests(TestCase): + def test_editable_default(self): + field = UrlsafeTokenField() + self.assertFalse(field.editable) + + def test_editable(self): + field = UrlsafeTokenField(editable=True) + self.assertTrue(field.editable) + + def test_max_length_default(self): + field = UrlsafeTokenField() + self.assertEqual(field.max_length, 128) + + def test_max_length(self): + field = UrlsafeTokenField(max_length=256) + self.assertEqual(field.max_length, 256) + + def test_factory_default(self): + field = UrlsafeTokenField() + self.assertIsNone(field._factory) + + def test_factory_not_callable(self): + with self.assertRaises(TypeError): + UrlsafeTokenField(factory='INVALID') + + def test_get_default(self): + field = UrlsafeTokenField() + value = field.get_default() + self.assertEqual(len(value), field.max_length) + + def test_get_default_with_non_default_max_length(self): + field = UrlsafeTokenField(max_length=64) + value = field.get_default() + self.assertEqual(len(value), 64) + + def test_get_default_with_factory(self): + token = 'SAMPLE_TOKEN' + factory = Mock(return_value=token) + field = UrlsafeTokenField(factory=factory) + value = field.get_default() + + self.assertEqual(value, token) + factory.assert_called_once_with(field.max_length) + + def test_no_default_param(self): + field = UrlsafeTokenField(default='DEFAULT') + self.assertIs(field.default, NOT_PROVIDED)