Skip to content

Commit

Permalink
adds utilities to look up, generate, and detect unique handles across…
Browse files Browse the repository at this point in the history
… multiple models (e.g. Users + Organizations)
  • Loading branch information
jontsai committed Aug 15, 2024
1 parent 81bdec4 commit 56854f5
Show file tree
Hide file tree
Showing 3 changed files with 134 additions and 1 deletion.
21 changes: 20 additions & 1 deletion apps/organizations/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@
)
from htk.models.fk_fields import fk_user
from htk.utils import htk_setting
from htk.utils.handles import (
_default_unique_across,
generate_unique_handle,
)


# isort: off
Expand Down Expand Up @@ -62,7 +66,9 @@ def __str__(self):

class BaseAbstractOrganization(HtkBaseModel, GoogleOrganizationMixin):
name = models.CharField(max_length=128)
handle = models.CharField(max_length=64, unique=True)
handle = models.CharField(
max_length=htk_setting('HTK_HANDLE_MAX_LENGTH'), unique=True
)

class Meta:
abstract = True
Expand All @@ -82,8 +88,21 @@ def json_encode(self):
)
return value

@classmethod
def generate_unique_handle(cls, name):
unique_across = [(cls, 'handle')] + _default_unique_across()

handle = generate_unique_handle(
name,
unique_across=unique_across,
)
return handle

@classmethod
def create_with_owner(cls, user, **kwargs):
if 'handle' not in kwargs:
kwargs['handle'] = cls.generate_unique_handle(kwargs['name'])

organization = cls.objects.create(**kwargs)
organization.add_owner(user)
return organization
Expand Down
2 changes: 2 additions & 0 deletions constants/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
HTK_TEMPLATE_CONTEXT_GENERATOR = 'htk.view_helpers.wrap_data'
HTK_CSS_EXTENSION = 'css'

HTK_HANDLE_MAX_LENGTH = 64

##
# JSON Serialization Settings
HTK_JSON_DECIMAL_SHOULD_QUANTIZE = True
Expand Down
112 changes: 112 additions & 0 deletions utils/handles.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# Python Standard Library Imports
import typing as T
from uuid import uuid4

# Third Party (PyPI) Imports
import rollbar

# Django Imports
from django.contrib.auth import get_user_model

# HTK Imports
from htk.utils import htk_setting
from htk.utils.text.transformers import seo_tokenize


# isort: off


def _default_unique_across() -> list[T.Tuple[type, str]]:
"""Returns the default list of models and fields that a handle
must be unique across.
This is used by `look_up_object_by_unique_handle` and `is_unique_handle`.
Returns a list of tuples, each containing a model and a field name.
"""
return [
(get_user_model(), 'username'),
]


def look_up_object_by_unique_handle(
handle: str,
unique_across: T.Optional[list[T.Tuple[type, str]]] = None,
) -> T.Optional[type]:
"""Looks up an object by its unique handle across a list of models and fields.
Returns the object if found, otherwise None.
"""
if unique_across is None:
unique_across = _default_unique_across()

for model, field in unique_across:
# return the first match
try:
obj = model.objects.get(**{field: handle})
break
except model.DoesNotExist:
obj = None

return obj


def is_unique_handle(
handle: str,
unique_across: T.Optional[list[T.Tuple[type, str]]] = None,
) -> bool:
"""Determines whether a handle is unique across a list of models and fields.
Params:
- `handle` is the handle to check for uniqueness
- `unique_across` a list of tuples, each containing a model and a field name
for which the handle must be unique.
"""
if unique_across is None:
unique_across = _default_unique_across()

is_unique = all(
not model.objects.filter(**{field: handle}).exists()
for model, field in unique_across
)
return is_unique


RANDOM_SUFFIX_LENGTH = 6


def generate_unique_handle(
name: str,
unique_across: T.Optional[list[T.Tuple[type, str]]] = None,
max_attempts: int = 5,
) -> T.Optional[str]:
"""Generates a unique handle based on a name.
If the inital handle is unique, it is returned.
If a handle is not unique, a random suffix is appended to the handle.
"""
base_handle = seo_tokenize(name).replace('-', '_')

handle = base_handle
is_unique = is_unique_handle(base_handle, unique_across=unique_across)

if not is_unique:
# truncate the handle to the max length
# and leave room for a random suffix
max_length = htk_setting('HTK_HANDLE_MAX_LENGTH')
base_handle = base_handle[
: max(max_length - RANDOM_SUFFIX_LENGTH, len(base_handle))
]
else:
pass

attempts = 0
while not is_unique:
handle = base_handle + uuid4().hex[:RANDOM_SUFFIX_LENGTH]
is_unique = is_unique_handle(handle)

if attempts > max_attempts:
rollbar.report_message('Failed to generate unique handle', 'error')
break

return handle

0 comments on commit 56854f5

Please sign in to comment.