django-api-usage 0.1.7__tar.gz → 0.1.8__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 (48) hide show
  1. {django_api_usage-0.1.7/src/django_api_usage.egg-info → django_api_usage-0.1.8}/PKG-INFO +28 -16
  2. {django_api_usage-0.1.7 → django_api_usage-0.1.8}/README.md +27 -15
  3. {django_api_usage-0.1.7 → django_api_usage-0.1.8}/pyproject.toml +1 -1
  4. {django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage/__init__.py +1 -1
  5. {django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage/admin.py +82 -11
  6. {django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage/apps.py +6 -7
  7. django_api_usage-0.1.8/src/django_api_usage/locale/eu/LC_MESSAGES/django.mo +0 -0
  8. {django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage/locale/eu/LC_MESSAGES/django.po +41 -21
  9. django_api_usage-0.1.8/src/django_api_usage/management/commands/api_usage_backfill_paths.py +59 -0
  10. django_api_usage-0.1.8/src/django_api_usage/migrations/0004_remove_clientapp_accounts_clientappaccount.py +77 -0
  11. {django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage/models.py +31 -6
  12. {django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage/resolvers.py +15 -5
  13. {django_api_usage-0.1.7 → django_api_usage-0.1.8/src/django_api_usage.egg-info}/PKG-INFO +28 -16
  14. {django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage.egg-info/SOURCES.txt +3 -0
  15. django_api_usage-0.1.8/tests/test_backfill_paths.py +66 -0
  16. {django_api_usage-0.1.7 → django_api_usage-0.1.8}/tests/test_client_app.py +36 -2
  17. {django_api_usage-0.1.7 → django_api_usage-0.1.8}/tests/test_filters.py +47 -0
  18. django_api_usage-0.1.7/src/django_api_usage/locale/eu/LC_MESSAGES/django.mo +0 -0
  19. {django_api_usage-0.1.7 → django_api_usage-0.1.8}/LICENSE +0 -0
  20. {django_api_usage-0.1.7 → django_api_usage-0.1.8}/setup.cfg +0 -0
  21. {django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage/buffers.py +0 -0
  22. {django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage/checks.py +0 -0
  23. {django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage/conf.py +0 -0
  24. {django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage/deprecation.py +0 -0
  25. {django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage/drf.py +0 -0
  26. {django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage/maintenance.py +0 -0
  27. {django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage/management/__init__.py +0 -0
  28. {django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage/management/commands/__init__.py +0 -0
  29. {django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage/management/commands/api_usage_flush.py +0 -0
  30. {django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage/management/commands/api_usage_report.py +0 -0
  31. {django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage/middleware.py +0 -0
  32. {django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage/migrations/0001_initial.py +0 -0
  33. {django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage/migrations/0002_endpoint_route_path.py +0 -0
  34. {django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage/migrations/0003_alter_endpointstat_unique_together_and_more.py +0 -0
  35. {django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage/migrations/__init__.py +0 -0
  36. {django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage/tasks.py +0 -0
  37. {django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage/templates/admin/django_api_usage/endpointstat/change_list.html +0 -0
  38. {django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage/templates/admin/django_api_usage/endpointstat/confirm_clear.html +0 -0
  39. {django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage.egg-info/dependency_links.txt +0 -0
  40. {django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage.egg-info/requires.txt +0 -0
  41. {django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage.egg-info/top_level.txt +0 -0
  42. {django_api_usage-0.1.7 → django_api_usage-0.1.8}/tests/test_admin.py +0 -0
  43. {django_api_usage-0.1.7 → django_api_usage-0.1.8}/tests/test_checks.py +0 -0
  44. {django_api_usage-0.1.7 → django_api_usage-0.1.8}/tests/test_commands.py +0 -0
  45. {django_api_usage-0.1.7 → django_api_usage-0.1.8}/tests/test_csv_export.py +0 -0
  46. {django_api_usage-0.1.7 → django_api_usage-0.1.8}/tests/test_drf.py +0 -0
  47. {django_api_usage-0.1.7 → django_api_usage-0.1.8}/tests/test_middleware.py +0 -0
  48. {django_api_usage-0.1.7 → django_api_usage-0.1.8}/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.7
3
+ Version: 0.1.8
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
@@ -130,7 +130,9 @@ similar):
130
130
  method or replacement), filters by date, client type, status class, and
131
131
  **Application** / **Method**, plus the **sum of the `count` column** for the
132
132
  rows matching the current filters. The Method filter lists the riskiest verbs
133
- first (`DELETE`, `POST`, ...), so write and destructive calls stand out.
133
+ first (`DELETE`, `POST`, ...), and the list shows **Application** and
134
+ **Method** as columns — the verb as a coloured badge (writes amber, deletes
135
+ red), so the dangerous traffic is obvious at a glance.
134
136
  * **Endpoint** changelist: the same Application / Method filters, plus
135
137
  deprecation state, the site and the CSV export.
136
138
  * **Flush now**: moves counters buffered in the cache into the database, so they
@@ -142,23 +144,33 @@ similar):
142
144
  and method), so the file can be fed straight to pandas or a spreadsheet. Use
143
145
  the "select all" link to export every row matching the current filters.
144
146
 
147
+ If you upgraded from an earlier release, run
148
+ `manage.py api_usage_backfill_paths` once: endpoints recorded before the path was
149
+ captured only have their route name, and the command resolves it back to the
150
+ path (`--dry-run` first if you want to see what it would do).
151
+
145
152
  ## Client applications
146
153
 
147
154
  Not every caller is a person: ERPs, partners, your own web front end and the
148
- native mobile apps also hit the API. `ClientApp` is an **editable table**, so a
149
- new consumer is recognised from the admin, without a deploy:
150
-
151
- | Rule | Matched against | Use it for |
152
- |---|---|---|
153
- | `accounts` | the authenticated user (and therefore its DRF token) | one dedicated token per integration (ERP, partner...) |
154
- | `domains` | the `Origin`/`Referer` host, subdomains included | your own web front end |
155
- | `ip_networks` | the client address, against a CIDR (one per line) | internal networks and servers |
156
- | `user_agent_patterns` | a case-insensitive substring of `User-Agent` | native mobile apps |
157
-
158
- Rules are checked in that order, and `priority` decides between applications
159
- (lower wins). A caller matching nothing stays unattributed, so the dimension
160
- cannot grow out of control: `EndpointStat.client_app` only ever holds a
161
- `ClientApp.slug`.
155
+ native mobile apps also hit the API. `ClientApp` is an **editable table**, and
156
+ the rules are checked in this order:
157
+
158
+ | # | Rule | Matched against | Use it for |
159
+ |---|---|---|---|
160
+ | 1 | `ClientAppAccount` | the authenticated account (and therefore its DRF token) | one dedicated token per integration (ERP, partner...) |
161
+ | 2 | `domains` | the `Origin`/`Referer` host, subdomains included | your own web front end |
162
+ | 3 | `ip_networks` | the client address, against a CIDR (one per line) | internal networks and servers |
163
+ | 4 | `user_agent_patterns` | a case-insensitive substring of `User-Agent` | native mobile apps |
164
+
165
+ An explicit assignment always wins: if the token's account belongs to an
166
+ application, that is the answer. Accounts without an assignment (an app user
167
+ reading the news) fall through to the user agent, which is what identifies the
168
+ native apps. `priority` decides between applications (lower wins), and a caller
169
+ matching nothing stays unattributed.
170
+
171
+ Assignments are one row per account (a point lookup, indexed) instead of a
172
+ many-to-many list holding every account, so nothing grows with the number of
173
+ users: `EndpointStat.client_app` only ever holds a `ClientApp.slug`.
162
174
 
163
175
  Counters, the CSV export and the admin filters all carry the application, so
164
176
  "which application calls this endpoint?" is one filter away. The **Endpoint
@@ -91,7 +91,9 @@ similar):
91
91
  method or replacement), filters by date, client type, status class, and
92
92
  **Application** / **Method**, plus the **sum of the `count` column** for the
93
93
  rows matching the current filters. The Method filter lists the riskiest verbs
94
- first (`DELETE`, `POST`, ...), so write and destructive calls stand out.
94
+ first (`DELETE`, `POST`, ...), and the list shows **Application** and
95
+ **Method** as columns — the verb as a coloured badge (writes amber, deletes
96
+ red), so the dangerous traffic is obvious at a glance.
95
97
  * **Endpoint** changelist: the same Application / Method filters, plus
96
98
  deprecation state, the site and the CSV export.
97
99
  * **Flush now**: moves counters buffered in the cache into the database, so they
@@ -103,23 +105,33 @@ similar):
103
105
  and method), so the file can be fed straight to pandas or a spreadsheet. Use
104
106
  the "select all" link to export every row matching the current filters.
105
107
 
108
+ If you upgraded from an earlier release, run
109
+ `manage.py api_usage_backfill_paths` once: endpoints recorded before the path was
110
+ captured only have their route name, and the command resolves it back to the
111
+ path (`--dry-run` first if you want to see what it would do).
112
+
106
113
  ## Client applications
107
114
 
108
115
  Not every caller is a person: ERPs, partners, your own web front end and the
109
- native mobile apps also hit the API. `ClientApp` is an **editable table**, so a
110
- new consumer is recognised from the admin, without a deploy:
111
-
112
- | Rule | Matched against | Use it for |
113
- |---|---|---|
114
- | `accounts` | the authenticated user (and therefore its DRF token) | one dedicated token per integration (ERP, partner...) |
115
- | `domains` | the `Origin`/`Referer` host, subdomains included | your own web front end |
116
- | `ip_networks` | the client address, against a CIDR (one per line) | internal networks and servers |
117
- | `user_agent_patterns` | a case-insensitive substring of `User-Agent` | native mobile apps |
118
-
119
- Rules are checked in that order, and `priority` decides between applications
120
- (lower wins). A caller matching nothing stays unattributed, so the dimension
121
- cannot grow out of control: `EndpointStat.client_app` only ever holds a
122
- `ClientApp.slug`.
116
+ native mobile apps also hit the API. `ClientApp` is an **editable table**, and
117
+ the rules are checked in this order:
118
+
119
+ | # | Rule | Matched against | Use it for |
120
+ |---|---|---|---|
121
+ | 1 | `ClientAppAccount` | the authenticated account (and therefore its DRF token) | one dedicated token per integration (ERP, partner...) |
122
+ | 2 | `domains` | the `Origin`/`Referer` host, subdomains included | your own web front end |
123
+ | 3 | `ip_networks` | the client address, against a CIDR (one per line) | internal networks and servers |
124
+ | 4 | `user_agent_patterns` | a case-insensitive substring of `User-Agent` | native mobile apps |
125
+
126
+ An explicit assignment always wins: if the token's account belongs to an
127
+ application, that is the answer. Accounts without an assignment (an app user
128
+ reading the news) fall through to the user agent, which is what identifies the
129
+ native apps. `priority` decides between applications (lower wins), and a caller
130
+ matching nothing stays unattributed.
131
+
132
+ Assignments are one row per account (a point lookup, indexed) instead of a
133
+ many-to-many list holding every account, so nothing grows with the number of
134
+ users: `EndpointStat.client_app` only ever holds a `ClientApp.slug`.
123
135
 
124
136
  Counters, the CSV export and the admin filters all carry the application, so
125
137
  "which application calls this endpoint?" is one filter away. The **Endpoint
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "django-api-usage"
7
- version = "0.1.7"
7
+ version = "0.1.8"
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"
@@ -1,3 +1,3 @@
1
1
  """django-api-usage: usage metering for Django APIs, with deprecation support."""
2
2
 
3
- __version__ = "0.1.7"
3
+ __version__ = "0.1.8"
@@ -21,11 +21,12 @@ from django.http import HttpResponse, HttpResponseRedirect
21
21
  from django.template.response import TemplateResponse
22
22
  from django.urls import path, reverse
23
23
  from django.utils import timezone
24
+ from django.utils.html import format_html
24
25
  from django.utils.text import format_lazy
25
26
  from django.utils.translation import gettext_lazy as _
26
27
 
27
28
  from .buffers import flush
28
- from .models import ClientApp, Consumer, Endpoint, EndpointStat
29
+ from .models import ClientApp, ClientAppAccount, Consumer, Endpoint, EndpointStat
29
30
 
30
31
 
31
32
  class CsvExportMixin:
@@ -64,6 +65,31 @@ class CsvExportMixin:
64
65
  #: write/destructive calls (POST, DELETE, ...) instead of the read ones.
65
66
  METHOD_ORDER = ("DELETE", "POST", "PUT", "PATCH", "GET", "HEAD", "OPTIONS")
66
67
 
68
+ #: Pill colours per verb: reads green/blue, writes amber, deletes red.
69
+ METHOD_COLORS = {
70
+ "GET": ("#d1e7dd", "#0f5132"),
71
+ "POST": ("#cfe2ff", "#084298"),
72
+ "PUT": ("#fff3cd", "#664d03"),
73
+ "PATCH": ("#fff3cd", "#664d03"),
74
+ "DELETE": ("#f8d7da", "#842029"),
75
+ "HEAD": ("#e2e3e5", "#41464b"),
76
+ "OPTIONS": ("#e2e3e5", "#41464b"),
77
+ }
78
+ DEFAULT_METHOD_COLORS = ("#e2e3e5", "#41464b")
79
+
80
+
81
+ def _method_badge_html(method):
82
+ """Coloured pill for an HTTP verb, so writes stand out at a glance."""
83
+ verb = (method or "").upper()
84
+ background, color = METHOD_COLORS.get(verb, DEFAULT_METHOD_COLORS)
85
+ return format_html(
86
+ '<span style="background:{};color:{};padding:1px 7px;border-radius:9px;'
87
+ 'font-size:11px;font-weight:600;white-space:nowrap">{}</span>',
88
+ background,
89
+ color,
90
+ verb,
91
+ )
92
+
67
93
 
68
94
  class AppLabelListFilter(admin.SimpleListFilter):
69
95
  """Distinct application labels, under a readable title."""
@@ -180,7 +206,7 @@ class EndpointAdmin(CsvExportMixin, admin.ModelAdmin):
180
206
  "app_label",
181
207
  "route_path",
182
208
  "route_name",
183
- "method",
209
+ "method_badge",
184
210
  "site_id",
185
211
  "deprecated",
186
212
  "sunset_date",
@@ -196,6 +222,10 @@ class EndpointAdmin(CsvExportMixin, admin.ModelAdmin):
196
222
  search_fields = ("route_path", "route_name", "replacement", "owner", "notes")
197
223
  list_editable = ("deprecated", "sunset_date", "replacement", "owner")
198
224
 
225
+ @admin.display(description=_("Method"), ordering="method")
226
+ def method_badge(self, obj):
227
+ return _method_badge_html(obj.method)
228
+
199
229
 
200
230
  @admin.register(EndpointStat)
201
231
  class EndpointStatAdmin(CsvExportMixin, admin.ModelAdmin):
@@ -216,7 +246,9 @@ class EndpointStatAdmin(CsvExportMixin, admin.ModelAdmin):
216
246
  )
217
247
  list_display = (
218
248
  "date",
219
- "endpoint",
249
+ "application",
250
+ "method_badge",
251
+ "endpoint_path",
220
252
  "client_type",
221
253
  "client_app",
222
254
  "status_class",
@@ -246,6 +278,22 @@ class EndpointStatAdmin(CsvExportMixin, admin.ModelAdmin):
246
278
  )
247
279
  change_list_template = "admin/django_api_usage/endpointstat/change_list.html"
248
280
 
281
+ # -- columns ------------------------------------------------------------
282
+
283
+ @admin.display(description=_("Application"), ordering="endpoint__app_label")
284
+ def application(self, obj):
285
+ return obj.endpoint.app_label
286
+
287
+ @admin.display(description=_("Method"), ordering="endpoint__method")
288
+ def method_badge(self, obj):
289
+ return _method_badge_html(obj.endpoint.method)
290
+
291
+ @admin.display(description=_("Endpoint"), ordering="endpoint__route_path")
292
+ def endpoint_path(self, obj):
293
+ """Just the path: the verb and the app already have their own columns."""
294
+ endpoint = obj.endpoint
295
+ return (endpoint.route_path or endpoint.route_name or "").strip("^$")
296
+
249
297
  def has_add_permission(self, request):
250
298
  return False
251
299
 
@@ -388,22 +436,17 @@ class ClientAppAdmin(CsvExportMixin, admin.ModelAdmin):
388
436
  )
389
437
  list_filter = ("is_active",)
390
438
  search_fields = ("slug", "name", "description")
391
- filter_horizontal = ("accounts",)
392
439
  fieldsets = (
393
440
  (None, {"fields": ("slug", "name", "description", "priority", "is_active")}),
394
441
  (
395
442
  _("Matching rules"),
396
443
  {
397
- "fields": (
398
- "accounts",
399
- "domains",
400
- "ip_networks",
401
- "user_agent_patterns",
402
- ),
444
+ "fields": ("domains", "ip_networks", "user_agent_patterns"),
403
445
  "description": _(
404
446
  "Checked in this order: account, request host, client "
405
447
  "network, user agent. Between applications, the lowest "
406
- "priority wins."
448
+ "priority wins. Accounts are assigned from the "
449
+ "'Client application accounts' table."
407
450
  ),
408
451
  },
409
452
  ),
@@ -431,3 +474,31 @@ class ClientAppAdmin(CsvExportMixin, admin.ModelAdmin):
431
474
  @admin.display(description=_("Counters"), ordering="counters_total")
432
475
  def counters_total(self, obj):
433
476
  return obj.counters_total or 0
477
+
478
+
479
+ @admin.register(ClientAppAccount)
480
+ class ClientAppAccountAdmin(CsvExportMixin, admin.ModelAdmin):
481
+ """One row per assigned account: search, do not scroll a giant list."""
482
+
483
+ actions = ("export_as_csv",)
484
+ csv_columns = (
485
+ ("client_app", "client_app__slug"),
486
+ ("username", "user__username"),
487
+ ("email", "user__email"),
488
+ ("notes", "notes"),
489
+ )
490
+ list_display = ("client_app", "account", "notes")
491
+ list_filter = ("client_app",)
492
+ search_fields = (
493
+ "user__username",
494
+ "user__email",
495
+ "user__first_name",
496
+ "user__last_name",
497
+ "notes",
498
+ )
499
+ raw_id_fields = ("user",)
500
+ list_select_related = ("user", "client_app")
501
+
502
+ @admin.display(description=_("Account"), ordering="user__username")
503
+ def account(self, obj):
504
+ return obj.user
@@ -13,8 +13,12 @@ class DjangoApiUsageConfig(AppConfig):
13
13
  self._connect_client_app_signals()
14
14
 
15
15
  def _connect_client_app_signals(self):
16
- """Drop the cached ClientApp rules as soon as the table changes."""
17
- from django.db.models.signals import m2m_changed, post_delete, post_save
16
+ """Drop the cached ClientApp rules as soon as the table changes.
17
+
18
+ Account assignments are not cached (they are a single indexed lookup),
19
+ so only the rule table needs to invalidate anything.
20
+ """
21
+ from django.db.models.signals import post_delete, post_save
18
22
 
19
23
  from .models import ClientApp
20
24
  from .resolvers import reset_client_app_cache
@@ -27,8 +31,3 @@ class DjangoApiUsageConfig(AppConfig):
27
31
  sender=ClientApp,
28
32
  dispatch_uid="api_usage_app_delete",
29
33
  )
30
- m2m_changed.connect(
31
- reset_client_app_cache,
32
- sender=ClientApp.accounts.through,
33
- dispatch_uid="api_usage_app_accounts",
34
- )
@@ -7,7 +7,7 @@ msgid ""
7
7
  msgstr ""
8
8
  "Project-Id-Version: django-api-usage\n"
9
9
  "Report-Msgid-Bugs-To: \n"
10
- "POT-Creation-Date: 2026-10-08 06:45-0500\n"
10
+ "POT-Creation-Date: 2026-10-08 07:11-0500\n"
11
11
  "PO-Revision-Date: 2026-10-08 06:55-0500\n"
12
12
  "Last-Translator: Urtzi Odriozola <uodriozola@codesyntax.com>\n"
13
13
  "Language-Team: Basque\n"
@@ -29,7 +29,7 @@ msgstr "Aplikazioa"
29
29
  msgid "Method"
30
30
  msgstr "Metodoa"
31
31
 
32
- #: src/django_api_usage/admin.py:136 src/django_api_usage/models.py:106
32
+ #: src/django_api_usage/admin.py:136 src/django_api_usage/models.py:100
33
33
  msgid "Client application"
34
34
  msgstr "Bezero-aplikazioa"
35
35
 
@@ -55,8 +55,8 @@ msgid ""
55
55
  "Nothing was buffered: with BUFFER_BACKEND='db' counters are already in the "
56
56
  "database."
57
57
  msgstr ""
58
- "Ez zegoen ezer bufferrean: BUFFER_BACKEND='db' denean kontagailuak jada "
59
- "datu-basean daude."
58
+ "Ez zegoen ezer bufferrean: BUFFER_BACKEND='db' denean kontagailuak jada datu-"
59
+ "basean daude."
60
60
 
61
61
  #: src/django_api_usage/admin.py:318
62
62
  #: src/django_api_usage/templates/admin/django_api_usage/endpointstat/change_list.html:12
@@ -69,26 +69,32 @@ msgstr "Estatistikak hustu"
69
69
  msgid "Deleted {count} statistic row(s)."
70
70
  msgstr "{count} estatistika-errenkada ezabatu dira."
71
71
 
72
- #: src/django_api_usage/admin.py:395
72
+ #: src/django_api_usage/admin.py:394
73
73
  msgid "Matching rules"
74
74
  msgstr "Bat-etortze arauak"
75
75
 
76
- #: src/django_api_usage/admin.py:404
76
+ #: src/django_api_usage/admin.py:398
77
77
  msgid ""
78
78
  "Checked in this order: account, request host, client network, user agent. "
79
- "Between applications, the lowest priority wins."
79
+ "Between applications, the lowest priority wins. Accounts are assigned from "
80
+ "the 'Client application accounts' table."
80
81
  msgstr ""
81
- "Ordena honetan egiaztatzen da: kontua, eskaeraren ostalaria, bezeroaren sarea "
82
- "eta user agent-a. Aplikazioen artean, lehentasun txikiena duenak irabazten du."
82
+ "Ordena honetan egiaztatzen da: kontua, eskaeraren ostalaria, bezeroaren "
83
+ "sarea eta user agent-a. Aplikazioen artean, lehentasun txikiena duenak "
84
+ "irabazten du. Kontuak 'Bezero-aplikazioko kontuak' taulatik esleitzen dira."
83
85
 
84
- #: src/django_api_usage/admin.py:427
86
+ #: src/django_api_usage/admin.py:422
85
87
  msgid "Accounts"
86
88
  msgstr "Kontuak"
87
89
 
88
- #: src/django_api_usage/admin.py:431
90
+ #: src/django_api_usage/admin.py:426
89
91
  msgid "Counters"
90
92
  msgstr "Kontagailuak"
91
93
 
94
+ #: src/django_api_usage/admin.py:454 src/django_api_usage/models.py:134
95
+ msgid "Account"
96
+ msgstr "Kontua"
97
+
92
98
  #: src/django_api_usage/models.py:79
93
99
  msgid "Short, stable label stored in the counters (e.g. 'mugikorra')."
94
100
  msgstr ""
@@ -98,26 +104,36 @@ msgstr ""
98
104
  msgid "Lower wins when several apps could match."
99
105
  msgstr "Hainbat aplikaziok bat etorri ahalketenean, txikienak irabazten du."
100
106
 
101
- #: src/django_api_usage/models.py:91
102
- msgid "Users (and therefore DRF tokens) that belong to this app."
103
- msgstr "Aplikazio honi dagozkion erabiltzaileak (eta, beraz, haien DRF tokenak)."
104
-
105
- #: src/django_api_usage/models.py:95
107
+ #: src/django_api_usage/models.py:89
106
108
  msgid "One host per line; matched against the Origin/Referer host."
107
- msgstr "Ostalari bat lerroko; Origin/Referer ostalariaren aurka egiaztatzen da."
109
+ msgstr ""
110
+ "Ostalari bat lerroko; Origin/Referer ostalariaren aurka egiaztatzen da."
108
111
 
109
- #: src/django_api_usage/models.py:98
112
+ #: src/django_api_usage/models.py:92
110
113
  msgid "One CIDR per line, e.g. 10.0.0.0/8."
111
114
  msgstr "CIDR bat lerroko, adib. 10.0.0.0/8."
112
115
 
113
- #: src/django_api_usage/models.py:102
116
+ #: src/django_api_usage/models.py:96
114
117
  msgid "One substring per line, matched case-insensitively."
115
- msgstr "Azpikate bat lerroko; maiuskulak/minuskulak bereizi gabe egiaztatzen da."
118
+ msgstr ""
119
+ "Azpikate bat lerroko; maiuskulak/minuskulak bereizi gabe egiaztatzen da."
116
120
 
117
- #: src/django_api_usage/models.py:107
121
+ #: src/django_api_usage/models.py:101
118
122
  msgid "Client applications"
119
123
  msgstr "Bezero-aplikazioak"
120
124
 
125
+ #: src/django_api_usage/models.py:135
126
+ msgid "One application per account, so the token is unambiguous."
127
+ msgstr "Aplikazio bat kontu bakoitzeko, tokena zalantzarik gabea izan dadin."
128
+
129
+ #: src/django_api_usage/models.py:140
130
+ msgid "Client application account"
131
+ msgstr "Bezero-aplikazioko kontua"
132
+
133
+ #: src/django_api_usage/models.py:141
134
+ msgid "Client application accounts"
135
+ msgstr "Bezero-aplikazioko kontuak"
136
+
121
137
  #: src/django_api_usage/templates/admin/django_api_usage/endpointstat/change_list.html:8
122
138
  msgid "Flush now"
123
139
  msgstr "Flush orain"
@@ -167,3 +183,7 @@ msgstr "Bai, estatistikak hustu"
167
183
  #: src/django_api_usage/templates/admin/django_api_usage/endpointstat/confirm_clear.html:25
168
184
  msgid "Cancel"
169
185
  msgstr ""
186
+
187
+ #~ msgid "Users (and therefore DRF tokens) that belong to this app."
188
+ #~ msgstr ""
189
+ #~ "Aplikazio honi dagozkion erabiltzaileak (eta, beraz, haien DRF tokenak)."
@@ -0,0 +1,59 @@
1
+ """Fill in the route path of endpoints recorded before it was captured.
2
+
3
+ ``Endpoint.route_path`` was added in 0.1.3, so endpoints recorded earlier only
4
+ have their route *name* (``town-list``). Running this once resolves the name
5
+ back to its path (``api/3.0/herriak/``), which is what the admin shows.
6
+
7
+ python manage.py api_usage_backfill_paths --dry-run
8
+ python manage.py api_usage_backfill_paths
9
+ """
10
+
11
+ from django.core.management.base import BaseCommand
12
+ from django.urls import NoReverseMatch, reverse
13
+
14
+ from django_api_usage.models import Endpoint
15
+
16
+
17
+ class Command(BaseCommand):
18
+ help = "Resolve the route path of endpoints that do not have one yet."
19
+
20
+ def add_arguments(self, parser):
21
+ parser.add_argument(
22
+ "--dry-run",
23
+ action="store_true",
24
+ help="Report what would change without writing anything.",
25
+ )
26
+
27
+ def handle(self, *args, **options):
28
+ dry_run = options["dry_run"]
29
+ resolved = unresolved = 0
30
+
31
+ for endpoint in Endpoint.objects.filter(route_path="").order_by("pk"):
32
+ path = self.resolve(endpoint.route_name)
33
+ if not path:
34
+ unresolved += 1
35
+ self.stderr.write(
36
+ f" could not resolve {endpoint.route_name!r} (left as it is)"
37
+ )
38
+ continue
39
+ resolved += 1
40
+ if not dry_run:
41
+ Endpoint.objects.filter(pk=endpoint.pk).update(route_path=path)
42
+
43
+ verb = "would be filled in" if dry_run else "filled in"
44
+ self.stdout.write(
45
+ f"{resolved} route path(s) {verb}; {unresolved} left without a path."
46
+ )
47
+
48
+ @staticmethod
49
+ def resolve(route_name):
50
+ """Path for a named route; a route that already is a pattern is reused."""
51
+ if not route_name or route_name == "unknown":
52
+ return ""
53
+ if route_name.startswith("^"):
54
+ # No url name: the captured "name" is the pattern itself.
55
+ return route_name.strip("^$")
56
+ try:
57
+ return reverse(route_name).lstrip("/")
58
+ except NoReverseMatch:
59
+ return ""
@@ -0,0 +1,77 @@
1
+ # Generated by Django 4.2.30 on 2026-10-08 12:08
2
+
3
+ from django.conf import settings
4
+ from django.db import migrations, models
5
+ import django.db.models.deletion
6
+
7
+
8
+ def accounts_to_rows(apps, schema_editor):
9
+ """Keep the assignments made through the removed many-to-many field.
10
+
11
+ ``ClientApp.accounts`` (0003) held every assigned account in a join table.
12
+ It was replaced by one row per account, which the resolver can read with a
13
+ single indexed lookup instead of loading the whole list into memory.
14
+ """
15
+ ClientApp = apps.get_model("django_api_usage", "ClientApp")
16
+ ClientAppAccount = apps.get_model("django_api_usage", "ClientAppAccount")
17
+
18
+ rows = [
19
+ ClientAppAccount(client_app_id=app.pk, user_id=user_id)
20
+ for app in ClientApp.objects.all()
21
+ for user_id in app.accounts.values_list("pk", flat=True)
22
+ ]
23
+ ClientAppAccount.objects.bulk_create(rows, ignore_conflicts=True)
24
+
25
+
26
+ class Migration(migrations.Migration):
27
+
28
+ dependencies = [
29
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
30
+ ("django_api_usage", "0003_alter_endpointstat_unique_together_and_more"),
31
+ ]
32
+
33
+ operations = [
34
+ migrations.CreateModel(
35
+ name="ClientAppAccount",
36
+ fields=[
37
+ (
38
+ "id",
39
+ models.BigAutoField(
40
+ auto_created=True,
41
+ primary_key=True,
42
+ serialize=False,
43
+ verbose_name="ID",
44
+ ),
45
+ ),
46
+ ("notes", models.CharField(blank=True, max_length=200)),
47
+ (
48
+ "client_app",
49
+ models.ForeignKey(
50
+ on_delete=django.db.models.deletion.CASCADE,
51
+ related_name="accounts",
52
+ to="django_api_usage.clientapp",
53
+ ),
54
+ ),
55
+ (
56
+ "user",
57
+ models.OneToOneField(
58
+ help_text="One application per account, so the token is unambiguous.",
59
+ on_delete=django.db.models.deletion.CASCADE,
60
+ related_name="api_usage_client_app",
61
+ to=settings.AUTH_USER_MODEL,
62
+ verbose_name="Account",
63
+ ),
64
+ ),
65
+ ],
66
+ options={
67
+ "verbose_name": "Client application account",
68
+ "verbose_name_plural": "Client application accounts",
69
+ "ordering": ("client_app", "user"),
70
+ },
71
+ ),
72
+ migrations.RunPython(accounts_to_rows, migrations.RunPython.noop),
73
+ migrations.RemoveField(
74
+ model_name="clientapp",
75
+ name="accounts",
76
+ ),
77
+ ]
@@ -84,12 +84,6 @@ class ClientApp(models.Model):
84
84
  priority = models.PositiveSmallIntegerField(
85
85
  default=100, help_text=_("Lower wins when several apps could match.")
86
86
  )
87
- accounts = models.ManyToManyField(
88
- settings.AUTH_USER_MODEL,
89
- blank=True,
90
- related_name="+",
91
- help_text=_("Users (and therefore DRF tokens) that belong to this app."),
92
- )
93
87
  domains = models.TextField(
94
88
  blank=True,
95
89
  help_text=_("One host per line; matched against the Origin/Referer host."),
@@ -120,6 +114,37 @@ class ClientApp(models.Model):
120
114
  return [pattern.lower() for pattern in _lines(self.user_agent_patterns)]
121
115
 
122
116
 
117
+ class ClientAppAccount(models.Model):
118
+ """Assigns one account (and therefore its DRF token) to an application.
119
+
120
+ One row per account, indexed, instead of a many-to-many holding the account
121
+ list of every application: the resolver does a single point lookup and
122
+ nothing has to be loaded into memory. Only real integrations belong here
123
+ (an ERP, a partner); ordinary readers of the mobile apps are matched by
124
+ their user agent, not by account.
125
+ """
126
+
127
+ client_app = models.ForeignKey(
128
+ ClientApp, on_delete=models.CASCADE, related_name="accounts"
129
+ )
130
+ user = models.OneToOneField(
131
+ settings.AUTH_USER_MODEL,
132
+ on_delete=models.CASCADE,
133
+ related_name="api_usage_client_app",
134
+ verbose_name=_("Account"),
135
+ help_text=_("One application per account, so the token is unambiguous."),
136
+ )
137
+ notes = models.CharField(max_length=200, blank=True)
138
+
139
+ class Meta:
140
+ verbose_name = _("Client application account")
141
+ verbose_name_plural = _("Client application accounts")
142
+ ordering = ("client_app", "user")
143
+
144
+ def __str__(self):
145
+ return f"{self.client_app} ← {self.user}"
146
+
147
+
123
148
  class EndpointStat(models.Model):
124
149
  """Aggregated counter per endpoint, day, client and status class."""
125
150
 
@@ -138,11 +138,9 @@ def reset_client_app_cache(**kwargs):
138
138
  def _load_client_app_rules():
139
139
  from .models import ClientApp
140
140
 
141
- rules = {"by_user": {}, "hosts": [], "networks": [], "patterns": []}
142
- apps = ClientApp.objects.filter(is_active=True).prefetch_related("accounts")
141
+ rules = {"hosts": [], "networks": [], "patterns": []}
142
+ apps = ClientApp.objects.filter(is_active=True)
143
143
  for app in apps: # ordered by priority, then slug
144
- for user_id in app.accounts.values_list("pk", flat=True):
145
- rules["by_user"].setdefault(user_id, app.slug)
146
144
  rules["hosts"].extend((host.lower(), app.slug) for host in app.domain_list())
147
145
  for cidr in app.network_list():
148
146
  try:
@@ -159,6 +157,18 @@ def _load_client_app_rules():
159
157
  return rules
160
158
 
161
159
 
160
+ def _client_app_for_account(user_id):
161
+ """Application assigned to an account, with a single indexed lookup."""
162
+ from .models import ClientAppAccount
163
+
164
+ return (
165
+ ClientAppAccount.objects.filter(user_id=user_id, client_app__is_active=True)
166
+ .values_list("client_app__slug", flat=True)
167
+ .first()
168
+ or ""
169
+ )
170
+
171
+
162
172
  def _client_app_rules():
163
173
  now = time.monotonic()
164
174
  if (
@@ -199,7 +209,7 @@ def client_app_from_rules(request):
199
209
 
200
210
  user = getattr(request, "user", None)
201
211
  if user is not None and getattr(user, "is_authenticated", False):
202
- slug = rules["by_user"].get(user.pk)
212
+ slug = _client_app_for_account(user.pk)
203
213
  if slug:
204
214
  return slug
205
215
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-api-usage
3
- Version: 0.1.7
3
+ Version: 0.1.8
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
@@ -130,7 +130,9 @@ similar):
130
130
  method or replacement), filters by date, client type, status class, and
131
131
  **Application** / **Method**, plus the **sum of the `count` column** for the
132
132
  rows matching the current filters. The Method filter lists the riskiest verbs
133
- first (`DELETE`, `POST`, ...), so write and destructive calls stand out.
133
+ first (`DELETE`, `POST`, ...), and the list shows **Application** and
134
+ **Method** as columns — the verb as a coloured badge (writes amber, deletes
135
+ red), so the dangerous traffic is obvious at a glance.
134
136
  * **Endpoint** changelist: the same Application / Method filters, plus
135
137
  deprecation state, the site and the CSV export.
136
138
  * **Flush now**: moves counters buffered in the cache into the database, so they
@@ -142,23 +144,33 @@ similar):
142
144
  and method), so the file can be fed straight to pandas or a spreadsheet. Use
143
145
  the "select all" link to export every row matching the current filters.
144
146
 
147
+ If you upgraded from an earlier release, run
148
+ `manage.py api_usage_backfill_paths` once: endpoints recorded before the path was
149
+ captured only have their route name, and the command resolves it back to the
150
+ path (`--dry-run` first if you want to see what it would do).
151
+
145
152
  ## Client applications
146
153
 
147
154
  Not every caller is a person: ERPs, partners, your own web front end and the
148
- native mobile apps also hit the API. `ClientApp` is an **editable table**, so a
149
- new consumer is recognised from the admin, without a deploy:
150
-
151
- | Rule | Matched against | Use it for |
152
- |---|---|---|
153
- | `accounts` | the authenticated user (and therefore its DRF token) | one dedicated token per integration (ERP, partner...) |
154
- | `domains` | the `Origin`/`Referer` host, subdomains included | your own web front end |
155
- | `ip_networks` | the client address, against a CIDR (one per line) | internal networks and servers |
156
- | `user_agent_patterns` | a case-insensitive substring of `User-Agent` | native mobile apps |
157
-
158
- Rules are checked in that order, and `priority` decides between applications
159
- (lower wins). A caller matching nothing stays unattributed, so the dimension
160
- cannot grow out of control: `EndpointStat.client_app` only ever holds a
161
- `ClientApp.slug`.
155
+ native mobile apps also hit the API. `ClientApp` is an **editable table**, and
156
+ the rules are checked in this order:
157
+
158
+ | # | Rule | Matched against | Use it for |
159
+ |---|---|---|---|
160
+ | 1 | `ClientAppAccount` | the authenticated account (and therefore its DRF token) | one dedicated token per integration (ERP, partner...) |
161
+ | 2 | `domains` | the `Origin`/`Referer` host, subdomains included | your own web front end |
162
+ | 3 | `ip_networks` | the client address, against a CIDR (one per line) | internal networks and servers |
163
+ | 4 | `user_agent_patterns` | a case-insensitive substring of `User-Agent` | native mobile apps |
164
+
165
+ An explicit assignment always wins: if the token's account belongs to an
166
+ application, that is the answer. Accounts without an assignment (an app user
167
+ reading the news) fall through to the user agent, which is what identifies the
168
+ native apps. `priority` decides between applications (lower wins), and a caller
169
+ matching nothing stays unattributed.
170
+
171
+ Assignments are one row per account (a point lookup, indexed) instead of a
172
+ many-to-many list holding every account, so nothing grows with the number of
173
+ users: `EndpointStat.client_app` only ever holds a `ClientApp.slug`.
162
174
 
163
175
  Counters, the CSV export and the admin filters all carry the application, so
164
176
  "which application calls this endpoint?" is one filter away. The **Endpoint
@@ -23,15 +23,18 @@ src/django_api_usage/locale/eu/LC_MESSAGES/django.mo
23
23
  src/django_api_usage/locale/eu/LC_MESSAGES/django.po
24
24
  src/django_api_usage/management/__init__.py
25
25
  src/django_api_usage/management/commands/__init__.py
26
+ src/django_api_usage/management/commands/api_usage_backfill_paths.py
26
27
  src/django_api_usage/management/commands/api_usage_flush.py
27
28
  src/django_api_usage/management/commands/api_usage_report.py
28
29
  src/django_api_usage/migrations/0001_initial.py
29
30
  src/django_api_usage/migrations/0002_endpoint_route_path.py
30
31
  src/django_api_usage/migrations/0003_alter_endpointstat_unique_together_and_more.py
32
+ src/django_api_usage/migrations/0004_remove_clientapp_accounts_clientappaccount.py
31
33
  src/django_api_usage/migrations/__init__.py
32
34
  src/django_api_usage/templates/admin/django_api_usage/endpointstat/change_list.html
33
35
  src/django_api_usage/templates/admin/django_api_usage/endpointstat/confirm_clear.html
34
36
  tests/test_admin.py
37
+ tests/test_backfill_paths.py
35
38
  tests/test_checks.py
36
39
  tests/test_client_app.py
37
40
  tests/test_commands.py
@@ -0,0 +1,66 @@
1
+ """Tests for the api_usage_backfill_paths command."""
2
+
3
+ from io import StringIO
4
+
5
+ from django.core.management import call_command
6
+ from django.test import TestCase
7
+
8
+ from django_api_usage.models import Endpoint
9
+
10
+
11
+ class BackfillPathsTest(TestCase):
12
+ def make(self, route_name, route_path=""):
13
+ return Endpoint.objects.create(
14
+ app_label="api",
15
+ route_name=route_name,
16
+ route_path=route_path,
17
+ method="GET",
18
+ )
19
+
20
+ def run_command(self, *args):
21
+ out, err = StringIO(), StringIO()
22
+ call_command("api_usage_backfill_paths", *args, stdout=out, stderr=err)
23
+ return out.getvalue(), err.getvalue()
24
+
25
+ def test_a_named_route_is_resolved_to_its_path(self):
26
+ endpoint = self.make("ping")
27
+
28
+ self.run_command()
29
+
30
+ endpoint.refresh_from_db()
31
+ self.assertEqual(endpoint.route_path, "ping/")
32
+
33
+ def test_a_route_without_a_name_keeps_its_pattern(self):
34
+ endpoint = self.make("^api/3.0/agenda/")
35
+
36
+ self.run_command()
37
+
38
+ endpoint.refresh_from_db()
39
+ self.assertEqual(endpoint.route_path, "api/3.0/agenda/")
40
+
41
+ def test_an_unresolvable_route_is_left_alone_and_reported(self):
42
+ endpoint = self.make("artikuluak-detail")
43
+
44
+ out, err = self.run_command()
45
+
46
+ endpoint.refresh_from_db()
47
+ self.assertEqual(endpoint.route_path, "")
48
+ self.assertIn("could not resolve", err)
49
+ self.assertIn("0 route path(s) filled in", out)
50
+
51
+ def test_dry_run_does_not_write(self):
52
+ endpoint = self.make("ping")
53
+
54
+ out, _ = self.run_command("--dry-run")
55
+
56
+ endpoint.refresh_from_db()
57
+ self.assertEqual(endpoint.route_path, "")
58
+ self.assertIn("would be filled in", out)
59
+
60
+ def test_endpoints_that_already_have_a_path_are_untouched(self):
61
+ endpoint = self.make("ping", route_path="api/3.0/ping/")
62
+
63
+ self.run_command()
64
+
65
+ endpoint.refresh_from_db()
66
+ self.assertEqual(endpoint.route_path, "api/3.0/ping/")
@@ -5,7 +5,7 @@ from django.test import RequestFactory
5
5
  from django.urls import reverse
6
6
 
7
7
  from django_api_usage.buffers import flush
8
- from django_api_usage.models import ClientApp, Endpoint, EndpointStat
8
+ from django_api_usage.models import ClientApp, ClientAppAccount, Endpoint, EndpointStat
9
9
  from django_api_usage.resolvers import client_app_from_rules, reset_client_app_cache
10
10
 
11
11
  from .base import UsageTestCase
@@ -29,10 +29,27 @@ class ClientAppResolverTest(UsageTestCase):
29
29
  """A dedicated token (its user) identifies the integration."""
30
30
  user = User.objects.create_user("zoho", "zoho@example.com", "pw")
31
31
  app = ClientApp.objects.create(slug="zoho", priority=10)
32
- app.accounts.add(user)
32
+ ClientAppAccount.objects.create(client_app=app, user=user)
33
33
 
34
34
  self.assertEqual(client_app_from_rules(self.request(user=user)), "zoho")
35
35
 
36
+ def test_an_account_of_an_inactive_application_is_ignored(self):
37
+ user = User.objects.create_user("zaharra", "z@example.com", "pw")
38
+ app = ClientApp.objects.create(slug="zaharra", is_active=False)
39
+ ClientAppAccount.objects.create(client_app=app, user=user)
40
+
41
+ self.assertEqual(client_app_from_rules(self.request(user=user)), "")
42
+
43
+ def test_the_account_rule_wins_over_the_user_agent(self):
44
+ user = User.objects.create_user("erp", "erp@example.com", "pw")
45
+ erp = ClientApp.objects.create(slug="goiena_erp")
46
+ ClientAppAccount.objects.create(client_app=erp, user=user)
47
+ ClientApp.objects.create(slug="mugikorra", user_agent_patterns="tokio")
48
+
49
+ request = self.request(user=user, HTTP_USER_AGENT="tokio/1")
50
+
51
+ self.assertEqual(client_app_from_rules(request), "goiena_erp")
52
+
36
53
  def test_the_request_host_matches_subdomains(self):
37
54
  ClientApp.objects.create(slug="elhuyar", domains="elhuyar.eus")
38
55
 
@@ -144,6 +161,23 @@ class ClientAppAdminTest(UsageTestCase):
144
161
  self.assertEqual(response.status_code, 200)
145
162
  self.assertContains(response, "Matching rules")
146
163
 
164
+ def test_account_assignments_are_editable_in_the_admin(self):
165
+ app = ClientApp.objects.create(slug="zoho", name="Zoho")
166
+ account = User.objects.create_user("zoho_erabiltzailea", "z@example.com", "pw")
167
+ ClientAppAccount.objects.create(client_app=app, user=account)
168
+
169
+ listing = self.client.get(
170
+ reverse("admin:django_api_usage_clientappaccount_changelist")
171
+ )
172
+
173
+ self.assertEqual(listing.status_code, 200)
174
+ self.assertContains(listing, "zoho_erabiltzailea")
175
+
176
+ add_page = self.client.get(
177
+ reverse("admin:django_api_usage_clientappaccount_add")
178
+ )
179
+ self.assertEqual(add_page.status_code, 200)
180
+
147
181
  def test_stats_can_be_filtered_by_application(self):
148
182
  self.assertEqual(self.rows(client_app="mugikorra"), {self.attributed})
149
183
 
@@ -1,5 +1,7 @@
1
1
  """Tests for the Application / Method changelist filters."""
2
2
 
3
+ import re
4
+
3
5
  from django.contrib import admin as django_admin
4
6
  from django.contrib.auth.models import User
5
7
  from django.urls import reverse
@@ -88,6 +90,51 @@ class FilterTest(UsageTestCase):
88
90
  rows = self.stats(app_label="api")
89
91
  self.assertEqual([row.endpoint for row in rows], [self.post_endpoint])
90
92
 
93
+ # -- columns ------------------------------------------------------------
94
+
95
+ def test_stats_show_the_application_and_the_method_as_columns(self):
96
+ html = self.client.get(reverse(STAT_CHANGELIST)).content.decode()
97
+
98
+ # The columns exist and carry the endpoint's data (not just the titles).
99
+ self.assertIn('class="field-application"', html)
100
+ self.assertIn('class="field-method_badge"', html)
101
+ self.assertIn(">gida</td>", html)
102
+
103
+ def test_the_method_is_rendered_as_a_coloured_badge(self):
104
+ html = self.client.get(reverse(STAT_CHANGELIST)).content.decode()
105
+
106
+ # DELETE in red, POST in blue; the verb lives inside the pill.
107
+ self.assertIn("background:#f8d7da", html)
108
+ self.assertIn(">DELETE</span>", html)
109
+ self.assertIn("background:#cfe2ff", html)
110
+ self.assertIn(">POST</span>", html)
111
+
112
+ def test_the_endpoint_column_shows_only_the_path(self):
113
+ html = self.client.get(reverse(STAT_CHANGELIST)).content.decode()
114
+
115
+ # The verb and the app have their own columns, so the cell carries just
116
+ # the path. Asserted on the cells: from Django 5.1 on, the action
117
+ # checkbox aria-label repeats str(obj), which includes the endpoint.
118
+ cells = re.findall(r'<td class="field-endpoint_path">([^<]*)</td>', html)
119
+
120
+ self.assertCountEqual(cells, ["api/3.0/artikuluak/", "api/3.0/gida/"])
121
+
122
+ def test_the_endpoint_column_strips_the_regex_anchors(self):
123
+ endpoint = Endpoint.objects.create(
124
+ app_label="api", route_path="^api/3.0/eskelak/$", method="GET"
125
+ )
126
+ EndpointStat.objects.create(
127
+ endpoint=endpoint,
128
+ date="2026-10-08",
129
+ client_type="anon",
130
+ status_class="2xx",
131
+ count=1,
132
+ )
133
+
134
+ html = self.client.get(reverse(STAT_CHANGELIST)).content.decode()
135
+
136
+ self.assertIn(">api/3.0/eskelak/</td>", html)
137
+
91
138
  # -- filter presentation ------------------------------------------------
92
139
 
93
140
  def test_titles_are_readable_instead_of_raw_field_names(self):