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.
- {django_api_usage-0.1.7/src/django_api_usage.egg-info → django_api_usage-0.1.8}/PKG-INFO +28 -16
- {django_api_usage-0.1.7 → django_api_usage-0.1.8}/README.md +27 -15
- {django_api_usage-0.1.7 → django_api_usage-0.1.8}/pyproject.toml +1 -1
- {django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage/__init__.py +1 -1
- {django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage/admin.py +82 -11
- {django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage/apps.py +6 -7
- django_api_usage-0.1.8/src/django_api_usage/locale/eu/LC_MESSAGES/django.mo +0 -0
- {django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage/locale/eu/LC_MESSAGES/django.po +41 -21
- django_api_usage-0.1.8/src/django_api_usage/management/commands/api_usage_backfill_paths.py +59 -0
- django_api_usage-0.1.8/src/django_api_usage/migrations/0004_remove_clientapp_accounts_clientappaccount.py +77 -0
- {django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage/models.py +31 -6
- {django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage/resolvers.py +15 -5
- {django_api_usage-0.1.7 → django_api_usage-0.1.8/src/django_api_usage.egg-info}/PKG-INFO +28 -16
- {django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage.egg-info/SOURCES.txt +3 -0
- django_api_usage-0.1.8/tests/test_backfill_paths.py +66 -0
- {django_api_usage-0.1.7 → django_api_usage-0.1.8}/tests/test_client_app.py +36 -2
- {django_api_usage-0.1.7 → django_api_usage-0.1.8}/tests/test_filters.py +47 -0
- django_api_usage-0.1.7/src/django_api_usage/locale/eu/LC_MESSAGES/django.mo +0 -0
- {django_api_usage-0.1.7 → django_api_usage-0.1.8}/LICENSE +0 -0
- {django_api_usage-0.1.7 → django_api_usage-0.1.8}/setup.cfg +0 -0
- {django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage/buffers.py +0 -0
- {django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage/checks.py +0 -0
- {django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage/conf.py +0 -0
- {django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage/deprecation.py +0 -0
- {django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage/drf.py +0 -0
- {django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage/maintenance.py +0 -0
- {django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage/management/__init__.py +0 -0
- {django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage/management/commands/__init__.py +0 -0
- {django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage/management/commands/api_usage_flush.py +0 -0
- {django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage/management/commands/api_usage_report.py +0 -0
- {django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage/middleware.py +0 -0
- {django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage/migrations/0001_initial.py +0 -0
- {django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage/migrations/0002_endpoint_route_path.py +0 -0
- {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
- {django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage/migrations/__init__.py +0 -0
- {django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage/tasks.py +0 -0
- {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
- {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
- {django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage.egg-info/dependency_links.txt +0 -0
- {django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage.egg-info/requires.txt +0 -0
- {django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage.egg-info/top_level.txt +0 -0
- {django_api_usage-0.1.7 → django_api_usage-0.1.8}/tests/test_admin.py +0 -0
- {django_api_usage-0.1.7 → django_api_usage-0.1.8}/tests/test_checks.py +0 -0
- {django_api_usage-0.1.7 → django_api_usage-0.1.8}/tests/test_commands.py +0 -0
- {django_api_usage-0.1.7 → django_api_usage-0.1.8}/tests/test_csv_export.py +0 -0
- {django_api_usage-0.1.7 → django_api_usage-0.1.8}/tests/test_drf.py +0 -0
- {django_api_usage-0.1.7 → django_api_usage-0.1.8}/tests/test_middleware.py +0 -0
- {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.
|
|
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`, ...),
|
|
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**,
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
| Rule | Matched against | Use it for |
|
|
152
|
-
|
|
153
|
-
| `
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
`
|
|
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`, ...),
|
|
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**,
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
| Rule | Matched against | Use it for |
|
|
113
|
-
|
|
114
|
-
| `
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
`
|
|
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
|
+
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"
|
|
@@ -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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
"
|
|
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:
|
|
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:
|
|
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
|
|
82
|
-
"eta user agent-a. Aplikazioen artean, lehentasun txikiena duenak
|
|
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:
|
|
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:
|
|
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:
|
|
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 "
|
|
109
|
+
msgstr ""
|
|
110
|
+
"Ostalari bat lerroko; Origin/Referer ostalariaren aurka egiaztatzen da."
|
|
108
111
|
|
|
109
|
-
#: src/django_api_usage/models.py:
|
|
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:
|
|
116
|
+
#: src/django_api_usage/models.py:96
|
|
114
117
|
msgid "One substring per line, matched case-insensitively."
|
|
115
|
-
msgstr "
|
|
118
|
+
msgstr ""
|
|
119
|
+
"Azpikate bat lerroko; maiuskulak/minuskulak bereizi gabe egiaztatzen da."
|
|
116
120
|
|
|
117
|
-
#: src/django_api_usage/models.py:
|
|
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 = {"
|
|
142
|
-
apps = ClientApp.objects.filter(is_active=True)
|
|
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 =
|
|
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.
|
|
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`, ...),
|
|
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**,
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
| Rule | Matched against | Use it for |
|
|
152
|
-
|
|
153
|
-
| `
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
`
|
|
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
|
-
|
|
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):
|
|
Binary file
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage/management/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage/migrations/0001_initial.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage/migrations/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage.egg-info/requires.txt
RENAMED
|
File without changes
|
{django_api_usage-0.1.7 → django_api_usage-0.1.8}/src/django_api_usage.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|