django-api-usage 0.1.2__tar.gz → 0.1.4__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.2/src/django_api_usage.egg-info → django_api_usage-0.1.4}/PKG-INFO +4 -4
- {django_api_usage-0.1.2 → django_api_usage-0.1.4}/README.md +3 -3
- {django_api_usage-0.1.2 → django_api_usage-0.1.4}/pyproject.toml +1 -1
- {django_api_usage-0.1.2 → django_api_usage-0.1.4}/src/django_api_usage/__init__.py +1 -1
- {django_api_usage-0.1.2 → django_api_usage-0.1.4}/src/django_api_usage/admin.py +5 -3
- {django_api_usage-0.1.2 → django_api_usage-0.1.4}/src/django_api_usage/buffers.py +27 -12
- {django_api_usage-0.1.2 → django_api_usage-0.1.4}/src/django_api_usage/conf.py +1 -0
- {django_api_usage-0.1.2 → django_api_usage-0.1.4}/src/django_api_usage/middleware.py +1 -0
- django_api_usage-0.1.4/src/django_api_usage/migrations/0002_endpoint_route_path.py +18 -0
- {django_api_usage-0.1.2 → django_api_usage-0.1.4}/src/django_api_usage/models.py +5 -1
- {django_api_usage-0.1.2 → django_api_usage-0.1.4}/src/django_api_usage/resolvers.py +12 -0
- {django_api_usage-0.1.2 → django_api_usage-0.1.4/src/django_api_usage.egg-info}/PKG-INFO +4 -4
- {django_api_usage-0.1.2 → django_api_usage-0.1.4}/src/django_api_usage.egg-info/SOURCES.txt +1 -0
- {django_api_usage-0.1.2 → django_api_usage-0.1.4}/tests/test_admin.py +24 -0
- {django_api_usage-0.1.2 → django_api_usage-0.1.4}/tests/test_middleware.py +1 -0
- {django_api_usage-0.1.2 → django_api_usage-0.1.4}/LICENSE +0 -0
- {django_api_usage-0.1.2 → django_api_usage-0.1.4}/setup.cfg +0 -0
- {django_api_usage-0.1.2 → django_api_usage-0.1.4}/src/django_api_usage/apps.py +0 -0
- {django_api_usage-0.1.2 → django_api_usage-0.1.4}/src/django_api_usage/checks.py +0 -0
- {django_api_usage-0.1.2 → django_api_usage-0.1.4}/src/django_api_usage/deprecation.py +0 -0
- {django_api_usage-0.1.2 → django_api_usage-0.1.4}/src/django_api_usage/drf.py +0 -0
- {django_api_usage-0.1.2 → django_api_usage-0.1.4}/src/django_api_usage/maintenance.py +0 -0
- {django_api_usage-0.1.2 → django_api_usage-0.1.4}/src/django_api_usage/management/__init__.py +0 -0
- {django_api_usage-0.1.2 → django_api_usage-0.1.4}/src/django_api_usage/management/commands/__init__.py +0 -0
- {django_api_usage-0.1.2 → django_api_usage-0.1.4}/src/django_api_usage/management/commands/api_usage_flush.py +0 -0
- {django_api_usage-0.1.2 → django_api_usage-0.1.4}/src/django_api_usage/management/commands/api_usage_report.py +0 -0
- {django_api_usage-0.1.2 → django_api_usage-0.1.4}/src/django_api_usage/migrations/0001_initial.py +0 -0
- {django_api_usage-0.1.2 → django_api_usage-0.1.4}/src/django_api_usage/migrations/__init__.py +0 -0
- {django_api_usage-0.1.2 → django_api_usage-0.1.4}/src/django_api_usage/tasks.py +0 -0
- {django_api_usage-0.1.2 → django_api_usage-0.1.4}/src/django_api_usage/templates/admin/django_api_usage/endpointstat/change_list.html +0 -0
- {django_api_usage-0.1.2 → django_api_usage-0.1.4}/src/django_api_usage/templates/admin/django_api_usage/endpointstat/confirm_clear.html +0 -0
- {django_api_usage-0.1.2 → django_api_usage-0.1.4}/src/django_api_usage.egg-info/dependency_links.txt +0 -0
- {django_api_usage-0.1.2 → django_api_usage-0.1.4}/src/django_api_usage.egg-info/requires.txt +0 -0
- {django_api_usage-0.1.2 → django_api_usage-0.1.4}/src/django_api_usage.egg-info/top_level.txt +0 -0
- {django_api_usage-0.1.2 → django_api_usage-0.1.4}/tests/test_checks.py +0 -0
- {django_api_usage-0.1.2 → django_api_usage-0.1.4}/tests/test_commands.py +0 -0
- {django_api_usage-0.1.2 → django_api_usage-0.1.4}/tests/test_drf.py +0 -0
- {django_api_usage-0.1.2 → django_api_usage-0.1.4}/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.4
|
|
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
|
|
@@ -37,11 +37,11 @@ Requires-Dist: black==26.5.1; extra == "dev"
|
|
|
37
37
|
Requires-Dist: ruff==0.16.10; extra == "dev"
|
|
38
38
|
Dynamic: license-file
|
|
39
39
|
|
|
40
|
-

|
|
40
|
+

|
|
41
41
|

|
|
42
42
|

|
|
43
|
-

|
|
44
|
-

|
|
43
|
+

|
|
44
|
+

|
|
45
45
|
|
|
46
46
|
# django-api-usage
|
|
47
47
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-

|
|
1
|
+

|
|
2
2
|

|
|
3
3
|

|
|
4
|
-

|
|
5
|
-

|
|
4
|
+

|
|
5
|
+

|
|
6
6
|
|
|
7
7
|
# django-api-usage
|
|
8
8
|
|
|
@@ -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.4"
|
|
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"
|
|
@@ -25,6 +25,7 @@ from .models import Consumer, Endpoint, EndpointStat
|
|
|
25
25
|
class EndpointAdmin(admin.ModelAdmin):
|
|
26
26
|
list_display = (
|
|
27
27
|
"app_label",
|
|
28
|
+
"route_path",
|
|
28
29
|
"route_name",
|
|
29
30
|
"method",
|
|
30
31
|
"site_id",
|
|
@@ -34,7 +35,7 @@ class EndpointAdmin(admin.ModelAdmin):
|
|
|
34
35
|
"owner",
|
|
35
36
|
)
|
|
36
37
|
list_filter = ("deprecated", "app_label", "method", "site_id")
|
|
37
|
-
search_fields = ("route_name", "replacement", "owner", "notes")
|
|
38
|
+
search_fields = ("route_path", "route_name", "replacement", "owner", "notes")
|
|
38
39
|
list_editable = ("deprecated", "sunset_date", "replacement", "owner")
|
|
39
40
|
|
|
40
41
|
|
|
@@ -46,14 +47,15 @@ class EndpointStatAdmin(admin.ModelAdmin):
|
|
|
46
47
|
date_hierarchy = "date"
|
|
47
48
|
# Search across the related endpoint (case-insensitive "contains").
|
|
48
49
|
search_fields = (
|
|
50
|
+
"endpoint__route_path",
|
|
49
51
|
"endpoint__route_name",
|
|
50
52
|
"endpoint__app_label",
|
|
51
53
|
"endpoint__method",
|
|
52
54
|
"endpoint__replacement",
|
|
53
55
|
)
|
|
54
56
|
search_help_text = _(
|
|
55
|
-
"Search by endpoint route, app, method or replacement (for
|
|
56
|
-
"'
|
|
57
|
+
"Search by endpoint path, route name, app, method or replacement (for "
|
|
58
|
+
"example 'herriak' or 'artikuluak')."
|
|
57
59
|
)
|
|
58
60
|
change_list_template = "admin/django_api_usage/endpointstat/change_list.html"
|
|
59
61
|
|
|
@@ -21,8 +21,16 @@ from .models import Consumer, Endpoint, EndpointStat
|
|
|
21
21
|
|
|
22
22
|
logger = logging.getLogger("django_api_usage")
|
|
23
23
|
|
|
24
|
-
#
|
|
25
|
-
|
|
24
|
+
# The dimensions stored per bucket. Route patterns may contain any character,
|
|
25
|
+
# so a bucket is encoded as JSON rather than with a separator.
|
|
26
|
+
_FIELDS = (
|
|
27
|
+
"site_id",
|
|
28
|
+
"app_label",
|
|
29
|
+
"route_name",
|
|
30
|
+
"route_path",
|
|
31
|
+
"method",
|
|
32
|
+
"client_type",
|
|
33
|
+
)
|
|
26
34
|
|
|
27
35
|
|
|
28
36
|
def _cache():
|
|
@@ -51,15 +59,17 @@ def record_hit(dimensions, status_code, consumer=None):
|
|
|
51
59
|
_touch_consumer(consumer)
|
|
52
60
|
|
|
53
61
|
|
|
62
|
+
def _bucket_key(dimensions, status_code_class):
|
|
63
|
+
payload = {field: dimensions.get(field) for field in _FIELDS}
|
|
64
|
+
return json.dumps([payload, status_code_class], sort_keys=True)
|
|
65
|
+
|
|
66
|
+
|
|
54
67
|
def _buffer(dimensions, status_code_class):
|
|
55
68
|
cache = _cache()
|
|
56
69
|
key = _buffer_key()
|
|
57
70
|
raw = cache.get(key)
|
|
58
71
|
counters = json.loads(raw) if raw else {}
|
|
59
|
-
bucket =
|
|
60
|
-
"|".join(str(dimensions.get(field, "")) for field in _FIELDS),
|
|
61
|
-
status_code_class,
|
|
62
|
-
)
|
|
72
|
+
bucket = _bucket_key(dimensions, status_code_class)
|
|
63
73
|
counters[bucket] = counters.get(bucket, 0) + 1
|
|
64
74
|
cache.set(key, json.dumps(counters), None)
|
|
65
75
|
|
|
@@ -78,12 +88,12 @@ def flush():
|
|
|
78
88
|
counters = json.loads(raw) if isinstance(raw, str) else raw
|
|
79
89
|
written = 0
|
|
80
90
|
for bucket, count in counters.items():
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
91
|
+
try:
|
|
92
|
+
payload, status_code_class = json.loads(bucket)
|
|
93
|
+
dimensions = {field: payload.get(field) for field in _FIELDS}
|
|
94
|
+
except (TypeError, ValueError):
|
|
95
|
+
logger.warning("django-api-usage: skipping unreadable bucket %r", bucket)
|
|
85
96
|
continue
|
|
86
|
-
dimensions = dict(zip(_FIELDS, values))
|
|
87
97
|
dimensions["site_id"] = _as_int(dimensions.get("site_id"))
|
|
88
98
|
_write_to_db(dimensions, status_code_class, count)
|
|
89
99
|
written += 1
|
|
@@ -100,13 +110,18 @@ def _as_int(value):
|
|
|
100
110
|
|
|
101
111
|
|
|
102
112
|
def _write_to_db(dimensions, status_code_class, count):
|
|
113
|
+
route_path = dimensions.get("route_path") or ""
|
|
103
114
|
with transaction.atomic():
|
|
104
|
-
endpoint,
|
|
115
|
+
endpoint, created = Endpoint.objects.get_or_create(
|
|
105
116
|
site_id=dimensions.get("site_id"),
|
|
106
117
|
app_label=dimensions.get("app_label") or "unknown",
|
|
107
118
|
route_name=dimensions.get("route_name") or "unknown",
|
|
108
119
|
method=dimensions.get("method") or "GET",
|
|
120
|
+
defaults={"route_path": route_path},
|
|
109
121
|
)
|
|
122
|
+
# Backfill the path for rows created before the field existed.
|
|
123
|
+
if not created and route_path and not endpoint.route_path:
|
|
124
|
+
Endpoint.objects.filter(pk=endpoint.pk).update(route_path=route_path)
|
|
110
125
|
stat, created = EndpointStat.objects.get_or_create(
|
|
111
126
|
endpoint=endpoint,
|
|
112
127
|
date=timezone.localdate(),
|
|
@@ -32,6 +32,7 @@ DEFAULTS = {
|
|
|
32
32
|
# Resolvers (dotted path or callable).
|
|
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
|
+
"ROUTE_PATH_RESOLVER": "django_api_usage.resolvers.default_route_path",
|
|
35
36
|
"CLIENT_TYPE_RESOLVER": "django_api_usage.resolvers.default_client_type",
|
|
36
37
|
"SITE_RESOLVER": "django_api_usage.resolvers.default_site_id",
|
|
37
38
|
"CONSUMER_RESOLVER": "django_api_usage.resolvers.default_consumer",
|
|
@@ -33,6 +33,7 @@ class ApiUsageMiddleware:
|
|
|
33
33
|
"site_id": api_settings.SITE_RESOLVER(request),
|
|
34
34
|
"app_label": api_settings.APP_LABEL_RESOLVER(request),
|
|
35
35
|
"route_name": api_settings.ROUTE_NAME_RESOLVER(request),
|
|
36
|
+
"route_path": api_settings.ROUTE_PATH_RESOLVER(request),
|
|
36
37
|
"method": request.method,
|
|
37
38
|
"client_type": api_settings.CLIENT_TYPE_RESOLVER(request),
|
|
38
39
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Generated by Django 4.2.30 on 2026-10-08 05:52
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
|
|
8
|
+
dependencies = [
|
|
9
|
+
("django_api_usage", "0001_initial"),
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
operations = [
|
|
13
|
+
migrations.AddField(
|
|
14
|
+
model_name="endpoint",
|
|
15
|
+
name="route_path",
|
|
16
|
+
field=models.CharField(blank=True, max_length=200),
|
|
17
|
+
),
|
|
18
|
+
]
|
|
@@ -20,6 +20,10 @@ class Endpoint(models.Model):
|
|
|
20
20
|
site_id = models.PositiveIntegerField(null=True, blank=True, db_index=True)
|
|
21
21
|
app_label = models.CharField(max_length=64, db_index=True)
|
|
22
22
|
route_name = models.CharField(max_length=160)
|
|
23
|
+
# URL pattern that matched (e.g. "api/3.0/herriak/"). Informational: the
|
|
24
|
+
# endpoint identity stays (site, app, route_name, method). Makes the admin
|
|
25
|
+
# searchable by path, which is how people actually refer to endpoints.
|
|
26
|
+
route_path = models.CharField(max_length=200, blank=True)
|
|
23
27
|
method = models.CharField(max_length=8)
|
|
24
28
|
|
|
25
29
|
# Deprecation lifecycle (optional layer).
|
|
@@ -34,7 +38,7 @@ class Endpoint(models.Model):
|
|
|
34
38
|
ordering = ("app_label", "route_name", "method")
|
|
35
39
|
|
|
36
40
|
def __str__(self):
|
|
37
|
-
return f"{self.method} {self.route_name} ({self.app_label})"
|
|
41
|
+
return f"{self.method} {self.route_path or self.route_name} ({self.app_label})"
|
|
38
42
|
|
|
39
43
|
@property
|
|
40
44
|
def is_sunset(self):
|
|
@@ -48,6 +48,18 @@ def default_route_name(request):
|
|
|
48
48
|
)
|
|
49
49
|
|
|
50
50
|
|
|
51
|
+
def default_route_path(request):
|
|
52
|
+
"""Return the URL pattern that matched, e.g. ``api/3.0/herriak/``.
|
|
53
|
+
|
|
54
|
+
Path *patterns* (never the raw path) keep cardinality bounded. This is what
|
|
55
|
+
people search for in the admin, so it is stored next to ``route_name``.
|
|
56
|
+
"""
|
|
57
|
+
match = getattr(request, "resolver_match", None)
|
|
58
|
+
if match is None:
|
|
59
|
+
return ""
|
|
60
|
+
return getattr(match, "route", "") or ""
|
|
61
|
+
|
|
62
|
+
|
|
51
63
|
def default_client_type(request):
|
|
52
64
|
"""Classify the caller as ``client``, ``user`` or ``anon``."""
|
|
53
65
|
if getattr(request, "api_client", None) is not 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.4
|
|
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
|
|
@@ -37,11 +37,11 @@ Requires-Dist: black==26.5.1; extra == "dev"
|
|
|
37
37
|
Requires-Dist: ruff==0.16.10; extra == "dev"
|
|
38
38
|
Dynamic: license-file
|
|
39
39
|
|
|
40
|
-

|
|
40
|
+

|
|
41
41
|

|
|
42
42
|

|
|
43
|
-

|
|
44
|
-

|
|
43
|
+

|
|
44
|
+

|
|
45
45
|
|
|
46
46
|
# django-api-usage
|
|
47
47
|
|
|
@@ -24,6 +24,7 @@ src/django_api_usage/management/commands/__init__.py
|
|
|
24
24
|
src/django_api_usage/management/commands/api_usage_flush.py
|
|
25
25
|
src/django_api_usage/management/commands/api_usage_report.py
|
|
26
26
|
src/django_api_usage/migrations/0001_initial.py
|
|
27
|
+
src/django_api_usage/migrations/0002_endpoint_route_path.py
|
|
27
28
|
src/django_api_usage/migrations/__init__.py
|
|
28
29
|
src/django_api_usage/templates/admin/django_api_usage/endpointstat/change_list.html
|
|
29
30
|
src/django_api_usage/templates/admin/django_api_usage/endpointstat/confirm_clear.html
|
|
@@ -145,6 +145,30 @@ class AdminButtonsTest(UsageTestCase):
|
|
|
145
145
|
|
|
146
146
|
self.assertEqual(response.context_data["usage_total"], 7)
|
|
147
147
|
|
|
148
|
+
def test_search_matches_the_endpoint_path(self):
|
|
149
|
+
api = Endpoint.objects.create(
|
|
150
|
+
app_label="api",
|
|
151
|
+
route_name="town-list",
|
|
152
|
+
route_path="api/3.0/herriak/",
|
|
153
|
+
method="GET",
|
|
154
|
+
)
|
|
155
|
+
gida = Endpoint.objects.create(
|
|
156
|
+
app_label="api",
|
|
157
|
+
route_name="artikuluak-list",
|
|
158
|
+
route_path="api/3.0/artikuluak/",
|
|
159
|
+
method="GET",
|
|
160
|
+
)
|
|
161
|
+
EndpointStat.objects.create(
|
|
162
|
+
endpoint=api, date="2026-10-08", client_type="anon", count=5
|
|
163
|
+
)
|
|
164
|
+
EndpointStat.objects.create(
|
|
165
|
+
endpoint=gida, date="2026-10-08", client_type="anon", count=7
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
response = self.client.get(reverse(CHANGELIST), {"q": "herriak"})
|
|
169
|
+
|
|
170
|
+
self.assertEqual(response.context_data["usage_total"], 5)
|
|
171
|
+
|
|
148
172
|
def test_search_combines_with_list_filters(self):
|
|
149
173
|
api = Endpoint.objects.create(
|
|
150
174
|
app_label="api", route_name="artikuluak-list", method="GET"
|
|
@@ -15,6 +15,7 @@ class MiddlewareTest(UsageTestCase):
|
|
|
15
15
|
self.assertEqual(stat.status_class, "2xx")
|
|
16
16
|
self.assertEqual(stat.client_type, "anon")
|
|
17
17
|
self.assertEqual(stat.endpoint.route_name, "ping")
|
|
18
|
+
self.assertEqual(stat.endpoint.route_path, "ping/")
|
|
18
19
|
self.assertEqual(stat.endpoint.method, "GET")
|
|
19
20
|
|
|
20
21
|
def test_aggregates_repeated_hits(self):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_api_usage-0.1.2 → django_api_usage-0.1.4}/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.2 → django_api_usage-0.1.4}/src/django_api_usage/migrations/0001_initial.py
RENAMED
|
File without changes
|
{django_api_usage-0.1.2 → django_api_usage-0.1.4}/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.2 → django_api_usage-0.1.4}/src/django_api_usage.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{django_api_usage-0.1.2 → django_api_usage-0.1.4}/src/django_api_usage.egg-info/requires.txt
RENAMED
|
File without changes
|
{django_api_usage-0.1.2 → django_api_usage-0.1.4}/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
|