django-api-usage 0.1.0__tar.gz → 0.1.2__tar.gz

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.
Files changed (38) hide show
  1. {django_api_usage-0.1.0/src/django_api_usage.egg-info → django_api_usage-0.1.2}/PKG-INFO +1 -1
  2. {django_api_usage-0.1.0 → django_api_usage-0.1.2}/pyproject.toml +4 -1
  3. {django_api_usage-0.1.0 → django_api_usage-0.1.2}/src/django_api_usage/__init__.py +1 -1
  4. django_api_usage-0.1.2/src/django_api_usage/admin.py +164 -0
  5. django_api_usage-0.1.2/src/django_api_usage/templates/admin/django_api_usage/endpointstat/change_list.html +24 -0
  6. django_api_usage-0.1.2/src/django_api_usage/templates/admin/django_api_usage/endpointstat/confirm_clear.html +27 -0
  7. {django_api_usage-0.1.0 → django_api_usage-0.1.2/src/django_api_usage.egg-info}/PKG-INFO +1 -1
  8. {django_api_usage-0.1.0 → django_api_usage-0.1.2}/src/django_api_usage.egg-info/SOURCES.txt +3 -0
  9. django_api_usage-0.1.2/tests/test_admin.py +172 -0
  10. django_api_usage-0.1.0/src/django_api_usage/admin.py +0 -56
  11. {django_api_usage-0.1.0 → django_api_usage-0.1.2}/LICENSE +0 -0
  12. {django_api_usage-0.1.0 → django_api_usage-0.1.2}/README.md +0 -0
  13. {django_api_usage-0.1.0 → django_api_usage-0.1.2}/setup.cfg +0 -0
  14. {django_api_usage-0.1.0 → django_api_usage-0.1.2}/src/django_api_usage/apps.py +0 -0
  15. {django_api_usage-0.1.0 → django_api_usage-0.1.2}/src/django_api_usage/buffers.py +0 -0
  16. {django_api_usage-0.1.0 → django_api_usage-0.1.2}/src/django_api_usage/checks.py +0 -0
  17. {django_api_usage-0.1.0 → django_api_usage-0.1.2}/src/django_api_usage/conf.py +0 -0
  18. {django_api_usage-0.1.0 → django_api_usage-0.1.2}/src/django_api_usage/deprecation.py +0 -0
  19. {django_api_usage-0.1.0 → django_api_usage-0.1.2}/src/django_api_usage/drf.py +0 -0
  20. {django_api_usage-0.1.0 → django_api_usage-0.1.2}/src/django_api_usage/maintenance.py +0 -0
  21. {django_api_usage-0.1.0 → django_api_usage-0.1.2}/src/django_api_usage/management/__init__.py +0 -0
  22. {django_api_usage-0.1.0 → django_api_usage-0.1.2}/src/django_api_usage/management/commands/__init__.py +0 -0
  23. {django_api_usage-0.1.0 → django_api_usage-0.1.2}/src/django_api_usage/management/commands/api_usage_flush.py +0 -0
  24. {django_api_usage-0.1.0 → django_api_usage-0.1.2}/src/django_api_usage/management/commands/api_usage_report.py +0 -0
  25. {django_api_usage-0.1.0 → django_api_usage-0.1.2}/src/django_api_usage/middleware.py +0 -0
  26. {django_api_usage-0.1.0 → django_api_usage-0.1.2}/src/django_api_usage/migrations/0001_initial.py +0 -0
  27. {django_api_usage-0.1.0 → django_api_usage-0.1.2}/src/django_api_usage/migrations/__init__.py +0 -0
  28. {django_api_usage-0.1.0 → django_api_usage-0.1.2}/src/django_api_usage/models.py +0 -0
  29. {django_api_usage-0.1.0 → django_api_usage-0.1.2}/src/django_api_usage/resolvers.py +0 -0
  30. {django_api_usage-0.1.0 → django_api_usage-0.1.2}/src/django_api_usage/tasks.py +0 -0
  31. {django_api_usage-0.1.0 → django_api_usage-0.1.2}/src/django_api_usage.egg-info/dependency_links.txt +0 -0
  32. {django_api_usage-0.1.0 → django_api_usage-0.1.2}/src/django_api_usage.egg-info/requires.txt +0 -0
  33. {django_api_usage-0.1.0 → django_api_usage-0.1.2}/src/django_api_usage.egg-info/top_level.txt +0 -0
  34. {django_api_usage-0.1.0 → django_api_usage-0.1.2}/tests/test_checks.py +0 -0
  35. {django_api_usage-0.1.0 → django_api_usage-0.1.2}/tests/test_commands.py +0 -0
  36. {django_api_usage-0.1.0 → django_api_usage-0.1.2}/tests/test_drf.py +0 -0
  37. {django_api_usage-0.1.0 → django_api_usage-0.1.2}/tests/test_middleware.py +0 -0
  38. {django_api_usage-0.1.0 → django_api_usage-0.1.2}/tests/test_models.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-api-usage
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Lightweight, privacy-first usage metering for Django APIs, with an optional deprecation lifecycle layer.
5
5
  Author-email: CodeSyntax <teknika@codesyntax.com>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "django-api-usage"
7
- version = "0.1.0"
7
+ version = "0.1.2"
8
8
  description = "Lightweight, privacy-first usage metering for Django APIs, with an optional deprecation lifecycle layer."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -41,6 +41,9 @@ Issues = "https://github.com/codesyntax/django-api-usage/issues"
41
41
  [tool.setuptools.packages.find]
42
42
  where = ["src"]
43
43
 
44
+ [tool.setuptools.package-data]
45
+ django_api_usage = ["templates/admin/django_api_usage/endpointstat/*.html"]
46
+
44
47
  [tool.black]
45
48
  line-length = 88
46
49
  target-version = ["py310"]
@@ -1,3 +1,3 @@
1
1
  """django-api-usage: usage metering for Django APIs, with deprecation support."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.1.2"
@@ -0,0 +1,164 @@
1
+ """Read-only admin for usage counters, with two maintenance buttons.
2
+
3
+ * **Flush now** moves counters buffered in the cache to the database, so they
4
+ show up here immediately when ``BUFFER_BACKEND = "cache"``.
5
+ * **Clear statistics** empties the aggregated counters. Endpoints (and their
6
+ deprecation metadata) are kept on purpose.
7
+
8
+ The changelist also shows the **sum of the ``count`` column** for the rows that
9
+ match the current filters.
10
+ """
11
+
12
+ from django.contrib import admin, messages
13
+ from django.db.models import Sum
14
+ from django.http import HttpResponseRedirect
15
+ from django.template.response import TemplateResponse
16
+ from django.urls import path, reverse
17
+ from django.utils.text import format_lazy
18
+ from django.utils.translation import gettext_lazy as _
19
+
20
+ from .buffers import flush
21
+ from .models import Consumer, Endpoint, EndpointStat
22
+
23
+
24
+ @admin.register(Endpoint)
25
+ class EndpointAdmin(admin.ModelAdmin):
26
+ list_display = (
27
+ "app_label",
28
+ "route_name",
29
+ "method",
30
+ "site_id",
31
+ "deprecated",
32
+ "sunset_date",
33
+ "replacement",
34
+ "owner",
35
+ )
36
+ list_filter = ("deprecated", "app_label", "method", "site_id")
37
+ search_fields = ("route_name", "replacement", "owner", "notes")
38
+ list_editable = ("deprecated", "sunset_date", "replacement", "owner")
39
+
40
+
41
+ @admin.register(EndpointStat)
42
+ class EndpointStatAdmin(admin.ModelAdmin):
43
+ list_display = ("date", "endpoint", "client_type", "status_class", "count")
44
+ list_filter = ("date", "client_type", "status_class")
45
+ list_select_related = ("endpoint",)
46
+ date_hierarchy = "date"
47
+ # Search across the related endpoint (case-insensitive "contains").
48
+ search_fields = (
49
+ "endpoint__route_name",
50
+ "endpoint__app_label",
51
+ "endpoint__method",
52
+ "endpoint__replacement",
53
+ )
54
+ search_help_text = _(
55
+ "Search by endpoint route, app, method or replacement (for example "
56
+ "'artikuluak' or 'gida')."
57
+ )
58
+ change_list_template = "admin/django_api_usage/endpointstat/change_list.html"
59
+
60
+ def has_add_permission(self, request):
61
+ return False
62
+
63
+ def has_change_permission(self, request, obj=None):
64
+ return False
65
+
66
+ # -- changelist total ---------------------------------------------------
67
+
68
+ def changelist_view(self, request, extra_context=None):
69
+ """Add the sum of ``count`` over the *filtered* queryset to the context."""
70
+ response = super().changelist_view(request, extra_context=extra_context)
71
+ try:
72
+ changelist = response.context_data["cl"]
73
+ except (AttributeError, KeyError):
74
+ return response
75
+ response.context_data["usage_total"] = (
76
+ changelist.queryset.aggregate(total=Sum("count"))["total"] or 0
77
+ )
78
+ return response
79
+
80
+ # -- custom maintenance views -------------------------------------------
81
+
82
+ def get_urls(self):
83
+ app_label, model_name = self.opts.app_label, self.opts.model_name
84
+ custom = [
85
+ path(
86
+ "flush/",
87
+ self.admin_site.admin_view(self.flush_view),
88
+ name=f"{app_label}_{model_name}_flush",
89
+ ),
90
+ path(
91
+ "clear/",
92
+ self.admin_site.admin_view(self.clear_view),
93
+ name=f"{app_label}_{model_name}_clear",
94
+ ),
95
+ ]
96
+ return custom + super().get_urls()
97
+
98
+ def _changelist_url(self):
99
+ app_label, model_name = self.opts.app_label, self.opts.model_name
100
+ return reverse(f"admin:{app_label}_{model_name}_changelist")
101
+
102
+ def flush_view(self, request):
103
+ """Move buffered counters from the cache into the database."""
104
+ if request.method != "POST":
105
+ return HttpResponseRedirect(self._changelist_url())
106
+ written = flush()
107
+ if written:
108
+ self.message_user(
109
+ request,
110
+ format_lazy(_("Flushed {count} buffered counter(s)."), count=written),
111
+ messages.SUCCESS,
112
+ )
113
+ else:
114
+ self.message_user(
115
+ request,
116
+ _(
117
+ "Nothing was buffered: with BUFFER_BACKEND='db' counters are "
118
+ "already in the database."
119
+ ),
120
+ messages.INFO,
121
+ )
122
+ return HttpResponseRedirect(self._changelist_url())
123
+
124
+ def clear_view(self, request):
125
+ """Empty the aggregated counters (a confirmation page is shown first)."""
126
+ if request.method != "POST":
127
+ context = {
128
+ **self.admin_site.each_context(request),
129
+ "title": _("Clear statistics"),
130
+ "opts": self.opts,
131
+ "total": EndpointStat.objects.count(),
132
+ }
133
+ return TemplateResponse(
134
+ request,
135
+ "admin/django_api_usage/endpointstat/confirm_clear.html",
136
+ context,
137
+ )
138
+ deleted = EndpointStat.objects.all().delete()[0]
139
+ self.message_user(
140
+ request,
141
+ format_lazy(_("Deleted {count} statistic row(s)."), count=deleted),
142
+ messages.SUCCESS,
143
+ )
144
+ return HttpResponseRedirect(self._changelist_url())
145
+
146
+
147
+ @admin.register(Consumer)
148
+ class ConsumerAdmin(admin.ModelAdmin):
149
+ list_display = (
150
+ "kind",
151
+ "ref_short",
152
+ "user_agent_family",
153
+ "request_count",
154
+ "first_seen",
155
+ "last_seen",
156
+ "contact",
157
+ )
158
+ list_filter = ("kind",)
159
+ search_fields = ("contact", "notes")
160
+ list_editable = ("contact",)
161
+
162
+ @admin.display(description="Reference")
163
+ def ref_short(self, obj):
164
+ return obj.ref_hash[:12]
@@ -0,0 +1,24 @@
1
+ {% extends "admin/change_list.html" %}
2
+ {% load i18n %}
3
+
4
+ {% block object-tools-items %}
5
+ <li>
6
+ <form method="post" action="{% url 'admin:django_api_usage_endpointstat_flush' %}">
7
+ {% csrf_token %}
8
+ <button type="submit" class="button">{% translate "Flush now" %}</button>
9
+ </form>
10
+ </li>
11
+ <li>
12
+ <a href="{% url 'admin:django_api_usage_endpointstat_clear' %}" class="button">{% translate "Clear statistics" %}</a>
13
+ </li>
14
+ {{ block.super }}
15
+ {% endblock %}
16
+
17
+ {% block result_list %}
18
+ {{ block.super }}
19
+ {% if usage_total is not None %}
20
+ <p class="help" style="text-align: right; padding: 0.5em 1em; font-weight: bold;">
21
+ {% blocktranslate with total=usage_total %}TOTAL COUNT: {{ total }}{% endblocktranslate %}
22
+ </p>
23
+ {% endif %}
24
+ {% endblock %}
@@ -0,0 +1,27 @@
1
+ {% extends "admin/base_site.html" %}
2
+ {% load i18n %}
3
+
4
+ {% block breadcrumbs %}
5
+ <div class="breadcrumbs">
6
+ <a href="{% url 'admin:index' %}">{% translate "Home" %}</a>
7
+ &rsaquo; <a href="{% url 'admin:django_api_usage_endpointstat_changelist' %}">{% translate "Endpoint stats" %}</a>
8
+ &rsaquo; {% translate "Clear statistics" %}
9
+ </div>
10
+ {% endblock %}
11
+
12
+ {% block content %}
13
+ <p>
14
+ {% blocktranslate count counter=total %}
15
+ This will delete the only aggregated statistic row. Endpoints and their
16
+ deprecation metadata are kept.
17
+ {% plural %}
18
+ This will delete all {{ counter }} aggregated statistic rows. Endpoints
19
+ and their deprecation metadata are kept.
20
+ {% endblocktranslate %}
21
+ </p>
22
+ <form method="post">
23
+ {% csrf_token %}
24
+ <input type="submit" value="{% translate 'Yes, clear statistics' %}">
25
+ <a href="{% url 'admin:django_api_usage_endpointstat_changelist' %}">{% translate "Cancel" %}</a>
26
+ </form>
27
+ {% endblock %}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-api-usage
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Lightweight, privacy-first usage metering for Django APIs, with an optional deprecation lifecycle layer.
5
5
  Author-email: CodeSyntax <teknika@codesyntax.com>
6
6
  License-Expression: MIT
@@ -25,6 +25,9 @@ src/django_api_usage/management/commands/api_usage_flush.py
25
25
  src/django_api_usage/management/commands/api_usage_report.py
26
26
  src/django_api_usage/migrations/0001_initial.py
27
27
  src/django_api_usage/migrations/__init__.py
28
+ src/django_api_usage/templates/admin/django_api_usage/endpointstat/change_list.html
29
+ src/django_api_usage/templates/admin/django_api_usage/endpointstat/confirm_clear.html
30
+ tests/test_admin.py
28
31
  tests/test_checks.py
29
32
  tests/test_commands.py
30
33
  tests/test_drf.py
@@ -0,0 +1,172 @@
1
+ from django.contrib.auth.models import User
2
+ from django.urls import reverse
3
+
4
+ from django_api_usage.models import Endpoint, EndpointStat
5
+
6
+ from .base import UsageTestCase
7
+
8
+ CHANGELIST = "admin:django_api_usage_endpointstat_changelist"
9
+ FLUSH_URL = "admin:django_api_usage_endpointstat_flush"
10
+ CLEAR_URL = "admin:django_api_usage_endpointstat_clear"
11
+
12
+
13
+ class AdminButtonsTest(UsageTestCase):
14
+ def setUp(self):
15
+ super().setUp()
16
+ self.admin = User.objects.create_superuser(
17
+ "admin", "admin@example.com", "password"
18
+ )
19
+ self.client.force_login(self.admin)
20
+
21
+ def _make_stat(self, count=1):
22
+ endpoint = Endpoint.objects.create(
23
+ app_label="api", route_name="artikuluak-list", method="GET"
24
+ )
25
+ return EndpointStat.objects.create(
26
+ endpoint=endpoint, date="2026-10-08", count=count
27
+ )
28
+
29
+ def test_buttons_are_rendered(self):
30
+ response = self.client.get(reverse(CHANGELIST))
31
+
32
+ self.assertEqual(response.status_code, 200)
33
+ content = response.content.decode()
34
+ self.assertIn(reverse(FLUSH_URL), content)
35
+ self.assertIn(reverse(CLEAR_URL), content)
36
+
37
+ def test_flush_reports_when_nothing_is_buffered(self):
38
+ response = self.client.post(reverse(FLUSH_URL), follow=True)
39
+
40
+ self.assertEqual(response.status_code, 200)
41
+ self.assertContains(response, "already in the database")
42
+
43
+ def test_flush_moves_buffered_counters_to_the_database(self):
44
+ # A request fills the cache buffer.
45
+ self.client.get("/ping/")
46
+ self.assertFalse(EndpointStat.objects.exists())
47
+
48
+ response = self.client.post(reverse(FLUSH_URL), follow=True)
49
+
50
+ self.assertEqual(response.status_code, 200)
51
+ self.assertTrue(
52
+ EndpointStat.objects.filter(endpoint__route_name="ping").exists()
53
+ )
54
+
55
+ def test_flush_rejects_get(self):
56
+ response = self.client.get(reverse(FLUSH_URL))
57
+
58
+ self.assertRedirects(response, reverse(CHANGELIST))
59
+
60
+ def test_clear_asks_for_confirmation_first(self):
61
+ self._make_stat()
62
+
63
+ response = self.client.get(reverse(CLEAR_URL))
64
+
65
+ self.assertEqual(response.status_code, 200)
66
+ self.assertTrue(EndpointStat.objects.exists())
67
+
68
+ def test_clear_deletes_statistics_but_keeps_endpoints(self):
69
+ self._make_stat()
70
+
71
+ response = self.client.post(reverse(CLEAR_URL), follow=True)
72
+
73
+ self.assertEqual(response.status_code, 200)
74
+ self.assertFalse(EndpointStat.objects.exists())
75
+ self.assertTrue(Endpoint.objects.exists())
76
+
77
+ def test_total_sums_the_count_column(self):
78
+ api = Endpoint.objects.create(app_label="api", route_name="a", method="GET")
79
+ gida = Endpoint.objects.create(app_label="gida", route_name="b", method="GET")
80
+ EndpointStat.objects.create(
81
+ endpoint=api, date="2026-10-08", client_type="anon", count=5
82
+ )
83
+ EndpointStat.objects.create(
84
+ endpoint=gida, date="2026-10-08", client_type="user", count=7
85
+ )
86
+
87
+ response = self.client.get(reverse(CHANGELIST))
88
+
89
+ self.assertEqual(response.context_data["usage_total"], 12)
90
+ self.assertContains(response, "TOTAL COUNT")
91
+
92
+ def test_total_respects_the_selected_filters(self):
93
+ api = Endpoint.objects.create(app_label="api", route_name="a", method="GET")
94
+ gida = Endpoint.objects.create(app_label="gida", route_name="b", method="GET")
95
+ EndpointStat.objects.create(
96
+ endpoint=api, date="2026-10-08", client_type="anon", count=5
97
+ )
98
+ EndpointStat.objects.create(
99
+ endpoint=gida, date="2026-10-08", client_type="user", count=7
100
+ )
101
+
102
+ response = self.client.get(reverse(CHANGELIST), {"client_type": "user"})
103
+
104
+ self.assertEqual(response.context_data["usage_total"], 7)
105
+
106
+ def test_search_box_is_rendered(self):
107
+ response = self.client.get(reverse(CHANGELIST))
108
+
109
+ self.assertContains(response, 'id="searchbar"')
110
+
111
+ def test_search_filters_rows_by_endpoint_text(self):
112
+ api = Endpoint.objects.create(
113
+ app_label="api", route_name="artikuluak-list", method="GET"
114
+ )
115
+ gida = Endpoint.objects.create(
116
+ app_label="gida", route_name="gida-sailak-list", method="GET"
117
+ )
118
+ EndpointStat.objects.create(
119
+ endpoint=api, date="2026-10-08", client_type="anon", count=5
120
+ )
121
+ EndpointStat.objects.create(
122
+ endpoint=gida, date="2026-10-08", client_type="anon", count=7
123
+ )
124
+
125
+ response = self.client.get(reverse(CHANGELIST), {"q": "artikuluak"})
126
+
127
+ self.assertEqual(response.context_data["usage_total"], 5)
128
+ self.assertEqual(len(response.context_data["cl"].result_list), 1)
129
+
130
+ def test_search_matches_the_app_label(self):
131
+ api = Endpoint.objects.create(
132
+ app_label="api", route_name="artikuluak-list", method="GET"
133
+ )
134
+ gida = Endpoint.objects.create(
135
+ app_label="gida", route_name="gida-sailak-list", method="GET"
136
+ )
137
+ EndpointStat.objects.create(
138
+ endpoint=api, date="2026-10-08", client_type="anon", count=5
139
+ )
140
+ EndpointStat.objects.create(
141
+ endpoint=gida, date="2026-10-08", client_type="anon", count=7
142
+ )
143
+
144
+ response = self.client.get(reverse(CHANGELIST), {"q": "gida"})
145
+
146
+ self.assertEqual(response.context_data["usage_total"], 7)
147
+
148
+ def test_search_combines_with_list_filters(self):
149
+ api = Endpoint.objects.create(
150
+ app_label="api", route_name="artikuluak-list", method="GET"
151
+ )
152
+ EndpointStat.objects.create(
153
+ endpoint=api, date="2026-10-08", client_type="anon", count=5
154
+ )
155
+ EndpointStat.objects.create(
156
+ endpoint=api, date="2026-10-08", client_type="user", count=3
157
+ )
158
+
159
+ response = self.client.get(
160
+ reverse(CHANGELIST), {"q": "artikuluak", "client_type": "user"}
161
+ )
162
+
163
+ self.assertEqual(response.context_data["usage_total"], 3)
164
+
165
+ def test_clear_is_not_available_without_admin_permission(self):
166
+ self._make_stat()
167
+ self.client.force_login(User.objects.create_user("plain", password="x"))
168
+
169
+ response = self.client.post(reverse(CLEAR_URL))
170
+
171
+ self.assertIn(response.status_code, (302, 403))
172
+ self.assertTrue(EndpointStat.objects.exists())
@@ -1,56 +0,0 @@
1
- """Read-only admin for usage counters, plus editable deprecation metadata."""
2
-
3
- from django.contrib import admin
4
-
5
- from .models import Consumer, Endpoint, EndpointStat
6
-
7
-
8
- @admin.register(Endpoint)
9
- class EndpointAdmin(admin.ModelAdmin):
10
- list_display = (
11
- "app_label",
12
- "route_name",
13
- "method",
14
- "site_id",
15
- "deprecated",
16
- "sunset_date",
17
- "replacement",
18
- "owner",
19
- )
20
- list_filter = ("deprecated", "app_label", "method", "site_id")
21
- search_fields = ("route_name", "replacement", "owner", "notes")
22
- list_editable = ("deprecated", "sunset_date", "replacement", "owner")
23
-
24
-
25
- @admin.register(EndpointStat)
26
- class EndpointStatAdmin(admin.ModelAdmin):
27
- list_display = ("date", "endpoint", "client_type", "status_class", "count")
28
- list_filter = ("date", "client_type", "status_class")
29
- list_select_related = ("endpoint",)
30
- date_hierarchy = "date"
31
-
32
- def has_add_permission(self, request):
33
- return False
34
-
35
- def has_change_permission(self, request, obj=None):
36
- return False
37
-
38
-
39
- @admin.register(Consumer)
40
- class ConsumerAdmin(admin.ModelAdmin):
41
- list_display = (
42
- "kind",
43
- "ref_short",
44
- "user_agent_family",
45
- "request_count",
46
- "first_seen",
47
- "last_seen",
48
- "contact",
49
- )
50
- list_filter = ("kind",)
51
- search_fields = ("contact", "notes")
52
- list_editable = ("contact",)
53
-
54
- @admin.display(description="Reference")
55
- def ref_short(self, obj):
56
- return obj.ref_hash[:12]