django-api-usage 0.1.6__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 (51) hide show
  1. {django_api_usage-0.1.6/src/django_api_usage.egg-info → django_api_usage-0.1.8}/PKG-INFO +36 -2
  2. {django_api_usage-0.1.6 → django_api_usage-0.1.8}/README.md +35 -1
  3. {django_api_usage-0.1.6 → django_api_usage-0.1.8}/pyproject.toml +1 -1
  4. {django_api_usage-0.1.6 → django_api_usage-0.1.8}/src/django_api_usage/__init__.py +1 -1
  5. {django_api_usage-0.1.6 → django_api_usage-0.1.8}/src/django_api_usage/admin.py +192 -4
  6. django_api_usage-0.1.8/src/django_api_usage/apps.py +33 -0
  7. {django_api_usage-0.1.6 → django_api_usage-0.1.8}/src/django_api_usage/buffers.py +2 -0
  8. {django_api_usage-0.1.6 → django_api_usage-0.1.8}/src/django_api_usage/conf.py +1 -0
  9. django_api_usage-0.1.8/src/django_api_usage/locale/eu/LC_MESSAGES/django.mo +0 -0
  10. {django_api_usage-0.1.6 → django_api_usage-0.1.8}/src/django_api_usage/locale/eu/LC_MESSAGES/django.po +85 -8
  11. django_api_usage-0.1.8/src/django_api_usage/management/commands/api_usage_backfill_paths.py +59 -0
  12. {django_api_usage-0.1.6 → django_api_usage-0.1.8}/src/django_api_usage/middleware.py +1 -0
  13. django_api_usage-0.1.8/src/django_api_usage/migrations/0003_alter_endpointstat_unique_together_and_more.py +96 -0
  14. django_api_usage-0.1.8/src/django_api_usage/migrations/0004_remove_clientapp_accounts_clientappaccount.py +77 -0
  15. django_api_usage-0.1.8/src/django_api_usage/models.py +200 -0
  16. django_api_usage-0.1.8/src/django_api_usage/resolvers.py +242 -0
  17. {django_api_usage-0.1.6 → django_api_usage-0.1.8/src/django_api_usage.egg-info}/PKG-INFO +36 -2
  18. {django_api_usage-0.1.6 → django_api_usage-0.1.8}/src/django_api_usage.egg-info/SOURCES.txt +5 -0
  19. django_api_usage-0.1.8/tests/test_backfill_paths.py +66 -0
  20. django_api_usage-0.1.8/tests/test_client_app.py +193 -0
  21. {django_api_usage-0.1.6 → django_api_usage-0.1.8}/tests/test_csv_export.py +5 -3
  22. {django_api_usage-0.1.6 → django_api_usage-0.1.8}/tests/test_filters.py +51 -2
  23. django_api_usage-0.1.6/src/django_api_usage/apps.py +0 -11
  24. django_api_usage-0.1.6/src/django_api_usage/locale/eu/LC_MESSAGES/django.mo +0 -0
  25. django_api_usage-0.1.6/src/django_api_usage/models.py +0 -99
  26. django_api_usage-0.1.6/src/django_api_usage/resolvers.py +0 -116
  27. {django_api_usage-0.1.6 → django_api_usage-0.1.8}/LICENSE +0 -0
  28. {django_api_usage-0.1.6 → django_api_usage-0.1.8}/setup.cfg +0 -0
  29. {django_api_usage-0.1.6 → django_api_usage-0.1.8}/src/django_api_usage/checks.py +0 -0
  30. {django_api_usage-0.1.6 → django_api_usage-0.1.8}/src/django_api_usage/deprecation.py +0 -0
  31. {django_api_usage-0.1.6 → django_api_usage-0.1.8}/src/django_api_usage/drf.py +0 -0
  32. {django_api_usage-0.1.6 → django_api_usage-0.1.8}/src/django_api_usage/maintenance.py +0 -0
  33. {django_api_usage-0.1.6 → django_api_usage-0.1.8}/src/django_api_usage/management/__init__.py +0 -0
  34. {django_api_usage-0.1.6 → django_api_usage-0.1.8}/src/django_api_usage/management/commands/__init__.py +0 -0
  35. {django_api_usage-0.1.6 → django_api_usage-0.1.8}/src/django_api_usage/management/commands/api_usage_flush.py +0 -0
  36. {django_api_usage-0.1.6 → django_api_usage-0.1.8}/src/django_api_usage/management/commands/api_usage_report.py +0 -0
  37. {django_api_usage-0.1.6 → django_api_usage-0.1.8}/src/django_api_usage/migrations/0001_initial.py +0 -0
  38. {django_api_usage-0.1.6 → django_api_usage-0.1.8}/src/django_api_usage/migrations/0002_endpoint_route_path.py +0 -0
  39. {django_api_usage-0.1.6 → django_api_usage-0.1.8}/src/django_api_usage/migrations/__init__.py +0 -0
  40. {django_api_usage-0.1.6 → django_api_usage-0.1.8}/src/django_api_usage/tasks.py +0 -0
  41. {django_api_usage-0.1.6 → django_api_usage-0.1.8}/src/django_api_usage/templates/admin/django_api_usage/endpointstat/change_list.html +0 -0
  42. {django_api_usage-0.1.6 → django_api_usage-0.1.8}/src/django_api_usage/templates/admin/django_api_usage/endpointstat/confirm_clear.html +0 -0
  43. {django_api_usage-0.1.6 → django_api_usage-0.1.8}/src/django_api_usage.egg-info/dependency_links.txt +0 -0
  44. {django_api_usage-0.1.6 → django_api_usage-0.1.8}/src/django_api_usage.egg-info/requires.txt +0 -0
  45. {django_api_usage-0.1.6 → django_api_usage-0.1.8}/src/django_api_usage.egg-info/top_level.txt +0 -0
  46. {django_api_usage-0.1.6 → django_api_usage-0.1.8}/tests/test_admin.py +0 -0
  47. {django_api_usage-0.1.6 → django_api_usage-0.1.8}/tests/test_checks.py +0 -0
  48. {django_api_usage-0.1.6 → django_api_usage-0.1.8}/tests/test_commands.py +0 -0
  49. {django_api_usage-0.1.6 → django_api_usage-0.1.8}/tests/test_drf.py +0 -0
  50. {django_api_usage-0.1.6 → django_api_usage-0.1.8}/tests/test_middleware.py +0 -0
  51. {django_api_usage-0.1.6 → 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.6
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,6 +144,38 @@ 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
+
152
+ ## Client applications
153
+
154
+ Not every caller is a person: ERPs, partners, your own web front end and the
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`.
174
+
175
+ Counters, the CSV export and the admin filters all carry the application, so
176
+ "which application calls this endpoint?" is one filter away. The **Endpoint
177
+ stats** changelist also offers *Not attributed*: the callers still to classify.
178
+
145
179
  ## Deprecation lifecycle
146
180
 
147
181
  `Endpoint` carries `deprecated`, `sunset_date`, `replacement` and `owner`, so the
@@ -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,6 +105,38 @@ 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
+
113
+ ## Client applications
114
+
115
+ Not every caller is a person: ERPs, partners, your own web front end and the
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`.
135
+
136
+ Counters, the CSV export and the admin filters all carry the application, so
137
+ "which application calls this endpoint?" is one filter away. The **Endpoint
138
+ stats** changelist also offers *Not attributed*: the callers still to classify.
139
+
106
140
  ## Deprecation lifecycle
107
141
 
108
142
  `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.6"
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.6"
3
+ __version__ = "0.1.8"
@@ -16,16 +16,17 @@ Everything is plain Django: no extra dependency beyond Django itself.
16
16
  import csv
17
17
 
18
18
  from django.contrib import admin, messages
19
- from django.db.models import Sum
19
+ from django.db.models import Count, OuterRef, Subquery, Sum
20
20
  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 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."""
@@ -122,6 +148,45 @@ class StatMethodListFilter(MethodListFilter):
122
148
  field_path = "endpoint__method"
123
149
 
124
150
 
151
+ #: Sentinel for the calls that could not be attributed to any application.
152
+ UNATTRIBUTED = "__none__"
153
+
154
+
155
+ class ClientAppListFilter(admin.SimpleListFilter):
156
+ """Client applications, plus the calls that were not attributed.
157
+
158
+ Applications defined in the table are listed even before they have traffic,
159
+ which is what you want while checking that a new rule works.
160
+ """
161
+
162
+ title = _("Client application")
163
+ parameter_name = "client_app"
164
+
165
+ def lookups(self, request, model_admin):
166
+ used = set(
167
+ model_admin.model.objects.order_by()
168
+ .values_list("client_app", flat=True)
169
+ .distinct()
170
+ )
171
+ names = dict(ClientApp.objects.values_list("slug", "name"))
172
+ defined = set(
173
+ ClientApp.objects.filter(is_active=True).values_list("slug", flat=True)
174
+ )
175
+ choices = [
176
+ (slug, names.get(slug) or slug) for slug in sorted((used | defined) - {""})
177
+ ]
178
+ choices.append((UNATTRIBUTED, _("Not attributed")))
179
+ return choices
180
+
181
+ def queryset(self, request, queryset):
182
+ value = self.value()
183
+ if value == UNATTRIBUTED:
184
+ return queryset.filter(client_app="")
185
+ if value:
186
+ return queryset.filter(client_app=value)
187
+ return queryset
188
+
189
+
125
190
  @admin.register(Endpoint)
126
191
  class EndpointAdmin(CsvExportMixin, admin.ModelAdmin):
127
192
  actions = ("export_as_csv",)
@@ -141,7 +206,7 @@ class EndpointAdmin(CsvExportMixin, admin.ModelAdmin):
141
206
  "app_label",
142
207
  "route_path",
143
208
  "route_name",
144
- "method",
209
+ "method_badge",
145
210
  "site_id",
146
211
  "deprecated",
147
212
  "sunset_date",
@@ -157,6 +222,10 @@ class EndpointAdmin(CsvExportMixin, admin.ModelAdmin):
157
222
  search_fields = ("route_path", "route_name", "replacement", "owner", "notes")
158
223
  list_editable = ("deprecated", "sunset_date", "replacement", "owner")
159
224
 
225
+ @admin.display(description=_("Method"), ordering="method")
226
+ def method_badge(self, obj):
227
+ return _method_badge_html(obj.method)
228
+
160
229
 
161
230
  @admin.register(EndpointStat)
162
231
  class EndpointStatAdmin(CsvExportMixin, admin.ModelAdmin):
@@ -171,13 +240,24 @@ class EndpointStatAdmin(CsvExportMixin, admin.ModelAdmin):
171
240
  ("method", "endpoint__method"),
172
241
  ("site_id", "endpoint__site_id"),
173
242
  ("client_type", "client_type"),
243
+ ("client_app", "client_app"),
174
244
  ("status_class", "status_class"),
175
245
  ("count", "count"),
176
246
  )
177
- list_display = ("date", "endpoint", "client_type", "status_class", "count")
247
+ list_display = (
248
+ "date",
249
+ "application",
250
+ "method_badge",
251
+ "endpoint_path",
252
+ "client_type",
253
+ "client_app",
254
+ "status_class",
255
+ "count",
256
+ )
178
257
  list_filter = (
179
258
  "date",
180
259
  "client_type",
260
+ ClientAppListFilter,
181
261
  "status_class",
182
262
  StatAppLabelListFilter,
183
263
  StatMethodListFilter,
@@ -198,6 +278,22 @@ class EndpointStatAdmin(CsvExportMixin, admin.ModelAdmin):
198
278
  )
199
279
  change_list_template = "admin/django_api_usage/endpointstat/change_list.html"
200
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
+
201
297
  def has_add_permission(self, request):
202
298
  return False
203
299
 
@@ -314,3 +410,95 @@ class ConsumerAdmin(CsvExportMixin, admin.ModelAdmin):
314
410
  @admin.display(description="Reference")
315
411
  def ref_short(self, obj):
316
412
  return obj.ref_hash[:12]
413
+
414
+
415
+ @admin.register(ClientApp)
416
+ class ClientAppAdmin(CsvExportMixin, admin.ModelAdmin):
417
+ """Editable table: recognise a new consumer without deploying anything."""
418
+
419
+ actions = ("export_as_csv",)
420
+ csv_columns = (
421
+ ("slug", "slug"),
422
+ ("name", "name"),
423
+ ("priority", "priority"),
424
+ ("is_active", "is_active"),
425
+ ("domains", "domains"),
426
+ ("ip_networks", "ip_networks"),
427
+ ("user_agent_patterns", "user_agent_patterns"),
428
+ )
429
+ list_display = (
430
+ "slug",
431
+ "name",
432
+ "priority",
433
+ "is_active",
434
+ "accounts_total",
435
+ "counters_total",
436
+ )
437
+ list_filter = ("is_active",)
438
+ search_fields = ("slug", "name", "description")
439
+ fieldsets = (
440
+ (None, {"fields": ("slug", "name", "description", "priority", "is_active")}),
441
+ (
442
+ _("Matching rules"),
443
+ {
444
+ "fields": ("domains", "ip_networks", "user_agent_patterns"),
445
+ "description": _(
446
+ "Checked in this order: account, request host, client "
447
+ "network, user agent. Between applications, the lowest "
448
+ "priority wins. Accounts are assigned from the "
449
+ "'Client application accounts' table."
450
+ ),
451
+ },
452
+ ),
453
+ )
454
+
455
+ def get_queryset(self, request):
456
+ counters = (
457
+ EndpointStat.objects.filter(client_app=OuterRef("slug"))
458
+ .order_by()
459
+ .values("client_app")
460
+ .annotate(total=Sum("count"))
461
+ .values("total")
462
+ )
463
+ return (
464
+ super()
465
+ .get_queryset(request)
466
+ .annotate(accounts_total=Count("accounts", distinct=True))
467
+ .annotate(counters_total=Subquery(counters))
468
+ )
469
+
470
+ @admin.display(description=_("Accounts"), ordering="accounts_total")
471
+ def accounts_total(self, obj):
472
+ return obj.accounts_total
473
+
474
+ @admin.display(description=_("Counters"), ordering="counters_total")
475
+ def counters_total(self, obj):
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
@@ -0,0 +1,33 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class DjangoApiUsageConfig(AppConfig):
5
+ name = "django_api_usage"
6
+ verbose_name = "Django API usage"
7
+ default_auto_field = "django.db.models.BigAutoField"
8
+
9
+ def ready(self):
10
+ # Register system checks (middleware installed, consumer salt, ...).
11
+ from . import checks # noqa: F401
12
+
13
+ self._connect_client_app_signals()
14
+
15
+ def _connect_client_app_signals(self):
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
22
+
23
+ from .models import ClientApp
24
+ from .resolvers import reset_client_app_cache
25
+
26
+ post_save.connect(
27
+ reset_client_app_cache, sender=ClientApp, dispatch_uid="api_usage_app_save"
28
+ )
29
+ post_delete.connect(
30
+ reset_client_app_cache,
31
+ sender=ClientApp,
32
+ dispatch_uid="api_usage_app_delete",
33
+ )
@@ -30,6 +30,7 @@ _FIELDS = (
30
30
  "route_path",
31
31
  "method",
32
32
  "client_type",
33
+ "client_app",
33
34
  )
34
35
 
35
36
 
@@ -126,6 +127,7 @@ def _write_to_db(dimensions, status_code_class, count):
126
127
  endpoint=endpoint,
127
128
  date=timezone.localdate(),
128
129
  client_type=dimensions.get("client_type") or "anon",
130
+ client_app=dimensions.get("client_app") or "",
129
131
  status_class=status_code_class,
130
132
  defaults={"count": count},
131
133
  )
@@ -33,6 +33,7 @@ DEFAULTS = {
33
33
  "APP_LABEL_RESOLVER": "django_api_usage.resolvers.default_app_label",
34
34
  "ROUTE_NAME_RESOLVER": "django_api_usage.resolvers.default_route_name",
35
35
  "ROUTE_PATH_RESOLVER": "django_api_usage.resolvers.default_route_path",
36
+ "CLIENT_APP_RESOLVER": "django_api_usage.resolvers.client_app_from_rules",
36
37
  "CLIENT_TYPE_RESOLVER": "django_api_usage.resolvers.default_client_type",
37
38
  "SITE_RESOLVER": "django_api_usage.resolvers.default_site_id",
38
39
  "CONSUMER_RESOLVER": "django_api_usage.resolvers.default_consumer",
@@ -7,8 +7,8 @@ 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:10-0500\n"
11
- "PO-Revision-Date: 2026-10-08 06:20-0500\n"
10
+ "POT-Creation-Date: 2026-10-08 07:11-0500\n"
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"
14
14
  "Language: eu\n"
@@ -29,7 +29,15 @@ msgstr "Aplikazioa"
29
29
  msgid "Method"
30
30
  msgstr "Metodoa"
31
31
 
32
- #: src/django_api_usage/admin.py:196
32
+ #: src/django_api_usage/admin.py:136 src/django_api_usage/models.py:100
33
+ msgid "Client application"
34
+ msgstr "Bezero-aplikazioa"
35
+
36
+ #: src/django_api_usage/admin.py:152
37
+ msgid "Not attributed"
38
+ msgstr "Esleitu gabea"
39
+
40
+ #: src/django_api_usage/admin.py:244
33
41
  msgid ""
34
42
  "Search by endpoint path, route name, app, method or replacement (for example "
35
43
  "'herriak' or 'artikuluak')."
@@ -37,12 +45,12 @@ msgstr ""
37
45
  "Bilatu endpointaren bidearen, route-izenaren, aplikazioaren, metodoaren edo "
38
46
  "ordezkoaren arabera (adibidez 'herriak' edo 'artikuluak')."
39
47
 
40
- #: src/django_api_usage/admin.py:251
48
+ #: src/django_api_usage/admin.py:299
41
49
  #, python-brace-format
42
50
  msgid "Flushed {count} buffered counter(s)."
43
51
  msgstr "{count} kontagailu eraman dira bufferretik datu-basera."
44
52
 
45
- #: src/django_api_usage/admin.py:258
53
+ #: src/django_api_usage/admin.py:306
46
54
  msgid ""
47
55
  "Nothing was buffered: with BUFFER_BACKEND='db' counters are already in the "
48
56
  "database."
@@ -50,17 +58,82 @@ msgstr ""
50
58
  "Ez zegoen ezer bufferrean: BUFFER_BACKEND='db' denean kontagailuak jada datu-"
51
59
  "basean daude."
52
60
 
53
- #: src/django_api_usage/admin.py:270
61
+ #: src/django_api_usage/admin.py:318
54
62
  #: src/django_api_usage/templates/admin/django_api_usage/endpointstat/change_list.html:12
55
63
  #: src/django_api_usage/templates/admin/django_api_usage/endpointstat/confirm_clear.html:8
56
64
  msgid "Clear statistics"
57
65
  msgstr "Estatistikak hustu"
58
66
 
59
- #: src/django_api_usage/admin.py:282
67
+ #: src/django_api_usage/admin.py:330
60
68
  #, python-brace-format
61
69
  msgid "Deleted {count} statistic row(s)."
62
70
  msgstr "{count} estatistika-errenkada ezabatu dira."
63
71
 
72
+ #: src/django_api_usage/admin.py:394
73
+ msgid "Matching rules"
74
+ msgstr "Bat-etortze arauak"
75
+
76
+ #: src/django_api_usage/admin.py:398
77
+ msgid ""
78
+ "Checked in this order: account, request host, client network, user agent. "
79
+ "Between applications, the lowest priority wins. Accounts are assigned from "
80
+ "the 'Client application accounts' table."
81
+ msgstr ""
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."
85
+
86
+ #: src/django_api_usage/admin.py:422
87
+ msgid "Accounts"
88
+ msgstr "Kontuak"
89
+
90
+ #: src/django_api_usage/admin.py:426
91
+ msgid "Counters"
92
+ msgstr "Kontagailuak"
93
+
94
+ #: src/django_api_usage/admin.py:454 src/django_api_usage/models.py:134
95
+ msgid "Account"
96
+ msgstr "Kontua"
97
+
98
+ #: src/django_api_usage/models.py:79
99
+ msgid "Short, stable label stored in the counters (e.g. 'mugikorra')."
100
+ msgstr ""
101
+ "Kontagailuetan gordetzen den etiketa labur eta egonkorra (adib. 'mugikorra')."
102
+
103
+ #: src/django_api_usage/models.py:85
104
+ msgid "Lower wins when several apps could match."
105
+ msgstr "Hainbat aplikaziok bat etorri ahalketenean, txikienak irabazten du."
106
+
107
+ #: src/django_api_usage/models.py:89
108
+ msgid "One host per line; matched against the Origin/Referer host."
109
+ msgstr ""
110
+ "Ostalari bat lerroko; Origin/Referer ostalariaren aurka egiaztatzen da."
111
+
112
+ #: src/django_api_usage/models.py:92
113
+ msgid "One CIDR per line, e.g. 10.0.0.0/8."
114
+ msgstr "CIDR bat lerroko, adib. 10.0.0.0/8."
115
+
116
+ #: src/django_api_usage/models.py:96
117
+ msgid "One substring per line, matched case-insensitively."
118
+ msgstr ""
119
+ "Azpikate bat lerroko; maiuskulak/minuskulak bereizi gabe egiaztatzen da."
120
+
121
+ #: src/django_api_usage/models.py:101
122
+ msgid "Client applications"
123
+ msgstr "Bezero-aplikazioak"
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
+
64
137
  #: src/django_api_usage/templates/admin/django_api_usage/endpointstat/change_list.html:8
65
138
  msgid "Flush now"
66
139
  msgstr "Flush orain"
@@ -68,7 +141,7 @@ msgstr "Flush orain"
68
141
  #: src/django_api_usage/templates/admin/django_api_usage/endpointstat/change_list.html:21
69
142
  #, python-format
70
143
  msgid "TOTAL COUNT: %(total)s"
71
- msgstr ""
144
+ msgstr "GUZTIRA: %(total)s"
72
145
 
73
146
  #: src/django_api_usage/templates/admin/django_api_usage/endpointstat/confirm_clear.html:6
74
147
  msgid "Home"
@@ -110,3 +183,7 @@ msgstr "Bai, estatistikak hustu"
110
183
  #: src/django_api_usage/templates/admin/django_api_usage/endpointstat/confirm_clear.html:25
111
184
  msgid "Cancel"
112
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 ""
@@ -36,6 +36,7 @@ class ApiUsageMiddleware:
36
36
  "route_path": api_settings.ROUTE_PATH_RESOLVER(request),
37
37
  "method": request.method,
38
38
  "client_type": api_settings.CLIENT_TYPE_RESOLVER(request),
39
+ "client_app": api_settings.CLIENT_APP_RESOLVER(request),
39
40
  }
40
41
  consumer = None
41
42
  if api_settings.TRACK_CONSUMERS: