django-api-usage 0.1.6__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.6/src/django_api_usage.egg-info → django_api_usage-0.1.7}/PKG-INFO +23 -1
- {django_api_usage-0.1.6 → django_api_usage-0.1.7}/README.md +22 -0
- {django_api_usage-0.1.6 → django_api_usage-0.1.7}/pyproject.toml +1 -1
- {django_api_usage-0.1.6 → django_api_usage-0.1.7}/src/django_api_usage/__init__.py +1 -1
- {django_api_usage-0.1.6 → django_api_usage-0.1.7}/src/django_api_usage/admin.py +120 -3
- django_api_usage-0.1.7/src/django_api_usage/apps.py +34 -0
- {django_api_usage-0.1.6 → django_api_usage-0.1.7}/src/django_api_usage/buffers.py +2 -0
- {django_api_usage-0.1.6 → 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.6 → django_api_usage-0.1.7}/src/django_api_usage/locale/eu/LC_MESSAGES/django.po +67 -10
- {django_api_usage-0.1.6 → 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.6 → django_api_usage-0.1.7}/src/django_api_usage/models.py +80 -4
- {django_api_usage-0.1.6 → django_api_usage-0.1.7}/src/django_api_usage/resolvers.py +116 -0
- {django_api_usage-0.1.6 → django_api_usage-0.1.7/src/django_api_usage.egg-info}/PKG-INFO +23 -1
- {django_api_usage-0.1.6 → django_api_usage-0.1.7}/src/django_api_usage.egg-info/SOURCES.txt +2 -0
- django_api_usage-0.1.7/tests/test_client_app.py +159 -0
- {django_api_usage-0.1.6 → django_api_usage-0.1.7}/tests/test_csv_export.py +5 -3
- {django_api_usage-0.1.6 → django_api_usage-0.1.7}/tests/test_filters.py +4 -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 → django_api_usage-0.1.7}/LICENSE +0 -0
- {django_api_usage-0.1.6 → django_api_usage-0.1.7}/setup.cfg +0 -0
- {django_api_usage-0.1.6 → django_api_usage-0.1.7}/src/django_api_usage/checks.py +0 -0
- {django_api_usage-0.1.6 → django_api_usage-0.1.7}/src/django_api_usage/deprecation.py +0 -0
- {django_api_usage-0.1.6 → django_api_usage-0.1.7}/src/django_api_usage/drf.py +0 -0
- {django_api_usage-0.1.6 → django_api_usage-0.1.7}/src/django_api_usage/maintenance.py +0 -0
- {django_api_usage-0.1.6 → django_api_usage-0.1.7}/src/django_api_usage/management/__init__.py +0 -0
- {django_api_usage-0.1.6 → django_api_usage-0.1.7}/src/django_api_usage/management/commands/__init__.py +0 -0
- {django_api_usage-0.1.6 → django_api_usage-0.1.7}/src/django_api_usage/management/commands/api_usage_flush.py +0 -0
- {django_api_usage-0.1.6 → django_api_usage-0.1.7}/src/django_api_usage/management/commands/api_usage_report.py +0 -0
- {django_api_usage-0.1.6 → django_api_usage-0.1.7}/src/django_api_usage/migrations/0001_initial.py +0 -0
- {django_api_usage-0.1.6 → django_api_usage-0.1.7}/src/django_api_usage/migrations/0002_endpoint_route_path.py +0 -0
- {django_api_usage-0.1.6 → django_api_usage-0.1.7}/src/django_api_usage/migrations/__init__.py +0 -0
- {django_api_usage-0.1.6 → django_api_usage-0.1.7}/src/django_api_usage/tasks.py +0 -0
- {django_api_usage-0.1.6 → 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.6 → 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.6 → django_api_usage-0.1.7}/src/django_api_usage.egg-info/dependency_links.txt +0 -0
- {django_api_usage-0.1.6 → django_api_usage-0.1.7}/src/django_api_usage.egg-info/requires.txt +0 -0
- {django_api_usage-0.1.6 → django_api_usage-0.1.7}/src/django_api_usage.egg-info/top_level.txt +0 -0
- {django_api_usage-0.1.6 → django_api_usage-0.1.7}/tests/test_admin.py +0 -0
- {django_api_usage-0.1.6 → django_api_usage-0.1.7}/tests/test_checks.py +0 -0
- {django_api_usage-0.1.6 → django_api_usage-0.1.7}/tests/test_commands.py +0 -0
- {django_api_usage-0.1.6 → django_api_usage-0.1.7}/tests/test_drf.py +0 -0
- {django_api_usage-0.1.6 → django_api_usage-0.1.7}/tests/test_middleware.py +0 -0
- {django_api_usage-0.1.6 → 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
|
|
@@ -142,6 +142,28 @@ similar):
|
|
|
142
142
|
and method), so the file can be fed straight to pandas or a spreadsheet. Use
|
|
143
143
|
the "select all" link to export every row matching the current filters.
|
|
144
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
|
+
|
|
145
167
|
## Deprecation lifecycle
|
|
146
168
|
|
|
147
169
|
`Endpoint` carries `deprecated`, `sunset_date`, `replacement` and `owner`, so the
|
|
@@ -103,6 +103,28 @@ similar):
|
|
|
103
103
|
and method), so the file can be fed straight to pandas or a spreadsheet. Use
|
|
104
104
|
the "select all" link to export every row matching the current filters.
|
|
105
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
|
+
|
|
106
128
|
## Deprecation lifecycle
|
|
107
129
|
|
|
108
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"
|
|
@@ -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:
|
|
@@ -122,6 +122,45 @@ class StatMethodListFilter(MethodListFilter):
|
|
|
122
122
|
field_path = "endpoint__method"
|
|
123
123
|
|
|
124
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
|
+
|
|
125
164
|
@admin.register(Endpoint)
|
|
126
165
|
class EndpointAdmin(CsvExportMixin, admin.ModelAdmin):
|
|
127
166
|
actions = ("export_as_csv",)
|
|
@@ -171,13 +210,22 @@ class EndpointStatAdmin(CsvExportMixin, admin.ModelAdmin):
|
|
|
171
210
|
("method", "endpoint__method"),
|
|
172
211
|
("site_id", "endpoint__site_id"),
|
|
173
212
|
("client_type", "client_type"),
|
|
213
|
+
("client_app", "client_app"),
|
|
174
214
|
("status_class", "status_class"),
|
|
175
215
|
("count", "count"),
|
|
176
216
|
)
|
|
177
|
-
list_display = (
|
|
217
|
+
list_display = (
|
|
218
|
+
"date",
|
|
219
|
+
"endpoint",
|
|
220
|
+
"client_type",
|
|
221
|
+
"client_app",
|
|
222
|
+
"status_class",
|
|
223
|
+
"count",
|
|
224
|
+
)
|
|
178
225
|
list_filter = (
|
|
179
226
|
"date",
|
|
180
227
|
"client_type",
|
|
228
|
+
ClientAppListFilter,
|
|
181
229
|
"status_class",
|
|
182
230
|
StatAppLabelListFilter,
|
|
183
231
|
StatMethodListFilter,
|
|
@@ -314,3 +362,72 @@ class ConsumerAdmin(CsvExportMixin, admin.ModelAdmin):
|
|
|
314
362
|
@admin.display(description="Reference")
|
|
315
363
|
def ref_short(self, obj):
|
|
316
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",
|
|
@@ -7,8 +7,8 @@ msgid ""
|
|
|
7
7
|
msgstr ""
|
|
8
8
|
"Project-Id-Version: django-api-usage\n"
|
|
9
9
|
"Report-Msgid-Bugs-To: \n"
|
|
10
|
-
"POT-Creation-Date: 2026-10-08 06:
|
|
11
|
-
"PO-Revision-Date: 2026-10-08 06:
|
|
10
|
+
"POT-Creation-Date: 2026-10-08 06:45-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: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
|
|
33
41
|
msgid ""
|
|
34
42
|
"Search by endpoint path, route name, app, method or replacement (for example "
|
|
35
43
|
"'herriak' or 'artikuluak')."
|
|
@@ -37,30 +45,79 @@ 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."
|
|
49
57
|
msgstr ""
|
|
50
|
-
"Ez zegoen ezer bufferrean: BUFFER_BACKEND='db' denean kontagailuak jada
|
|
51
|
-
"basean daude."
|
|
58
|
+
"Ez zegoen ezer bufferrean: BUFFER_BACKEND='db' denean kontagailuak jada "
|
|
59
|
+
"datu-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: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
|
+
|
|
64
121
|
#: src/django_api_usage/templates/admin/django_api_usage/endpointstat/change_list.html:8
|
|
65
122
|
msgid "Flush now"
|
|
66
123
|
msgstr "Flush orain"
|
|
@@ -68,7 +125,7 @@ msgstr "Flush orain"
|
|
|
68
125
|
#: src/django_api_usage/templates/admin/django_api_usage/endpointstat/change_list.html:21
|
|
69
126
|
#, python-format
|
|
70
127
|
msgid "TOTAL COUNT: %(total)s"
|
|
71
|
-
msgstr ""
|
|
128
|
+
msgstr "GUZTIRA: %(total)s"
|
|
72
129
|
|
|
73
130
|
#: src/django_api_usage/templates/admin/django_api_usage/endpointstat/confirm_clear.html:6
|
|
74
131
|
msgid "Home"
|
|
@@ -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:
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# Generated by Django 4.2.30 on 2026-10-08 11:42
|
|
2
|
+
|
|
3
|
+
from django.conf import settings
|
|
4
|
+
from django.db import migrations, models
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Migration(migrations.Migration):
|
|
8
|
+
|
|
9
|
+
dependencies = [
|
|
10
|
+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
11
|
+
("django_api_usage", "0002_endpoint_route_path"),
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
operations = [
|
|
15
|
+
migrations.AlterUniqueTogether(
|
|
16
|
+
name="endpointstat",
|
|
17
|
+
unique_together=set(),
|
|
18
|
+
),
|
|
19
|
+
migrations.AddField(
|
|
20
|
+
model_name="endpointstat",
|
|
21
|
+
name="client_app",
|
|
22
|
+
field=models.CharField(blank=True, max_length=64),
|
|
23
|
+
),
|
|
24
|
+
migrations.AlterUniqueTogether(
|
|
25
|
+
name="endpointstat",
|
|
26
|
+
unique_together={
|
|
27
|
+
("endpoint", "date", "client_type", "client_app", "status_class")
|
|
28
|
+
},
|
|
29
|
+
),
|
|
30
|
+
migrations.CreateModel(
|
|
31
|
+
name="ClientApp",
|
|
32
|
+
fields=[
|
|
33
|
+
(
|
|
34
|
+
"id",
|
|
35
|
+
models.BigAutoField(
|
|
36
|
+
auto_created=True,
|
|
37
|
+
primary_key=True,
|
|
38
|
+
serialize=False,
|
|
39
|
+
verbose_name="ID",
|
|
40
|
+
),
|
|
41
|
+
),
|
|
42
|
+
(
|
|
43
|
+
"slug",
|
|
44
|
+
models.SlugField(
|
|
45
|
+
help_text="Short, stable label stored in the counters (e.g. 'mugikorra').",
|
|
46
|
+
max_length=64,
|
|
47
|
+
unique=True,
|
|
48
|
+
),
|
|
49
|
+
),
|
|
50
|
+
("name", models.CharField(blank=True, max_length=160)),
|
|
51
|
+
("description", models.TextField(blank=True)),
|
|
52
|
+
("is_active", models.BooleanField(default=True)),
|
|
53
|
+
(
|
|
54
|
+
"priority",
|
|
55
|
+
models.PositiveSmallIntegerField(
|
|
56
|
+
default=100,
|
|
57
|
+
help_text="Lower wins when several apps could match.",
|
|
58
|
+
),
|
|
59
|
+
),
|
|
60
|
+
(
|
|
61
|
+
"domains",
|
|
62
|
+
models.TextField(
|
|
63
|
+
blank=True,
|
|
64
|
+
help_text="One host per line; matched against the Origin/Referer host.",
|
|
65
|
+
),
|
|
66
|
+
),
|
|
67
|
+
(
|
|
68
|
+
"ip_networks",
|
|
69
|
+
models.TextField(
|
|
70
|
+
blank=True, help_text="One CIDR per line, e.g. 10.0.0.0/8."
|
|
71
|
+
),
|
|
72
|
+
),
|
|
73
|
+
(
|
|
74
|
+
"user_agent_patterns",
|
|
75
|
+
models.TextField(
|
|
76
|
+
blank=True,
|
|
77
|
+
help_text="One substring per line, matched case-insensitively.",
|
|
78
|
+
),
|
|
79
|
+
),
|
|
80
|
+
(
|
|
81
|
+
"accounts",
|
|
82
|
+
models.ManyToManyField(
|
|
83
|
+
blank=True,
|
|
84
|
+
help_text="Users (and therefore DRF tokens) that belong to this app.",
|
|
85
|
+
related_name="+",
|
|
86
|
+
to=settings.AUTH_USER_MODEL,
|
|
87
|
+
),
|
|
88
|
+
),
|
|
89
|
+
],
|
|
90
|
+
options={
|
|
91
|
+
"verbose_name": "Client application",
|
|
92
|
+
"verbose_name_plural": "Client applications",
|
|
93
|
+
"ordering": ("priority", "slug"),
|
|
94
|
+
},
|
|
95
|
+
),
|
|
96
|
+
]
|
|
@@ -1,15 +1,25 @@
|
|
|
1
1
|
"""Data model.
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Three layers live here on purpose:
|
|
4
4
|
|
|
5
5
|
* **Usage metering** (:class:`Endpoint`, :class:`EndpointStat`) is the neutral
|
|
6
|
-
core: how much is each endpoint of each app used
|
|
6
|
+
core: how much is each endpoint of each app used, and by which client
|
|
7
|
+
application (:class:`ClientApp`).
|
|
8
|
+
* **Client attribution** (:class:`ClientApp`) is editable data, so new consumers
|
|
9
|
+
can be recognised from the admin without a deploy.
|
|
7
10
|
* **Deprecation lifecycle** (the extra fields on :class:`Endpoint`) is the
|
|
8
11
|
optional layer built on top of the same data.
|
|
9
12
|
"""
|
|
10
13
|
|
|
14
|
+
from django.conf import settings
|
|
11
15
|
from django.db import models
|
|
12
16
|
from django.utils import timezone
|
|
17
|
+
from django.utils.translation import gettext_lazy as _
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _lines(value):
|
|
21
|
+
"""Split a textarea into non-empty, stripped lines."""
|
|
22
|
+
return [line.strip() for line in (value or "").splitlines() if line.strip()]
|
|
13
23
|
|
|
14
24
|
|
|
15
25
|
class Endpoint(models.Model):
|
|
@@ -52,19 +62,85 @@ class Endpoint(models.Model):
|
|
|
52
62
|
return (self.sunset_date - timezone.localdate()).days
|
|
53
63
|
|
|
54
64
|
|
|
65
|
+
class ClientApp(models.Model):
|
|
66
|
+
"""An application that consumes the API (mobile app, ERP, partner, ...).
|
|
67
|
+
|
|
68
|
+
Editable on purpose: the mapping is data, not code. A blank ``slug``-less
|
|
69
|
+
request is attributed to no app at all, so the rules can grow without a
|
|
70
|
+
deploy.
|
|
71
|
+
|
|
72
|
+
Resolution order is: authenticated account, request host, client network,
|
|
73
|
+
then user agent. ``priority`` breaks ties (lower wins).
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
slug = models.SlugField(
|
|
77
|
+
max_length=64,
|
|
78
|
+
unique=True,
|
|
79
|
+
help_text=_("Short, stable label stored in the counters (e.g. 'mugikorra')."),
|
|
80
|
+
)
|
|
81
|
+
name = models.CharField(max_length=160, blank=True)
|
|
82
|
+
description = models.TextField(blank=True)
|
|
83
|
+
is_active = models.BooleanField(default=True)
|
|
84
|
+
priority = models.PositiveSmallIntegerField(
|
|
85
|
+
default=100, help_text=_("Lower wins when several apps could match.")
|
|
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
|
+
domains = models.TextField(
|
|
94
|
+
blank=True,
|
|
95
|
+
help_text=_("One host per line; matched against the Origin/Referer host."),
|
|
96
|
+
)
|
|
97
|
+
ip_networks = models.TextField(
|
|
98
|
+
blank=True, help_text=_("One CIDR per line, e.g. 10.0.0.0/8.")
|
|
99
|
+
)
|
|
100
|
+
user_agent_patterns = models.TextField(
|
|
101
|
+
blank=True,
|
|
102
|
+
help_text=_("One substring per line, matched case-insensitively."),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
class Meta:
|
|
106
|
+
verbose_name = _("Client application")
|
|
107
|
+
verbose_name_plural = _("Client applications")
|
|
108
|
+
ordering = ("priority", "slug")
|
|
109
|
+
|
|
110
|
+
def __str__(self):
|
|
111
|
+
return self.name or self.slug
|
|
112
|
+
|
|
113
|
+
def domain_list(self):
|
|
114
|
+
return _lines(self.domains)
|
|
115
|
+
|
|
116
|
+
def network_list(self):
|
|
117
|
+
return _lines(self.ip_networks)
|
|
118
|
+
|
|
119
|
+
def user_agent_list(self):
|
|
120
|
+
return [pattern.lower() for pattern in _lines(self.user_agent_patterns)]
|
|
121
|
+
|
|
122
|
+
|
|
55
123
|
class EndpointStat(models.Model):
|
|
56
|
-
"""Aggregated counter per endpoint, day, client
|
|
124
|
+
"""Aggregated counter per endpoint, day, client and status class."""
|
|
57
125
|
|
|
58
126
|
endpoint = models.ForeignKey(
|
|
59
127
|
Endpoint, on_delete=models.CASCADE, related_name="stats"
|
|
60
128
|
)
|
|
61
129
|
date = models.DateField(db_index=True)
|
|
62
130
|
client_type = models.CharField(max_length=16, default="anon")
|
|
131
|
+
#: ``ClientApp.slug``; blank when the caller could not be attributed.
|
|
132
|
+
client_app = models.CharField(max_length=64, blank=True)
|
|
63
133
|
status_class = models.CharField(max_length=4, default="2xx")
|
|
64
134
|
count = models.PositiveIntegerField(default=0)
|
|
65
135
|
|
|
66
136
|
class Meta:
|
|
67
|
-
unique_together = (
|
|
137
|
+
unique_together = (
|
|
138
|
+
"endpoint",
|
|
139
|
+
"date",
|
|
140
|
+
"client_type",
|
|
141
|
+
"client_app",
|
|
142
|
+
"status_class",
|
|
143
|
+
)
|
|
68
144
|
ordering = ("-date",)
|
|
69
145
|
indexes = [models.Index(fields=["date", "endpoint"])]
|
|
70
146
|
|
|
@@ -5,9 +5,15 @@ project-specific knowledge. Resolvers must be cheap: they run on every request.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import hashlib
|
|
8
|
+
import ipaddress
|
|
9
|
+
import logging
|
|
10
|
+
import time
|
|
11
|
+
from urllib.parse import urlsplit
|
|
8
12
|
|
|
9
13
|
from django.conf import settings as django_settings
|
|
10
14
|
|
|
15
|
+
logger = logging.getLogger("django_api_usage")
|
|
16
|
+
|
|
11
17
|
|
|
12
18
|
def _view_module(match):
|
|
13
19
|
"""Best-effort module name of the callable that handled the request."""
|
|
@@ -114,3 +120,113 @@ def default_consumer(request):
|
|
|
114
120
|
if not ip:
|
|
115
121
|
return None
|
|
116
122
|
return ("ip_hash", _hash(ip, salt), user_agent_family)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# -- client application attribution -----------------------------------------
|
|
126
|
+
|
|
127
|
+
#: Safety net for processes that did not see the signal (see ``apps.py``).
|
|
128
|
+
CLIENT_APP_RULES_TTL = 60
|
|
129
|
+
_rules_cache = {"loaded_at": 0.0, "rules": None}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def reset_client_app_cache(**kwargs):
|
|
133
|
+
"""Forget the cached :class:`~django_api_usage.models.ClientApp` rules."""
|
|
134
|
+
_rules_cache["rules"] = None
|
|
135
|
+
_rules_cache["loaded_at"] = 0.0
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _load_client_app_rules():
|
|
139
|
+
from .models import ClientApp
|
|
140
|
+
|
|
141
|
+
rules = {"by_user": {}, "hosts": [], "networks": [], "patterns": []}
|
|
142
|
+
apps = ClientApp.objects.filter(is_active=True).prefetch_related("accounts")
|
|
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
|
+
rules["hosts"].extend((host.lower(), app.slug) for host in app.domain_list())
|
|
147
|
+
for cidr in app.network_list():
|
|
148
|
+
try:
|
|
149
|
+
network = ipaddress.ip_network(cidr, strict=False)
|
|
150
|
+
except ValueError:
|
|
151
|
+
logger.warning(
|
|
152
|
+
"django-api-usage: ignoring invalid network %r on %s", cidr, app
|
|
153
|
+
)
|
|
154
|
+
continue
|
|
155
|
+
rules["networks"].append((network, app.slug))
|
|
156
|
+
rules["patterns"].extend(
|
|
157
|
+
(pattern, app.slug) for pattern in app.user_agent_list()
|
|
158
|
+
)
|
|
159
|
+
return rules
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _client_app_rules():
|
|
163
|
+
now = time.monotonic()
|
|
164
|
+
if (
|
|
165
|
+
_rules_cache["rules"] is None
|
|
166
|
+
or now - _rules_cache["loaded_at"] > CLIENT_APP_RULES_TTL
|
|
167
|
+
):
|
|
168
|
+
_rules_cache["rules"] = _load_client_app_rules()
|
|
169
|
+
_rules_cache["loaded_at"] = now
|
|
170
|
+
return _rules_cache["rules"]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _request_host(request):
|
|
174
|
+
"""Host of the ``Origin`` (or ``Referer``) header, lowercased."""
|
|
175
|
+
for header in ("HTTP_ORIGIN", "HTTP_REFERER"):
|
|
176
|
+
value = request.META.get(header)
|
|
177
|
+
if value:
|
|
178
|
+
host = urlsplit(value).hostname
|
|
179
|
+
if host:
|
|
180
|
+
return host.lower()
|
|
181
|
+
return ""
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def client_ip(request):
|
|
185
|
+
"""Client address, honouring the first ``X-Forwarded-For`` entry."""
|
|
186
|
+
forwarded = request.META.get("HTTP_X_FORWARDED_FOR", "")
|
|
187
|
+
return forwarded.split(",")[0].strip() or request.META.get("REMOTE_ADDR", "")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def client_app_from_rules(request):
|
|
191
|
+
"""Attribute the caller to a :class:`ClientApp`, or return ``""``.
|
|
192
|
+
|
|
193
|
+
The rules live in the database and are editable from the admin, so a new
|
|
194
|
+
consumer can be recognised without a deploy. Monitored order: the caller's
|
|
195
|
+
account (a dedicated token), then the request host, then the client network,
|
|
196
|
+
then the user agent. ``ClientApp.priority`` decides the order between apps.
|
|
197
|
+
"""
|
|
198
|
+
rules = _client_app_rules()
|
|
199
|
+
|
|
200
|
+
user = getattr(request, "user", None)
|
|
201
|
+
if user is not None and getattr(user, "is_authenticated", False):
|
|
202
|
+
slug = rules["by_user"].get(user.pk)
|
|
203
|
+
if slug:
|
|
204
|
+
return slug
|
|
205
|
+
|
|
206
|
+
host = _request_host(request)
|
|
207
|
+
if host:
|
|
208
|
+
for pattern, slug in rules["hosts"]:
|
|
209
|
+
if host == pattern or host.endswith("." + pattern):
|
|
210
|
+
return slug
|
|
211
|
+
|
|
212
|
+
address = _as_address(client_ip(request))
|
|
213
|
+
if address is not None:
|
|
214
|
+
for network, slug in rules["networks"]:
|
|
215
|
+
if address.version == network.version and address in network:
|
|
216
|
+
return slug
|
|
217
|
+
|
|
218
|
+
user_agent = request.META.get("HTTP_USER_AGENT", "").lower()
|
|
219
|
+
if user_agent:
|
|
220
|
+
for pattern, slug in rules["patterns"]:
|
|
221
|
+
if pattern in user_agent:
|
|
222
|
+
return slug
|
|
223
|
+
return ""
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _as_address(value):
|
|
227
|
+
if not value:
|
|
228
|
+
return None
|
|
229
|
+
try:
|
|
230
|
+
return ipaddress.ip_address(value)
|
|
231
|
+
except ValueError:
|
|
232
|
+
return None
|
|
@@ -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
|
|
@@ -142,6 +142,28 @@ similar):
|
|
|
142
142
|
and method), so the file can be fed straight to pandas or a spreadsheet. Use
|
|
143
143
|
the "select all" link to export every row matching the current filters.
|
|
144
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
|
+
|
|
145
167
|
## Deprecation lifecycle
|
|
146
168
|
|
|
147
169
|
`Endpoint` carries `deprecated`, `sunset_date`, `replacement` and `owner`, so the
|
|
@@ -27,11 +27,13 @@ src/django_api_usage/management/commands/api_usage_flush.py
|
|
|
27
27
|
src/django_api_usage/management/commands/api_usage_report.py
|
|
28
28
|
src/django_api_usage/migrations/0001_initial.py
|
|
29
29
|
src/django_api_usage/migrations/0002_endpoint_route_path.py
|
|
30
|
+
src/django_api_usage/migrations/0003_alter_endpointstat_unique_together_and_more.py
|
|
30
31
|
src/django_api_usage/migrations/__init__.py
|
|
31
32
|
src/django_api_usage/templates/admin/django_api_usage/endpointstat/change_list.html
|
|
32
33
|
src/django_api_usage/templates/admin/django_api_usage/endpointstat/confirm_clear.html
|
|
33
34
|
tests/test_admin.py
|
|
34
35
|
tests/test_checks.py
|
|
36
|
+
tests/test_client_app.py
|
|
35
37
|
tests/test_commands.py
|
|
36
38
|
tests/test_csv_export.py
|
|
37
39
|
tests/test_drf.py
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Tests for the editable ClientApp table and its matching rules."""
|
|
2
|
+
|
|
3
|
+
from django.contrib.auth.models import User
|
|
4
|
+
from django.test import RequestFactory
|
|
5
|
+
from django.urls import reverse
|
|
6
|
+
|
|
7
|
+
from django_api_usage.buffers import flush
|
|
8
|
+
from django_api_usage.models import ClientApp, Endpoint, EndpointStat
|
|
9
|
+
from django_api_usage.resolvers import client_app_from_rules, reset_client_app_cache
|
|
10
|
+
|
|
11
|
+
from .base import UsageTestCase
|
|
12
|
+
|
|
13
|
+
STAT_CHANGELIST = "admin:django_api_usage_endpointstat_changelist"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ClientAppResolverTest(UsageTestCase):
|
|
17
|
+
def setUp(self):
|
|
18
|
+
super().setUp()
|
|
19
|
+
reset_client_app_cache()
|
|
20
|
+
self.factory = RequestFactory()
|
|
21
|
+
|
|
22
|
+
def request(self, user=None, **meta):
|
|
23
|
+
request = self.factory.get("/api/3.0/artikuluak/", **meta)
|
|
24
|
+
if user is not None:
|
|
25
|
+
request.user = user
|
|
26
|
+
return request
|
|
27
|
+
|
|
28
|
+
def test_a_dedicated_account_wins(self):
|
|
29
|
+
"""A dedicated token (its user) identifies the integration."""
|
|
30
|
+
user = User.objects.create_user("zoho", "zoho@example.com", "pw")
|
|
31
|
+
app = ClientApp.objects.create(slug="zoho", priority=10)
|
|
32
|
+
app.accounts.add(user)
|
|
33
|
+
|
|
34
|
+
self.assertEqual(client_app_from_rules(self.request(user=user)), "zoho")
|
|
35
|
+
|
|
36
|
+
def test_the_request_host_matches_subdomains(self):
|
|
37
|
+
ClientApp.objects.create(slug="elhuyar", domains="elhuyar.eus")
|
|
38
|
+
|
|
39
|
+
request = self.request(HTTP_ORIGIN="https://www.elhuyar.eus/tresnak")
|
|
40
|
+
|
|
41
|
+
self.assertEqual(client_app_from_rules(request), "elhuyar")
|
|
42
|
+
|
|
43
|
+
def test_the_client_network_matches(self):
|
|
44
|
+
ClientApp.objects.create(
|
|
45
|
+
slug="goiena_erp", ip_networks="10.0.0.0/8\n192.168.1.0/24"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
self.assertEqual(
|
|
49
|
+
client_app_from_rules(self.request(REMOTE_ADDR="10.1.2.3")), "goiena_erp"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def test_the_user_agent_matches_for_anonymous_readers(self):
|
|
53
|
+
"""The bulk of the traffic: readers on the native mobile apps."""
|
|
54
|
+
ClientApp.objects.create(slug="mugikorra", user_agent_patterns="TokikomApp")
|
|
55
|
+
|
|
56
|
+
request = self.request(HTTP_USER_AGENT="TokikomApp/2.1 (Android 15)")
|
|
57
|
+
|
|
58
|
+
self.assertEqual(client_app_from_rules(request), "mugikorra")
|
|
59
|
+
|
|
60
|
+
def test_everything_else_is_not_attributed(self):
|
|
61
|
+
ClientApp.objects.create(slug="mugikorra", user_agent_patterns="TokikomApp")
|
|
62
|
+
|
|
63
|
+
self.assertEqual(client_app_from_rules(self.request()), "")
|
|
64
|
+
|
|
65
|
+
def test_the_lowest_priority_wins(self):
|
|
66
|
+
ClientApp.objects.create(
|
|
67
|
+
slug="orokorra", user_agent_patterns="tokio", priority=100
|
|
68
|
+
)
|
|
69
|
+
ClientApp.objects.create(
|
|
70
|
+
slug="mugikorra", user_agent_patterns="tokio", priority=10
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
self.assertEqual(
|
|
74
|
+
client_app_from_rules(self.request(HTTP_USER_AGENT="tokio/1")), "mugikorra"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def test_inactive_applications_are_ignored(self):
|
|
78
|
+
ClientApp.objects.create(
|
|
79
|
+
slug="zaharra", user_agent_patterns="tokio", is_active=False
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
self.assertEqual(
|
|
83
|
+
client_app_from_rules(self.request(HTTP_USER_AGENT="tokio/1")), ""
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def test_invalid_networks_are_skipped(self):
|
|
87
|
+
ClientApp.objects.create(slug="gaizki", ip_networks="not-a-network")
|
|
88
|
+
|
|
89
|
+
self.assertEqual(
|
|
90
|
+
client_app_from_rules(self.request(REMOTE_ADDR="10.1.2.3")), ""
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def test_saving_a_rule_invalidates_the_cache(self):
|
|
94
|
+
self.assertEqual(client_app_from_rules(self.request()), "")
|
|
95
|
+
|
|
96
|
+
ClientApp.objects.create(slug="berria", domains="berria.eus")
|
|
97
|
+
|
|
98
|
+
request = self.request(HTTP_ORIGIN="https://berria.eus")
|
|
99
|
+
self.assertEqual(client_app_from_rules(request), "berria")
|
|
100
|
+
|
|
101
|
+
def test_middleware_stores_the_application_in_the_counters(self):
|
|
102
|
+
ClientApp.objects.create(slug="mugikorra", user_agent_patterns="TokikomApp")
|
|
103
|
+
|
|
104
|
+
self.client.get("/ping/", HTTP_USER_AGENT="TokikomApp/2.1")
|
|
105
|
+
flush()
|
|
106
|
+
|
|
107
|
+
self.assertEqual(EndpointStat.objects.get().client_app, "mugikorra")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class ClientAppAdminTest(UsageTestCase):
|
|
111
|
+
def setUp(self):
|
|
112
|
+
super().setUp()
|
|
113
|
+
self.admin = User.objects.create_superuser("admin", "a@example.com", "pw")
|
|
114
|
+
self.client.force_login(self.admin)
|
|
115
|
+
self.endpoint = Endpoint.objects.create(
|
|
116
|
+
app_label="api", route_path="api/3.0/artikuluak/", method="GET"
|
|
117
|
+
)
|
|
118
|
+
self.attributed = EndpointStat.objects.create(
|
|
119
|
+
endpoint=self.endpoint,
|
|
120
|
+
date="2026-10-08",
|
|
121
|
+
client_app="mugikorra",
|
|
122
|
+
count=2,
|
|
123
|
+
)
|
|
124
|
+
self.unattributed = EndpointStat.objects.create(
|
|
125
|
+
endpoint=self.endpoint,
|
|
126
|
+
date="2026-10-08",
|
|
127
|
+
client_type="user",
|
|
128
|
+
client_app="",
|
|
129
|
+
count=5,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
def rows(self, **params):
|
|
133
|
+
response = self.client.get(reverse(STAT_CHANGELIST), params)
|
|
134
|
+
self.assertEqual(response.status_code, 200)
|
|
135
|
+
return set(response.context["cl"].queryset)
|
|
136
|
+
|
|
137
|
+
def test_the_table_is_editable_in_the_admin(self):
|
|
138
|
+
app = ClientApp.objects.create(slug="mugikorra", name="Mugikorra")
|
|
139
|
+
|
|
140
|
+
response = self.client.get(
|
|
141
|
+
reverse("admin:django_api_usage_clientapp_change", args=[app.pk])
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
self.assertEqual(response.status_code, 200)
|
|
145
|
+
self.assertContains(response, "Matching rules")
|
|
146
|
+
|
|
147
|
+
def test_stats_can_be_filtered_by_application(self):
|
|
148
|
+
self.assertEqual(self.rows(client_app="mugikorra"), {self.attributed})
|
|
149
|
+
|
|
150
|
+
def test_stats_can_be_filtered_by_unattributed_calls(self):
|
|
151
|
+
self.assertEqual(self.rows(client_app="__none__"), {self.unattributed})
|
|
152
|
+
|
|
153
|
+
def test_applications_without_traffic_are_still_offered(self):
|
|
154
|
+
ClientApp.objects.create(slug="erp", name="ERP")
|
|
155
|
+
|
|
156
|
+
response = self.client.get(reverse(STAT_CHANGELIST))
|
|
157
|
+
|
|
158
|
+
self.assertContains(response, "?client_app=erp")
|
|
159
|
+
self.assertContains(response, ">ERP</a>")
|
|
@@ -103,6 +103,7 @@ class CsvExportTest(UsageTestCase):
|
|
|
103
103
|
"method",
|
|
104
104
|
"site_id",
|
|
105
105
|
"client_type",
|
|
106
|
+
"client_app",
|
|
106
107
|
"status_class",
|
|
107
108
|
"count",
|
|
108
109
|
],
|
|
@@ -121,8 +122,9 @@ class CsvExportTest(UsageTestCase):
|
|
|
121
122
|
self.assertEqual(body[3], "town-list")
|
|
122
123
|
self.assertEqual(body[4], "GET")
|
|
123
124
|
self.assertEqual(body[6], "anon")
|
|
124
|
-
self.assertEqual(body[7], "
|
|
125
|
-
self.assertEqual(body[8], "
|
|
125
|
+
self.assertEqual(body[7], "")
|
|
126
|
+
self.assertEqual(body[8], "2xx")
|
|
127
|
+
self.assertEqual(body[9], "7")
|
|
126
128
|
|
|
127
129
|
def test_stats_export_only_includes_the_selected_rows(self):
|
|
128
130
|
endpoint = self.make_endpoint()
|
|
@@ -133,7 +135,7 @@ class CsvExportTest(UsageTestCase):
|
|
|
133
135
|
|
|
134
136
|
rows = self.rows(response)
|
|
135
137
|
self.assertEqual(len(rows), 2) # header + one row
|
|
136
|
-
self.assertEqual(rows[1][
|
|
138
|
+
self.assertEqual(rows[1][9], "1")
|
|
137
139
|
|
|
138
140
|
def test_stats_export_filename_contains_the_date(self):
|
|
139
141
|
endpoint = self.make_endpoint()
|
|
@@ -114,9 +114,11 @@ class FilterTest(UsageTestCase):
|
|
|
114
114
|
self.assertIn("Aplikazioa", html)
|
|
115
115
|
self.assertIn("Metodoa", html)
|
|
116
116
|
|
|
117
|
-
def
|
|
118
|
-
"""The label was requested verbatim, so the eu catalog keeps it English."""
|
|
117
|
+
def test_total_count_follows_the_active_language(self):
|
|
119
118
|
with translation.override("eu"):
|
|
120
119
|
html = self.client.get(reverse(STAT_CHANGELIST)).content.decode()
|
|
120
|
+
self.assertIn("GUZTIRA", html)
|
|
121
121
|
|
|
122
|
+
with translation.override("en"):
|
|
123
|
+
html = self.client.get(reverse(STAT_CHANGELIST)).content.decode()
|
|
122
124
|
self.assertIn("TOTAL COUNT", html)
|
|
@@ -1,11 +0,0 @@
|
|
|
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
|
|
Binary file
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_api_usage-0.1.6 → django_api_usage-0.1.7}/src/django_api_usage/management/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_api_usage-0.1.6 → django_api_usage-0.1.7}/src/django_api_usage/migrations/0001_initial.py
RENAMED
|
File without changes
|
|
File without changes
|
{django_api_usage-0.1.6 → django_api_usage-0.1.7}/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.6 → django_api_usage-0.1.7}/src/django_api_usage.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{django_api_usage-0.1.6 → django_api_usage-0.1.7}/src/django_api_usage.egg-info/requires.txt
RENAMED
|
File without changes
|
{django_api_usage-0.1.6 → django_api_usage-0.1.7}/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
|