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.
Files changed (38) hide show
  1. {django_api_usage-0.1.2/src/django_api_usage.egg-info → django_api_usage-0.1.4}/PKG-INFO +4 -4
  2. {django_api_usage-0.1.2 → django_api_usage-0.1.4}/README.md +3 -3
  3. {django_api_usage-0.1.2 → django_api_usage-0.1.4}/pyproject.toml +1 -1
  4. {django_api_usage-0.1.2 → django_api_usage-0.1.4}/src/django_api_usage/__init__.py +1 -1
  5. {django_api_usage-0.1.2 → django_api_usage-0.1.4}/src/django_api_usage/admin.py +5 -3
  6. {django_api_usage-0.1.2 → django_api_usage-0.1.4}/src/django_api_usage/buffers.py +27 -12
  7. {django_api_usage-0.1.2 → django_api_usage-0.1.4}/src/django_api_usage/conf.py +1 -0
  8. {django_api_usage-0.1.2 → django_api_usage-0.1.4}/src/django_api_usage/middleware.py +1 -0
  9. django_api_usage-0.1.4/src/django_api_usage/migrations/0002_endpoint_route_path.py +18 -0
  10. {django_api_usage-0.1.2 → django_api_usage-0.1.4}/src/django_api_usage/models.py +5 -1
  11. {django_api_usage-0.1.2 → django_api_usage-0.1.4}/src/django_api_usage/resolvers.py +12 -0
  12. {django_api_usage-0.1.2 → django_api_usage-0.1.4/src/django_api_usage.egg-info}/PKG-INFO +4 -4
  13. {django_api_usage-0.1.2 → django_api_usage-0.1.4}/src/django_api_usage.egg-info/SOURCES.txt +1 -0
  14. {django_api_usage-0.1.2 → django_api_usage-0.1.4}/tests/test_admin.py +24 -0
  15. {django_api_usage-0.1.2 → django_api_usage-0.1.4}/tests/test_middleware.py +1 -0
  16. {django_api_usage-0.1.2 → django_api_usage-0.1.4}/LICENSE +0 -0
  17. {django_api_usage-0.1.2 → django_api_usage-0.1.4}/setup.cfg +0 -0
  18. {django_api_usage-0.1.2 → django_api_usage-0.1.4}/src/django_api_usage/apps.py +0 -0
  19. {django_api_usage-0.1.2 → django_api_usage-0.1.4}/src/django_api_usage/checks.py +0 -0
  20. {django_api_usage-0.1.2 → django_api_usage-0.1.4}/src/django_api_usage/deprecation.py +0 -0
  21. {django_api_usage-0.1.2 → django_api_usage-0.1.4}/src/django_api_usage/drf.py +0 -0
  22. {django_api_usage-0.1.2 → django_api_usage-0.1.4}/src/django_api_usage/maintenance.py +0 -0
  23. {django_api_usage-0.1.2 → django_api_usage-0.1.4}/src/django_api_usage/management/__init__.py +0 -0
  24. {django_api_usage-0.1.2 → django_api_usage-0.1.4}/src/django_api_usage/management/commands/__init__.py +0 -0
  25. {django_api_usage-0.1.2 → django_api_usage-0.1.4}/src/django_api_usage/management/commands/api_usage_flush.py +0 -0
  26. {django_api_usage-0.1.2 → django_api_usage-0.1.4}/src/django_api_usage/management/commands/api_usage_report.py +0 -0
  27. {django_api_usage-0.1.2 → django_api_usage-0.1.4}/src/django_api_usage/migrations/0001_initial.py +0 -0
  28. {django_api_usage-0.1.2 → django_api_usage-0.1.4}/src/django_api_usage/migrations/__init__.py +0 -0
  29. {django_api_usage-0.1.2 → django_api_usage-0.1.4}/src/django_api_usage/tasks.py +0 -0
  30. {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
  31. {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
  32. {django_api_usage-0.1.2 → django_api_usage-0.1.4}/src/django_api_usage.egg-info/dependency_links.txt +0 -0
  33. {django_api_usage-0.1.2 → django_api_usage-0.1.4}/src/django_api_usage.egg-info/requires.txt +0 -0
  34. {django_api_usage-0.1.2 → django_api_usage-0.1.4}/src/django_api_usage.egg-info/top_level.txt +0 -0
  35. {django_api_usage-0.1.2 → django_api_usage-0.1.4}/tests/test_checks.py +0 -0
  36. {django_api_usage-0.1.2 → django_api_usage-0.1.4}/tests/test_commands.py +0 -0
  37. {django_api_usage-0.1.2 → django_api_usage-0.1.4}/tests/test_drf.py +0 -0
  38. {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.2
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
- ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/django-api-usage)
40
+ ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/django-api-usage?logo=pypi)
41
41
  ![Django versions](https://img.shields.io/badge/django-4.2%20%7C%205.2-0C4B33)
42
42
  ![Status](https://img.shields.io/badge/status-alpha-orange)
43
- ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/codesyntax/django-api-usage/ci.yml)
44
- ![PyPI - Version](https://img.shields.io/pypi/v/django-api-usage)
43
+ ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/codesyntax/django-api-usage/ci.yml?logo=github)
44
+ ![PyPI - Version](https://img.shields.io/pypi/v/django-api-usage?logo=pypi)
45
45
 
46
46
  # django-api-usage
47
47
 
@@ -1,8 +1,8 @@
1
- ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/django-api-usage)
1
+ ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/django-api-usage?logo=pypi)
2
2
  ![Django versions](https://img.shields.io/badge/django-4.2%20%7C%205.2-0C4B33)
3
3
  ![Status](https://img.shields.io/badge/status-alpha-orange)
4
- ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/codesyntax/django-api-usage/ci.yml)
5
- ![PyPI - Version](https://img.shields.io/pypi/v/django-api-usage)
4
+ ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/codesyntax/django-api-usage/ci.yml?logo=github)
5
+ ![PyPI - Version](https://img.shields.io/pypi/v/django-api-usage?logo=pypi)
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.2"
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"
@@ -1,3 +1,3 @@
1
1
  """django-api-usage: usage metering for Django APIs, with deprecation support."""
2
2
 
3
- __version__ = "0.1.2"
3
+ __version__ = "0.1.4"
@@ -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 example "
56
- "'artikuluak' or 'gida')."
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
- # Order matters: it is how a buffered bucket is encoded into a cache key.
25
- _FIELDS = ("site_id", "app_label", "route_name", "method", "client_type")
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 = "{}|{}".format(
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
- dimension_key, _, status_code_class = bucket.rpartition("|")
82
- values = dimension_key.split("|")
83
- if len(values) != len(_FIELDS):
84
- logger.warning("django-api-usage: skipping malformed bucket %r", bucket)
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, _ = Endpoint.objects.get_or_create(
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.2
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
- ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/django-api-usage)
40
+ ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/django-api-usage?logo=pypi)
41
41
  ![Django versions](https://img.shields.io/badge/django-4.2%20%7C%205.2-0C4B33)
42
42
  ![Status](https://img.shields.io/badge/status-alpha-orange)
43
- ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/codesyntax/django-api-usage/ci.yml)
44
- ![PyPI - Version](https://img.shields.io/pypi/v/django-api-usage)
43
+ ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/codesyntax/django-api-usage/ci.yml?logo=github)
44
+ ![PyPI - Version](https://img.shields.io/pypi/v/django-api-usage?logo=pypi)
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):