django-display-ids 0.1.4__py3-none-any.whl → 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_display_ids/__init__.py +22 -4
- django_display_ids/admin.py +8 -6
- django_display_ids/apps.py +11 -0
- django_display_ids/conf.py +5 -2
- django_display_ids/contrib/drf_spectacular/__init__.py +10 -3
- django_display_ids/contrib/rest_framework/serializers.py +4 -3
- django_display_ids/contrib/rest_framework/views.py +1 -1
- django_display_ids/converters.py +111 -0
- django_display_ids/examples.py +1 -1
- django_display_ids/managers.py +88 -6
- django_display_ids/models.py +3 -3
- django_display_ids/resolver.py +2 -2
- django_display_ids/templatetags/__init__.py +0 -0
- django_display_ids/templatetags/display_ids.py +48 -0
- django_display_ids/views.py +1 -1
- django_display_ids-0.3.0.dist-info/METADATA +130 -0
- django_display_ids-0.3.0.dist-info/RECORD +25 -0
- {django_display_ids-0.1.4.dist-info → django_display_ids-0.3.0.dist-info}/WHEEL +1 -1
- django_display_ids-0.1.4.dist-info/METADATA +0 -422
- django_display_ids-0.1.4.dist-info/RECORD +0 -21
django_display_ids/__init__.py
CHANGED
|
@@ -23,7 +23,10 @@ Example:
|
|
|
23
23
|
display_id_prefix = "inv"
|
|
24
24
|
"""
|
|
25
25
|
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
26
28
|
from .admin import DisplayIDSearchMixin
|
|
29
|
+
from .converters import DisplayIDConverter, DisplayIDOrUUIDConverter, UUIDConverter
|
|
27
30
|
from .encoding import (
|
|
28
31
|
decode_display_id,
|
|
29
32
|
decode_uuid,
|
|
@@ -45,12 +48,29 @@ from .exceptions import (
|
|
|
45
48
|
UnknownPrefixError,
|
|
46
49
|
)
|
|
47
50
|
from .managers import DisplayIDManager, DisplayIDQuerySet
|
|
48
|
-
from .models import DisplayIDMixin, get_model_for_prefix
|
|
49
51
|
from .resolver import resolve_object
|
|
50
52
|
from .typing import DEFAULT_STRATEGIES, StrategyName
|
|
51
53
|
from .views import DisplayIDObjectMixin
|
|
52
54
|
|
|
55
|
+
|
|
56
|
+
def __getattr__(name: str) -> Any:
|
|
57
|
+
"""Lazy import for model-related items to avoid app registry issues."""
|
|
58
|
+
if name == "DisplayIDMixin":
|
|
59
|
+
from .models import DisplayIDMixin
|
|
60
|
+
|
|
61
|
+
return DisplayIDMixin
|
|
62
|
+
if name == "get_model_for_prefix":
|
|
63
|
+
from .models import get_model_for_prefix
|
|
64
|
+
|
|
65
|
+
return get_model_for_prefix
|
|
66
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
67
|
+
|
|
68
|
+
|
|
53
69
|
__all__ = [ # noqa: RUF022 - keep categorized order for readability
|
|
70
|
+
# URL converters
|
|
71
|
+
"DisplayIDConverter",
|
|
72
|
+
"UUIDConverter",
|
|
73
|
+
"DisplayIDOrUUIDConverter",
|
|
54
74
|
# Encoding
|
|
55
75
|
"encode_uuid",
|
|
56
76
|
"decode_uuid",
|
|
@@ -85,10 +105,8 @@ __all__ = [ # noqa: RUF022 - keep categorized order for readability
|
|
|
85
105
|
"DEFAULT_STRATEGIES",
|
|
86
106
|
]
|
|
87
107
|
|
|
88
|
-
__version__ = "0.1.1"
|
|
89
|
-
|
|
90
108
|
|
|
91
|
-
def get_drf_mixin():
|
|
109
|
+
def get_drf_mixin() -> type:
|
|
92
110
|
"""Lazily import the DRF mixin to avoid hard dependency.
|
|
93
111
|
|
|
94
112
|
Returns:
|
django_display_ids/admin.py
CHANGED
|
@@ -4,12 +4,12 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import contextlib
|
|
6
6
|
import uuid
|
|
7
|
-
from typing import TYPE_CHECKING
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
8
|
|
|
9
9
|
from .encoding import decode_display_id
|
|
10
10
|
|
|
11
11
|
if TYPE_CHECKING:
|
|
12
|
-
from django.db.models import QuerySet
|
|
12
|
+
from django.db.models import Model, QuerySet
|
|
13
13
|
from django.http import HttpRequest
|
|
14
14
|
|
|
15
15
|
__all__ = ["DisplayIDSearchMixin"]
|
|
@@ -39,13 +39,15 @@ class DisplayIDSearchMixin:
|
|
|
39
39
|
"""
|
|
40
40
|
|
|
41
41
|
uuid_field: str | None = None
|
|
42
|
+
model: type[Model]
|
|
42
43
|
|
|
43
44
|
def _get_uuid_field(self) -> str:
|
|
44
45
|
"""Get the UUID field name to search."""
|
|
45
46
|
if self.uuid_field is not None:
|
|
46
47
|
return self.uuid_field
|
|
47
48
|
# Try to get from model's uuid_field attribute
|
|
48
|
-
|
|
49
|
+
uuid_field: str | None = getattr(self.model, "uuid_field", None)
|
|
50
|
+
return uuid_field or "id"
|
|
49
51
|
|
|
50
52
|
def _try_parse_uuid(self, value: str) -> uuid.UUID | None:
|
|
51
53
|
"""Try to parse a string as a UUID."""
|
|
@@ -57,16 +59,16 @@ class DisplayIDSearchMixin:
|
|
|
57
59
|
def get_search_results(
|
|
58
60
|
self,
|
|
59
61
|
request: HttpRequest,
|
|
60
|
-
queryset: QuerySet,
|
|
62
|
+
queryset: QuerySet[Any],
|
|
61
63
|
search_term: str,
|
|
62
|
-
) -> tuple[QuerySet, bool]:
|
|
64
|
+
) -> tuple[QuerySet[Any], bool]:
|
|
63
65
|
"""Extend search to handle display IDs and raw UUIDs.
|
|
64
66
|
|
|
65
67
|
Tries to match the search term as:
|
|
66
68
|
1. A display ID (prefix_base62uuid) if it contains an underscore
|
|
67
69
|
2. A raw UUID if it looks like a UUID format
|
|
68
70
|
"""
|
|
69
|
-
queryset, use_distinct = super().get_search_results(
|
|
71
|
+
queryset, use_distinct = super().get_search_results( # type: ignore[misc]
|
|
70
72
|
request, queryset, search_term
|
|
71
73
|
)
|
|
72
74
|
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Django app configuration for django-display-ids."""
|
|
2
|
+
|
|
3
|
+
from django.apps import AppConfig
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DjangoDisplayIdsConfig(AppConfig):
|
|
7
|
+
"""App configuration for django-display-ids."""
|
|
8
|
+
|
|
9
|
+
name = "django_display_ids"
|
|
10
|
+
verbose_name = "Django Display IDs"
|
|
11
|
+
default_auto_field = "django.db.models.BigAutoField"
|
django_display_ids/conf.py
CHANGED
|
@@ -45,5 +45,8 @@ def get_setting(name: str) -> str | tuple[StrategyName, ...]:
|
|
|
45
45
|
if name not in DEFAULTS:
|
|
46
46
|
raise KeyError(f"Unknown setting: {name}")
|
|
47
47
|
|
|
48
|
-
user_settings = getattr(
|
|
49
|
-
|
|
48
|
+
user_settings: dict[str, str | tuple[str, ...]] = getattr(
|
|
49
|
+
settings, "DISPLAY_IDS", {}
|
|
50
|
+
)
|
|
51
|
+
result = user_settings.get(name, DEFAULTS[name])
|
|
52
|
+
return result # type: ignore[return-value]
|
|
@@ -6,16 +6,21 @@ proper OpenAPI schema generation for DisplayIDField.
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
9
11
|
try:
|
|
10
12
|
from drf_spectacular.extensions import OpenApiSerializerFieldExtension
|
|
11
13
|
except ImportError:
|
|
12
14
|
# drf-spectacular not installed, skip extension registration
|
|
13
15
|
pass
|
|
14
16
|
else:
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from drf_spectacular.openapi import AutoSchema
|
|
19
|
+
|
|
15
20
|
from django_display_ids.encoding import ENCODED_UUID_LENGTH, encode_uuid
|
|
16
21
|
from django_display_ids.examples import example_uuid_for_prefix
|
|
17
22
|
|
|
18
|
-
class DisplayIDFieldExtension(OpenApiSerializerFieldExtension):
|
|
23
|
+
class DisplayIDFieldExtension(OpenApiSerializerFieldExtension): # type: ignore[no-untyped-call]
|
|
19
24
|
"""OpenAPI schema extension for DisplayIDField.
|
|
20
25
|
|
|
21
26
|
Generates schema with correct prefix example based on the field's
|
|
@@ -27,7 +32,7 @@ else:
|
|
|
27
32
|
)
|
|
28
33
|
match_subclasses = True
|
|
29
34
|
|
|
30
|
-
def _get_model_from_view(self, auto_schema):
|
|
35
|
+
def _get_model_from_view(self, auto_schema: AutoSchema | None) -> Any:
|
|
31
36
|
"""Try to get model from the view's queryset."""
|
|
32
37
|
if auto_schema is None:
|
|
33
38
|
return None
|
|
@@ -48,7 +53,9 @@ else:
|
|
|
48
53
|
return queryset.model
|
|
49
54
|
return None
|
|
50
55
|
|
|
51
|
-
def map_serializer_field(
|
|
56
|
+
def map_serializer_field(
|
|
57
|
+
self, auto_schema: AutoSchema, direction: str
|
|
58
|
+
) -> dict[str, Any]:
|
|
52
59
|
"""Generate OpenAPI schema for DisplayIDField."""
|
|
53
60
|
# Get prefix from field override or try to get from model
|
|
54
61
|
prefix = self.target._prefix_override
|
|
@@ -95,9 +95,9 @@ class DisplayIDField(serializers.SerializerMethodField):
|
|
|
95
95
|
# If using prefix override, generate display_id with that prefix
|
|
96
96
|
if self._prefix_override is not None:
|
|
97
97
|
# Get uuid_field name from model, then fall back to settings
|
|
98
|
-
uuid_field_name = getattr(obj, "uuid_field", None)
|
|
98
|
+
uuid_field_name: str | None = getattr(obj, "uuid_field", None)
|
|
99
99
|
if uuid_field_name is None:
|
|
100
|
-
uuid_field_name = get_setting("UUID_FIELD")
|
|
100
|
+
uuid_field_name = str(get_setting("UUID_FIELD"))
|
|
101
101
|
uuid_value = getattr(obj, uuid_field_name, None)
|
|
102
102
|
if uuid_value is None:
|
|
103
103
|
raise ValueError(
|
|
@@ -108,7 +108,8 @@ class DisplayIDField(serializers.SerializerMethodField):
|
|
|
108
108
|
|
|
109
109
|
# Use the model's display_id property
|
|
110
110
|
if hasattr(obj, "display_id"):
|
|
111
|
-
|
|
111
|
+
display_id: str = obj.display_id
|
|
112
|
+
return display_id
|
|
112
113
|
|
|
113
114
|
raise ValueError(
|
|
114
115
|
f"Cannot generate display_id: {obj.__class__.__name__} "
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Django URL path converters for display IDs and UUIDs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"DisplayIDConverter",
|
|
7
|
+
"DisplayIDOrUUIDConverter",
|
|
8
|
+
"UUIDConverter",
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DisplayIDConverter:
|
|
13
|
+
"""Path converter for display IDs.
|
|
14
|
+
|
|
15
|
+
Matches the format: {prefix}_{base62} where prefix is 1-16 lowercase
|
|
16
|
+
letters and base62 is exactly 22 alphanumeric characters.
|
|
17
|
+
|
|
18
|
+
Example:
|
|
19
|
+
from django.urls import path, register_converter
|
|
20
|
+
from django_display_ids.converters import DisplayIDConverter
|
|
21
|
+
|
|
22
|
+
register_converter(DisplayIDConverter, "display_id")
|
|
23
|
+
|
|
24
|
+
urlpatterns = [
|
|
25
|
+
path("invoices/<display_id:id>/", InvoiceDetailView.as_view()),
|
|
26
|
+
]
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
regex = r"[a-z]{1,16}_[0-9A-Za-z]{22}"
|
|
30
|
+
|
|
31
|
+
def to_python(self, value: str) -> str:
|
|
32
|
+
"""Convert the URL value to a Python object."""
|
|
33
|
+
return value
|
|
34
|
+
|
|
35
|
+
def to_url(self, value: str) -> str:
|
|
36
|
+
"""Convert a Python object to a URL string."""
|
|
37
|
+
return value
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class UUIDConverter:
|
|
41
|
+
"""Path converter for UUIDs.
|
|
42
|
+
|
|
43
|
+
Matches UUIDs in both hyphenated and unhyphenated formats:
|
|
44
|
+
- 550e8400-e29b-41d4-a716-446655440000 (hyphenated)
|
|
45
|
+
- 550e8400e29b41d4a716446655440000 (unhyphenated)
|
|
46
|
+
|
|
47
|
+
Example:
|
|
48
|
+
from django.urls import path, register_converter
|
|
49
|
+
from django_display_ids.converters import UUIDConverter
|
|
50
|
+
|
|
51
|
+
register_converter(UUIDConverter, "uuid")
|
|
52
|
+
|
|
53
|
+
urlpatterns = [
|
|
54
|
+
path("invoices/<uuid:id>/", InvoiceDetailView.as_view()),
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
Note:
|
|
58
|
+
Django's built-in UUIDConverter only accepts hyphenated UUIDs.
|
|
59
|
+
This converter is more permissive.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
# Hyphenated: 8-4-4-4-12 hex chars with hyphens
|
|
63
|
+
# Unhyphenated: 32 hex chars
|
|
64
|
+
# Note: Parentheses group the alternatives so ^ and $ anchor correctly
|
|
65
|
+
regex = (
|
|
66
|
+
r"(?:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32})"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def to_python(self, value: str) -> str:
|
|
70
|
+
"""Convert the URL value to a Python object."""
|
|
71
|
+
return value
|
|
72
|
+
|
|
73
|
+
def to_url(self, value: str) -> str:
|
|
74
|
+
"""Convert a Python object to a URL string."""
|
|
75
|
+
return value
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class DisplayIDOrUUIDConverter:
|
|
79
|
+
"""Path converter for display IDs or UUIDs.
|
|
80
|
+
|
|
81
|
+
Matches either format:
|
|
82
|
+
- Display ID: {prefix}_{base62}
|
|
83
|
+
- UUID: hyphenated or unhyphenated
|
|
84
|
+
|
|
85
|
+
Example:
|
|
86
|
+
from django.urls import path, register_converter
|
|
87
|
+
from django_display_ids.converters import DisplayIDOrUUIDConverter
|
|
88
|
+
|
|
89
|
+
register_converter(DisplayIDOrUUIDConverter, "display_id_or_uuid")
|
|
90
|
+
|
|
91
|
+
urlpatterns = [
|
|
92
|
+
path("invoices/<display_id_or_uuid:id>/", InvoiceDetailView.as_view()),
|
|
93
|
+
]
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
# Note: Parentheses group the alternatives so ^ and $ anchor correctly
|
|
97
|
+
regex = (
|
|
98
|
+
r"(?:"
|
|
99
|
+
r"[a-z]{1,16}_[0-9A-Za-z]{22}"
|
|
100
|
+
r"|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
|
|
101
|
+
r"|[0-9a-f]{32}"
|
|
102
|
+
r")"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def to_python(self, value: str) -> str:
|
|
106
|
+
"""Convert the URL value to a Python object."""
|
|
107
|
+
return value
|
|
108
|
+
|
|
109
|
+
def to_url(self, value: str) -> str:
|
|
110
|
+
"""Convert a Python object to a URL string."""
|
|
111
|
+
return value
|
django_display_ids/examples.py
CHANGED
|
@@ -26,7 +26,7 @@ def _get_prefix(prefix_or_model: str | type[Model]) -> str:
|
|
|
26
26
|
if isinstance(prefix_or_model, str):
|
|
27
27
|
return prefix_or_model
|
|
28
28
|
# It's a model class
|
|
29
|
-
prefix = getattr(prefix_or_model, "display_id_prefix", None)
|
|
29
|
+
prefix: str | None = getattr(prefix_or_model, "display_id_prefix", None)
|
|
30
30
|
if prefix is None:
|
|
31
31
|
raise ValueError(f"Model {prefix_or_model.__name__} has no display_id_prefix")
|
|
32
32
|
return prefix
|
django_display_ids/managers.py
CHANGED
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
from typing import TYPE_CHECKING, Any, TypeVar
|
|
6
6
|
|
|
7
7
|
from django.db import models
|
|
8
|
+
from django.db.models import Q
|
|
8
9
|
|
|
9
10
|
from .conf import get_setting
|
|
10
11
|
from .encoding import decode_display_id
|
|
@@ -18,6 +19,8 @@ from .exceptions import (
|
|
|
18
19
|
from .strategies import parse_identifier
|
|
19
20
|
|
|
20
21
|
if TYPE_CHECKING:
|
|
22
|
+
from collections.abc import Sequence
|
|
23
|
+
|
|
21
24
|
from .typing import StrategyName
|
|
22
25
|
|
|
23
26
|
__all__ = [
|
|
@@ -91,7 +94,7 @@ class DisplayIDQuerySet(models.QuerySet[M]):
|
|
|
91
94
|
# Query the database
|
|
92
95
|
try:
|
|
93
96
|
return self.get(**{uuid_field: uuid_value})
|
|
94
|
-
except model.DoesNotExist:
|
|
97
|
+
except model.DoesNotExist: # type: ignore[attr-defined]
|
|
95
98
|
raise ObjectNotFoundError(value, model_name=model.__name__) from None
|
|
96
99
|
|
|
97
100
|
def get_by_identifier(
|
|
@@ -140,22 +143,85 @@ class DisplayIDQuerySet(models.QuerySet[M]):
|
|
|
140
143
|
# Execute the query
|
|
141
144
|
try:
|
|
142
145
|
return self.get(**lookup)
|
|
143
|
-
except model.DoesNotExist:
|
|
146
|
+
except model.DoesNotExist: # type: ignore[attr-defined]
|
|
144
147
|
raise ObjectNotFoundError(value, model_name=model.__name__) from None
|
|
145
|
-
except model.MultipleObjectsReturned:
|
|
148
|
+
except model.MultipleObjectsReturned: # type: ignore[attr-defined]
|
|
146
149
|
count = self.filter(**lookup).count()
|
|
147
150
|
raise AmbiguousIdentifierError(value, count) from None
|
|
148
151
|
|
|
152
|
+
def get_by_identifiers(
|
|
153
|
+
self,
|
|
154
|
+
values: Sequence[str],
|
|
155
|
+
*,
|
|
156
|
+
strategies: tuple[StrategyName, ...] | None = None,
|
|
157
|
+
prefix: str | None = None,
|
|
158
|
+
) -> DisplayIDQuerySet[M]:
|
|
159
|
+
"""Get multiple objects by any supported identifier type in a single query.
|
|
160
|
+
|
|
161
|
+
Parses each identifier to determine its type (display ID, UUID, or slug),
|
|
162
|
+
then executes a single database query using `__in` lookups.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
values: A sequence of identifier strings (display IDs, UUIDs, or slugs).
|
|
166
|
+
strategies: Strategies to try. Defaults to settings.
|
|
167
|
+
prefix: Expected display ID prefix for validation.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
A queryset containing matching objects. Order is not guaranteed
|
|
171
|
+
to match input order. Missing identifiers are silently excluded.
|
|
172
|
+
|
|
173
|
+
Raises:
|
|
174
|
+
InvalidIdentifierError: If any identifier cannot be parsed.
|
|
175
|
+
|
|
176
|
+
Example:
|
|
177
|
+
invoices = Invoice.objects.get_by_identifiers([
|
|
178
|
+
'inv_2aUyqjCzEIiEcYMKj7TZtw',
|
|
179
|
+
'inv_7kN3xPqRmLwYvTzJ5HfUaB',
|
|
180
|
+
'550e8400-e29b-41d4-a716-446655440000',
|
|
181
|
+
])
|
|
182
|
+
"""
|
|
183
|
+
if not values:
|
|
184
|
+
return self.none()
|
|
185
|
+
|
|
186
|
+
uuid_field = self._get_uuid_field()
|
|
187
|
+
slug_field = self._get_slug_field()
|
|
188
|
+
expected_prefix = prefix or self._get_model_prefix()
|
|
189
|
+
lookup_strategies = strategies or self._get_strategies()
|
|
190
|
+
|
|
191
|
+
# Collect UUIDs and slugs separately
|
|
192
|
+
uuids: list[Any] = []
|
|
193
|
+
slugs: list[str] = []
|
|
194
|
+
|
|
195
|
+
for value in values:
|
|
196
|
+
result = parse_identifier(
|
|
197
|
+
value, lookup_strategies, expected_prefix=expected_prefix
|
|
198
|
+
)
|
|
199
|
+
if result.strategy in ("uuid", "display_id"):
|
|
200
|
+
uuids.append(result.uuid)
|
|
201
|
+
else:
|
|
202
|
+
slugs.append(result.slug) # type: ignore[arg-type]
|
|
203
|
+
|
|
204
|
+
# Build query with OR conditions
|
|
205
|
+
query = Q()
|
|
206
|
+
if uuids:
|
|
207
|
+
query |= Q(**{f"{uuid_field}__in": uuids})
|
|
208
|
+
if slugs:
|
|
209
|
+
query |= Q(**{f"{slug_field}__in": slugs})
|
|
210
|
+
|
|
211
|
+
return self.filter(query)
|
|
212
|
+
|
|
149
213
|
def _get_uuid_field(self) -> str:
|
|
150
214
|
"""Get the UUID field name for this model."""
|
|
151
215
|
if hasattr(self.model, "_get_uuid_field"):
|
|
152
|
-
|
|
216
|
+
result: str = self.model._get_uuid_field() # type: ignore[attr-defined]
|
|
217
|
+
return result
|
|
153
218
|
return str(get_setting("UUID_FIELD"))
|
|
154
219
|
|
|
155
220
|
def _get_slug_field(self) -> str:
|
|
156
221
|
"""Get the slug field name for this model."""
|
|
157
222
|
if hasattr(self.model, "_get_slug_field"):
|
|
158
|
-
|
|
223
|
+
result: str = self.model._get_slug_field() # type: ignore[attr-defined]
|
|
224
|
+
return result
|
|
159
225
|
return str(get_setting("SLUG_FIELD"))
|
|
160
226
|
|
|
161
227
|
def _get_strategies(self) -> tuple[StrategyName, ...]:
|
|
@@ -166,7 +232,8 @@ class DisplayIDQuerySet(models.QuerySet[M]):
|
|
|
166
232
|
"""Get the display ID prefix from the model, if defined."""
|
|
167
233
|
if hasattr(self.model, "get_display_id_prefix"):
|
|
168
234
|
try:
|
|
169
|
-
|
|
235
|
+
result: str | None = self.model.get_display_id_prefix() # type: ignore[attr-defined]
|
|
236
|
+
return result
|
|
170
237
|
except NotImplementedError:
|
|
171
238
|
return None
|
|
172
239
|
return None
|
|
@@ -210,3 +277,18 @@ class DisplayIDManager(models.Manager[M]):
|
|
|
210
277
|
return self.get_queryset().get_by_identifier(
|
|
211
278
|
value, strategies=strategies, prefix=prefix
|
|
212
279
|
)
|
|
280
|
+
|
|
281
|
+
def get_by_identifiers(
|
|
282
|
+
self,
|
|
283
|
+
values: Sequence[str],
|
|
284
|
+
*,
|
|
285
|
+
strategies: tuple[StrategyName, ...] | None = None,
|
|
286
|
+
prefix: str | None = None,
|
|
287
|
+
) -> DisplayIDQuerySet[M]:
|
|
288
|
+
"""Get multiple objects by any supported identifier type.
|
|
289
|
+
|
|
290
|
+
See DisplayIDQuerySet.get_by_identifiers for details.
|
|
291
|
+
"""
|
|
292
|
+
return self.get_queryset().get_by_identifiers(
|
|
293
|
+
values, strategies=strategies, prefix=prefix
|
|
294
|
+
)
|
django_display_ids/models.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from typing import ClassVar
|
|
5
|
+
from typing import Any, ClassVar
|
|
6
6
|
|
|
7
7
|
from django.db import models
|
|
8
8
|
|
|
@@ -84,7 +84,7 @@ class DisplayIDMixin(models.Model):
|
|
|
84
84
|
class Meta:
|
|
85
85
|
abstract = True
|
|
86
86
|
|
|
87
|
-
def __init_subclass__(cls, **kwargs) -> None:
|
|
87
|
+
def __init_subclass__(cls, **kwargs: Any) -> None:
|
|
88
88
|
"""Register prefix when subclass is created."""
|
|
89
89
|
super().__init_subclass__(**kwargs)
|
|
90
90
|
|
|
@@ -134,4 +134,4 @@ class DisplayIDMixin(models.Model):
|
|
|
134
134
|
return encode_display_id(prefix, uuid_value)
|
|
135
135
|
|
|
136
136
|
# Django admin display configuration
|
|
137
|
-
display_id.fget.short_description = "Display ID"
|
|
137
|
+
display_id.fget.short_description = "Display ID" # type: ignore[attr-defined]
|
django_display_ids/resolver.py
CHANGED
|
@@ -70,8 +70,8 @@ def resolve_object(
|
|
|
70
70
|
# Execute the query
|
|
71
71
|
try:
|
|
72
72
|
return qs.get(**lookup)
|
|
73
|
-
except model.DoesNotExist:
|
|
73
|
+
except model.DoesNotExist: # type: ignore[attr-defined]
|
|
74
74
|
raise ObjectNotFoundError(value, model_name=model.__name__) from None
|
|
75
|
-
except model.MultipleObjectsReturned:
|
|
75
|
+
except model.MultipleObjectsReturned: # type: ignore[attr-defined]
|
|
76
76
|
count = qs.filter(**lookup).count()
|
|
77
77
|
raise AmbiguousIdentifierError(value, count) from None
|
|
File without changes
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Template tags and filters for generating display IDs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
|
|
7
|
+
from django import template
|
|
8
|
+
|
|
9
|
+
from ..encoding import encode_display_id
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"display_id",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
register = template.Library()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@register.filter(name="display_id")
|
|
19
|
+
def display_id(value: uuid.UUID | None, prefix: str) -> str:
|
|
20
|
+
"""Encode a UUID as a display ID.
|
|
21
|
+
|
|
22
|
+
Usage:
|
|
23
|
+
{{ some_uuid|display_id:"inv" }}
|
|
24
|
+
{{ invoice.customer_id|display_id:"cust" }}
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
value: UUID to encode.
|
|
28
|
+
prefix: Display ID prefix (1-16 lowercase letters).
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Display ID string like "inv_2aUyqjCzEIiEcYMKj7TZtw", or empty string
|
|
32
|
+
if value is None.
|
|
33
|
+
|
|
34
|
+
Raises:
|
|
35
|
+
TemplateSyntaxError: If prefix is invalid or value is not a UUID.
|
|
36
|
+
"""
|
|
37
|
+
if value is None:
|
|
38
|
+
return ""
|
|
39
|
+
|
|
40
|
+
if not isinstance(value, uuid.UUID):
|
|
41
|
+
raise template.TemplateSyntaxError(
|
|
42
|
+
f"display_id filter requires a UUID, got {type(value).__name__}"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
return encode_display_id(prefix, value)
|
|
47
|
+
except ValueError as e:
|
|
48
|
+
raise template.TemplateSyntaxError(str(e)) from e
|
django_display_ids/views.py
CHANGED
|
@@ -133,7 +133,7 @@ class DisplayIDObjectMixin:
|
|
|
133
133
|
qs = queryset if queryset is not None else self.get_queryset()
|
|
134
134
|
|
|
135
135
|
try:
|
|
136
|
-
return resolve_object(
|
|
136
|
+
return resolve_object( # type: ignore[no-any-return]
|
|
137
137
|
model=self.model,
|
|
138
138
|
value=str(value),
|
|
139
139
|
strategies=self._get_strategies(),
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: django-display-ids
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Stripe-like prefixed IDs for Django. Works with existing UUIDs — no schema changes.
|
|
5
|
+
Keywords: django,stripe,uuid,base62,prefixed-id,drf,shortuuid,nanoid,ulid
|
|
6
|
+
License: MIT
|
|
7
|
+
Classifier: Development Status :: 4 - Beta
|
|
8
|
+
Classifier: Framework :: Django
|
|
9
|
+
Classifier: Framework :: Django :: 4.2
|
|
10
|
+
Classifier: Framework :: Django :: 5.2
|
|
11
|
+
Classifier: Framework :: Django :: 6.0
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Dist: django>=4.2
|
|
19
|
+
Requires-Python: >=3.12
|
|
20
|
+
Project-URL: Documentation, https://django-display-ids.readthedocs.io/
|
|
21
|
+
Project-URL: Repository, https://github.com/josephabrahams/django-display-ids
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# django-display-ids
|
|
25
|
+
|
|
26
|
+
[](https://pypi.org/project/django-display-ids/)
|
|
27
|
+
[](https://pypi.org/project/django-display-ids/)
|
|
28
|
+
[](https://pypi.org/project/django-display-ids/)
|
|
29
|
+
[](https://github.com/josephabrahams/django-display-ids/actions/workflows/ci.yml)
|
|
30
|
+
[](https://codecov.io/gh/josephabrahams/django-display-ids)
|
|
31
|
+
[](https://django-display-ids.readthedocs.io/)
|
|
32
|
+
[](https://github.com/josephabrahams/django-display-ids/blob/main/LICENSE)
|
|
33
|
+
|
|
34
|
+
Stripe-like prefixed IDs for Django. Works with existing UUIDs — no schema changes.
|
|
35
|
+
|
|
36
|
+
## Why?
|
|
37
|
+
|
|
38
|
+
UUIDv7 (native in Python 3.14+) offers excellent database performance with time-ordered indexing. But they lack context — seeing `550e8400-e29b-41d4-a716-446655440000` in a URL or log doesn't tell you what kind of object it refers to.
|
|
39
|
+
|
|
40
|
+
Display IDs like `inv_2aUyqjCzEIiEcYMKj7TZtw` are more useful: the prefix identifies the object type at a glance, and they're compact and URL-safe. But storing display IDs in the database is far less efficient than native UUIDs.
|
|
41
|
+
|
|
42
|
+
Different consumers have different needs:
|
|
43
|
+
- **Humans** prefer slugs (`my-invoice`) or display IDs (`inv_xxx`)
|
|
44
|
+
- **APIs and integrations** work well with UUIDs
|
|
45
|
+
|
|
46
|
+
This library gives you the best of both worlds: accept any format in your URLs and API endpoints, then translate to an efficient UUID lookup in the database. Store UUIDs, expose whatever format your users need.
|
|
47
|
+
|
|
48
|
+
## Installation
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install django-display-ids
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Add to `INSTALLED_APPS`:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
INSTALLED_APPS = [
|
|
58
|
+
# ...
|
|
59
|
+
"django_display_ids",
|
|
60
|
+
]
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Quick Start
|
|
64
|
+
|
|
65
|
+
**Django views:**
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from django.views.generic import DetailView
|
|
69
|
+
from django_display_ids import DisplayIDObjectMixin
|
|
70
|
+
|
|
71
|
+
class InvoiceDetailView(DisplayIDObjectMixin, DetailView):
|
|
72
|
+
model = Invoice
|
|
73
|
+
lookup_param = "id"
|
|
74
|
+
lookup_strategies = ("display_id", "uuid", "slug")
|
|
75
|
+
display_id_prefix = "inv"
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
**Django REST Framework:**
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from rest_framework.viewsets import ModelViewSet
|
|
82
|
+
from django_display_ids.contrib.rest_framework import DisplayIDLookupMixin
|
|
83
|
+
|
|
84
|
+
class InvoiceViewSet(DisplayIDLookupMixin, ModelViewSet):
|
|
85
|
+
queryset = Invoice.objects.all()
|
|
86
|
+
serializer_class = InvoiceSerializer
|
|
87
|
+
lookup_url_kwarg = "id"
|
|
88
|
+
lookup_strategies = ("display_id", "uuid", "slug")
|
|
89
|
+
display_id_prefix = "inv"
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Now your views accept:
|
|
93
|
+
- `inv_2aUyqjCzEIiEcYMKj7TZtw` (display ID)
|
|
94
|
+
- `550e8400-e29b-41d4-a716-446655440000` (UUID)
|
|
95
|
+
- `my-invoice-slug` (slug)
|
|
96
|
+
|
|
97
|
+
**Templates:**
|
|
98
|
+
|
|
99
|
+
```django
|
|
100
|
+
{% load display_ids %}
|
|
101
|
+
|
|
102
|
+
{{ invoice.display_id }} {# inv_2aUyqjCzEIiEcYMKj7TZtw #}
|
|
103
|
+
{{ order.customer_id|display_id:"cust" }} {# encode any UUID #}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Features
|
|
107
|
+
|
|
108
|
+
- **Multiple identifier formats**: display ID (`prefix_base62uuid`), UUID (v4/v7), slug
|
|
109
|
+
- **Framework support**: Django CBVs and Django REST Framework
|
|
110
|
+
- **Template filter**: Encode UUIDs as display IDs in templates
|
|
111
|
+
- **Zero model changes required**: Works with any existing UUID field
|
|
112
|
+
- **OpenAPI integration**: Automatic schema generation with drf-spectacular
|
|
113
|
+
|
|
114
|
+
## Documentation
|
|
115
|
+
|
|
116
|
+
Full documentation at [django-display-ids.readthedocs.io](https://django-display-ids.readthedocs.io/).
|
|
117
|
+
|
|
118
|
+
## Contributing
|
|
119
|
+
|
|
120
|
+
See the [contributing guide](https://django-display-ids.readthedocs.io/en/latest/contributing.html).
|
|
121
|
+
|
|
122
|
+
## Related Projects
|
|
123
|
+
|
|
124
|
+
If you need ID generation and storage (custom model fields), consider:
|
|
125
|
+
|
|
126
|
+
- **[django-prefix-id](https://github.com/jaddison/django-prefix-id)** — PrefixIDField that generates and stores base62-encoded UUIDs
|
|
127
|
+
- **[django-spicy-id](https://github.com/mik3y/django-spicy-id)** — Drop-in AutoField replacement
|
|
128
|
+
- **[django-charid-field](https://github.com/yunojuno/django-charid-field)** — CharField wrapper supporting cuid, ksuid, ulid
|
|
129
|
+
|
|
130
|
+
**django-display-ids** works with existing UUID fields and handles resolution only — no migrations required.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
django_display_ids/__init__.py,sha256=2qX0YC7Jwpoom5rLOhvOewR3GQEIbUhu5ZYwjfEoklo,3207
|
|
2
|
+
django_display_ids/admin.py,sha256=_voqWbr8AwPRC_uCTJWTcEhAhc7RZUgvs7DyVsutDuw,3046
|
|
3
|
+
django_display_ids/apps.py,sha256=UqblGiYNONOIEH-giEAuKp1YDgxl2yf0jS0ELMj1iig,315
|
|
4
|
+
django_display_ids/conf.py,sha256=Mg5ZIhTRSF5dzNO_iYndoF4_g5Umo-5oUw1vB76M7fw,1230
|
|
5
|
+
django_display_ids/contrib/__init__.py,sha256=sxGJK8Whb6cL7RqACqGRYrIZvaMwP3l6dYk3mIYWzDY,62
|
|
6
|
+
django_display_ids/contrib/drf_spectacular/__init__.py,sha256=21n56CH7tp2lKq4EGW99KVEgVB9fJ5GnfllUW_KrIe8,4003
|
|
7
|
+
django_display_ids/contrib/rest_framework/__init__.py,sha256=Xun6zMhCZzQJZQ7ywvBYoKhTJIZEV84AntrbSWfBjYI,1567
|
|
8
|
+
django_display_ids/contrib/rest_framework/serializers.py,sha256=Jp-z7qHafxkGNYv30YC9rrqLt936SrhONJv3rqfQaC0,4049
|
|
9
|
+
django_display_ids/contrib/rest_framework/views.py,sha256=nDaze7MJwVqL0MrxQLOur3sPVrRXud_WT4ijGR02jDY,6087
|
|
10
|
+
django_display_ids/converters.py,sha256=9xP3vKW1keN1glJvwmZun0BaBRLMKio0aD7Coc888ms,3197
|
|
11
|
+
django_display_ids/encoding.py,sha256=csIwUZaQKSOLwRU6-DWGTNGvSxmroyK0Yt7TBCo0AFE,2945
|
|
12
|
+
django_display_ids/examples.py,sha256=gap5NNPTmE7B5uxiYKoMoK8G-OEtL1Ek0W039l6oJ9I,2689
|
|
13
|
+
django_display_ids/exceptions.py,sha256=nmyRfpsqVvz226Zcu_QANwr8MudbfoX09mAgOCwuPuQ,3022
|
|
14
|
+
django_display_ids/managers.py,sha256=PymcK4BZL6UsUOtoloHP34MCRNmvNHSKEcOImhZxGag,9779
|
|
15
|
+
django_display_ids/models.py,sha256=r2SGrP-6g2LeiZZ4yC1Zp8CcJQ5VvpXS8kQMXl2FBgU,4196
|
|
16
|
+
django_display_ids/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
17
|
+
django_display_ids/resolver.py,sha256=TJub6nT6JFThanxETbH8kXVliScjFiksD2kGpu0OvXA,2554
|
|
18
|
+
django_display_ids/strategies.py,sha256=Rq00-AW_FB8-K04u2oBK5J6kPiYgsE3TdYlLyK_zro0,4436
|
|
19
|
+
django_display_ids/templatetags/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
20
|
+
django_display_ids/templatetags/display_ids.py,sha256=4KHE8r8mgSKb7LgIuXJaJB_3UGrzRZvTdLqSCYQtb5I,1157
|
|
21
|
+
django_display_ids/typing.py,sha256=2O3kT7XKkiE7WI9A5KkILPM-Zi7-zCy5gVvXQL_J2mI,478
|
|
22
|
+
django_display_ids/views.py,sha256=-y_Zwo4QLU0lPRPjABpijsze5vsG0CBvJtrVwVtuLwM,5127
|
|
23
|
+
django_display_ids-0.3.0.dist-info/WHEEL,sha256=fAguSjoiATBe7TNBkJwOjyL1Tt4wwiaQGtNtjRPNMQA,80
|
|
24
|
+
django_display_ids-0.3.0.dist-info/METADATA,sha256=9QObcv7ll6Nt2c-WzRF-KCm3olO0RNheX-xKUfqzLEc,5299
|
|
25
|
+
django_display_ids-0.3.0.dist-info/RECORD,,
|
|
@@ -1,422 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.3
|
|
2
|
-
Name: django-display-ids
|
|
3
|
-
Version: 0.1.4
|
|
4
|
-
Summary: Stripe-like prefixed IDs for Django. Works with existing UUIDs — no schema changes.
|
|
5
|
-
Keywords: django,stripe,uuid,base62,prefixed-id,drf,shortuuid,nanoid,ulid
|
|
6
|
-
License: ISC
|
|
7
|
-
Classifier: Development Status :: 4 - Beta
|
|
8
|
-
Classifier: Framework :: Django
|
|
9
|
-
Classifier: Framework :: Django :: 4.2
|
|
10
|
-
Classifier: Framework :: Django :: 5.2
|
|
11
|
-
Classifier: Framework :: Django :: 6.0
|
|
12
|
-
Classifier: License :: OSI Approved :: ISC License (ISCL)
|
|
13
|
-
Classifier: Programming Language :: Python :: 3
|
|
14
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
-
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
-
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
-
Classifier: Typing :: Typed
|
|
18
|
-
Requires-Dist: django>=4.2
|
|
19
|
-
Requires-Python: >=3.12
|
|
20
|
-
Project-URL: Homepage, https://joseph.is/django-display-ids
|
|
21
|
-
Description-Content-Type: text/markdown
|
|
22
|
-
|
|
23
|
-
# django-display-ids
|
|
24
|
-
|
|
25
|
-
Stripe-like prefixed IDs for Django. Works with existing UUIDs — no schema changes.
|
|
26
|
-
|
|
27
|
-
Display IDs are human-friendly identifiers like `inv_2aUyqjCzEIiEcYMKj7TZtw` — a short prefix indicating the object type, followed by a base62-encoded UUID. This format, popularized by Stripe, makes IDs recognizable at a glance while remaining URL-safe and compact.
|
|
28
|
-
|
|
29
|
-
This library focuses on **lookup only** — it works with your existing UUID fields and requires no migrations or schema changes.
|
|
30
|
-
|
|
31
|
-
## Installation
|
|
32
|
-
|
|
33
|
-
```bash
|
|
34
|
-
pip install django-display-ids
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
No `INSTALLED_APPS` entry required — just import and use.
|
|
38
|
-
|
|
39
|
-
## Quick Start
|
|
40
|
-
|
|
41
|
-
```python
|
|
42
|
-
from django.views.generic import DetailView
|
|
43
|
-
from django_display_ids import DisplayIDObjectMixin
|
|
44
|
-
|
|
45
|
-
class InvoiceDetailView(DisplayIDObjectMixin, DetailView):
|
|
46
|
-
model = Invoice
|
|
47
|
-
lookup_param = "id"
|
|
48
|
-
lookup_strategies = ("display_id", "uuid")
|
|
49
|
-
display_id_prefix = "inv"
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
```python
|
|
53
|
-
# urls.py
|
|
54
|
-
urlpatterns = [
|
|
55
|
-
path("invoices/<str:id>/", InvoiceDetailView.as_view()),
|
|
56
|
-
]
|
|
57
|
-
```
|
|
58
|
-
|
|
59
|
-
Now your view accepts both formats:
|
|
60
|
-
- `inv_2aUyqjCzEIiEcYMKj7TZtw` (display ID)
|
|
61
|
-
- `550e8400-e29b-41d4-a716-446655440000` (UUID)
|
|
62
|
-
|
|
63
|
-
## Features
|
|
64
|
-
|
|
65
|
-
- **Multiple identifier formats**: display ID (`prefix_base62uuid`), UUID (v4/v7), slug
|
|
66
|
-
- **Framework support**: Django CBVs and Django REST Framework
|
|
67
|
-
- **Zero model changes required**: Works with any existing UUID field
|
|
68
|
-
- **Stateless**: Pure lookup, no database writes
|
|
69
|
-
|
|
70
|
-
## Usage
|
|
71
|
-
|
|
72
|
-
### Django Class-Based Views
|
|
73
|
-
|
|
74
|
-
```python
|
|
75
|
-
from django.views.generic import DetailView, UpdateView, DeleteView
|
|
76
|
-
from django_display_ids import DisplayIDObjectMixin
|
|
77
|
-
|
|
78
|
-
class InvoiceDetailView(DisplayIDObjectMixin, DetailView):
|
|
79
|
-
model = Invoice
|
|
80
|
-
lookup_param = "id"
|
|
81
|
-
lookup_strategies = ("display_id", "uuid")
|
|
82
|
-
display_id_prefix = "inv"
|
|
83
|
-
|
|
84
|
-
# Works with any view that uses get_object()
|
|
85
|
-
class InvoiceUpdateView(DisplayIDObjectMixin, UpdateView):
|
|
86
|
-
model = Invoice
|
|
87
|
-
lookup_param = "id"
|
|
88
|
-
display_id_prefix = "inv"
|
|
89
|
-
```
|
|
90
|
-
|
|
91
|
-
### Django REST Framework
|
|
92
|
-
|
|
93
|
-
```python
|
|
94
|
-
from rest_framework.viewsets import ModelViewSet
|
|
95
|
-
from django_display_ids.contrib.rest_framework import DisplayIDLookupMixin
|
|
96
|
-
|
|
97
|
-
class InvoiceViewSet(DisplayIDLookupMixin, ModelViewSet):
|
|
98
|
-
queryset = Invoice.objects.all()
|
|
99
|
-
serializer_class = InvoiceSerializer
|
|
100
|
-
lookup_url_kwarg = "id"
|
|
101
|
-
lookup_strategies = ("display_id", "uuid")
|
|
102
|
-
display_id_prefix = "inv"
|
|
103
|
-
```
|
|
104
|
-
|
|
105
|
-
Or with APIView:
|
|
106
|
-
|
|
107
|
-
```python
|
|
108
|
-
from rest_framework.views import APIView
|
|
109
|
-
from rest_framework.response import Response
|
|
110
|
-
from django_display_ids.contrib.rest_framework import DisplayIDLookupMixin
|
|
111
|
-
|
|
112
|
-
class InvoiceView(DisplayIDLookupMixin, APIView):
|
|
113
|
-
lookup_url_kwarg = "id"
|
|
114
|
-
lookup_strategies = ("display_id", "uuid")
|
|
115
|
-
display_id_prefix = "inv"
|
|
116
|
-
|
|
117
|
-
def get_queryset(self):
|
|
118
|
-
return Invoice.objects.all()
|
|
119
|
-
|
|
120
|
-
def get(self, request, *args, **kwargs):
|
|
121
|
-
invoice = self.get_object()
|
|
122
|
-
return Response({"id": str(invoice.id)})
|
|
123
|
-
```
|
|
124
|
-
|
|
125
|
-
#### Serializer Field
|
|
126
|
-
|
|
127
|
-
Include `display_id` in your API responses:
|
|
128
|
-
|
|
129
|
-
```python
|
|
130
|
-
from rest_framework import serializers
|
|
131
|
-
from django_display_ids.contrib.rest_framework import DisplayIDField
|
|
132
|
-
|
|
133
|
-
class InvoiceSerializer(serializers.Serializer):
|
|
134
|
-
id = serializers.UUIDField(read_only=True)
|
|
135
|
-
display_id = DisplayIDField()
|
|
136
|
-
name = serializers.CharField()
|
|
137
|
-
|
|
138
|
-
# Output: {"id": "...", "display_id": "inv_2aUyqjCzEIiEcYMKj7TZtw", ...}
|
|
139
|
-
```
|
|
140
|
-
|
|
141
|
-
The field reads `display_id_prefix` from the model. You can override it:
|
|
142
|
-
|
|
143
|
-
```python
|
|
144
|
-
display_id = DisplayIDField(prefix="inv") # Use custom prefix
|
|
145
|
-
```
|
|
146
|
-
|
|
147
|
-
Prefix must be 1-16 lowercase letters. Invalid prefixes raise `ValueError` at initialization.
|
|
148
|
-
|
|
149
|
-
**OpenAPI/drf-spectacular**: When drf-spectacular is installed, the field automatically generates proper schema with prefix-specific examples (e.g., `inv_2aUyqjCzEIiEcYMKj7TZtw`). The prefix is resolved from (in order): field's `prefix=` argument, serializer's `Meta.model.display_id_prefix`, or the view's queryset model.
|
|
150
|
-
|
|
151
|
-
#### OpenAPI Parameter Descriptions
|
|
152
|
-
|
|
153
|
-
For consistent API documentation, use the provided description helpers:
|
|
154
|
-
|
|
155
|
-
```python
|
|
156
|
-
from django_display_ids.contrib.rest_framework import id_param_description
|
|
157
|
-
from drf_spectacular.utils import extend_schema, OpenApiParameter
|
|
158
|
-
from drf_spectacular.types import OpenApiTypes
|
|
159
|
-
|
|
160
|
-
@extend_schema(
|
|
161
|
-
parameters=[
|
|
162
|
-
OpenApiParameter(
|
|
163
|
-
"id",
|
|
164
|
-
OpenApiTypes.STR,
|
|
165
|
-
OpenApiParameter.PATH,
|
|
166
|
-
description=id_param_description("inv"),
|
|
167
|
-
# -> "Identifier: display_id (inv_xxx) or UUID"
|
|
168
|
-
)
|
|
169
|
-
],
|
|
170
|
-
)
|
|
171
|
-
class InvoiceViewSet(DisplayIDLookupMixin, ModelViewSet):
|
|
172
|
-
...
|
|
173
|
-
```
|
|
174
|
-
|
|
175
|
-
For endpoints that also accept slugs:
|
|
176
|
-
|
|
177
|
-
```python
|
|
178
|
-
description=id_param_description("app", with_slug=True)
|
|
179
|
-
# -> "Identifier: display_id (app_xxx), UUID, or slug"
|
|
180
|
-
```
|
|
181
|
-
|
|
182
|
-
Generic constants are also available:
|
|
183
|
-
|
|
184
|
-
```python
|
|
185
|
-
from django_display_ids.contrib.rest_framework import (
|
|
186
|
-
ID_PARAM_DESCRIPTION, # "Identifier: display_id (prefix_xxx) or UUID"
|
|
187
|
-
ID_PARAM_DESCRIPTION_WITH_SLUG, # "Identifier: display_id (prefix_xxx), UUID, or slug"
|
|
188
|
-
)
|
|
189
|
-
```
|
|
190
|
-
|
|
191
|
-
#### Deterministic Examples for OpenAPI
|
|
192
|
-
|
|
193
|
-
Generate consistent example UUIDs and display IDs for OpenAPI schemas:
|
|
194
|
-
|
|
195
|
-
```python
|
|
196
|
-
from django_display_ids import example_uuid, example_display_id
|
|
197
|
-
|
|
198
|
-
# Generate deterministic UUID for a prefix
|
|
199
|
-
example_uuid("inv")
|
|
200
|
-
# -> UUID('a172cedc-ae47-474b-615c-54d510a5d84a')
|
|
201
|
-
|
|
202
|
-
# Generate deterministic display ID
|
|
203
|
-
example_display_id("inv")
|
|
204
|
-
# -> "inv_4ueEO5Nz4X7u9qc3FVHokM"
|
|
205
|
-
|
|
206
|
-
# Also works with model classes
|
|
207
|
-
example_uuid(Invoice) # Uses Invoice.display_id_prefix
|
|
208
|
-
```
|
|
209
|
-
|
|
210
|
-
The same prefix always produces the same example, ensuring consistent documentation across regenerations.
|
|
211
|
-
|
|
212
|
-
### Model Mixin
|
|
213
|
-
|
|
214
|
-
Add a `display_id` property to your models:
|
|
215
|
-
|
|
216
|
-
```python
|
|
217
|
-
import uuid
|
|
218
|
-
from django.db import models
|
|
219
|
-
from django_display_ids import DisplayIDMixin
|
|
220
|
-
|
|
221
|
-
class Invoice(DisplayIDMixin, models.Model):
|
|
222
|
-
display_id_prefix = "inv"
|
|
223
|
-
id = models.UUIDField(primary_key=True, default=uuid.uuid4)
|
|
224
|
-
|
|
225
|
-
invoice = Invoice.objects.first()
|
|
226
|
-
invoice.display_id # -> "inv_2aUyqjCzEIiEcYMKj7TZtw"
|
|
227
|
-
```
|
|
228
|
-
|
|
229
|
-
### Model Manager
|
|
230
|
-
|
|
231
|
-
```python
|
|
232
|
-
from django_display_ids import DisplayIDMixin, DisplayIDManager
|
|
233
|
-
|
|
234
|
-
class Invoice(DisplayIDMixin, models.Model):
|
|
235
|
-
display_id_prefix = "inv"
|
|
236
|
-
objects = DisplayIDManager()
|
|
237
|
-
id = models.UUIDField(primary_key=True, default=uuid.uuid4)
|
|
238
|
-
|
|
239
|
-
# Get by display ID
|
|
240
|
-
invoice = Invoice.objects.get_by_display_id("inv_2aUyqjCzEIiEcYMKj7TZtw")
|
|
241
|
-
|
|
242
|
-
# Get by any identifier type
|
|
243
|
-
invoice = Invoice.objects.get_by_identifier("inv_2aUyqjCzEIiEcYMKj7TZtw")
|
|
244
|
-
invoice = Invoice.objects.get_by_identifier("550e8400-e29b-41d4-a716-446655440000")
|
|
245
|
-
|
|
246
|
-
# Works with filtered querysets
|
|
247
|
-
invoice = Invoice.objects.filter(active=True).get_by_identifier("inv_xxx")
|
|
248
|
-
```
|
|
249
|
-
|
|
250
|
-
### Django Admin
|
|
251
|
-
|
|
252
|
-
Enable searching by display ID or raw UUID in the admin:
|
|
253
|
-
|
|
254
|
-
```python
|
|
255
|
-
from django.contrib import admin
|
|
256
|
-
from django_display_ids import DisplayIDSearchMixin
|
|
257
|
-
|
|
258
|
-
@admin.register(Invoice)
|
|
259
|
-
class InvoiceAdmin(DisplayIDSearchMixin, admin.ModelAdmin):
|
|
260
|
-
list_display = ["id", "display_id", "name", "created"]
|
|
261
|
-
search_fields = ["name"] # display_id/UUID search is automatic
|
|
262
|
-
```
|
|
263
|
-
|
|
264
|
-
Now you can search by either format in the admin search box:
|
|
265
|
-
- `inv_2aUyqjCzEIiEcYMKj7TZtw` (display ID)
|
|
266
|
-
- `550e8400-e29b-41d4-a716-446655440000` (raw UUID from logs)
|
|
267
|
-
|
|
268
|
-
The mixin automatically detects the UUID field from your model's `uuid_field`
|
|
269
|
-
attribute (if using `DisplayIDMixin`), or defaults to `id`. Override with:
|
|
270
|
-
|
|
271
|
-
```python
|
|
272
|
-
class InvoiceAdmin(DisplayIDSearchMixin, admin.ModelAdmin):
|
|
273
|
-
uuid_field = "uid" # custom UUID field name
|
|
274
|
-
```
|
|
275
|
-
|
|
276
|
-
### Encoding and Decoding
|
|
277
|
-
|
|
278
|
-
```python
|
|
279
|
-
import uuid
|
|
280
|
-
from django_display_ids import encode_display_id, decode_display_id
|
|
281
|
-
|
|
282
|
-
# Create a display ID from a UUID
|
|
283
|
-
invoice_id = uuid.uuid4()
|
|
284
|
-
display_id = encode_display_id("inv", invoice_id)
|
|
285
|
-
# -> "inv_2aUyqjCzEIiEcYMKj7TZtw"
|
|
286
|
-
|
|
287
|
-
# Decode back to prefix and UUID
|
|
288
|
-
prefix, decoded_uuid = decode_display_id(display_id)
|
|
289
|
-
```
|
|
290
|
-
|
|
291
|
-
### Direct Resolution
|
|
292
|
-
|
|
293
|
-
```python
|
|
294
|
-
from django_display_ids import resolve_object
|
|
295
|
-
|
|
296
|
-
invoice = resolve_object(
|
|
297
|
-
model=Invoice,
|
|
298
|
-
value="inv_2aUyqjCzEIiEcYMKj7TZtw",
|
|
299
|
-
strategies=("display_id", "uuid", "slug"),
|
|
300
|
-
prefix="inv",
|
|
301
|
-
)
|
|
302
|
-
```
|
|
303
|
-
|
|
304
|
-
## Identifier Formats
|
|
305
|
-
|
|
306
|
-
| Format | Example | Description |
|
|
307
|
-
|--------|---------|-------------|
|
|
308
|
-
| Display ID | `inv_2aUyqjCzEIiEcYMKj7TZtw` | Prefix + base62-encoded UUID |
|
|
309
|
-
| UUID | `550e8400-e29b-41d4-a716-446655440000` | Standard UUID (v4/v7) |
|
|
310
|
-
| Slug | `my-invoice-slug` | Human-readable identifier |
|
|
311
|
-
|
|
312
|
-
Display ID format:
|
|
313
|
-
- Prefix: 1-16 lowercase letters
|
|
314
|
-
- Separator: underscore
|
|
315
|
-
- Encoded UUID: 22 base62 characters (fixed length)
|
|
316
|
-
|
|
317
|
-
## Lookup Strategies
|
|
318
|
-
|
|
319
|
-
Strategies are tried in order. The first successful match is returned.
|
|
320
|
-
|
|
321
|
-
| Strategy | Description |
|
|
322
|
-
|----------|-------------|
|
|
323
|
-
| `display_id` | Decode display ID, lookup by UUID field |
|
|
324
|
-
| `uuid` | Parse as UUID, lookup by UUID field |
|
|
325
|
-
| `slug` | Lookup by slug field |
|
|
326
|
-
|
|
327
|
-
Default: `("display_id", "uuid")`
|
|
328
|
-
|
|
329
|
-
The slug strategy is a catch-all, so it should always be last.
|
|
330
|
-
|
|
331
|
-
The `display_id` strategy requires a prefix. If no prefix is configured, the strategy is skipped.
|
|
332
|
-
|
|
333
|
-
## Configuration
|
|
334
|
-
|
|
335
|
-
### View/Mixin Attributes
|
|
336
|
-
|
|
337
|
-
| Attribute | Default | Description |
|
|
338
|
-
|-----------|---------|-------------|
|
|
339
|
-
| `lookup_param` / `lookup_url_kwarg` | `"pk"` | URL parameter name |
|
|
340
|
-
| `lookup_strategies` | from settings | Strategies to try |
|
|
341
|
-
| `display_id_prefix` | from model | Expected prefix (falls back to model's `display_id_prefix`) |
|
|
342
|
-
| `uuid_field` | `"id"` | UUID field name on model |
|
|
343
|
-
| `slug_field` | `"slug"` | Slug field name on model |
|
|
344
|
-
|
|
345
|
-
### Django Settings (Optional)
|
|
346
|
-
|
|
347
|
-
All settings have sensible defaults. Only add this if you need to override them:
|
|
348
|
-
|
|
349
|
-
```python
|
|
350
|
-
# settings.py
|
|
351
|
-
DISPLAY_IDS = {
|
|
352
|
-
"UUID_FIELD": "id", # default
|
|
353
|
-
"SLUG_FIELD": "slug", # default
|
|
354
|
-
"STRATEGIES": ("display_id", "uuid"), # default
|
|
355
|
-
}
|
|
356
|
-
```
|
|
357
|
-
|
|
358
|
-
## Error Handling
|
|
359
|
-
|
|
360
|
-
| Exception | When Raised |
|
|
361
|
-
|-----------|-------------|
|
|
362
|
-
| `InvalidIdentifierError` | Identifier cannot be parsed |
|
|
363
|
-
| `UnknownPrefixError` | Display ID prefix doesn't match expected |
|
|
364
|
-
| `ObjectNotFoundError` | No matching database record |
|
|
365
|
-
|
|
366
|
-
In views, errors are converted to HTTP responses:
|
|
367
|
-
- Django CBV: `Http404`
|
|
368
|
-
- DRF: `NotFound` (404) or `ParseError` (400)
|
|
369
|
-
|
|
370
|
-
## Requirements
|
|
371
|
-
|
|
372
|
-
- Python 3.12+
|
|
373
|
-
- Django 4.2+
|
|
374
|
-
- Django REST Framework 3.14+ (optional)
|
|
375
|
-
|
|
376
|
-
## Development
|
|
377
|
-
|
|
378
|
-
Clone the repository and install dependencies:
|
|
379
|
-
|
|
380
|
-
```bash
|
|
381
|
-
git clone https://github.com/josephabrahams/django-display-ids.git
|
|
382
|
-
cd django-display-ids
|
|
383
|
-
uv sync
|
|
384
|
-
```
|
|
385
|
-
|
|
386
|
-
Run tests:
|
|
387
|
-
|
|
388
|
-
```bash
|
|
389
|
-
uv run pytest
|
|
390
|
-
```
|
|
391
|
-
|
|
392
|
-
Run tests with coverage:
|
|
393
|
-
|
|
394
|
-
```bash
|
|
395
|
-
uv run pytest --cov=src/django_display_ids
|
|
396
|
-
```
|
|
397
|
-
|
|
398
|
-
Run tests across Python and Django versions:
|
|
399
|
-
|
|
400
|
-
```bash
|
|
401
|
-
uvx nox
|
|
402
|
-
```
|
|
403
|
-
|
|
404
|
-
Lint and format:
|
|
405
|
-
|
|
406
|
-
```bash
|
|
407
|
-
uvx pre-commit run --all-files
|
|
408
|
-
```
|
|
409
|
-
|
|
410
|
-
## Related Projects
|
|
411
|
-
|
|
412
|
-
If you need ID generation and storage (custom model fields), consider these alternatives:
|
|
413
|
-
|
|
414
|
-
- **[django-prefix-id](https://github.com/jaddison/django-prefix-id)** — PrefixIDField that generates and stores base62-encoded UUIDs
|
|
415
|
-
- **[django-spicy-id](https://github.com/mik3y/django-spicy-id)** — Drop-in AutoField replacement that displays numeric IDs as prefixed strings
|
|
416
|
-
- **[django-charid-field](https://github.com/yunojuno/django-charid-field)** — CharField wrapper supporting cuid, ksuid, ulid, and other generators
|
|
417
|
-
|
|
418
|
-
**django-display-ids** takes a different approach: it works with your existing UUID fields and handles resolution only. No migrations, no schema changes — just add the mixin to your views.
|
|
419
|
-
|
|
420
|
-
## License
|
|
421
|
-
|
|
422
|
-
ISC
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
django_display_ids/__init__.py,sha256=7_rl0cA0LWGIbhg5SbnWAZfKAWyXEpA5XzKDmPnoM8U,2650
|
|
2
|
-
django_display_ids/admin.py,sha256=uRyPH3q5e9D5oMxs6PtCHq0syBXs--i1alRoe-EcIJo,2935
|
|
3
|
-
django_display_ids/conf.py,sha256=qTsCzKeNBdJpEVeEkx2kFeWHBFa_NZwV_tpt-UTyRR0,1132
|
|
4
|
-
django_display_ids/contrib/__init__.py,sha256=sxGJK8Whb6cL7RqACqGRYrIZvaMwP3l6dYk3mIYWzDY,62
|
|
5
|
-
django_display_ids/contrib/drf_spectacular/__init__.py,sha256=uPA1foQ8BjyHX3SS9Ta9yI7wp0y3sLdQ4EPOx4KIBlI,3770
|
|
6
|
-
django_display_ids/contrib/rest_framework/__init__.py,sha256=Xun6zMhCZzQJZQ7ywvBYoKhTJIZEV84AntrbSWfBjYI,1567
|
|
7
|
-
django_display_ids/contrib/rest_framework/serializers.py,sha256=zsYDmXVbra5gUkOnupwJd_vEQnLbgXVWNwehHje2GTs,3991
|
|
8
|
-
django_display_ids/contrib/rest_framework/views.py,sha256=mu-twvdRbJedzSfLWVNn2BazFpAQzwRB5Eq3w2bxvvs,6056
|
|
9
|
-
django_display_ids/encoding.py,sha256=csIwUZaQKSOLwRU6-DWGTNGvSxmroyK0Yt7TBCo0AFE,2945
|
|
10
|
-
django_display_ids/examples.py,sha256=rrZgL-EoWz0mC32T-RNWCo45Y8_BXV_6r_5tRIz77gs,2677
|
|
11
|
-
django_display_ids/exceptions.py,sha256=nmyRfpsqVvz226Zcu_QANwr8MudbfoX09mAgOCwuPuQ,3022
|
|
12
|
-
django_display_ids/managers.py,sha256=EFvlQxsSFXeM8TVvV4NZKeKMC7QB3C0zYGgZ6bvSr4k,6884
|
|
13
|
-
django_display_ids/models.py,sha256=_IXxaFlVw2MqobQUw4Cy4rB66LsTz6kiI5YpoSpnkCY,4156
|
|
14
|
-
django_display_ids/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
15
|
-
django_display_ids/resolver.py,sha256=oCoA6jbGCFS8SMrkfD_oSSBQNrSxnxdooK5j933eA9Y,2494
|
|
16
|
-
django_display_ids/strategies.py,sha256=Rq00-AW_FB8-K04u2oBK5J6kPiYgsE3TdYlLyK_zro0,4436
|
|
17
|
-
django_display_ids/typing.py,sha256=2O3kT7XKkiE7WI9A5KkILPM-Zi7-zCy5gVvXQL_J2mI,478
|
|
18
|
-
django_display_ids/views.py,sha256=sLsJm8Tpe3Qk1gOLcDzfpazxuaVqTCAdgVIXOONFnKQ,5096
|
|
19
|
-
django_display_ids-0.1.4.dist-info/WHEEL,sha256=XV0cjMrO7zXhVAIyyc8aFf1VjZ33Fen4IiJk5zFlC3g,80
|
|
20
|
-
django_display_ids-0.1.4.dist-info/METADATA,sha256=46ob0brsZSBW7QFrmISYvNkD7mQGu3NkIXRe3ueae6g,12356
|
|
21
|
-
django_display_ids-0.1.4.dist-info/RECORD,,
|