django-api-usage 0.1.7__tar.gz → 0.1.9__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 (49) hide show
  1. {django_api_usage-0.1.7/src/django_api_usage.egg-info → django_api_usage-0.1.9}/PKG-INFO +28 -16
  2. {django_api_usage-0.1.7 → django_api_usage-0.1.9}/README.md +27 -15
  3. {django_api_usage-0.1.7 → django_api_usage-0.1.9}/pyproject.toml +1 -1
  4. {django_api_usage-0.1.7 → django_api_usage-0.1.9}/src/django_api_usage/__init__.py +1 -1
  5. {django_api_usage-0.1.7 → django_api_usage-0.1.9}/src/django_api_usage/admin.py +170 -13
  6. {django_api_usage-0.1.7 → django_api_usage-0.1.9}/src/django_api_usage/apps.py +6 -7
  7. django_api_usage-0.1.9/src/django_api_usage/locale/eu/LC_MESSAGES/django.mo +0 -0
  8. {django_api_usage-0.1.7 → django_api_usage-0.1.9}/src/django_api_usage/locale/eu/LC_MESSAGES/django.po +60 -30
  9. django_api_usage-0.1.9/src/django_api_usage/management/commands/api_usage_backfill_paths.py +59 -0
  10. django_api_usage-0.1.9/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.9}/src/django_api_usage/models.py +31 -6
  12. {django_api_usage-0.1.7 → django_api_usage-0.1.9}/src/django_api_usage/resolvers.py +15 -5
  13. {django_api_usage-0.1.7 → django_api_usage-0.1.9/src/django_api_usage.egg-info}/PKG-INFO +28 -16
  14. {django_api_usage-0.1.7 → django_api_usage-0.1.9}/src/django_api_usage.egg-info/SOURCES.txt +5 -1
  15. django_api_usage-0.1.9/tests/test_backfill_paths.py +66 -0
  16. {django_api_usage-0.1.7 → django_api_usage-0.1.9}/tests/test_client_app.py +36 -2
  17. {django_api_usage-0.1.7 → django_api_usage-0.1.9}/tests/test_filters.py +47 -0
  18. django_api_usage-0.1.9/tests/test_sites.py +92 -0
  19. django_api_usage-0.1.7/src/django_api_usage/locale/eu/LC_MESSAGES/django.mo +0 -0
  20. {django_api_usage-0.1.7 → django_api_usage-0.1.9}/LICENSE +0 -0
  21. {django_api_usage-0.1.7 → django_api_usage-0.1.9}/setup.cfg +0 -0
  22. {django_api_usage-0.1.7 → django_api_usage-0.1.9}/src/django_api_usage/buffers.py +0 -0
  23. {django_api_usage-0.1.7 → django_api_usage-0.1.9}/src/django_api_usage/checks.py +0 -0
  24. {django_api_usage-0.1.7 → django_api_usage-0.1.9}/src/django_api_usage/conf.py +0 -0
  25. {django_api_usage-0.1.7 → django_api_usage-0.1.9}/src/django_api_usage/deprecation.py +0 -0
  26. {django_api_usage-0.1.7 → django_api_usage-0.1.9}/src/django_api_usage/drf.py +0 -0
  27. {django_api_usage-0.1.7 → django_api_usage-0.1.9}/src/django_api_usage/maintenance.py +0 -0
  28. {django_api_usage-0.1.7 → django_api_usage-0.1.9}/src/django_api_usage/management/__init__.py +0 -0
  29. {django_api_usage-0.1.7 → django_api_usage-0.1.9}/src/django_api_usage/management/commands/__init__.py +0 -0
  30. {django_api_usage-0.1.7 → django_api_usage-0.1.9}/src/django_api_usage/management/commands/api_usage_flush.py +0 -0
  31. {django_api_usage-0.1.7 → django_api_usage-0.1.9}/src/django_api_usage/management/commands/api_usage_report.py +0 -0
  32. {django_api_usage-0.1.7 → django_api_usage-0.1.9}/src/django_api_usage/middleware.py +0 -0
  33. {django_api_usage-0.1.7 → django_api_usage-0.1.9}/src/django_api_usage/migrations/0001_initial.py +0 -0
  34. {django_api_usage-0.1.7 → django_api_usage-0.1.9}/src/django_api_usage/migrations/0002_endpoint_route_path.py +0 -0
  35. {django_api_usage-0.1.7 → django_api_usage-0.1.9}/src/django_api_usage/migrations/0003_alter_endpointstat_unique_together_and_more.py +0 -0
  36. {django_api_usage-0.1.7 → django_api_usage-0.1.9}/src/django_api_usage/migrations/__init__.py +0 -0
  37. {django_api_usage-0.1.7 → django_api_usage-0.1.9}/src/django_api_usage/tasks.py +0 -0
  38. {django_api_usage-0.1.7 → django_api_usage-0.1.9}/src/django_api_usage/templates/admin/django_api_usage/endpointstat/change_list.html +0 -0
  39. {django_api_usage-0.1.7 → django_api_usage-0.1.9}/src/django_api_usage/templates/admin/django_api_usage/endpointstat/confirm_clear.html +0 -0
  40. {django_api_usage-0.1.7 → django_api_usage-0.1.9}/src/django_api_usage.egg-info/dependency_links.txt +0 -0
  41. {django_api_usage-0.1.7 → django_api_usage-0.1.9}/src/django_api_usage.egg-info/requires.txt +0 -0
  42. {django_api_usage-0.1.7 → django_api_usage-0.1.9}/src/django_api_usage.egg-info/top_level.txt +0 -0
  43. {django_api_usage-0.1.7 → django_api_usage-0.1.9}/tests/test_admin.py +0 -0
  44. {django_api_usage-0.1.7 → django_api_usage-0.1.9}/tests/test_checks.py +0 -0
  45. {django_api_usage-0.1.7 → django_api_usage-0.1.9}/tests/test_commands.py +0 -0
  46. {django_api_usage-0.1.7 → django_api_usage-0.1.9}/tests/test_csv_export.py +0 -0
  47. {django_api_usage-0.1.7 → django_api_usage-0.1.9}/tests/test_drf.py +0 -0
  48. {django_api_usage-0.1.7 → django_api_usage-0.1.9}/tests/test_middleware.py +0 -0
  49. {django_api_usage-0.1.7 → django_api_usage-0.1.9}/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.9
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.9"
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.9"
@@ -14,6 +14,7 @@ Everything is plain Django: no extra dependency beyond Django itself.
14
14
  """
15
15
 
16
16
  import csv
17
+ import time
17
18
 
18
19
  from django.contrib import admin, messages
19
20
  from django.db.models import Count, OuterRef, Subquery, Sum
@@ -21,11 +22,12 @@ from django.http import HttpResponse, HttpResponseRedirect
21
22
  from django.template.response import TemplateResponse
22
23
  from django.urls import path, reverse
23
24
  from django.utils import timezone
25
+ from django.utils.html import format_html
24
26
  from django.utils.text import format_lazy
25
27
  from django.utils.translation import gettext_lazy as _
26
28
 
27
29
  from .buffers import flush
28
- from .models import ClientApp, Consumer, Endpoint, EndpointStat
30
+ from .models import ClientApp, ClientAppAccount, Consumer, Endpoint, EndpointStat
29
31
 
30
32
 
31
33
  class CsvExportMixin:
@@ -64,6 +66,70 @@ class CsvExportMixin:
64
66
  #: write/destructive calls (POST, DELETE, ...) instead of the read ones.
65
67
  METHOD_ORDER = ("DELETE", "POST", "PUT", "PATCH", "GET", "HEAD", "OPTIONS")
66
68
 
69
+ #: Pill colours per verb: reads green/blue, writes amber, deletes red.
70
+ METHOD_COLORS = {
71
+ "GET": ("#d1e7dd", "#0f5132"),
72
+ "POST": ("#cfe2ff", "#084298"),
73
+ "PUT": ("#fff3cd", "#664d03"),
74
+ "PATCH": ("#fff3cd", "#664d03"),
75
+ "DELETE": ("#f8d7da", "#842029"),
76
+ "HEAD": ("#e2e3e5", "#41464b"),
77
+ "OPTIONS": ("#e2e3e5", "#41464b"),
78
+ }
79
+ DEFAULT_METHOD_COLORS = ("#e2e3e5", "#41464b")
80
+
81
+
82
+ #: Site labels are read for every row of the changelist, so they are cached for
83
+ #: a short while (the table changes very rarely).
84
+ SITE_LABELS_TTL = 60
85
+ _site_labels_cache = {"loaded_at": 0.0, "value": None}
86
+
87
+
88
+ def _load_site_labels():
89
+ """``{pk: domain}`` when django.contrib.sites is installed, else ``{}``."""
90
+ try:
91
+ from django.contrib.sites.models import Site
92
+ except (ImportError, RuntimeError):
93
+ return {}
94
+ return dict(Site.objects.values_list("pk", "domain"))
95
+
96
+
97
+ def _site_labels():
98
+ now = time.monotonic()
99
+ if (
100
+ _site_labels_cache["value"] is None
101
+ or now - _site_labels_cache["loaded_at"] > SITE_LABELS_TTL
102
+ ):
103
+ _site_labels_cache["value"] = _load_site_labels()
104
+ _site_labels_cache["loaded_at"] = now
105
+ return _site_labels_cache["value"]
106
+
107
+
108
+ def reset_site_labels_cache(**kwargs):
109
+ """Forget the cached site domains (used by tests and after editing sites)."""
110
+ _site_labels_cache["value"] = None
111
+ _site_labels_cache["loaded_at"] = 0.0
112
+
113
+
114
+ def _site_label(site_id):
115
+ """Domain of a site, falling back to the raw id (or ``None``)."""
116
+ if site_id is None:
117
+ return None
118
+ return _site_labels().get(site_id, str(site_id))
119
+
120
+
121
+ def _method_badge_html(method):
122
+ """Coloured pill for an HTTP verb, so writes stand out at a glance."""
123
+ verb = (method or "").upper()
124
+ background, color = METHOD_COLORS.get(verb, DEFAULT_METHOD_COLORS)
125
+ return format_html(
126
+ '<span style="background:{};color:{};padding:1px 7px;border-radius:9px;'
127
+ 'font-size:11px;font-weight:600;white-space:nowrap">{}</span>',
128
+ background,
129
+ color,
130
+ verb,
131
+ )
132
+
67
133
 
68
134
  class AppLabelListFilter(admin.SimpleListFilter):
69
135
  """Distinct application labels, under a readable title."""
@@ -122,6 +188,42 @@ class StatMethodListFilter(MethodListFilter):
122
188
  field_path = "endpoint__method"
123
189
 
124
190
 
191
+ class SiteListFilter(admin.SimpleListFilter):
192
+ """Sites, shown by domain when django.contrib.sites is available.
193
+
194
+ Useful when several front ends (several sites) call the same backend: the
195
+ endpoint's site tells them apart.
196
+ """
197
+
198
+ title = _("Site")
199
+ parameter_name = "site"
200
+ field_path = "site_id"
201
+
202
+ def lookups(self, request, model_admin):
203
+ labels = _site_labels()
204
+ if labels:
205
+ return [(pk, domain) for pk, domain in sorted(labels.items())]
206
+ used = (
207
+ model_admin.model.objects.order_by()
208
+ .values_list(self.field_path, flat=True)
209
+ .distinct()
210
+ )
211
+ return [
212
+ (value, str(value)) for value in sorted(v for v in used if v is not None)
213
+ ]
214
+
215
+ def queryset(self, request, queryset):
216
+ if not self.value():
217
+ return queryset
218
+ return queryset.filter(**{self.field_path: self.value()})
219
+
220
+
221
+ class StatSiteListFilter(SiteListFilter):
222
+ """Site filter for the stats changelist (through the endpoint)."""
223
+
224
+ field_path = "endpoint__site_id"
225
+
226
+
125
227
  #: Sentinel for the calls that could not be attributed to any application.
126
228
  UNATTRIBUTED = "__none__"
127
229
 
@@ -180,8 +282,8 @@ class EndpointAdmin(CsvExportMixin, admin.ModelAdmin):
180
282
  "app_label",
181
283
  "route_path",
182
284
  "route_name",
183
- "method",
184
- "site_id",
285
+ "method_badge",
286
+ "site",
185
287
  "deprecated",
186
288
  "sunset_date",
187
289
  "replacement",
@@ -191,11 +293,19 @@ class EndpointAdmin(CsvExportMixin, admin.ModelAdmin):
191
293
  "deprecated",
192
294
  AppLabelListFilter,
193
295
  MethodListFilter,
194
- "site_id",
296
+ SiteListFilter,
195
297
  )
196
298
  search_fields = ("route_path", "route_name", "replacement", "owner", "notes")
197
299
  list_editable = ("deprecated", "sunset_date", "replacement", "owner")
198
300
 
301
+ @admin.display(description=_("Method"), ordering="method")
302
+ def method_badge(self, obj):
303
+ return _method_badge_html(obj.method)
304
+
305
+ @admin.display(description=_("Site"), ordering="site_id")
306
+ def site(self, obj):
307
+ return _site_label(obj.site_id)
308
+
199
309
 
200
310
  @admin.register(EndpointStat)
201
311
  class EndpointStatAdmin(CsvExportMixin, admin.ModelAdmin):
@@ -216,7 +326,10 @@ class EndpointStatAdmin(CsvExportMixin, admin.ModelAdmin):
216
326
  )
217
327
  list_display = (
218
328
  "date",
219
- "endpoint",
329
+ "site",
330
+ "application",
331
+ "method_badge",
332
+ "endpoint_path",
220
333
  "client_type",
221
334
  "client_app",
222
335
  "status_class",
@@ -227,6 +340,7 @@ class EndpointStatAdmin(CsvExportMixin, admin.ModelAdmin):
227
340
  "client_type",
228
341
  ClientAppListFilter,
229
342
  "status_class",
343
+ StatSiteListFilter,
230
344
  StatAppLabelListFilter,
231
345
  StatMethodListFilter,
232
346
  )
@@ -246,6 +360,26 @@ class EndpointStatAdmin(CsvExportMixin, admin.ModelAdmin):
246
360
  )
247
361
  change_list_template = "admin/django_api_usage/endpointstat/change_list.html"
248
362
 
363
+ # -- columns ------------------------------------------------------------
364
+
365
+ @admin.display(description=_("Application"), ordering="endpoint__app_label")
366
+ def application(self, obj):
367
+ return obj.endpoint.app_label
368
+
369
+ @admin.display(description=_("Site"), ordering="endpoint__site_id")
370
+ def site(self, obj):
371
+ return _site_label(obj.endpoint.site_id)
372
+
373
+ @admin.display(description=_("Method"), ordering="endpoint__method")
374
+ def method_badge(self, obj):
375
+ return _method_badge_html(obj.endpoint.method)
376
+
377
+ @admin.display(description=_("Endpoint"), ordering="endpoint__route_path")
378
+ def endpoint_path(self, obj):
379
+ """Just the path: the verb and the app already have their own columns."""
380
+ endpoint = obj.endpoint
381
+ return (endpoint.route_path or endpoint.route_name or "").strip("^$")
382
+
249
383
  def has_add_permission(self, request):
250
384
  return False
251
385
 
@@ -388,22 +522,17 @@ class ClientAppAdmin(CsvExportMixin, admin.ModelAdmin):
388
522
  )
389
523
  list_filter = ("is_active",)
390
524
  search_fields = ("slug", "name", "description")
391
- filter_horizontal = ("accounts",)
392
525
  fieldsets = (
393
526
  (None, {"fields": ("slug", "name", "description", "priority", "is_active")}),
394
527
  (
395
528
  _("Matching rules"),
396
529
  {
397
- "fields": (
398
- "accounts",
399
- "domains",
400
- "ip_networks",
401
- "user_agent_patterns",
402
- ),
530
+ "fields": ("domains", "ip_networks", "user_agent_patterns"),
403
531
  "description": _(
404
532
  "Checked in this order: account, request host, client "
405
533
  "network, user agent. Between applications, the lowest "
406
- "priority wins."
534
+ "priority wins. Accounts are assigned from the "
535
+ "'Client application accounts' table."
407
536
  ),
408
537
  },
409
538
  ),
@@ -431,3 +560,31 @@ class ClientAppAdmin(CsvExportMixin, admin.ModelAdmin):
431
560
  @admin.display(description=_("Counters"), ordering="counters_total")
432
561
  def counters_total(self, obj):
433
562
  return obj.counters_total or 0
563
+
564
+
565
+ @admin.register(ClientAppAccount)
566
+ class ClientAppAccountAdmin(CsvExportMixin, admin.ModelAdmin):
567
+ """One row per assigned account: search, do not scroll a giant list."""
568
+
569
+ actions = ("export_as_csv",)
570
+ csv_columns = (
571
+ ("client_app", "client_app__slug"),
572
+ ("username", "user__username"),
573
+ ("email", "user__email"),
574
+ ("notes", "notes"),
575
+ )
576
+ list_display = ("client_app", "account", "notes")
577
+ list_filter = ("client_app",)
578
+ search_fields = (
579
+ "user__username",
580
+ "user__email",
581
+ "user__first_name",
582
+ "user__last_name",
583
+ "notes",
584
+ )
585
+ raw_id_fields = ("user",)
586
+ list_select_related = ("user", "client_app")
587
+
588
+ @admin.display(description=_("Account"), ordering="user__username")
589
+ def account(self, obj):
590
+ 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 08: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"
@@ -17,27 +17,33 @@ msgstr ""
17
17
  "Content-Transfer-Encoding: 8bit\n"
18
18
  "Plural-Forms: nplurals=2; plural=(n != 1);\n"
19
19
 
20
- #: src/django_api_usage/admin.py:46
20
+ #: src/django_api_usage/admin.py:48
21
21
  msgid "Export selected rows to CSV"
22
22
  msgstr "Esportatu hautatutako errenkadak CSVra"
23
23
 
24
- #: src/django_api_usage/admin.py:71
24
+ #: src/django_api_usage/admin.py:131 src/django_api_usage/admin.py:359
25
25
  msgid "Application"
26
26
  msgstr "Aplikazioa"
27
27
 
28
- #: src/django_api_usage/admin.py:93
28
+ #: src/django_api_usage/admin.py:153 src/django_api_usage/admin.py:295
29
+ #: src/django_api_usage/admin.py:367
29
30
  msgid "Method"
30
31
  msgstr "Metodoa"
31
32
 
32
- #: src/django_api_usage/admin.py:136 src/django_api_usage/models.py:106
33
+ #: src/django_api_usage/admin.py:192 src/django_api_usage/admin.py:299
34
+ #: src/django_api_usage/admin.py:363
35
+ msgid "Site"
36
+ msgstr "Webgunea"
37
+
38
+ #: src/django_api_usage/admin.py:232 src/django_api_usage/models.py:100
33
39
  msgid "Client application"
34
40
  msgstr "Bezero-aplikazioa"
35
41
 
36
- #: src/django_api_usage/admin.py:152
42
+ #: src/django_api_usage/admin.py:248
37
43
  msgid "Not attributed"
38
44
  msgstr "Esleitu gabea"
39
45
 
40
- #: src/django_api_usage/admin.py:244
46
+ #: src/django_api_usage/admin.py:352
41
47
  msgid ""
42
48
  "Search by endpoint path, route name, app, method or replacement (for example "
43
49
  "'herriak' or 'artikuluak')."
@@ -45,50 +51,60 @@ msgstr ""
45
51
  "Bilatu endpointaren bidearen, route-izenaren, aplikazioaren, metodoaren edo "
46
52
  "ordezkoaren arabera (adibidez 'herriak' edo 'artikuluak')."
47
53
 
48
- #: src/django_api_usage/admin.py:299
54
+ #: src/django_api_usage/admin.py:371
55
+ msgid "Endpoint"
56
+ msgstr "Endpointa"
57
+
58
+ #: src/django_api_usage/admin.py:427
49
59
  #, python-brace-format
50
60
  msgid "Flushed {count} buffered counter(s)."
51
61
  msgstr "{count} kontagailu eraman dira bufferretik datu-basera."
52
62
 
53
- #: src/django_api_usage/admin.py:306
63
+ #: src/django_api_usage/admin.py:434
54
64
  msgid ""
55
65
  "Nothing was buffered: with BUFFER_BACKEND='db' counters are already in the "
56
66
  "database."
57
67
  msgstr ""
58
- "Ez zegoen ezer bufferrean: BUFFER_BACKEND='db' denean kontagailuak jada "
59
- "datu-basean daude."
68
+ "Ez zegoen ezer bufferrean: BUFFER_BACKEND='db' denean kontagailuak jada datu-"
69
+ "basean daude."
60
70
 
61
- #: src/django_api_usage/admin.py:318
71
+ #: src/django_api_usage/admin.py:446
62
72
  #: src/django_api_usage/templates/admin/django_api_usage/endpointstat/change_list.html:12
63
73
  #: src/django_api_usage/templates/admin/django_api_usage/endpointstat/confirm_clear.html:8
64
74
  msgid "Clear statistics"
65
75
  msgstr "Estatistikak hustu"
66
76
 
67
- #: src/django_api_usage/admin.py:330
77
+ #: src/django_api_usage/admin.py:458
68
78
  #, python-brace-format
69
79
  msgid "Deleted {count} statistic row(s)."
70
80
  msgstr "{count} estatistika-errenkada ezabatu dira."
71
81
 
72
- #: src/django_api_usage/admin.py:395
82
+ #: src/django_api_usage/admin.py:522
73
83
  msgid "Matching rules"
74
84
  msgstr "Bat-etortze arauak"
75
85
 
76
- #: src/django_api_usage/admin.py:404
86
+ #: src/django_api_usage/admin.py:526
77
87
  msgid ""
78
88
  "Checked in this order: account, request host, client network, user agent. "
79
- "Between applications, the lowest priority wins."
89
+ "Between applications, the lowest priority wins. Accounts are assigned from "
90
+ "the 'Client application accounts' table."
80
91
  msgstr ""
81
- "Ordena honetan egiaztatzen da: kontua, eskaeraren ostalaria, bezeroaren sarea "
82
- "eta user agent-a. Aplikazioen artean, lehentasun txikiena duenak irabazten du."
92
+ "Ordena honetan egiaztatzen da: kontua, eskaeraren ostalaria, bezeroaren "
93
+ "sarea eta user agent-a. Aplikazioen artean, lehentasun txikiena duenak "
94
+ "irabazten du. Kontuak 'Bezero-aplikazioko kontuak' taulatik esleitzen dira."
83
95
 
84
- #: src/django_api_usage/admin.py:427
96
+ #: src/django_api_usage/admin.py:550
85
97
  msgid "Accounts"
86
98
  msgstr "Kontuak"
87
99
 
88
- #: src/django_api_usage/admin.py:431
100
+ #: src/django_api_usage/admin.py:554
89
101
  msgid "Counters"
90
102
  msgstr "Kontagailuak"
91
103
 
104
+ #: src/django_api_usage/admin.py:582 src/django_api_usage/models.py:134
105
+ msgid "Account"
106
+ msgstr "Kontua"
107
+
92
108
  #: src/django_api_usage/models.py:79
93
109
  msgid "Short, stable label stored in the counters (e.g. 'mugikorra')."
94
110
  msgstr ""
@@ -98,26 +114,36 @@ msgstr ""
98
114
  msgid "Lower wins when several apps could match."
99
115
  msgstr "Hainbat aplikaziok bat etorri ahalketenean, txikienak irabazten du."
100
116
 
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
117
+ #: src/django_api_usage/models.py:89
106
118
  msgid "One host per line; matched against the Origin/Referer host."
107
- msgstr "Ostalari bat lerroko; Origin/Referer ostalariaren aurka egiaztatzen da."
119
+ msgstr ""
120
+ "Ostalari bat lerroko; Origin/Referer ostalariaren aurka egiaztatzen da."
108
121
 
109
- #: src/django_api_usage/models.py:98
122
+ #: src/django_api_usage/models.py:92
110
123
  msgid "One CIDR per line, e.g. 10.0.0.0/8."
111
124
  msgstr "CIDR bat lerroko, adib. 10.0.0.0/8."
112
125
 
113
- #: src/django_api_usage/models.py:102
126
+ #: src/django_api_usage/models.py:96
114
127
  msgid "One substring per line, matched case-insensitively."
115
- msgstr "Azpikate bat lerroko; maiuskulak/minuskulak bereizi gabe egiaztatzen da."
128
+ msgstr ""
129
+ "Azpikate bat lerroko; maiuskulak/minuskulak bereizi gabe egiaztatzen da."
116
130
 
117
- #: src/django_api_usage/models.py:107
131
+ #: src/django_api_usage/models.py:101
118
132
  msgid "Client applications"
119
133
  msgstr "Bezero-aplikazioak"
120
134
 
135
+ #: src/django_api_usage/models.py:135
136
+ msgid "One application per account, so the token is unambiguous."
137
+ msgstr "Aplikazio bat kontu bakoitzeko, tokena zalantzarik gabea izan dadin."
138
+
139
+ #: src/django_api_usage/models.py:140
140
+ msgid "Client application account"
141
+ msgstr "Bezero-aplikazioko kontua"
142
+
143
+ #: src/django_api_usage/models.py:141
144
+ msgid "Client application accounts"
145
+ msgstr "Bezero-aplikazioko kontuak"
146
+
121
147
  #: src/django_api_usage/templates/admin/django_api_usage/endpointstat/change_list.html:8
122
148
  msgid "Flush now"
123
149
  msgstr "Flush orain"
@@ -167,3 +193,7 @@ msgstr "Bai, estatistikak hustu"
167
193
  #: src/django_api_usage/templates/admin/django_api_usage/endpointstat/confirm_clear.html:25
168
194
  msgid "Cancel"
169
195
  msgstr ""
196
+
197
+ #~ msgid "Users (and therefore DRF tokens) that belong to this app."
198
+ #~ msgstr ""
199
+ #~ "Aplikazio honi dagozkion erabiltzaileak (eta, beraz, haien DRF tokenak)."