Skip to content

Commit

Permalink
Added urlsafe token field
Browse files Browse the repository at this point in the history
  • Loading branch information
nafeesanwar committed Apr 30, 2021
1 parent 57d26ee commit a56d07c
Show file tree
Hide file tree
Showing 5 changed files with 141 additions and 0 deletions.
1 change: 1 addition & 0 deletions AUTHORS.rst
Expand Up @@ -99,3 +99,4 @@
| zyegfryed <zyegfryed@gmail.com>
| Éric Araujo <merwok@netwok.org>
| Őry Máté <ory.mate@cloud.bme.hu>
| Nafees Anwar <h.nafees.anwar@gmail.com>
2 changes: 2 additions & 0 deletions CHANGES.rst
Expand Up @@ -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)
Expand Down
43 changes: 43 additions & 0 deletions docs/fields.rst
Expand Up @@ -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)
40 changes: 40 additions & 0 deletions 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
Expand Down Expand Up @@ -309,3 +311,41 @@ def __init__(self, primary_key=True, version=4, editable=False, *args, **kwargs)
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
55 changes: 55 additions & 0 deletions 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)

0 comments on commit a56d07c

Please sign in to comment.