django-api-usage 0.1.1__tar.gz → 0.1.3__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.1/src/django_api_usage.egg-info → django_api_usage-0.1.3}/PKG-INFO +1 -1
  2. {django_api_usage-0.1.1 → django_api_usage-0.1.3}/pyproject.toml +1 -1
  3. {django_api_usage-0.1.1 → django_api_usage-0.1.3}/src/django_api_usage/__init__.py +1 -1
  4. {django_api_usage-0.1.1 → django_api_usage-0.1.3}/src/django_api_usage/admin.py +14 -1
  5. {django_api_usage-0.1.1 → django_api_usage-0.1.3}/src/django_api_usage/buffers.py +27 -12
  6. {django_api_usage-0.1.1 → django_api_usage-0.1.3}/src/django_api_usage/conf.py +1 -0
  7. {django_api_usage-0.1.1 → django_api_usage-0.1.3}/src/django_api_usage/middleware.py +1 -0
  8. django_api_usage-0.1.3/src/django_api_usage/migrations/0002_endpoint_route_path.py +18 -0
  9. {django_api_usage-0.1.1 → django_api_usage-0.1.3}/src/django_api_usage/models.py +5 -1
  10. {django_api_usage-0.1.1 → django_api_usage-0.1.3}/src/django_api_usage/resolvers.py +12 -0
  11. {django_api_usage-0.1.1 → django_api_usage-0.1.3}/src/django_api_usage/templates/admin/django_api_usage/endpointstat/change_list.html +1 -1
  12. {django_api_usage-0.1.1 → django_api_usage-0.1.3/src/django_api_usage.egg-info}/PKG-INFO +1 -1
  13. {django_api_usage-0.1.1 → django_api_usage-0.1.3}/src/django_api_usage.egg-info/SOURCES.txt +1 -0
  14. {django_api_usage-0.1.1 → django_api_usage-0.1.3}/tests/test_admin.py +84 -1
  15. {django_api_usage-0.1.1 → django_api_usage-0.1.3}/tests/test_middleware.py +1 -0
  16. {django_api_usage-0.1.1 → django_api_usage-0.1.3}/LICENSE +0 -0
  17. {django_api_usage-0.1.1 → django_api_usage-0.1.3}/README.md +0 -0
  18. {django_api_usage-0.1.1 → django_api_usage-0.1.3}/setup.cfg +0 -0
  19. {django_api_usage-0.1.1 → django_api_usage-0.1.3}/src/django_api_usage/apps.py +0 -0
  20. {django_api_usage-0.1.1 → django_api_usage-0.1.3}/src/django_api_usage/checks.py +0 -0
  21. {django_api_usage-0.1.1 → django_api_usage-0.1.3}/src/django_api_usage/deprecation.py +0 -0
  22. {django_api_usage-0.1.1 → django_api_usage-0.1.3}/src/django_api_usage/drf.py +0 -0
  23. {django_api_usage-0.1.1 → django_api_usage-0.1.3}/src/django_api_usage/maintenance.py +0 -0
  24. {django_api_usage-0.1.1 → django_api_usage-0.1.3}/src/django_api_usage/management/__init__.py +0 -0
  25. {django_api_usage-0.1.1 → django_api_usage-0.1.3}/src/django_api_usage/management/commands/__init__.py +0 -0
  26. {django_api_usage-0.1.1 → django_api_usage-0.1.3}/src/django_api_usage/management/commands/api_usage_flush.py +0 -0
  27. {django_api_usage-0.1.1 → django_api_usage-0.1.3}/src/django_api_usage/management/commands/api_usage_report.py +0 -0
  28. {django_api_usage-0.1.1 → django_api_usage-0.1.3}/src/django_api_usage/migrations/0001_initial.py +0 -0
  29. {django_api_usage-0.1.1 → django_api_usage-0.1.3}/src/django_api_usage/migrations/__init__.py +0 -0
  30. {django_api_usage-0.1.1 → django_api_usage-0.1.3}/src/django_api_usage/tasks.py +0 -0
  31. {django_api_usage-0.1.1 → django_api_usage-0.1.3}/src/django_api_usage/templates/admin/django_api_usage/endpointstat/confirm_clear.html +0 -0
  32. {django_api_usage-0.1.1 → django_api_usage-0.1.3}/src/django_api_usage.egg-info/dependency_links.txt +0 -0
  33. {django_api_usage-0.1.1 → django_api_usage-0.1.3}/src/django_api_usage.egg-info/requires.txt +0 -0
  34. {django_api_usage-0.1.1 → django_api_usage-0.1.3}/src/django_api_usage.egg-info/top_level.txt +0 -0
  35. {django_api_usage-0.1.1 → django_api_usage-0.1.3}/tests/test_checks.py +0 -0
  36. {django_api_usage-0.1.1 → django_api_usage-0.1.3}/tests/test_commands.py +0 -0
  37. {django_api_usage-0.1.1 → django_api_usage-0.1.3}/tests/test_drf.py +0 -0
  38. {django_api_usage-0.1.1 → django_api_usage-0.1.3}/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.1
3
+ Version: 0.1.3
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "django-api-usage"
7
- version = "0.1.1"
7
+ version = "0.1.3"
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.1"
3
+ __version__ = "0.1.3"
@@ -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
 
@@ -44,6 +45,18 @@ class EndpointStatAdmin(admin.ModelAdmin):
44
45
  list_filter = ("date", "client_type", "status_class")
45
46
  list_select_related = ("endpoint",)
46
47
  date_hierarchy = "date"
48
+ # Search across the related endpoint (case-insensitive "contains").
49
+ search_fields = (
50
+ "endpoint__route_path",
51
+ "endpoint__route_name",
52
+ "endpoint__app_label",
53
+ "endpoint__method",
54
+ "endpoint__replacement",
55
+ )
56
+ search_help_text = _(
57
+ "Search by endpoint path, route name, app, method or replacement (for "
58
+ "example 'herriak' or 'artikuluak')."
59
+ )
47
60
  change_list_template = "admin/django_api_usage/endpointstat/change_list.html"
48
61
 
49
62
  def has_add_permission(self, request):
@@ -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:
@@ -18,7 +18,7 @@
18
18
  {{ block.super }}
19
19
  {% if usage_total is not None %}
20
20
  <p class="help" style="text-align: right; padding: 0.5em 1em; font-weight: bold;">
21
- {% blocktranslate with total=usage_total %}Total calls in the filtered rows: {{ total }}{% endblocktranslate %}
21
+ {% blocktranslate with total=usage_total %}TOTAL COUNT: {{ total }}{% endblocktranslate %}
22
22
  </p>
23
23
  {% endif %}
24
24
  {% endblock %}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-api-usage
3
- Version: 0.1.1
3
+ Version: 0.1.3
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
@@ -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
@@ -87,7 +87,7 @@ class AdminButtonsTest(UsageTestCase):
87
87
  response = self.client.get(reverse(CHANGELIST))
88
88
 
89
89
  self.assertEqual(response.context_data["usage_total"], 12)
90
- self.assertContains(response, "Total calls in the filtered rows")
90
+ self.assertContains(response, "TOTAL COUNT")
91
91
 
92
92
  def test_total_respects_the_selected_filters(self):
93
93
  api = Endpoint.objects.create(app_label="api", route_name="a", method="GET")
@@ -103,6 +103,89 @@ class AdminButtonsTest(UsageTestCase):
103
103
 
104
104
  self.assertEqual(response.context_data["usage_total"], 7)
105
105
 
106
+ def test_search_box_is_rendered(self):
107
+ response = self.client.get(reverse(CHANGELIST))
108
+
109
+ self.assertContains(response, 'id="searchbar"')
110
+
111
+ def test_search_filters_rows_by_endpoint_text(self):
112
+ api = Endpoint.objects.create(
113
+ app_label="api", route_name="artikuluak-list", method="GET"
114
+ )
115
+ gida = Endpoint.objects.create(
116
+ app_label="gida", route_name="gida-sailak-list", method="GET"
117
+ )
118
+ EndpointStat.objects.create(
119
+ endpoint=api, date="2026-10-08", client_type="anon", count=5
120
+ )
121
+ EndpointStat.objects.create(
122
+ endpoint=gida, date="2026-10-08", client_type="anon", count=7
123
+ )
124
+
125
+ response = self.client.get(reverse(CHANGELIST), {"q": "artikuluak"})
126
+
127
+ self.assertEqual(response.context_data["usage_total"], 5)
128
+ self.assertEqual(len(response.context_data["cl"].result_list), 1)
129
+
130
+ def test_search_matches_the_app_label(self):
131
+ api = Endpoint.objects.create(
132
+ app_label="api", route_name="artikuluak-list", method="GET"
133
+ )
134
+ gida = Endpoint.objects.create(
135
+ app_label="gida", route_name="gida-sailak-list", method="GET"
136
+ )
137
+ EndpointStat.objects.create(
138
+ endpoint=api, date="2026-10-08", client_type="anon", count=5
139
+ )
140
+ EndpointStat.objects.create(
141
+ endpoint=gida, date="2026-10-08", client_type="anon", count=7
142
+ )
143
+
144
+ response = self.client.get(reverse(CHANGELIST), {"q": "gida"})
145
+
146
+ self.assertEqual(response.context_data["usage_total"], 7)
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
+
172
+ def test_search_combines_with_list_filters(self):
173
+ api = Endpoint.objects.create(
174
+ app_label="api", route_name="artikuluak-list", method="GET"
175
+ )
176
+ EndpointStat.objects.create(
177
+ endpoint=api, date="2026-10-08", client_type="anon", count=5
178
+ )
179
+ EndpointStat.objects.create(
180
+ endpoint=api, date="2026-10-08", client_type="user", count=3
181
+ )
182
+
183
+ response = self.client.get(
184
+ reverse(CHANGELIST), {"q": "artikuluak", "client_type": "user"}
185
+ )
186
+
187
+ self.assertEqual(response.context_data["usage_total"], 3)
188
+
106
189
  def test_clear_is_not_available_without_admin_permission(self):
107
190
  self._make_stat()
108
191
  self.client.force_login(User.objects.create_user("plain", password="x"))
@@ -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):