django-api-usage 0.1.5__tar.gz → 0.1.7__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.5/src/django_api_usage.egg-info → django_api_usage-0.1.7}/PKG-INFO +29 -3
- {django_api_usage-0.1.5 → django_api_usage-0.1.7}/README.md +28 -2
- {django_api_usage-0.1.5 → django_api_usage-0.1.7}/pyproject.toml +6 -2
- {django_api_usage-0.1.5 → django_api_usage-0.1.7}/src/django_api_usage/__init__.py +1 -1
- {django_api_usage-0.1.5 → django_api_usage-0.1.7}/src/django_api_usage/admin.py +195 -5
- django_api_usage-0.1.7/src/django_api_usage/apps.py +34 -0
- {django_api_usage-0.1.5 → django_api_usage-0.1.7}/src/django_api_usage/buffers.py +2 -0
- {django_api_usage-0.1.5 → django_api_usage-0.1.7}/src/django_api_usage/conf.py +1 -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/src/django_api_usage/locale/eu/LC_MESSAGES/django.po +169 -0
- {django_api_usage-0.1.5 → django_api_usage-0.1.7}/src/django_api_usage/middleware.py +1 -0
- django_api_usage-0.1.7/src/django_api_usage/migrations/0003_alter_endpointstat_unique_together_and_more.py +96 -0
- {django_api_usage-0.1.5 → django_api_usage-0.1.7}/src/django_api_usage/models.py +80 -4
- {django_api_usage-0.1.5 → django_api_usage-0.1.7}/src/django_api_usage/resolvers.py +116 -0
- {django_api_usage-0.1.5 → django_api_usage-0.1.7/src/django_api_usage.egg-info}/PKG-INFO +29 -3
- {django_api_usage-0.1.5 → django_api_usage-0.1.7}/src/django_api_usage.egg-info/SOURCES.txt +5 -0
- django_api_usage-0.1.7/tests/test_client_app.py +159 -0
- {django_api_usage-0.1.5 → django_api_usage-0.1.7}/tests/test_csv_export.py +5 -3
- django_api_usage-0.1.7/tests/test_filters.py +124 -0
- django_api_usage-0.1.5/src/django_api_usage/apps.py +0 -11
- {django_api_usage-0.1.5 → django_api_usage-0.1.7}/LICENSE +0 -0
- {django_api_usage-0.1.5 → django_api_usage-0.1.7}/setup.cfg +0 -0
- {django_api_usage-0.1.5 → django_api_usage-0.1.7}/src/django_api_usage/checks.py +0 -0
- {django_api_usage-0.1.5 → django_api_usage-0.1.7}/src/django_api_usage/deprecation.py +0 -0
- {django_api_usage-0.1.5 → django_api_usage-0.1.7}/src/django_api_usage/drf.py +0 -0
- {django_api_usage-0.1.5 → django_api_usage-0.1.7}/src/django_api_usage/maintenance.py +0 -0
- {django_api_usage-0.1.5 → django_api_usage-0.1.7}/src/django_api_usage/management/__init__.py +0 -0
- {django_api_usage-0.1.5 → django_api_usage-0.1.7}/src/django_api_usage/management/commands/__init__.py +0 -0
- {django_api_usage-0.1.5 → django_api_usage-0.1.7}/src/django_api_usage/management/commands/api_usage_flush.py +0 -0
- {django_api_usage-0.1.5 → django_api_usage-0.1.7}/src/django_api_usage/management/commands/api_usage_report.py +0 -0
- {django_api_usage-0.1.5 → django_api_usage-0.1.7}/src/django_api_usage/migrations/0001_initial.py +0 -0
- {django_api_usage-0.1.5 → django_api_usage-0.1.7}/src/django_api_usage/migrations/0002_endpoint_route_path.py +0 -0
- {django_api_usage-0.1.5 → django_api_usage-0.1.7}/src/django_api_usage/migrations/__init__.py +0 -0
- {django_api_usage-0.1.5 → django_api_usage-0.1.7}/src/django_api_usage/tasks.py +0 -0
- {django_api_usage-0.1.5 → django_api_usage-0.1.7}/src/django_api_usage/templates/admin/django_api_usage/endpointstat/change_list.html +0 -0
- {django_api_usage-0.1.5 → django_api_usage-0.1.7}/src/django_api_usage/templates/admin/django_api_usage/endpointstat/confirm_clear.html +0 -0
- {django_api_usage-0.1.5 → django_api_usage-0.1.7}/src/django_api_usage.egg-info/dependency_links.txt +0 -0
- {django_api_usage-0.1.5 → django_api_usage-0.1.7}/src/django_api_usage.egg-info/requires.txt +0 -0
- {django_api_usage-0.1.5 → django_api_usage-0.1.7}/src/django_api_usage.egg-info/top_level.txt +0 -0
- {django_api_usage-0.1.5 → django_api_usage-0.1.7}/tests/test_admin.py +0 -0
- {django_api_usage-0.1.5 → django_api_usage-0.1.7}/tests/test_checks.py +0 -0
- {django_api_usage-0.1.5 → django_api_usage-0.1.7}/tests/test_commands.py +0 -0
- {django_api_usage-0.1.5 → django_api_usage-0.1.7}/tests/test_drf.py +0 -0
- {django_api_usage-0.1.5 → django_api_usage-0.1.7}/tests/test_middleware.py +0 -0
- {django_api_usage-0.1.5 → django_api_usage-0.1.7}/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.7
|
|
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
|
|
@@ -127,8 +127,12 @@ Django — **no dependency beyond Django itself** (no `django-object-actions` or
|
|
|
127
127
|
similar):
|
|
128
128
|
|
|
129
129
|
* **Endpoint stats** changelist: a search box (by endpoint path, route name, app,
|
|
130
|
-
method or replacement), filters by date,
|
|
131
|
-
the `count` column** for the
|
|
130
|
+
method or replacement), filters by date, client type, status class, and
|
|
131
|
+
**Application** / **Method**, plus the **sum of the `count` column** for the
|
|
132
|
+
rows matching the current filters. The Method filter lists the riskiest verbs
|
|
133
|
+
first (`DELETE`, `POST`, ...), so write and destructive calls stand out.
|
|
134
|
+
* **Endpoint** changelist: the same Application / Method filters, plus
|
|
135
|
+
deprecation state, the site and the CSV export.
|
|
132
136
|
* **Flush now**: moves counters buffered in the cache into the database, so they
|
|
133
137
|
appear immediately when you run with `BUFFER_BACKEND = "cache"`.
|
|
134
138
|
* **Clear statistics**: empties the aggregated counters behind a confirmation
|
|
@@ -138,6 +142,28 @@ similar):
|
|
|
138
142
|
and method), so the file can be fed straight to pandas or a spreadsheet. Use
|
|
139
143
|
the "select all" link to export every row matching the current filters.
|
|
140
144
|
|
|
145
|
+
## Client applications
|
|
146
|
+
|
|
147
|
+
Not every caller is a person: ERPs, partners, your own web front end and the
|
|
148
|
+
native mobile apps also hit the API. `ClientApp` is an **editable table**, so a
|
|
149
|
+
new consumer is recognised from the admin, without a deploy:
|
|
150
|
+
|
|
151
|
+
| Rule | Matched against | Use it for |
|
|
152
|
+
|---|---|---|
|
|
153
|
+
| `accounts` | the authenticated user (and therefore its DRF token) | one dedicated token per integration (ERP, partner...) |
|
|
154
|
+
| `domains` | the `Origin`/`Referer` host, subdomains included | your own web front end |
|
|
155
|
+
| `ip_networks` | the client address, against a CIDR (one per line) | internal networks and servers |
|
|
156
|
+
| `user_agent_patterns` | a case-insensitive substring of `User-Agent` | native mobile apps |
|
|
157
|
+
|
|
158
|
+
Rules are checked in that order, and `priority` decides between applications
|
|
159
|
+
(lower wins). A caller matching nothing stays unattributed, so the dimension
|
|
160
|
+
cannot grow out of control: `EndpointStat.client_app` only ever holds a
|
|
161
|
+
`ClientApp.slug`.
|
|
162
|
+
|
|
163
|
+
Counters, the CSV export and the admin filters all carry the application, so
|
|
164
|
+
"which application calls this endpoint?" is one filter away. The **Endpoint
|
|
165
|
+
stats** changelist also offers *Not attributed*: the callers still to classify.
|
|
166
|
+
|
|
141
167
|
## Deprecation lifecycle
|
|
142
168
|
|
|
143
169
|
`Endpoint` carries `deprecated`, `sunset_date`, `replacement` and `owner`, so the
|
|
@@ -88,8 +88,12 @@ Django — **no dependency beyond Django itself** (no `django-object-actions` or
|
|
|
88
88
|
similar):
|
|
89
89
|
|
|
90
90
|
* **Endpoint stats** changelist: a search box (by endpoint path, route name, app,
|
|
91
|
-
method or replacement), filters by date,
|
|
92
|
-
the `count` column** for the
|
|
91
|
+
method or replacement), filters by date, client type, status class, and
|
|
92
|
+
**Application** / **Method**, plus the **sum of the `count` column** for the
|
|
93
|
+
rows matching the current filters. The Method filter lists the riskiest verbs
|
|
94
|
+
first (`DELETE`, `POST`, ...), so write and destructive calls stand out.
|
|
95
|
+
* **Endpoint** changelist: the same Application / Method filters, plus
|
|
96
|
+
deprecation state, the site and the CSV export.
|
|
93
97
|
* **Flush now**: moves counters buffered in the cache into the database, so they
|
|
94
98
|
appear immediately when you run with `BUFFER_BACKEND = "cache"`.
|
|
95
99
|
* **Clear statistics**: empties the aggregated counters behind a confirmation
|
|
@@ -99,6 +103,28 @@ similar):
|
|
|
99
103
|
and method), so the file can be fed straight to pandas or a spreadsheet. Use
|
|
100
104
|
the "select all" link to export every row matching the current filters.
|
|
101
105
|
|
|
106
|
+
## Client applications
|
|
107
|
+
|
|
108
|
+
Not every caller is a person: ERPs, partners, your own web front end and the
|
|
109
|
+
native mobile apps also hit the API. `ClientApp` is an **editable table**, so a
|
|
110
|
+
new consumer is recognised from the admin, without a deploy:
|
|
111
|
+
|
|
112
|
+
| Rule | Matched against | Use it for |
|
|
113
|
+
|---|---|---|
|
|
114
|
+
| `accounts` | the authenticated user (and therefore its DRF token) | one dedicated token per integration (ERP, partner...) |
|
|
115
|
+
| `domains` | the `Origin`/`Referer` host, subdomains included | your own web front end |
|
|
116
|
+
| `ip_networks` | the client address, against a CIDR (one per line) | internal networks and servers |
|
|
117
|
+
| `user_agent_patterns` | a case-insensitive substring of `User-Agent` | native mobile apps |
|
|
118
|
+
|
|
119
|
+
Rules are checked in that order, and `priority` decides between applications
|
|
120
|
+
(lower wins). A caller matching nothing stays unattributed, so the dimension
|
|
121
|
+
cannot grow out of control: `EndpointStat.client_app` only ever holds a
|
|
122
|
+
`ClientApp.slug`.
|
|
123
|
+
|
|
124
|
+
Counters, the CSV export and the admin filters all carry the application, so
|
|
125
|
+
"which application calls this endpoint?" is one filter away. The **Endpoint
|
|
126
|
+
stats** changelist also offers *Not attributed*: the callers still to classify.
|
|
127
|
+
|
|
102
128
|
## Deprecation lifecycle
|
|
103
129
|
|
|
104
130
|
`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.7"
|
|
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"
|
|
@@ -42,7 +42,11 @@ Issues = "https://github.com/codesyntax/django-api-usage/issues"
|
|
|
42
42
|
where = ["src"]
|
|
43
43
|
|
|
44
44
|
[tool.setuptools.package-data]
|
|
45
|
-
django_api_usage = [
|
|
45
|
+
django_api_usage = [
|
|
46
|
+
"templates/admin/django_api_usage/endpointstat/*.html",
|
|
47
|
+
"locale/*/LC_MESSAGES/*.po",
|
|
48
|
+
"locale/*/LC_MESSAGES/*.mo",
|
|
49
|
+
]
|
|
46
50
|
|
|
47
51
|
[tool.black]
|
|
48
52
|
line-length = 88
|
|
@@ -16,7 +16,7 @@ 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
|
|
@@ -25,7 +25,7 @@ from django.utils.text import format_lazy
|
|
|
25
25
|
from django.utils.translation import gettext_lazy as _
|
|
26
26
|
|
|
27
27
|
from .buffers import flush
|
|
28
|
-
from .models import Consumer, Endpoint, EndpointStat
|
|
28
|
+
from .models import ClientApp, Consumer, Endpoint, EndpointStat
|
|
29
29
|
|
|
30
30
|
|
|
31
31
|
class CsvExportMixin:
|
|
@@ -60,6 +60,107 @@ class CsvExportMixin:
|
|
|
60
60
|
return response
|
|
61
61
|
|
|
62
62
|
|
|
63
|
+
#: HTTP methods, riskiest first: the point of the filter is spotting the
|
|
64
|
+
#: write/destructive calls (POST, DELETE, ...) instead of the read ones.
|
|
65
|
+
METHOD_ORDER = ("DELETE", "POST", "PUT", "PATCH", "GET", "HEAD", "OPTIONS")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class AppLabelListFilter(admin.SimpleListFilter):
|
|
69
|
+
"""Distinct application labels, under a readable title."""
|
|
70
|
+
|
|
71
|
+
title = _("Application")
|
|
72
|
+
parameter_name = "app_label"
|
|
73
|
+
#: Lookup against the admin's model; overridden for related filters.
|
|
74
|
+
field_path = "app_label"
|
|
75
|
+
|
|
76
|
+
def lookups(self, request, model_admin):
|
|
77
|
+
values = (
|
|
78
|
+
model_admin.model.objects.order_by()
|
|
79
|
+
.values_list(self.field_path, flat=True)
|
|
80
|
+
.distinct()
|
|
81
|
+
)
|
|
82
|
+
return [(value, value) for value in sorted(v for v in values if v)]
|
|
83
|
+
|
|
84
|
+
def queryset(self, request, queryset):
|
|
85
|
+
if not self.value():
|
|
86
|
+
return queryset
|
|
87
|
+
return queryset.filter(**{self.field_path: self.value()})
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class MethodListFilter(admin.SimpleListFilter):
|
|
91
|
+
"""HTTP methods, riskiest (DELETE, POST, ...) first."""
|
|
92
|
+
|
|
93
|
+
title = _("Method")
|
|
94
|
+
parameter_name = "method"
|
|
95
|
+
field_path = "method"
|
|
96
|
+
|
|
97
|
+
def lookups(self, request, model_admin):
|
|
98
|
+
values = set(
|
|
99
|
+
model_admin.model.objects.order_by()
|
|
100
|
+
.values_list(self.field_path, flat=True)
|
|
101
|
+
.distinct()
|
|
102
|
+
)
|
|
103
|
+
ordered = [method for method in METHOD_ORDER if method in values]
|
|
104
|
+
rest = sorted(m for m in values if m and m not in METHOD_ORDER)
|
|
105
|
+
return [(method, method) for method in ordered + rest]
|
|
106
|
+
|
|
107
|
+
def queryset(self, request, queryset):
|
|
108
|
+
if not self.value():
|
|
109
|
+
return queryset
|
|
110
|
+
return queryset.filter(**{self.field_path: self.value()})
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class StatAppLabelListFilter(AppLabelListFilter):
|
|
114
|
+
"""Application filter for the stats changelist (through the endpoint)."""
|
|
115
|
+
|
|
116
|
+
field_path = "endpoint__app_label"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class StatMethodListFilter(MethodListFilter):
|
|
120
|
+
"""Method filter for the stats changelist (through the endpoint)."""
|
|
121
|
+
|
|
122
|
+
field_path = "endpoint__method"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
#: Sentinel for the calls that could not be attributed to any application.
|
|
126
|
+
UNATTRIBUTED = "__none__"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class ClientAppListFilter(admin.SimpleListFilter):
|
|
130
|
+
"""Client applications, plus the calls that were not attributed.
|
|
131
|
+
|
|
132
|
+
Applications defined in the table are listed even before they have traffic,
|
|
133
|
+
which is what you want while checking that a new rule works.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
title = _("Client application")
|
|
137
|
+
parameter_name = "client_app"
|
|
138
|
+
|
|
139
|
+
def lookups(self, request, model_admin):
|
|
140
|
+
used = set(
|
|
141
|
+
model_admin.model.objects.order_by()
|
|
142
|
+
.values_list("client_app", flat=True)
|
|
143
|
+
.distinct()
|
|
144
|
+
)
|
|
145
|
+
names = dict(ClientApp.objects.values_list("slug", "name"))
|
|
146
|
+
defined = set(
|
|
147
|
+
ClientApp.objects.filter(is_active=True).values_list("slug", flat=True)
|
|
148
|
+
)
|
|
149
|
+
choices = [
|
|
150
|
+
(slug, names.get(slug) or slug) for slug in sorted((used | defined) - {""})
|
|
151
|
+
]
|
|
152
|
+
choices.append((UNATTRIBUTED, _("Not attributed")))
|
|
153
|
+
return choices
|
|
154
|
+
|
|
155
|
+
def queryset(self, request, queryset):
|
|
156
|
+
value = self.value()
|
|
157
|
+
if value == UNATTRIBUTED:
|
|
158
|
+
return queryset.filter(client_app="")
|
|
159
|
+
if value:
|
|
160
|
+
return queryset.filter(client_app=value)
|
|
161
|
+
return queryset
|
|
162
|
+
|
|
163
|
+
|
|
63
164
|
@admin.register(Endpoint)
|
|
64
165
|
class EndpointAdmin(CsvExportMixin, admin.ModelAdmin):
|
|
65
166
|
actions = ("export_as_csv",)
|
|
@@ -86,7 +187,12 @@ class EndpointAdmin(CsvExportMixin, admin.ModelAdmin):
|
|
|
86
187
|
"replacement",
|
|
87
188
|
"owner",
|
|
88
189
|
)
|
|
89
|
-
list_filter = (
|
|
190
|
+
list_filter = (
|
|
191
|
+
"deprecated",
|
|
192
|
+
AppLabelListFilter,
|
|
193
|
+
MethodListFilter,
|
|
194
|
+
"site_id",
|
|
195
|
+
)
|
|
90
196
|
search_fields = ("route_path", "route_name", "replacement", "owner", "notes")
|
|
91
197
|
list_editable = ("deprecated", "sunset_date", "replacement", "owner")
|
|
92
198
|
|
|
@@ -104,11 +210,26 @@ class EndpointStatAdmin(CsvExportMixin, admin.ModelAdmin):
|
|
|
104
210
|
("method", "endpoint__method"),
|
|
105
211
|
("site_id", "endpoint__site_id"),
|
|
106
212
|
("client_type", "client_type"),
|
|
213
|
+
("client_app", "client_app"),
|
|
107
214
|
("status_class", "status_class"),
|
|
108
215
|
("count", "count"),
|
|
109
216
|
)
|
|
110
|
-
list_display = (
|
|
111
|
-
|
|
217
|
+
list_display = (
|
|
218
|
+
"date",
|
|
219
|
+
"endpoint",
|
|
220
|
+
"client_type",
|
|
221
|
+
"client_app",
|
|
222
|
+
"status_class",
|
|
223
|
+
"count",
|
|
224
|
+
)
|
|
225
|
+
list_filter = (
|
|
226
|
+
"date",
|
|
227
|
+
"client_type",
|
|
228
|
+
ClientAppListFilter,
|
|
229
|
+
"status_class",
|
|
230
|
+
StatAppLabelListFilter,
|
|
231
|
+
StatMethodListFilter,
|
|
232
|
+
)
|
|
112
233
|
list_select_related = ("endpoint",)
|
|
113
234
|
date_hierarchy = "date"
|
|
114
235
|
# Search across the related endpoint (case-insensitive "contains").
|
|
@@ -241,3 +362,72 @@ class ConsumerAdmin(CsvExportMixin, admin.ModelAdmin):
|
|
|
241
362
|
@admin.display(description="Reference")
|
|
242
363
|
def ref_short(self, obj):
|
|
243
364
|
return obj.ref_hash[:12]
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
@admin.register(ClientApp)
|
|
368
|
+
class ClientAppAdmin(CsvExportMixin, admin.ModelAdmin):
|
|
369
|
+
"""Editable table: recognise a new consumer without deploying anything."""
|
|
370
|
+
|
|
371
|
+
actions = ("export_as_csv",)
|
|
372
|
+
csv_columns = (
|
|
373
|
+
("slug", "slug"),
|
|
374
|
+
("name", "name"),
|
|
375
|
+
("priority", "priority"),
|
|
376
|
+
("is_active", "is_active"),
|
|
377
|
+
("domains", "domains"),
|
|
378
|
+
("ip_networks", "ip_networks"),
|
|
379
|
+
("user_agent_patterns", "user_agent_patterns"),
|
|
380
|
+
)
|
|
381
|
+
list_display = (
|
|
382
|
+
"slug",
|
|
383
|
+
"name",
|
|
384
|
+
"priority",
|
|
385
|
+
"is_active",
|
|
386
|
+
"accounts_total",
|
|
387
|
+
"counters_total",
|
|
388
|
+
)
|
|
389
|
+
list_filter = ("is_active",)
|
|
390
|
+
search_fields = ("slug", "name", "description")
|
|
391
|
+
filter_horizontal = ("accounts",)
|
|
392
|
+
fieldsets = (
|
|
393
|
+
(None, {"fields": ("slug", "name", "description", "priority", "is_active")}),
|
|
394
|
+
(
|
|
395
|
+
_("Matching rules"),
|
|
396
|
+
{
|
|
397
|
+
"fields": (
|
|
398
|
+
"accounts",
|
|
399
|
+
"domains",
|
|
400
|
+
"ip_networks",
|
|
401
|
+
"user_agent_patterns",
|
|
402
|
+
),
|
|
403
|
+
"description": _(
|
|
404
|
+
"Checked in this order: account, request host, client "
|
|
405
|
+
"network, user agent. Between applications, the lowest "
|
|
406
|
+
"priority wins."
|
|
407
|
+
),
|
|
408
|
+
},
|
|
409
|
+
),
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
def get_queryset(self, request):
|
|
413
|
+
counters = (
|
|
414
|
+
EndpointStat.objects.filter(client_app=OuterRef("slug"))
|
|
415
|
+
.order_by()
|
|
416
|
+
.values("client_app")
|
|
417
|
+
.annotate(total=Sum("count"))
|
|
418
|
+
.values("total")
|
|
419
|
+
)
|
|
420
|
+
return (
|
|
421
|
+
super()
|
|
422
|
+
.get_queryset(request)
|
|
423
|
+
.annotate(accounts_total=Count("accounts", distinct=True))
|
|
424
|
+
.annotate(counters_total=Subquery(counters))
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
@admin.display(description=_("Accounts"), ordering="accounts_total")
|
|
428
|
+
def accounts_total(self, obj):
|
|
429
|
+
return obj.accounts_total
|
|
430
|
+
|
|
431
|
+
@admin.display(description=_("Counters"), ordering="counters_total")
|
|
432
|
+
def counters_total(self, obj):
|
|
433
|
+
return obj.counters_total or 0
|
|
@@ -0,0 +1,34 @@
|
|
|
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
|
+
from django.db.models.signals import m2m_changed, post_delete, post_save
|
|
18
|
+
|
|
19
|
+
from .models import ClientApp
|
|
20
|
+
from .resolvers import reset_client_app_cache
|
|
21
|
+
|
|
22
|
+
post_save.connect(
|
|
23
|
+
reset_client_app_cache, sender=ClientApp, dispatch_uid="api_usage_app_save"
|
|
24
|
+
)
|
|
25
|
+
post_delete.connect(
|
|
26
|
+
reset_client_app_cache,
|
|
27
|
+
sender=ClientApp,
|
|
28
|
+
dispatch_uid="api_usage_app_delete",
|
|
29
|
+
)
|
|
30
|
+
m2m_changed.connect(
|
|
31
|
+
reset_client_app_cache,
|
|
32
|
+
sender=ClientApp.accounts.through,
|
|
33
|
+
dispatch_uid="api_usage_app_accounts",
|
|
34
|
+
)
|
|
@@ -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",
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# Basque translation of django-api-usage.
|
|
2
|
+
# Copyright (C) 2026 CodeSyntax
|
|
3
|
+
# This file is distributed under the same license as the django-api-usage package.
|
|
4
|
+
# Urtzi Odriozola <uodriozola@codesyntax.com>, 2026.
|
|
5
|
+
#
|
|
6
|
+
msgid ""
|
|
7
|
+
msgstr ""
|
|
8
|
+
"Project-Id-Version: django-api-usage\n"
|
|
9
|
+
"Report-Msgid-Bugs-To: \n"
|
|
10
|
+
"POT-Creation-Date: 2026-10-08 06:45-0500\n"
|
|
11
|
+
"PO-Revision-Date: 2026-10-08 06:55-0500\n"
|
|
12
|
+
"Last-Translator: Urtzi Odriozola <uodriozola@codesyntax.com>\n"
|
|
13
|
+
"Language-Team: Basque\n"
|
|
14
|
+
"Language: eu\n"
|
|
15
|
+
"MIME-Version: 1.0\n"
|
|
16
|
+
"Content-Type: text/plain; charset=UTF-8\n"
|
|
17
|
+
"Content-Transfer-Encoding: 8bit\n"
|
|
18
|
+
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
|
19
|
+
|
|
20
|
+
#: src/django_api_usage/admin.py:46
|
|
21
|
+
msgid "Export selected rows to CSV"
|
|
22
|
+
msgstr "Esportatu hautatutako errenkadak CSVra"
|
|
23
|
+
|
|
24
|
+
#: src/django_api_usage/admin.py:71
|
|
25
|
+
msgid "Application"
|
|
26
|
+
msgstr "Aplikazioa"
|
|
27
|
+
|
|
28
|
+
#: src/django_api_usage/admin.py:93
|
|
29
|
+
msgid "Method"
|
|
30
|
+
msgstr "Metodoa"
|
|
31
|
+
|
|
32
|
+
#: src/django_api_usage/admin.py:136 src/django_api_usage/models.py:106
|
|
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
|
|
41
|
+
msgid ""
|
|
42
|
+
"Search by endpoint path, route name, app, method or replacement (for example "
|
|
43
|
+
"'herriak' or 'artikuluak')."
|
|
44
|
+
msgstr ""
|
|
45
|
+
"Bilatu endpointaren bidearen, route-izenaren, aplikazioaren, metodoaren edo "
|
|
46
|
+
"ordezkoaren arabera (adibidez 'herriak' edo 'artikuluak')."
|
|
47
|
+
|
|
48
|
+
#: src/django_api_usage/admin.py:299
|
|
49
|
+
#, python-brace-format
|
|
50
|
+
msgid "Flushed {count} buffered counter(s)."
|
|
51
|
+
msgstr "{count} kontagailu eraman dira bufferretik datu-basera."
|
|
52
|
+
|
|
53
|
+
#: src/django_api_usage/admin.py:306
|
|
54
|
+
msgid ""
|
|
55
|
+
"Nothing was buffered: with BUFFER_BACKEND='db' counters are already in the "
|
|
56
|
+
"database."
|
|
57
|
+
msgstr ""
|
|
58
|
+
"Ez zegoen ezer bufferrean: BUFFER_BACKEND='db' denean kontagailuak jada "
|
|
59
|
+
"datu-basean daude."
|
|
60
|
+
|
|
61
|
+
#: src/django_api_usage/admin.py:318
|
|
62
|
+
#: src/django_api_usage/templates/admin/django_api_usage/endpointstat/change_list.html:12
|
|
63
|
+
#: src/django_api_usage/templates/admin/django_api_usage/endpointstat/confirm_clear.html:8
|
|
64
|
+
msgid "Clear statistics"
|
|
65
|
+
msgstr "Estatistikak hustu"
|
|
66
|
+
|
|
67
|
+
#: src/django_api_usage/admin.py:330
|
|
68
|
+
#, python-brace-format
|
|
69
|
+
msgid "Deleted {count} statistic row(s)."
|
|
70
|
+
msgstr "{count} estatistika-errenkada ezabatu dira."
|
|
71
|
+
|
|
72
|
+
#: src/django_api_usage/admin.py:395
|
|
73
|
+
msgid "Matching rules"
|
|
74
|
+
msgstr "Bat-etortze arauak"
|
|
75
|
+
|
|
76
|
+
#: src/django_api_usage/admin.py:404
|
|
77
|
+
msgid ""
|
|
78
|
+
"Checked in this order: account, request host, client network, user agent. "
|
|
79
|
+
"Between applications, the lowest priority wins."
|
|
80
|
+
msgstr ""
|
|
81
|
+
"Ordena honetan egiaztatzen da: kontua, eskaeraren ostalaria, bezeroaren sarea "
|
|
82
|
+
"eta user agent-a. Aplikazioen artean, lehentasun txikiena duenak irabazten du."
|
|
83
|
+
|
|
84
|
+
#: src/django_api_usage/admin.py:427
|
|
85
|
+
msgid "Accounts"
|
|
86
|
+
msgstr "Kontuak"
|
|
87
|
+
|
|
88
|
+
#: src/django_api_usage/admin.py:431
|
|
89
|
+
msgid "Counters"
|
|
90
|
+
msgstr "Kontagailuak"
|
|
91
|
+
|
|
92
|
+
#: src/django_api_usage/models.py:79
|
|
93
|
+
msgid "Short, stable label stored in the counters (e.g. 'mugikorra')."
|
|
94
|
+
msgstr ""
|
|
95
|
+
"Kontagailuetan gordetzen den etiketa labur eta egonkorra (adib. 'mugikorra')."
|
|
96
|
+
|
|
97
|
+
#: src/django_api_usage/models.py:85
|
|
98
|
+
msgid "Lower wins when several apps could match."
|
|
99
|
+
msgstr "Hainbat aplikaziok bat etorri ahalketenean, txikienak irabazten du."
|
|
100
|
+
|
|
101
|
+
#: src/django_api_usage/models.py:91
|
|
102
|
+
msgid "Users (and therefore DRF tokens) that belong to this app."
|
|
103
|
+
msgstr "Aplikazio honi dagozkion erabiltzaileak (eta, beraz, haien DRF tokenak)."
|
|
104
|
+
|
|
105
|
+
#: src/django_api_usage/models.py:95
|
|
106
|
+
msgid "One host per line; matched against the Origin/Referer host."
|
|
107
|
+
msgstr "Ostalari bat lerroko; Origin/Referer ostalariaren aurka egiaztatzen da."
|
|
108
|
+
|
|
109
|
+
#: src/django_api_usage/models.py:98
|
|
110
|
+
msgid "One CIDR per line, e.g. 10.0.0.0/8."
|
|
111
|
+
msgstr "CIDR bat lerroko, adib. 10.0.0.0/8."
|
|
112
|
+
|
|
113
|
+
#: src/django_api_usage/models.py:102
|
|
114
|
+
msgid "One substring per line, matched case-insensitively."
|
|
115
|
+
msgstr "Azpikate bat lerroko; maiuskulak/minuskulak bereizi gabe egiaztatzen da."
|
|
116
|
+
|
|
117
|
+
#: src/django_api_usage/models.py:107
|
|
118
|
+
msgid "Client applications"
|
|
119
|
+
msgstr "Bezero-aplikazioak"
|
|
120
|
+
|
|
121
|
+
#: src/django_api_usage/templates/admin/django_api_usage/endpointstat/change_list.html:8
|
|
122
|
+
msgid "Flush now"
|
|
123
|
+
msgstr "Flush orain"
|
|
124
|
+
|
|
125
|
+
#: src/django_api_usage/templates/admin/django_api_usage/endpointstat/change_list.html:21
|
|
126
|
+
#, python-format
|
|
127
|
+
msgid "TOTAL COUNT: %(total)s"
|
|
128
|
+
msgstr "GUZTIRA: %(total)s"
|
|
129
|
+
|
|
130
|
+
#: src/django_api_usage/templates/admin/django_api_usage/endpointstat/confirm_clear.html:6
|
|
131
|
+
msgid "Home"
|
|
132
|
+
msgstr ""
|
|
133
|
+
|
|
134
|
+
#: src/django_api_usage/templates/admin/django_api_usage/endpointstat/confirm_clear.html:7
|
|
135
|
+
msgid "Endpoint stats"
|
|
136
|
+
msgstr "Endpoint estatistikak"
|
|
137
|
+
|
|
138
|
+
#: src/django_api_usage/templates/admin/django_api_usage/endpointstat/confirm_clear.html:14
|
|
139
|
+
#, python-format
|
|
140
|
+
msgid ""
|
|
141
|
+
"\n"
|
|
142
|
+
" This will delete the only aggregated statistic row. Endpoints and "
|
|
143
|
+
"their\n"
|
|
144
|
+
" deprecation metadata are kept.\n"
|
|
145
|
+
" "
|
|
146
|
+
msgid_plural ""
|
|
147
|
+
"\n"
|
|
148
|
+
" This will delete all %(counter)s aggregated statistic rows. Endpoints\n"
|
|
149
|
+
" and their deprecation metadata are kept.\n"
|
|
150
|
+
" "
|
|
151
|
+
msgstr[0] ""
|
|
152
|
+
"\n"
|
|
153
|
+
" Estatistika-errenkada agregatu bakarra ezabatuko da. Endpointak eta "
|
|
154
|
+
"haien\n"
|
|
155
|
+
" deprecation-metadatuak mantendu egingo dira.\n"
|
|
156
|
+
" "
|
|
157
|
+
msgstr[1] ""
|
|
158
|
+
"\n"
|
|
159
|
+
" %(counter)s estatistika-errenkada agregatu ezabatuko dira. Endpointak\n"
|
|
160
|
+
" eta haien deprecation-metadatuak mantendu egingo dira.\n"
|
|
161
|
+
" "
|
|
162
|
+
|
|
163
|
+
#: src/django_api_usage/templates/admin/django_api_usage/endpointstat/confirm_clear.html:24
|
|
164
|
+
msgid "Yes, clear statistics"
|
|
165
|
+
msgstr "Bai, estatistikak hustu"
|
|
166
|
+
|
|
167
|
+
#: src/django_api_usage/templates/admin/django_api_usage/endpointstat/confirm_clear.html:25
|
|
168
|
+
msgid "Cancel"
|
|
169
|
+
msgstr ""
|
|
@@ -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:
|