django-api-usage 0.1.4__tar.gz → 0.1.6__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 (43) hide show
  1. {django_api_usage-0.1.4/src/django_api_usage.egg-info → django_api_usage-0.1.6}/PKG-INFO +23 -1
  2. {django_api_usage-0.1.4 → django_api_usage-0.1.6}/README.md +22 -0
  3. {django_api_usage-0.1.4 → django_api_usage-0.1.6}/pyproject.toml +6 -2
  4. {django_api_usage-0.1.4 → django_api_usage-0.1.6}/src/django_api_usage/__init__.py +1 -1
  5. django_api_usage-0.1.6/src/django_api_usage/admin.py +316 -0
  6. django_api_usage-0.1.6/src/django_api_usage/locale/eu/LC_MESSAGES/django.mo +0 -0
  7. django_api_usage-0.1.6/src/django_api_usage/locale/eu/LC_MESSAGES/django.po +112 -0
  8. {django_api_usage-0.1.4 → django_api_usage-0.1.6/src/django_api_usage.egg-info}/PKG-INFO +23 -1
  9. {django_api_usage-0.1.4 → django_api_usage-0.1.6}/src/django_api_usage.egg-info/SOURCES.txt +4 -0
  10. django_api_usage-0.1.6/tests/test_csv_export.py +209 -0
  11. django_api_usage-0.1.6/tests/test_filters.py +122 -0
  12. django_api_usage-0.1.4/src/django_api_usage/admin.py +0 -166
  13. {django_api_usage-0.1.4 → django_api_usage-0.1.6}/LICENSE +0 -0
  14. {django_api_usage-0.1.4 → django_api_usage-0.1.6}/setup.cfg +0 -0
  15. {django_api_usage-0.1.4 → django_api_usage-0.1.6}/src/django_api_usage/apps.py +0 -0
  16. {django_api_usage-0.1.4 → django_api_usage-0.1.6}/src/django_api_usage/buffers.py +0 -0
  17. {django_api_usage-0.1.4 → django_api_usage-0.1.6}/src/django_api_usage/checks.py +0 -0
  18. {django_api_usage-0.1.4 → django_api_usage-0.1.6}/src/django_api_usage/conf.py +0 -0
  19. {django_api_usage-0.1.4 → django_api_usage-0.1.6}/src/django_api_usage/deprecation.py +0 -0
  20. {django_api_usage-0.1.4 → django_api_usage-0.1.6}/src/django_api_usage/drf.py +0 -0
  21. {django_api_usage-0.1.4 → django_api_usage-0.1.6}/src/django_api_usage/maintenance.py +0 -0
  22. {django_api_usage-0.1.4 → django_api_usage-0.1.6}/src/django_api_usage/management/__init__.py +0 -0
  23. {django_api_usage-0.1.4 → django_api_usage-0.1.6}/src/django_api_usage/management/commands/__init__.py +0 -0
  24. {django_api_usage-0.1.4 → django_api_usage-0.1.6}/src/django_api_usage/management/commands/api_usage_flush.py +0 -0
  25. {django_api_usage-0.1.4 → django_api_usage-0.1.6}/src/django_api_usage/management/commands/api_usage_report.py +0 -0
  26. {django_api_usage-0.1.4 → django_api_usage-0.1.6}/src/django_api_usage/middleware.py +0 -0
  27. {django_api_usage-0.1.4 → django_api_usage-0.1.6}/src/django_api_usage/migrations/0001_initial.py +0 -0
  28. {django_api_usage-0.1.4 → django_api_usage-0.1.6}/src/django_api_usage/migrations/0002_endpoint_route_path.py +0 -0
  29. {django_api_usage-0.1.4 → django_api_usage-0.1.6}/src/django_api_usage/migrations/__init__.py +0 -0
  30. {django_api_usage-0.1.4 → django_api_usage-0.1.6}/src/django_api_usage/models.py +0 -0
  31. {django_api_usage-0.1.4 → django_api_usage-0.1.6}/src/django_api_usage/resolvers.py +0 -0
  32. {django_api_usage-0.1.4 → django_api_usage-0.1.6}/src/django_api_usage/tasks.py +0 -0
  33. {django_api_usage-0.1.4 → django_api_usage-0.1.6}/src/django_api_usage/templates/admin/django_api_usage/endpointstat/change_list.html +0 -0
  34. {django_api_usage-0.1.4 → django_api_usage-0.1.6}/src/django_api_usage/templates/admin/django_api_usage/endpointstat/confirm_clear.html +0 -0
  35. {django_api_usage-0.1.4 → django_api_usage-0.1.6}/src/django_api_usage.egg-info/dependency_links.txt +0 -0
  36. {django_api_usage-0.1.4 → django_api_usage-0.1.6}/src/django_api_usage.egg-info/requires.txt +0 -0
  37. {django_api_usage-0.1.4 → django_api_usage-0.1.6}/src/django_api_usage.egg-info/top_level.txt +0 -0
  38. {django_api_usage-0.1.4 → django_api_usage-0.1.6}/tests/test_admin.py +0 -0
  39. {django_api_usage-0.1.4 → django_api_usage-0.1.6}/tests/test_checks.py +0 -0
  40. {django_api_usage-0.1.4 → django_api_usage-0.1.6}/tests/test_commands.py +0 -0
  41. {django_api_usage-0.1.4 → django_api_usage-0.1.6}/tests/test_drf.py +0 -0
  42. {django_api_usage-0.1.4 → django_api_usage-0.1.6}/tests/test_middleware.py +0 -0
  43. {django_api_usage-0.1.4 → django_api_usage-0.1.6}/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.4
3
+ Version: 0.1.6
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
@@ -120,6 +120,28 @@ API_USAGE = {
120
120
  }
121
121
  ```
122
122
 
123
+ ## Admin
124
+
125
+ The package ships a read-only admin for the three models, built with plain
126
+ Django — **no dependency beyond Django itself** (no `django-object-actions` or
127
+ similar):
128
+
129
+ * **Endpoint stats** changelist: a search box (by endpoint path, route name, app,
130
+ method or replacement), filters by date, client type, status class, and
131
+ **Application** / **Method**, plus the **sum of the `count` column** for the
132
+ rows matching the current filters. The Method filter lists the riskiest verbs
133
+ first (`DELETE`, `POST`, ...), so write and destructive calls stand out.
134
+ * **Endpoint** changelist: the same Application / Method filters, plus
135
+ deprecation state, the site and the CSV export.
136
+ * **Flush now**: moves counters buffered in the cache into the database, so they
137
+ appear immediately when you run with `BUFFER_BACKEND = "cache"`.
138
+ * **Clear statistics**: empties the aggregated counters behind a confirmation
139
+ page (endpoints and their deprecation metadata are kept).
140
+ * **Export selected rows to CSV** on all three admins. Relations are flattened
141
+ into one analysis-friendly table (a stat row carries its endpoint's path, app
142
+ and method), so the file can be fed straight to pandas or a spreadsheet. Use
143
+ the "select all" link to export every row matching the current filters.
144
+
123
145
  ## Deprecation lifecycle
124
146
 
125
147
  `Endpoint` carries `deprecated`, `sunset_date`, `replacement` and `owner`, so the
@@ -81,6 +81,28 @@ API_USAGE = {
81
81
  }
82
82
  ```
83
83
 
84
+ ## Admin
85
+
86
+ The package ships a read-only admin for the three models, built with plain
87
+ Django — **no dependency beyond Django itself** (no `django-object-actions` or
88
+ similar):
89
+
90
+ * **Endpoint stats** changelist: a search box (by endpoint path, route name, app,
91
+ method or replacement), filters by date, client type, status class, and
92
+ **Application** / **Method**, plus the **sum of the `count` column** for the
93
+ rows matching the current filters. The Method filter lists the riskiest verbs
94
+ first (`DELETE`, `POST`, ...), so write and destructive calls stand out.
95
+ * **Endpoint** changelist: the same Application / Method filters, plus
96
+ deprecation state, the site and the CSV export.
97
+ * **Flush now**: moves counters buffered in the cache into the database, so they
98
+ appear immediately when you run with `BUFFER_BACKEND = "cache"`.
99
+ * **Clear statistics**: empties the aggregated counters behind a confirmation
100
+ page (endpoints and their deprecation metadata are kept).
101
+ * **Export selected rows to CSV** on all three admins. Relations are flattened
102
+ into one analysis-friendly table (a stat row carries its endpoint's path, app
103
+ and method), so the file can be fed straight to pandas or a spreadsheet. Use
104
+ the "select all" link to export every row matching the current filters.
105
+
84
106
  ## Deprecation lifecycle
85
107
 
86
108
  `Endpoint` carries `deprecated`, `sunset_date`, `replacement` and `owner`, so the
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "django-api-usage"
7
- version = "0.1.4"
7
+ version = "0.1.6"
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"
@@ -42,7 +42,11 @@ Issues = "https://github.com/codesyntax/django-api-usage/issues"
42
42
  where = ["src"]
43
43
 
44
44
  [tool.setuptools.package-data]
45
- django_api_usage = ["templates/admin/django_api_usage/endpointstat/*.html"]
45
+ django_api_usage = [
46
+ "templates/admin/django_api_usage/endpointstat/*.html",
47
+ "locale/*/LC_MESSAGES/*.po",
48
+ "locale/*/LC_MESSAGES/*.mo",
49
+ ]
46
50
 
47
51
  [tool.black]
48
52
  line-length = 88
@@ -1,3 +1,3 @@
1
1
  """django-api-usage: usage metering for Django APIs, with deprecation support."""
2
2
 
3
- __version__ = "0.1.4"
3
+ __version__ = "0.1.6"
@@ -0,0 +1,316 @@
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, and every admin ships an **export to CSV** action for
10
+ the selected rows (all the rows matching the filters can be selected with the
11
+ "select all" link).
12
+
13
+ Everything is plain Django: no extra dependency beyond Django itself.
14
+ """
15
+
16
+ import csv
17
+
18
+ from django.contrib import admin, messages
19
+ from django.db.models import Sum
20
+ from django.http import HttpResponse, HttpResponseRedirect
21
+ from django.template.response import TemplateResponse
22
+ from django.urls import path, reverse
23
+ from django.utils import timezone
24
+ from django.utils.text import format_lazy
25
+ from django.utils.translation import gettext_lazy as _
26
+
27
+ from .buffers import flush
28
+ from .models import Consumer, Endpoint, EndpointStat
29
+
30
+
31
+ class CsvExportMixin:
32
+ """Admin action to download the selected rows as CSV.
33
+
34
+ ``csv_columns`` is an iterable of ``(header, queryset lookup)`` pairs. The
35
+ lookups are resolved by the database in a single query, so related rows can
36
+ be flattened into one analysis-friendly table (for example a stat row plus
37
+ its endpoint's path and app label).
38
+ """
39
+
40
+ #: ``(header, lookup)`` pairs. The header is written as-is (keep it stable
41
+ #: and ASCII: the file is meant to be read by pandas or a spreadsheet).
42
+ csv_columns = ()
43
+ #: Prefix of the downloaded filename; the date is appended.
44
+ csv_filename_prefix = "django-api-usage"
45
+
46
+ @admin.action(description=_("Export selected rows to CSV"))
47
+ def export_as_csv(self, request, queryset):
48
+ columns = list(self.csv_columns)
49
+ rows = queryset.values_list(*(lookup for _, lookup in columns))
50
+
51
+ filename = f"{self.csv_filename_prefix}-{timezone.localdate():%Y-%m-%d}.csv"
52
+ response = HttpResponse(content_type="text/csv; charset=utf-8")
53
+ response["Content-Disposition"] = f'attachment; filename="{filename}"'
54
+ # Byte order mark, so Excel detects UTF-8 (content may be Basque).
55
+ response.write("\ufeff")
56
+
57
+ writer = csv.writer(response)
58
+ writer.writerow([header for header, _ in columns])
59
+ writer.writerows(rows)
60
+ return response
61
+
62
+
63
+ #: HTTP methods, riskiest first: the point of the filter is spotting the
64
+ #: write/destructive calls (POST, DELETE, ...) instead of the read ones.
65
+ METHOD_ORDER = ("DELETE", "POST", "PUT", "PATCH", "GET", "HEAD", "OPTIONS")
66
+
67
+
68
+ class AppLabelListFilter(admin.SimpleListFilter):
69
+ """Distinct application labels, under a readable title."""
70
+
71
+ title = _("Application")
72
+ parameter_name = "app_label"
73
+ #: Lookup against the admin's model; overridden for related filters.
74
+ field_path = "app_label"
75
+
76
+ def lookups(self, request, model_admin):
77
+ values = (
78
+ model_admin.model.objects.order_by()
79
+ .values_list(self.field_path, flat=True)
80
+ .distinct()
81
+ )
82
+ return [(value, value) for value in sorted(v for v in values if v)]
83
+
84
+ def queryset(self, request, queryset):
85
+ if not self.value():
86
+ return queryset
87
+ return queryset.filter(**{self.field_path: self.value()})
88
+
89
+
90
+ class MethodListFilter(admin.SimpleListFilter):
91
+ """HTTP methods, riskiest (DELETE, POST, ...) first."""
92
+
93
+ title = _("Method")
94
+ parameter_name = "method"
95
+ field_path = "method"
96
+
97
+ def lookups(self, request, model_admin):
98
+ values = set(
99
+ model_admin.model.objects.order_by()
100
+ .values_list(self.field_path, flat=True)
101
+ .distinct()
102
+ )
103
+ ordered = [method for method in METHOD_ORDER if method in values]
104
+ rest = sorted(m for m in values if m and m not in METHOD_ORDER)
105
+ return [(method, method) for method in ordered + rest]
106
+
107
+ def queryset(self, request, queryset):
108
+ if not self.value():
109
+ return queryset
110
+ return queryset.filter(**{self.field_path: self.value()})
111
+
112
+
113
+ class StatAppLabelListFilter(AppLabelListFilter):
114
+ """Application filter for the stats changelist (through the endpoint)."""
115
+
116
+ field_path = "endpoint__app_label"
117
+
118
+
119
+ class StatMethodListFilter(MethodListFilter):
120
+ """Method filter for the stats changelist (through the endpoint)."""
121
+
122
+ field_path = "endpoint__method"
123
+
124
+
125
+ @admin.register(Endpoint)
126
+ class EndpointAdmin(CsvExportMixin, admin.ModelAdmin):
127
+ actions = ("export_as_csv",)
128
+ csv_columns = (
129
+ ("app_label", "app_label"),
130
+ ("route_path", "route_path"),
131
+ ("route_name", "route_name"),
132
+ ("method", "method"),
133
+ ("site_id", "site_id"),
134
+ ("deprecated", "deprecated"),
135
+ ("sunset_date", "sunset_date"),
136
+ ("replacement", "replacement"),
137
+ ("owner", "owner"),
138
+ ("notes", "notes"),
139
+ )
140
+ list_display = (
141
+ "app_label",
142
+ "route_path",
143
+ "route_name",
144
+ "method",
145
+ "site_id",
146
+ "deprecated",
147
+ "sunset_date",
148
+ "replacement",
149
+ "owner",
150
+ )
151
+ list_filter = (
152
+ "deprecated",
153
+ AppLabelListFilter,
154
+ MethodListFilter,
155
+ "site_id",
156
+ )
157
+ search_fields = ("route_path", "route_name", "replacement", "owner", "notes")
158
+ list_editable = ("deprecated", "sunset_date", "replacement", "owner")
159
+
160
+
161
+ @admin.register(EndpointStat)
162
+ class EndpointStatAdmin(CsvExportMixin, admin.ModelAdmin):
163
+ actions = ("export_as_csv",)
164
+ # Flattened on purpose: the CSV is meant for analysis (pandas, spreadsheet)
165
+ # without having to join endpoints by hand.
166
+ csv_columns = (
167
+ ("date", "date"),
168
+ ("app_label", "endpoint__app_label"),
169
+ ("route_path", "endpoint__route_path"),
170
+ ("route_name", "endpoint__route_name"),
171
+ ("method", "endpoint__method"),
172
+ ("site_id", "endpoint__site_id"),
173
+ ("client_type", "client_type"),
174
+ ("status_class", "status_class"),
175
+ ("count", "count"),
176
+ )
177
+ list_display = ("date", "endpoint", "client_type", "status_class", "count")
178
+ list_filter = (
179
+ "date",
180
+ "client_type",
181
+ "status_class",
182
+ StatAppLabelListFilter,
183
+ StatMethodListFilter,
184
+ )
185
+ list_select_related = ("endpoint",)
186
+ date_hierarchy = "date"
187
+ # Search across the related endpoint (case-insensitive "contains").
188
+ search_fields = (
189
+ "endpoint__route_path",
190
+ "endpoint__route_name",
191
+ "endpoint__app_label",
192
+ "endpoint__method",
193
+ "endpoint__replacement",
194
+ )
195
+ search_help_text = _(
196
+ "Search by endpoint path, route name, app, method or replacement (for "
197
+ "example 'herriak' or 'artikuluak')."
198
+ )
199
+ change_list_template = "admin/django_api_usage/endpointstat/change_list.html"
200
+
201
+ def has_add_permission(self, request):
202
+ return False
203
+
204
+ def has_change_permission(self, request, obj=None):
205
+ return False
206
+
207
+ # -- changelist total ---------------------------------------------------
208
+
209
+ def changelist_view(self, request, extra_context=None):
210
+ """Add the sum of ``count`` over the *filtered* queryset to the context."""
211
+ response = super().changelist_view(request, extra_context=extra_context)
212
+ try:
213
+ changelist = response.context_data["cl"]
214
+ except (AttributeError, KeyError):
215
+ return response
216
+ response.context_data["usage_total"] = (
217
+ changelist.queryset.aggregate(total=Sum("count"))["total"] or 0
218
+ )
219
+ return response
220
+
221
+ # -- custom maintenance views -------------------------------------------
222
+
223
+ def get_urls(self):
224
+ app_label, model_name = self.opts.app_label, self.opts.model_name
225
+ custom = [
226
+ path(
227
+ "flush/",
228
+ self.admin_site.admin_view(self.flush_view),
229
+ name=f"{app_label}_{model_name}_flush",
230
+ ),
231
+ path(
232
+ "clear/",
233
+ self.admin_site.admin_view(self.clear_view),
234
+ name=f"{app_label}_{model_name}_clear",
235
+ ),
236
+ ]
237
+ return custom + super().get_urls()
238
+
239
+ def _changelist_url(self):
240
+ app_label, model_name = self.opts.app_label, self.opts.model_name
241
+ return reverse(f"admin:{app_label}_{model_name}_changelist")
242
+
243
+ def flush_view(self, request):
244
+ """Move buffered counters from the cache into the database."""
245
+ if request.method != "POST":
246
+ return HttpResponseRedirect(self._changelist_url())
247
+ written = flush()
248
+ if written:
249
+ self.message_user(
250
+ request,
251
+ format_lazy(_("Flushed {count} buffered counter(s)."), count=written),
252
+ messages.SUCCESS,
253
+ )
254
+ else:
255
+ self.message_user(
256
+ request,
257
+ _(
258
+ "Nothing was buffered: with BUFFER_BACKEND='db' counters are "
259
+ "already in the database."
260
+ ),
261
+ messages.INFO,
262
+ )
263
+ return HttpResponseRedirect(self._changelist_url())
264
+
265
+ def clear_view(self, request):
266
+ """Empty the aggregated counters (a confirmation page is shown first)."""
267
+ if request.method != "POST":
268
+ context = {
269
+ **self.admin_site.each_context(request),
270
+ "title": _("Clear statistics"),
271
+ "opts": self.opts,
272
+ "total": EndpointStat.objects.count(),
273
+ }
274
+ return TemplateResponse(
275
+ request,
276
+ "admin/django_api_usage/endpointstat/confirm_clear.html",
277
+ context,
278
+ )
279
+ deleted = EndpointStat.objects.all().delete()[0]
280
+ self.message_user(
281
+ request,
282
+ format_lazy(_("Deleted {count} statistic row(s)."), count=deleted),
283
+ messages.SUCCESS,
284
+ )
285
+ return HttpResponseRedirect(self._changelist_url())
286
+
287
+
288
+ @admin.register(Consumer)
289
+ class ConsumerAdmin(CsvExportMixin, admin.ModelAdmin):
290
+ actions = ("export_as_csv",)
291
+ csv_columns = (
292
+ ("kind", "kind"),
293
+ ("ref_hash", "ref_hash"),
294
+ ("user_agent_family", "user_agent_family"),
295
+ ("request_count", "request_count"),
296
+ ("first_seen", "first_seen"),
297
+ ("last_seen", "last_seen"),
298
+ ("contact", "contact"),
299
+ ("notes", "notes"),
300
+ )
301
+ list_display = (
302
+ "kind",
303
+ "ref_short",
304
+ "user_agent_family",
305
+ "request_count",
306
+ "first_seen",
307
+ "last_seen",
308
+ "contact",
309
+ )
310
+ list_filter = ("kind",)
311
+ search_fields = ("contact", "notes")
312
+ list_editable = ("contact",)
313
+
314
+ @admin.display(description="Reference")
315
+ def ref_short(self, obj):
316
+ return obj.ref_hash[:12]
@@ -0,0 +1,112 @@
1
+ # Basque translation of django-api-usage.
2
+ # Copyright (C) 2026 CodeSyntax
3
+ # This file is distributed under the same license as the django-api-usage package.
4
+ # Urtzi Odriozola <uodriozola@codesyntax.com>, 2026.
5
+ #
6
+ msgid ""
7
+ msgstr ""
8
+ "Project-Id-Version: django-api-usage\n"
9
+ "Report-Msgid-Bugs-To: \n"
10
+ "POT-Creation-Date: 2026-10-08 06:10-0500\n"
11
+ "PO-Revision-Date: 2026-10-08 06:20-0500\n"
12
+ "Last-Translator: Urtzi Odriozola <uodriozola@codesyntax.com>\n"
13
+ "Language-Team: Basque\n"
14
+ "Language: eu\n"
15
+ "MIME-Version: 1.0\n"
16
+ "Content-Type: text/plain; charset=UTF-8\n"
17
+ "Content-Transfer-Encoding: 8bit\n"
18
+ "Plural-Forms: nplurals=2; plural=(n != 1);\n"
19
+
20
+ #: src/django_api_usage/admin.py:46
21
+ msgid "Export selected rows to CSV"
22
+ msgstr "Esportatu hautatutako errenkadak CSVra"
23
+
24
+ #: src/django_api_usage/admin.py:71
25
+ msgid "Application"
26
+ msgstr "Aplikazioa"
27
+
28
+ #: src/django_api_usage/admin.py:93
29
+ msgid "Method"
30
+ msgstr "Metodoa"
31
+
32
+ #: src/django_api_usage/admin.py:196
33
+ msgid ""
34
+ "Search by endpoint path, route name, app, method or replacement (for example "
35
+ "'herriak' or 'artikuluak')."
36
+ msgstr ""
37
+ "Bilatu endpointaren bidearen, route-izenaren, aplikazioaren, metodoaren edo "
38
+ "ordezkoaren arabera (adibidez 'herriak' edo 'artikuluak')."
39
+
40
+ #: src/django_api_usage/admin.py:251
41
+ #, python-brace-format
42
+ msgid "Flushed {count} buffered counter(s)."
43
+ msgstr "{count} kontagailu eraman dira bufferretik datu-basera."
44
+
45
+ #: src/django_api_usage/admin.py:258
46
+ msgid ""
47
+ "Nothing was buffered: with BUFFER_BACKEND='db' counters are already in the "
48
+ "database."
49
+ msgstr ""
50
+ "Ez zegoen ezer bufferrean: BUFFER_BACKEND='db' denean kontagailuak jada datu-"
51
+ "basean daude."
52
+
53
+ #: src/django_api_usage/admin.py:270
54
+ #: src/django_api_usage/templates/admin/django_api_usage/endpointstat/change_list.html:12
55
+ #: src/django_api_usage/templates/admin/django_api_usage/endpointstat/confirm_clear.html:8
56
+ msgid "Clear statistics"
57
+ msgstr "Estatistikak hustu"
58
+
59
+ #: src/django_api_usage/admin.py:282
60
+ #, python-brace-format
61
+ msgid "Deleted {count} statistic row(s)."
62
+ msgstr "{count} estatistika-errenkada ezabatu dira."
63
+
64
+ #: src/django_api_usage/templates/admin/django_api_usage/endpointstat/change_list.html:8
65
+ msgid "Flush now"
66
+ msgstr "Flush orain"
67
+
68
+ #: src/django_api_usage/templates/admin/django_api_usage/endpointstat/change_list.html:21
69
+ #, python-format
70
+ msgid "TOTAL COUNT: %(total)s"
71
+ msgstr ""
72
+
73
+ #: src/django_api_usage/templates/admin/django_api_usage/endpointstat/confirm_clear.html:6
74
+ msgid "Home"
75
+ msgstr ""
76
+
77
+ #: src/django_api_usage/templates/admin/django_api_usage/endpointstat/confirm_clear.html:7
78
+ msgid "Endpoint stats"
79
+ msgstr "Endpoint estatistikak"
80
+
81
+ #: src/django_api_usage/templates/admin/django_api_usage/endpointstat/confirm_clear.html:14
82
+ #, python-format
83
+ msgid ""
84
+ "\n"
85
+ " This will delete the only aggregated statistic row. Endpoints and "
86
+ "their\n"
87
+ " deprecation metadata are kept.\n"
88
+ " "
89
+ msgid_plural ""
90
+ "\n"
91
+ " This will delete all %(counter)s aggregated statistic rows. Endpoints\n"
92
+ " and their deprecation metadata are kept.\n"
93
+ " "
94
+ msgstr[0] ""
95
+ "\n"
96
+ " Estatistika-errenkada agregatu bakarra ezabatuko da. Endpointak eta "
97
+ "haien\n"
98
+ " deprecation-metadatuak mantendu egingo dira.\n"
99
+ " "
100
+ msgstr[1] ""
101
+ "\n"
102
+ " %(counter)s estatistika-errenkada agregatu ezabatuko dira. Endpointak\n"
103
+ " eta haien deprecation-metadatuak mantendu egingo dira.\n"
104
+ " "
105
+
106
+ #: src/django_api_usage/templates/admin/django_api_usage/endpointstat/confirm_clear.html:24
107
+ msgid "Yes, clear statistics"
108
+ msgstr "Bai, estatistikak hustu"
109
+
110
+ #: src/django_api_usage/templates/admin/django_api_usage/endpointstat/confirm_clear.html:25
111
+ msgid "Cancel"
112
+ msgstr ""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-api-usage
3
- Version: 0.1.4
3
+ Version: 0.1.6
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
@@ -120,6 +120,28 @@ API_USAGE = {
120
120
  }
121
121
  ```
122
122
 
123
+ ## Admin
124
+
125
+ The package ships a read-only admin for the three models, built with plain
126
+ Django — **no dependency beyond Django itself** (no `django-object-actions` or
127
+ similar):
128
+
129
+ * **Endpoint stats** changelist: a search box (by endpoint path, route name, app,
130
+ method or replacement), filters by date, client type, status class, and
131
+ **Application** / **Method**, plus the **sum of the `count` column** for the
132
+ rows matching the current filters. The Method filter lists the riskiest verbs
133
+ first (`DELETE`, `POST`, ...), so write and destructive calls stand out.
134
+ * **Endpoint** changelist: the same Application / Method filters, plus
135
+ deprecation state, the site and the CSV export.
136
+ * **Flush now**: moves counters buffered in the cache into the database, so they
137
+ appear immediately when you run with `BUFFER_BACKEND = "cache"`.
138
+ * **Clear statistics**: empties the aggregated counters behind a confirmation
139
+ page (endpoints and their deprecation metadata are kept).
140
+ * **Export selected rows to CSV** on all three admins. Relations are flattened
141
+ into one analysis-friendly table (a stat row carries its endpoint's path, app
142
+ and method), so the file can be fed straight to pandas or a spreadsheet. Use
143
+ the "select all" link to export every row matching the current filters.
144
+
123
145
  ## Deprecation lifecycle
124
146
 
125
147
  `Endpoint` carries `deprecated`, `sunset_date`, `replacement` and `owner`, so the
@@ -19,6 +19,8 @@ src/django_api_usage.egg-info/SOURCES.txt
19
19
  src/django_api_usage.egg-info/dependency_links.txt
20
20
  src/django_api_usage.egg-info/requires.txt
21
21
  src/django_api_usage.egg-info/top_level.txt
22
+ src/django_api_usage/locale/eu/LC_MESSAGES/django.mo
23
+ src/django_api_usage/locale/eu/LC_MESSAGES/django.po
22
24
  src/django_api_usage/management/__init__.py
23
25
  src/django_api_usage/management/commands/__init__.py
24
26
  src/django_api_usage/management/commands/api_usage_flush.py
@@ -31,6 +33,8 @@ src/django_api_usage/templates/admin/django_api_usage/endpointstat/confirm_clear
31
33
  tests/test_admin.py
32
34
  tests/test_checks.py
33
35
  tests/test_commands.py
36
+ tests/test_csv_export.py
34
37
  tests/test_drf.py
38
+ tests/test_filters.py
35
39
  tests/test_middleware.py
36
40
  tests/test_models.py
@@ -0,0 +1,209 @@
1
+ """Tests for the "export selected rows to CSV" admin action."""
2
+
3
+ import csv
4
+ import io
5
+
6
+ from django.contrib.auth.models import User
7
+ from django.urls import reverse
8
+
9
+ from django_api_usage.models import Consumer, Endpoint, EndpointStat
10
+
11
+ from .base import UsageTestCase
12
+
13
+ ENDPOINT_CHANGELIST = "admin:django_api_usage_endpoint_changelist"
14
+ STAT_CHANGELIST = "admin:django_api_usage_endpointstat_changelist"
15
+ CONSUMER_CHANGELIST = "admin:django_api_usage_consumer_changelist"
16
+
17
+ BOM = b"\xef\xbb\xbf"
18
+
19
+
20
+ class CsvExportTest(UsageTestCase):
21
+ def setUp(self):
22
+ super().setUp()
23
+ self.admin = User.objects.create_superuser("admin", "a@example.com", "pw")
24
+ self.client.force_login(self.admin)
25
+
26
+ # -- helpers ------------------------------------------------------------
27
+
28
+ def export(self, changelist, objects, action="export_as_csv"):
29
+ return self.client.post(
30
+ reverse(changelist),
31
+ {
32
+ "action": action,
33
+ "select_across": "0",
34
+ "index": "0",
35
+ "_selected_action": [str(obj.pk) for obj in objects],
36
+ },
37
+ )
38
+
39
+ def rows(self, response):
40
+ """Return the CSV as a list of rows (BOM stripped)."""
41
+ content = response.content.decode("utf-8-sig")
42
+ return list(csv.reader(io.StringIO(content)))
43
+
44
+ def make_endpoint(self, route_path="api/3.0/herriak/", app_label="api"):
45
+ return Endpoint.objects.create(
46
+ app_label=app_label,
47
+ route_path=route_path,
48
+ route_name="town-list",
49
+ method="GET",
50
+ )
51
+
52
+ def make_stat(self, endpoint, count=1, client_type="anon"):
53
+ return EndpointStat.objects.create(
54
+ endpoint=endpoint,
55
+ date="2026-10-08",
56
+ client_type=client_type,
57
+ status_class="2xx",
58
+ count=count,
59
+ )
60
+
61
+ def assert_csv_response(self, response):
62
+ self.assertEqual(response.status_code, 200)
63
+ self.assertEqual(response["Content-Type"], "text/csv; charset=utf-8")
64
+ self.assertIn("attachment;", response["Content-Disposition"])
65
+ self.assertIn(".csv", response["Content-Disposition"])
66
+ # The BOM makes Excel detect UTF-8.
67
+ self.assertTrue(response.content.startswith(BOM))
68
+
69
+ # -- action availability ------------------------------------------------
70
+
71
+ def test_action_is_offered_in_the_changelist(self):
72
+ endpoint = self.make_endpoint()
73
+ self.make_stat(endpoint)
74
+
75
+ response = self.client.get(reverse(STAT_CHANGELIST))
76
+
77
+ self.assertContains(response, "Export selected rows to CSV")
78
+ self.assertContains(response, 'value="export_as_csv"')
79
+
80
+ def test_delete_selected_is_still_available(self):
81
+ self.make_endpoint()
82
+
83
+ changelist = self.client.get(reverse(ENDPOINT_CHANGELIST))
84
+
85
+ self.assertContains(changelist, "delete_selected")
86
+
87
+ # -- stats export -------------------------------------------------------
88
+
89
+ def test_stats_export_header_is_stable(self):
90
+ endpoint = self.make_endpoint()
91
+ stat = self.make_stat(endpoint)
92
+
93
+ response = self.export(STAT_CHANGELIST, [stat])
94
+
95
+ self.assert_csv_response(response)
96
+ self.assertEqual(
97
+ self.rows(response)[0],
98
+ [
99
+ "date",
100
+ "app_label",
101
+ "route_path",
102
+ "route_name",
103
+ "method",
104
+ "site_id",
105
+ "client_type",
106
+ "status_class",
107
+ "count",
108
+ ],
109
+ )
110
+
111
+ def test_stats_export_flattens_the_endpoint(self):
112
+ endpoint = self.make_endpoint(route_path="^api/3.0/herriak/$")
113
+ stat = self.make_stat(endpoint, count=7)
114
+
115
+ response = self.export(STAT_CHANGELIST, [stat])
116
+
117
+ body = self.rows(response)[1]
118
+ self.assertEqual(body[0], "2026-10-08")
119
+ self.assertEqual(body[1], "api")
120
+ self.assertEqual(body[2], "^api/3.0/herriak/$")
121
+ self.assertEqual(body[3], "town-list")
122
+ self.assertEqual(body[4], "GET")
123
+ self.assertEqual(body[6], "anon")
124
+ self.assertEqual(body[7], "2xx")
125
+ self.assertEqual(body[8], "7")
126
+
127
+ def test_stats_export_only_includes_the_selected_rows(self):
128
+ endpoint = self.make_endpoint()
129
+ selected = self.make_stat(endpoint, count=1)
130
+ self.make_stat(endpoint, count=99, client_type="user")
131
+
132
+ response = self.export(STAT_CHANGELIST, [selected])
133
+
134
+ rows = self.rows(response)
135
+ self.assertEqual(len(rows), 2) # header + one row
136
+ self.assertEqual(rows[1][8], "1")
137
+
138
+ def test_stats_export_filename_contains_the_date(self):
139
+ endpoint = self.make_endpoint()
140
+ stat = self.make_stat(endpoint)
141
+
142
+ response = self.export(STAT_CHANGELIST, [stat])
143
+
144
+ self.assertIn('filename="django-api-usage-', response["Content-Disposition"])
145
+
146
+ # -- endpoints and consumers export -------------------------------------
147
+
148
+ def test_endpoints_export(self):
149
+ endpoint = self.make_endpoint()
150
+ Endpoint.objects.filter(pk=endpoint.pk).update(
151
+ deprecated=True, replacement="api/4.0/herriak/"
152
+ )
153
+
154
+ response = self.export(ENDPOINT_CHANGELIST, [endpoint])
155
+
156
+ self.assert_csv_response(response)
157
+ rows = self.rows(response)
158
+ self.assertIn("deprecated", rows[0])
159
+ self.assertIn("replacement", rows[0])
160
+ self.assertIn("True", rows[1])
161
+ self.assertIn("api/4.0/herriak/", rows[1])
162
+
163
+ def test_consumers_export(self):
164
+ consumer = Consumer.objects.create(
165
+ kind="ip_hash",
166
+ ref_hash="a" * 64,
167
+ user_agent_family="firefox",
168
+ request_count=12,
169
+ )
170
+
171
+ response = self.export(CONSUMER_CHANGELIST, [consumer])
172
+
173
+ self.assert_csv_response(response)
174
+ rows = self.rows(response)
175
+ self.assertEqual(rows[0][0], "kind")
176
+ self.assertEqual(rows[0][1], "ref_hash")
177
+ self.assertEqual(rows[1][0], "ip_hash")
178
+ self.assertEqual(rows[1][1], "a" * 64)
179
+
180
+ # -- edge cases ---------------------------------------------------------
181
+
182
+ def test_export_without_selection_is_not_a_csv(self):
183
+ """Django warns and redirects instead of downloading an empty file."""
184
+ response = self.client.post(
185
+ reverse(STAT_CHANGELIST),
186
+ {"action": "export_as_csv", "select_across": "0", "index": "0"},
187
+ )
188
+
189
+ self.assertEqual(response.status_code, 302)
190
+ self.assertNotEqual(response.get("Content-Type"), "text/csv; charset=utf-8")
191
+
192
+ def test_export_all_matching_rows_with_select_across(self):
193
+ """One checked row + "select all" exports every row matching the filters."""
194
+ endpoint = self.make_endpoint()
195
+ first = self.make_stat(endpoint, count=1)
196
+ self.make_stat(endpoint, count=2, client_type="user")
197
+
198
+ response = self.client.post(
199
+ reverse(STAT_CHANGELIST),
200
+ {
201
+ "action": "export_as_csv",
202
+ "select_across": "1",
203
+ "index": "0",
204
+ "_selected_action": [str(first.pk)],
205
+ },
206
+ )
207
+
208
+ self.assert_csv_response(response)
209
+ self.assertEqual(len(self.rows(response)), 3) # header + two rows
@@ -0,0 +1,122 @@
1
+ """Tests for the Application / Method changelist filters."""
2
+
3
+ from django.contrib import admin as django_admin
4
+ from django.contrib.auth.models import User
5
+ from django.urls import reverse
6
+ from django.utils import translation
7
+
8
+ from django_api_usage.admin import MethodListFilter
9
+ from django_api_usage.models import Endpoint, EndpointStat
10
+
11
+ from .base import UsageTestCase
12
+
13
+ ENDPOINT_CHANGELIST = "admin:django_api_usage_endpoint_changelist"
14
+ STAT_CHANGELIST = "admin:django_api_usage_endpointstat_changelist"
15
+
16
+
17
+ class FilterTest(UsageTestCase):
18
+ def setUp(self):
19
+ super().setUp()
20
+ self.admin = User.objects.create_superuser("admin", "a@example.com", "pw")
21
+ self.client.force_login(self.admin)
22
+
23
+ self.get_endpoint = Endpoint.objects.create(
24
+ app_label="api",
25
+ route_path="api/3.0/artikuluak/",
26
+ route_name="artikuluak-list",
27
+ method="GET",
28
+ )
29
+ self.post_endpoint = Endpoint.objects.create(
30
+ app_label="api",
31
+ route_path="api/3.0/artikuluak/",
32
+ route_name="artikuluak-create",
33
+ method="POST",
34
+ )
35
+ self.delete_endpoint = Endpoint.objects.create(
36
+ app_label="gida",
37
+ route_path="api/3.0/gida/",
38
+ route_name="gida-list",
39
+ method="DELETE",
40
+ )
41
+ # Stats for the risky ones only; GET has no usage.
42
+ EndpointStat.objects.create(
43
+ endpoint=self.post_endpoint,
44
+ date="2026-10-08",
45
+ client_type="anon",
46
+ status_class="2xx",
47
+ count=3,
48
+ )
49
+ self.delete_stat = EndpointStat.objects.create(
50
+ endpoint=self.delete_endpoint,
51
+ date="2026-10-08",
52
+ client_type="anon",
53
+ status_class="2xx",
54
+ count=1,
55
+ )
56
+
57
+ def endpoints(self, **params):
58
+ response = self.client.get(reverse(ENDPOINT_CHANGELIST), params)
59
+ self.assertEqual(response.status_code, 200)
60
+ return set(response.context["cl"].queryset)
61
+
62
+ def stats(self, **params):
63
+ response = self.client.get(reverse(STAT_CHANGELIST), params)
64
+ self.assertEqual(response.status_code, 200)
65
+ return set(response.context["cl"].queryset)
66
+
67
+ # -- endpoints ----------------------------------------------------------
68
+
69
+ def test_endpoint_admin_filters_by_method(self):
70
+ self.assertEqual(self.endpoints(method="POST"), {self.post_endpoint})
71
+
72
+ def test_endpoint_admin_filters_by_application(self):
73
+ self.assertEqual(self.endpoints(app_label="gida"), {self.delete_endpoint})
74
+
75
+ def test_endpoint_admin_without_filter_lists_everything(self):
76
+ self.assertEqual(
77
+ self.endpoints(),
78
+ {self.get_endpoint, self.post_endpoint, self.delete_endpoint},
79
+ )
80
+
81
+ # -- stats (through the endpoint) ---------------------------------------
82
+
83
+ def test_stats_admin_filters_by_method(self):
84
+ rows = self.stats(method="DELETE")
85
+ self.assertEqual([row.endpoint for row in rows], [self.delete_endpoint])
86
+
87
+ def test_stats_admin_filters_by_application(self):
88
+ rows = self.stats(app_label="api")
89
+ self.assertEqual([row.endpoint for row in rows], [self.post_endpoint])
90
+
91
+ # -- filter presentation ------------------------------------------------
92
+
93
+ def test_titles_are_readable_instead_of_raw_field_names(self):
94
+ html = self.client.get(reverse(STAT_CHANGELIST)).content.decode()
95
+
96
+ self.assertIn("Application", html)
97
+ self.assertIn("Method", html)
98
+ self.assertNotIn("Irizpidea: app label", html)
99
+
100
+ def test_method_lookups_put_the_risky_ones_first(self):
101
+ model_admin = django_admin.site._registry[Endpoint]
102
+ list_filter = MethodListFilter(
103
+ request=None, params={}, model=Endpoint, model_admin=model_admin
104
+ )
105
+
106
+ lookups = [value for value, _ in list_filter.lookups(None, model_admin)]
107
+
108
+ self.assertEqual(lookups, ["DELETE", "POST", "GET"])
109
+
110
+ def test_basque_catalog_is_shipped(self):
111
+ with translation.override("eu"):
112
+ html = self.client.get(reverse(ENDPOINT_CHANGELIST)).content.decode()
113
+
114
+ self.assertIn("Aplikazioa", html)
115
+ self.assertIn("Metodoa", html)
116
+
117
+ def test_total_count_label_is_left_untranslated(self):
118
+ """The label was requested verbatim, so the eu catalog keeps it English."""
119
+ with translation.override("eu"):
120
+ html = self.client.get(reverse(STAT_CHANGELIST)).content.decode()
121
+
122
+ self.assertIn("TOTAL COUNT", html)
@@ -1,166 +0,0 @@
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_path",
29
- "route_name",
30
- "method",
31
- "site_id",
32
- "deprecated",
33
- "sunset_date",
34
- "replacement",
35
- "owner",
36
- )
37
- list_filter = ("deprecated", "app_label", "method", "site_id")
38
- search_fields = ("route_path", "route_name", "replacement", "owner", "notes")
39
- list_editable = ("deprecated", "sunset_date", "replacement", "owner")
40
-
41
-
42
- @admin.register(EndpointStat)
43
- class EndpointStatAdmin(admin.ModelAdmin):
44
- list_display = ("date", "endpoint", "client_type", "status_class", "count")
45
- list_filter = ("date", "client_type", "status_class")
46
- list_select_related = ("endpoint",)
47
- date_hierarchy = "date"
48
- # Search across the related endpoint (case-insensitive "contains").
49
- search_fields = (
50
- "endpoint__route_path",
51
- "endpoint__route_name",
52
- "endpoint__app_label",
53
- "endpoint__method",
54
- "endpoint__replacement",
55
- )
56
- search_help_text = _(
57
- "Search by endpoint path, route name, app, method or replacement (for "
58
- "example 'herriak' or 'artikuluak')."
59
- )
60
- change_list_template = "admin/django_api_usage/endpointstat/change_list.html"
61
-
62
- def has_add_permission(self, request):
63
- return False
64
-
65
- def has_change_permission(self, request, obj=None):
66
- return False
67
-
68
- # -- changelist total ---------------------------------------------------
69
-
70
- def changelist_view(self, request, extra_context=None):
71
- """Add the sum of ``count`` over the *filtered* queryset to the context."""
72
- response = super().changelist_view(request, extra_context=extra_context)
73
- try:
74
- changelist = response.context_data["cl"]
75
- except (AttributeError, KeyError):
76
- return response
77
- response.context_data["usage_total"] = (
78
- changelist.queryset.aggregate(total=Sum("count"))["total"] or 0
79
- )
80
- return response
81
-
82
- # -- custom maintenance views -------------------------------------------
83
-
84
- def get_urls(self):
85
- app_label, model_name = self.opts.app_label, self.opts.model_name
86
- custom = [
87
- path(
88
- "flush/",
89
- self.admin_site.admin_view(self.flush_view),
90
- name=f"{app_label}_{model_name}_flush",
91
- ),
92
- path(
93
- "clear/",
94
- self.admin_site.admin_view(self.clear_view),
95
- name=f"{app_label}_{model_name}_clear",
96
- ),
97
- ]
98
- return custom + super().get_urls()
99
-
100
- def _changelist_url(self):
101
- app_label, model_name = self.opts.app_label, self.opts.model_name
102
- return reverse(f"admin:{app_label}_{model_name}_changelist")
103
-
104
- def flush_view(self, request):
105
- """Move buffered counters from the cache into the database."""
106
- if request.method != "POST":
107
- return HttpResponseRedirect(self._changelist_url())
108
- written = flush()
109
- if written:
110
- self.message_user(
111
- request,
112
- format_lazy(_("Flushed {count} buffered counter(s)."), count=written),
113
- messages.SUCCESS,
114
- )
115
- else:
116
- self.message_user(
117
- request,
118
- _(
119
- "Nothing was buffered: with BUFFER_BACKEND='db' counters are "
120
- "already in the database."
121
- ),
122
- messages.INFO,
123
- )
124
- return HttpResponseRedirect(self._changelist_url())
125
-
126
- def clear_view(self, request):
127
- """Empty the aggregated counters (a confirmation page is shown first)."""
128
- if request.method != "POST":
129
- context = {
130
- **self.admin_site.each_context(request),
131
- "title": _("Clear statistics"),
132
- "opts": self.opts,
133
- "total": EndpointStat.objects.count(),
134
- }
135
- return TemplateResponse(
136
- request,
137
- "admin/django_api_usage/endpointstat/confirm_clear.html",
138
- context,
139
- )
140
- deleted = EndpointStat.objects.all().delete()[0]
141
- self.message_user(
142
- request,
143
- format_lazy(_("Deleted {count} statistic row(s)."), count=deleted),
144
- messages.SUCCESS,
145
- )
146
- return HttpResponseRedirect(self._changelist_url())
147
-
148
-
149
- @admin.register(Consumer)
150
- class ConsumerAdmin(admin.ModelAdmin):
151
- list_display = (
152
- "kind",
153
- "ref_short",
154
- "user_agent_family",
155
- "request_count",
156
- "first_seen",
157
- "last_seen",
158
- "contact",
159
- )
160
- list_filter = ("kind",)
161
- search_fields = ("contact", "notes")
162
- list_editable = ("contact",)
163
-
164
- @admin.display(description="Reference")
165
- def ref_short(self, obj):
166
- return obj.ref_hash[:12]