django-display-ids 0.2.0__py3-none-any.whl → 0.3.1__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 +7 -4
- django_display_ids/resolver.py +12 -3
- 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.2.0.dist-info → django_display_ids-0.3.1.dist-info}/METADATA +56 -37
- django_display_ids-0.3.1.dist-info/RECORD +25 -0
- {django_display_ids-0.2.0.dist-info → django_display_ids-0.3.1.dist-info}/WHEEL +1 -1
- django_display_ids-0.2.0.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
|
|
|
@@ -125,13 +125,16 @@ class DisplayIDMixin(models.Model):
|
|
|
125
125
|
"""Generate the display ID for this instance.
|
|
126
126
|
|
|
127
127
|
Returns:
|
|
128
|
-
Display ID in format {prefix}_{base62(uuid)}, or None if no prefix
|
|
128
|
+
Display ID in format {prefix}_{base62(uuid)}, or None if no prefix
|
|
129
|
+
or if the UUID field is None (e.g., unsaved instance).
|
|
129
130
|
"""
|
|
130
131
|
prefix = self.get_display_id_prefix()
|
|
131
132
|
if prefix is None:
|
|
132
133
|
return None
|
|
133
134
|
uuid_value = getattr(self, self._get_uuid_field())
|
|
135
|
+
if uuid_value is None:
|
|
136
|
+
return None
|
|
134
137
|
return encode_display_id(prefix, uuid_value)
|
|
135
138
|
|
|
136
139
|
# Django admin display configuration
|
|
137
|
-
display_id.fget.short_description = "Display ID"
|
|
140
|
+
display_id.fget.short_description = "Display ID" # type: ignore[attr-defined]
|
django_display_ids/resolver.py
CHANGED
|
@@ -51,12 +51,21 @@ def resolve_object(
|
|
|
51
51
|
UnknownPrefixError: If display ID prefix doesn't match expected.
|
|
52
52
|
ObjectNotFoundError: If no matching object exists.
|
|
53
53
|
AmbiguousIdentifierError: If multiple objects match (slug lookup).
|
|
54
|
+
TypeError: If queryset is not for the specified model.
|
|
54
55
|
"""
|
|
55
56
|
# Parse the identifier to determine type
|
|
56
57
|
result = parse_identifier(value, strategies, expected_prefix=prefix)
|
|
57
58
|
|
|
58
59
|
# Get the base queryset
|
|
59
|
-
|
|
60
|
+
if queryset is not None:
|
|
61
|
+
if queryset.model is not model:
|
|
62
|
+
raise TypeError(
|
|
63
|
+
f"queryset must be for {model.__name__}, "
|
|
64
|
+
f"got queryset for {queryset.model.__name__}"
|
|
65
|
+
)
|
|
66
|
+
qs: QuerySet[M] = queryset
|
|
67
|
+
else:
|
|
68
|
+
qs = model._default_manager.all()
|
|
60
69
|
|
|
61
70
|
# Build the lookup based on strategy
|
|
62
71
|
lookup: dict[str, Any]
|
|
@@ -70,8 +79,8 @@ def resolve_object(
|
|
|
70
79
|
# Execute the query
|
|
71
80
|
try:
|
|
72
81
|
return qs.get(**lookup)
|
|
73
|
-
except model.DoesNotExist:
|
|
82
|
+
except model.DoesNotExist: # type: ignore[attr-defined]
|
|
74
83
|
raise ObjectNotFoundError(value, model_name=model.__name__) from None
|
|
75
|
-
except model.MultipleObjectsReturned:
|
|
84
|
+
except model.MultipleObjectsReturned: # type: ignore[attr-defined]
|
|
76
85
|
count = qs.filter(**lookup).count()
|
|
77
86
|
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(),
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: django-display-ids
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Stripe-like prefixed IDs for Django. Works with existing UUIDs — no schema changes.
|
|
5
5
|
Keywords: django,stripe,uuid,base62,prefixed-id,drf,shortuuid,nanoid,ulid
|
|
6
|
-
License:
|
|
6
|
+
License: MIT
|
|
7
7
|
Classifier: Development Status :: 4 - Beta
|
|
8
8
|
Classifier: Framework :: Django
|
|
9
9
|
Classifier: Framework :: Django :: 4.2
|
|
10
10
|
Classifier: Framework :: Django :: 5.2
|
|
11
11
|
Classifier: Framework :: Django :: 6.0
|
|
12
|
-
Classifier: License :: OSI Approved ::
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
13
|
Classifier: Programming Language :: Python :: 3
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.12
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.13
|
|
@@ -26,10 +26,24 @@ Description-Content-Type: text/markdown
|
|
|
26
26
|
[](https://pypi.org/project/django-display-ids/)
|
|
27
27
|
[](https://pypi.org/project/django-display-ids/)
|
|
28
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)
|
|
29
33
|
|
|
30
34
|
Stripe-like prefixed IDs for Django. Works with existing UUIDs — no schema changes.
|
|
31
35
|
|
|
32
|
-
|
|
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.
|
|
33
47
|
|
|
34
48
|
## Installation
|
|
35
49
|
|
|
@@ -37,10 +51,19 @@ Stripe-like prefixed IDs for Django. Works with existing UUIDs — no schema cha
|
|
|
37
51
|
pip install django-display-ids
|
|
38
52
|
```
|
|
39
53
|
|
|
40
|
-
|
|
54
|
+
Add to `INSTALLED_APPS`:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
INSTALLED_APPS = [
|
|
58
|
+
# ...
|
|
59
|
+
"django_display_ids",
|
|
60
|
+
]
|
|
61
|
+
```
|
|
41
62
|
|
|
42
63
|
## Quick Start
|
|
43
64
|
|
|
65
|
+
**Django views:**
|
|
66
|
+
|
|
44
67
|
```python
|
|
45
68
|
from django.views.generic import DetailView
|
|
46
69
|
from django_display_ids import DisplayIDObjectMixin
|
|
@@ -48,53 +71,53 @@ from django_display_ids import DisplayIDObjectMixin
|
|
|
48
71
|
class InvoiceDetailView(DisplayIDObjectMixin, DetailView):
|
|
49
72
|
model = Invoice
|
|
50
73
|
lookup_param = "id"
|
|
51
|
-
lookup_strategies = ("display_id", "uuid")
|
|
74
|
+
lookup_strategies = ("display_id", "uuid", "slug")
|
|
52
75
|
display_id_prefix = "inv"
|
|
53
76
|
```
|
|
54
77
|
|
|
78
|
+
**Django REST Framework:**
|
|
79
|
+
|
|
55
80
|
```python
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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"
|
|
60
90
|
```
|
|
61
91
|
|
|
62
|
-
Now your
|
|
92
|
+
Now your views accept:
|
|
63
93
|
- `inv_2aUyqjCzEIiEcYMKj7TZtw` (display ID)
|
|
64
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
|
+
```
|
|
65
105
|
|
|
66
106
|
## Features
|
|
67
107
|
|
|
68
108
|
- **Multiple identifier formats**: display ID (`prefix_base62uuid`), UUID (v4/v7), slug
|
|
69
109
|
- **Framework support**: Django CBVs and Django REST Framework
|
|
110
|
+
- **Template filter**: Encode UUIDs as display IDs in templates
|
|
70
111
|
- **Zero model changes required**: Works with any existing UUID field
|
|
71
112
|
- **OpenAPI integration**: Automatic schema generation with drf-spectacular
|
|
72
113
|
|
|
73
|
-
##
|
|
74
|
-
|
|
75
|
-
```bash
|
|
76
|
-
git clone https://github.com/josephabrahams/django-display-ids.git
|
|
77
|
-
cd django-display-ids
|
|
78
|
-
uv sync
|
|
79
|
-
```
|
|
114
|
+
## Documentation
|
|
80
115
|
|
|
81
|
-
|
|
116
|
+
Full documentation at [django-display-ids.readthedocs.io](https://django-display-ids.readthedocs.io/).
|
|
82
117
|
|
|
83
|
-
|
|
84
|
-
uv run pytest
|
|
85
|
-
```
|
|
118
|
+
## Contributing
|
|
86
119
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
```bash
|
|
90
|
-
uvx nox
|
|
91
|
-
```
|
|
92
|
-
|
|
93
|
-
Lint and format:
|
|
94
|
-
|
|
95
|
-
```bash
|
|
96
|
-
uvx pre-commit run --all-files
|
|
97
|
-
```
|
|
120
|
+
See the [contributing guide](https://django-display-ids.readthedocs.io/en/latest/contributing/).
|
|
98
121
|
|
|
99
122
|
## Related Projects
|
|
100
123
|
|
|
@@ -105,7 +128,3 @@ If you need ID generation and storage (custom model fields), consider:
|
|
|
105
128
|
- **[django-charid-field](https://github.com/yunojuno/django-charid-field)** — CharField wrapper supporting cuid, ksuid, ulid
|
|
106
129
|
|
|
107
130
|
**django-display-ids** works with existing UUID fields and handles resolution only — no migrations required.
|
|
108
|
-
|
|
109
|
-
## License
|
|
110
|
-
|
|
111
|
-
ISC
|
|
@@ -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=XI73N4bvxy1Pr2oHeTaJP3uq3huyCX67CFZ2T8mefsA,4317
|
|
16
|
+
django_display_ids/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
17
|
+
django_display_ids/resolver.py,sha256=ZlDVoxX0PmVf0MSwPyiNNwQVzdqJGDGE8fm2iyV7QjE,2848
|
|
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.1.dist-info/WHEEL,sha256=fAguSjoiATBe7TNBkJwOjyL1Tt4wwiaQGtNtjRPNMQA,80
|
|
24
|
+
django_display_ids-0.3.1.dist-info/METADATA,sha256=eKOjG80Rb_fK-_FQtUYR67fyWGZlN5SzgZuhjD4RGDA,5295
|
|
25
|
+
django_display_ids-0.3.1.dist-info/RECORD,,
|
|
@@ -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.2.0.dist-info/WHEEL,sha256=e_m4S054HL0hyR3CpOk-b7Q7fDX6BuFkgL5OjAExXas,80
|
|
20
|
-
django_display_ids-0.2.0.dist-info/METADATA,sha256=cXxwTVjdSXtYv0ghEid7n-udEjrdZrZWhbEWSkvFr6M,3342
|
|
21
|
-
django_display_ids-0.2.0.dist-info/RECORD,,
|