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.
- {django_api_usage-0.1.6/src/django_api_usage.egg-info → django_api_usage-0.1.8}/PKG-INFO +36 -2
- {django_api_usage-0.1.6 → django_api_usage-0.1.8}/README.md +35 -1
- {django_api_usage-0.1.6 → django_api_usage-0.1.8}/pyproject.toml +1 -1
- {django_api_usage-0.1.6 → django_api_usage-0.1.8}/src/django_api_usage/__init__.py +1 -1
- {django_api_usage-0.1.6 → django_api_usage-0.1.8}/src/django_api_usage/admin.py +192 -4
- django_api_usage-0.1.8/src/django_api_usage/apps.py +33 -0
- {django_api_usage-0.1.6 → django_api_usage-0.1.8}/src/django_api_usage/buffers.py +2 -0
- {django_api_usage-0.1.6 → django_api_usage-0.1.8}/src/django_api_usage/conf.py +1 -0
- django_api_usage-0.1.8/src/django_api_usage/locale/eu/LC_MESSAGES/django.mo +0 -0
- {django_api_usage-0.1.6 → django_api_usage-0.1.8}/src/django_api_usage/locale/eu/LC_MESSAGES/django.po +85 -8
- django_api_usage-0.1.8/src/django_api_usage/management/commands/api_usage_backfill_paths.py +59 -0
- {django_api_usage-0.1.6 → django_api_usage-0.1.8}/src/django_api_usage/middleware.py +1 -0
- django_api_usage-0.1.8/src/django_api_usage/migrations/0003_alter_endpointstat_unique_together_and_more.py +96 -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.8/src/django_api_usage/models.py +200 -0
- django_api_usage-0.1.8/src/django_api_usage/resolvers.py +242 -0
- {django_api_usage-0.1.6 → django_api_usage-0.1.8/src/django_api_usage.egg-info}/PKG-INFO +36 -2
- {django_api_usage-0.1.6 → django_api_usage-0.1.8}/src/django_api_usage.egg-info/SOURCES.txt +5 -0
- django_api_usage-0.1.8/tests/test_backfill_paths.py +66 -0
- django_api_usage-0.1.8/tests/test_client_app.py +193 -0
- {django_api_usage-0.1.6 → django_api_usage-0.1.8}/tests/test_csv_export.py +5 -3
- {django_api_usage-0.1.6 → django_api_usage-0.1.8}/tests/test_filters.py +51 -2
- django_api_usage-0.1.6/src/django_api_usage/apps.py +0 -11
- django_api_usage-0.1.6/src/django_api_usage/locale/eu/LC_MESSAGES/django.mo +0 -0
- django_api_usage-0.1.6/src/django_api_usage/models.py +0 -99
- django_api_usage-0.1.6/src/django_api_usage/resolvers.py +0 -116
- {django_api_usage-0.1.6 → django_api_usage-0.1.8}/LICENSE +0 -0
- {django_api_usage-0.1.6 → django_api_usage-0.1.8}/setup.cfg +0 -0
- {django_api_usage-0.1.6 → django_api_usage-0.1.8}/src/django_api_usage/checks.py +0 -0
- {django_api_usage-0.1.6 → django_api_usage-0.1.8}/src/django_api_usage/deprecation.py +0 -0
- {django_api_usage-0.1.6 → django_api_usage-0.1.8}/src/django_api_usage/drf.py +0 -0
- {django_api_usage-0.1.6 → django_api_usage-0.1.8}/src/django_api_usage/maintenance.py +0 -0
- {django_api_usage-0.1.6 → django_api_usage-0.1.8}/src/django_api_usage/management/__init__.py +0 -0
- {django_api_usage-0.1.6 → django_api_usage-0.1.8}/src/django_api_usage/management/commands/__init__.py +0 -0
- {django_api_usage-0.1.6 → django_api_usage-0.1.8}/src/django_api_usage/management/commands/api_usage_flush.py +0 -0
- {django_api_usage-0.1.6 → django_api_usage-0.1.8}/src/django_api_usage/management/commands/api_usage_report.py +0 -0
- {django_api_usage-0.1.6 → django_api_usage-0.1.8}/src/django_api_usage/migrations/0001_initial.py +0 -0
- {django_api_usage-0.1.6 → django_api_usage-0.1.8}/src/django_api_usage/migrations/0002_endpoint_route_path.py +0 -0
- {django_api_usage-0.1.6 → django_api_usage-0.1.8}/src/django_api_usage/migrations/__init__.py +0 -0
- {django_api_usage-0.1.6 → django_api_usage-0.1.8}/src/django_api_usage/tasks.py +0 -0
- {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
- {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
- {django_api_usage-0.1.6 → django_api_usage-0.1.8}/src/django_api_usage.egg-info/dependency_links.txt +0 -0
- {django_api_usage-0.1.6 → django_api_usage-0.1.8}/src/django_api_usage.egg-info/requires.txt +0 -0
- {django_api_usage-0.1.6 → django_api_usage-0.1.8}/src/django_api_usage.egg-info/top_level.txt +0 -0
- {django_api_usage-0.1.6 → django_api_usage-0.1.8}/tests/test_admin.py +0 -0
- {django_api_usage-0.1.6 → django_api_usage-0.1.8}/tests/test_checks.py +0 -0
- {django_api_usage-0.1.6 → django_api_usage-0.1.8}/tests/test_commands.py +0 -0
- {django_api_usage-0.1.6 → django_api_usage-0.1.8}/tests/test_drf.py +0 -0
- {django_api_usage-0.1.6 → django_api_usage-0.1.8}/tests/test_middleware.py +0 -0
- {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.
|
|
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,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`, ...),
|
|
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.
|
|
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"
|
|
@@ -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
|
-
"
|
|
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 = (
|
|
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
|
|
11
|
-
"PO-Revision-Date: 2026-10-08 06:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|