django-api-usage 0.1.8__tar.gz → 0.1.9__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 (49) hide show
  1. {django_api_usage-0.1.8/src/django_api_usage.egg-info → django_api_usage-0.1.9}/PKG-INFO +1 -1
  2. {django_api_usage-0.1.8 → django_api_usage-0.1.9}/pyproject.toml +1 -1
  3. {django_api_usage-0.1.8 → django_api_usage-0.1.9}/src/django_api_usage/__init__.py +1 -1
  4. {django_api_usage-0.1.8 → django_api_usage-0.1.9}/src/django_api_usage/admin.py +88 -2
  5. django_api_usage-0.1.9/src/django_api_usage/locale/eu/LC_MESSAGES/django.mo +0 -0
  6. {django_api_usage-0.1.8 → django_api_usage-0.1.9}/src/django_api_usage/locale/eu/LC_MESSAGES/django.po +26 -16
  7. {django_api_usage-0.1.8 → django_api_usage-0.1.9/src/django_api_usage.egg-info}/PKG-INFO +1 -1
  8. {django_api_usage-0.1.8 → django_api_usage-0.1.9}/src/django_api_usage.egg-info/SOURCES.txt +2 -1
  9. django_api_usage-0.1.9/tests/test_sites.py +92 -0
  10. django_api_usage-0.1.8/src/django_api_usage/locale/eu/LC_MESSAGES/django.mo +0 -0
  11. {django_api_usage-0.1.8 → django_api_usage-0.1.9}/LICENSE +0 -0
  12. {django_api_usage-0.1.8 → django_api_usage-0.1.9}/README.md +0 -0
  13. {django_api_usage-0.1.8 → django_api_usage-0.1.9}/setup.cfg +0 -0
  14. {django_api_usage-0.1.8 → django_api_usage-0.1.9}/src/django_api_usage/apps.py +0 -0
  15. {django_api_usage-0.1.8 → django_api_usage-0.1.9}/src/django_api_usage/buffers.py +0 -0
  16. {django_api_usage-0.1.8 → django_api_usage-0.1.9}/src/django_api_usage/checks.py +0 -0
  17. {django_api_usage-0.1.8 → django_api_usage-0.1.9}/src/django_api_usage/conf.py +0 -0
  18. {django_api_usage-0.1.8 → django_api_usage-0.1.9}/src/django_api_usage/deprecation.py +0 -0
  19. {django_api_usage-0.1.8 → django_api_usage-0.1.9}/src/django_api_usage/drf.py +0 -0
  20. {django_api_usage-0.1.8 → django_api_usage-0.1.9}/src/django_api_usage/maintenance.py +0 -0
  21. {django_api_usage-0.1.8 → django_api_usage-0.1.9}/src/django_api_usage/management/__init__.py +0 -0
  22. {django_api_usage-0.1.8 → django_api_usage-0.1.9}/src/django_api_usage/management/commands/__init__.py +0 -0
  23. {django_api_usage-0.1.8 → django_api_usage-0.1.9}/src/django_api_usage/management/commands/api_usage_backfill_paths.py +0 -0
  24. {django_api_usage-0.1.8 → django_api_usage-0.1.9}/src/django_api_usage/management/commands/api_usage_flush.py +0 -0
  25. {django_api_usage-0.1.8 → django_api_usage-0.1.9}/src/django_api_usage/management/commands/api_usage_report.py +0 -0
  26. {django_api_usage-0.1.8 → django_api_usage-0.1.9}/src/django_api_usage/middleware.py +0 -0
  27. {django_api_usage-0.1.8 → django_api_usage-0.1.9}/src/django_api_usage/migrations/0001_initial.py +0 -0
  28. {django_api_usage-0.1.8 → django_api_usage-0.1.9}/src/django_api_usage/migrations/0002_endpoint_route_path.py +0 -0
  29. {django_api_usage-0.1.8 → django_api_usage-0.1.9}/src/django_api_usage/migrations/0003_alter_endpointstat_unique_together_and_more.py +0 -0
  30. {django_api_usage-0.1.8 → django_api_usage-0.1.9}/src/django_api_usage/migrations/0004_remove_clientapp_accounts_clientappaccount.py +0 -0
  31. {django_api_usage-0.1.8 → django_api_usage-0.1.9}/src/django_api_usage/migrations/__init__.py +0 -0
  32. {django_api_usage-0.1.8 → django_api_usage-0.1.9}/src/django_api_usage/models.py +0 -0
  33. {django_api_usage-0.1.8 → django_api_usage-0.1.9}/src/django_api_usage/resolvers.py +0 -0
  34. {django_api_usage-0.1.8 → django_api_usage-0.1.9}/src/django_api_usage/tasks.py +0 -0
  35. {django_api_usage-0.1.8 → django_api_usage-0.1.9}/src/django_api_usage/templates/admin/django_api_usage/endpointstat/change_list.html +0 -0
  36. {django_api_usage-0.1.8 → django_api_usage-0.1.9}/src/django_api_usage/templates/admin/django_api_usage/endpointstat/confirm_clear.html +0 -0
  37. {django_api_usage-0.1.8 → django_api_usage-0.1.9}/src/django_api_usage.egg-info/dependency_links.txt +0 -0
  38. {django_api_usage-0.1.8 → django_api_usage-0.1.9}/src/django_api_usage.egg-info/requires.txt +0 -0
  39. {django_api_usage-0.1.8 → django_api_usage-0.1.9}/src/django_api_usage.egg-info/top_level.txt +0 -0
  40. {django_api_usage-0.1.8 → django_api_usage-0.1.9}/tests/test_admin.py +0 -0
  41. {django_api_usage-0.1.8 → django_api_usage-0.1.9}/tests/test_backfill_paths.py +0 -0
  42. {django_api_usage-0.1.8 → django_api_usage-0.1.9}/tests/test_checks.py +0 -0
  43. {django_api_usage-0.1.8 → django_api_usage-0.1.9}/tests/test_client_app.py +0 -0
  44. {django_api_usage-0.1.8 → django_api_usage-0.1.9}/tests/test_commands.py +0 -0
  45. {django_api_usage-0.1.8 → django_api_usage-0.1.9}/tests/test_csv_export.py +0 -0
  46. {django_api_usage-0.1.8 → django_api_usage-0.1.9}/tests/test_drf.py +0 -0
  47. {django_api_usage-0.1.8 → django_api_usage-0.1.9}/tests/test_filters.py +0 -0
  48. {django_api_usage-0.1.8 → django_api_usage-0.1.9}/tests/test_middleware.py +0 -0
  49. {django_api_usage-0.1.8 → django_api_usage-0.1.9}/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.8
3
+ Version: 0.1.9
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.8"
7
+ version = "0.1.9"
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.8"
3
+ __version__ = "0.1.9"
@@ -14,6 +14,7 @@ Everything is plain Django: no extra dependency beyond Django itself.
14
14
  """
15
15
 
16
16
  import csv
17
+ import time
17
18
 
18
19
  from django.contrib import admin, messages
19
20
  from django.db.models import Count, OuterRef, Subquery, Sum
@@ -78,6 +79,45 @@ METHOD_COLORS = {
78
79
  DEFAULT_METHOD_COLORS = ("#e2e3e5", "#41464b")
79
80
 
80
81
 
82
+ #: Site labels are read for every row of the changelist, so they are cached for
83
+ #: a short while (the table changes very rarely).
84
+ SITE_LABELS_TTL = 60
85
+ _site_labels_cache = {"loaded_at": 0.0, "value": None}
86
+
87
+
88
+ def _load_site_labels():
89
+ """``{pk: domain}`` when django.contrib.sites is installed, else ``{}``."""
90
+ try:
91
+ from django.contrib.sites.models import Site
92
+ except (ImportError, RuntimeError):
93
+ return {}
94
+ return dict(Site.objects.values_list("pk", "domain"))
95
+
96
+
97
+ def _site_labels():
98
+ now = time.monotonic()
99
+ if (
100
+ _site_labels_cache["value"] is None
101
+ or now - _site_labels_cache["loaded_at"] > SITE_LABELS_TTL
102
+ ):
103
+ _site_labels_cache["value"] = _load_site_labels()
104
+ _site_labels_cache["loaded_at"] = now
105
+ return _site_labels_cache["value"]
106
+
107
+
108
+ def reset_site_labels_cache(**kwargs):
109
+ """Forget the cached site domains (used by tests and after editing sites)."""
110
+ _site_labels_cache["value"] = None
111
+ _site_labels_cache["loaded_at"] = 0.0
112
+
113
+
114
+ def _site_label(site_id):
115
+ """Domain of a site, falling back to the raw id (or ``None``)."""
116
+ if site_id is None:
117
+ return None
118
+ return _site_labels().get(site_id, str(site_id))
119
+
120
+
81
121
  def _method_badge_html(method):
82
122
  """Coloured pill for an HTTP verb, so writes stand out at a glance."""
83
123
  verb = (method or "").upper()
@@ -148,6 +188,42 @@ class StatMethodListFilter(MethodListFilter):
148
188
  field_path = "endpoint__method"
149
189
 
150
190
 
191
+ class SiteListFilter(admin.SimpleListFilter):
192
+ """Sites, shown by domain when django.contrib.sites is available.
193
+
194
+ Useful when several front ends (several sites) call the same backend: the
195
+ endpoint's site tells them apart.
196
+ """
197
+
198
+ title = _("Site")
199
+ parameter_name = "site"
200
+ field_path = "site_id"
201
+
202
+ def lookups(self, request, model_admin):
203
+ labels = _site_labels()
204
+ if labels:
205
+ return [(pk, domain) for pk, domain in sorted(labels.items())]
206
+ used = (
207
+ model_admin.model.objects.order_by()
208
+ .values_list(self.field_path, flat=True)
209
+ .distinct()
210
+ )
211
+ return [
212
+ (value, str(value)) for value in sorted(v for v in used if v is not None)
213
+ ]
214
+
215
+ def queryset(self, request, queryset):
216
+ if not self.value():
217
+ return queryset
218
+ return queryset.filter(**{self.field_path: self.value()})
219
+
220
+
221
+ class StatSiteListFilter(SiteListFilter):
222
+ """Site filter for the stats changelist (through the endpoint)."""
223
+
224
+ field_path = "endpoint__site_id"
225
+
226
+
151
227
  #: Sentinel for the calls that could not be attributed to any application.
152
228
  UNATTRIBUTED = "__none__"
153
229
 
@@ -207,7 +283,7 @@ class EndpointAdmin(CsvExportMixin, admin.ModelAdmin):
207
283
  "route_path",
208
284
  "route_name",
209
285
  "method_badge",
210
- "site_id",
286
+ "site",
211
287
  "deprecated",
212
288
  "sunset_date",
213
289
  "replacement",
@@ -217,7 +293,7 @@ class EndpointAdmin(CsvExportMixin, admin.ModelAdmin):
217
293
  "deprecated",
218
294
  AppLabelListFilter,
219
295
  MethodListFilter,
220
- "site_id",
296
+ SiteListFilter,
221
297
  )
222
298
  search_fields = ("route_path", "route_name", "replacement", "owner", "notes")
223
299
  list_editable = ("deprecated", "sunset_date", "replacement", "owner")
@@ -226,6 +302,10 @@ class EndpointAdmin(CsvExportMixin, admin.ModelAdmin):
226
302
  def method_badge(self, obj):
227
303
  return _method_badge_html(obj.method)
228
304
 
305
+ @admin.display(description=_("Site"), ordering="site_id")
306
+ def site(self, obj):
307
+ return _site_label(obj.site_id)
308
+
229
309
 
230
310
  @admin.register(EndpointStat)
231
311
  class EndpointStatAdmin(CsvExportMixin, admin.ModelAdmin):
@@ -246,6 +326,7 @@ class EndpointStatAdmin(CsvExportMixin, admin.ModelAdmin):
246
326
  )
247
327
  list_display = (
248
328
  "date",
329
+ "site",
249
330
  "application",
250
331
  "method_badge",
251
332
  "endpoint_path",
@@ -259,6 +340,7 @@ class EndpointStatAdmin(CsvExportMixin, admin.ModelAdmin):
259
340
  "client_type",
260
341
  ClientAppListFilter,
261
342
  "status_class",
343
+ StatSiteListFilter,
262
344
  StatAppLabelListFilter,
263
345
  StatMethodListFilter,
264
346
  )
@@ -284,6 +366,10 @@ class EndpointStatAdmin(CsvExportMixin, admin.ModelAdmin):
284
366
  def application(self, obj):
285
367
  return obj.endpoint.app_label
286
368
 
369
+ @admin.display(description=_("Site"), ordering="endpoint__site_id")
370
+ def site(self, obj):
371
+ return _site_label(obj.endpoint.site_id)
372
+
287
373
  @admin.display(description=_("Method"), ordering="endpoint__method")
288
374
  def method_badge(self, obj):
289
375
  return _method_badge_html(obj.endpoint.method)
@@ -7,7 +7,7 @@ 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 07:11-0500\n"
10
+ "POT-Creation-Date: 2026-10-08 08:11-0500\n"
11
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"
@@ -17,27 +17,33 @@ msgstr ""
17
17
  "Content-Transfer-Encoding: 8bit\n"
18
18
  "Plural-Forms: nplurals=2; plural=(n != 1);\n"
19
19
 
20
- #: src/django_api_usage/admin.py:46
20
+ #: src/django_api_usage/admin.py:48
21
21
  msgid "Export selected rows to CSV"
22
22
  msgstr "Esportatu hautatutako errenkadak CSVra"
23
23
 
24
- #: src/django_api_usage/admin.py:71
24
+ #: src/django_api_usage/admin.py:131 src/django_api_usage/admin.py:359
25
25
  msgid "Application"
26
26
  msgstr "Aplikazioa"
27
27
 
28
- #: src/django_api_usage/admin.py:93
28
+ #: src/django_api_usage/admin.py:153 src/django_api_usage/admin.py:295
29
+ #: src/django_api_usage/admin.py:367
29
30
  msgid "Method"
30
31
  msgstr "Metodoa"
31
32
 
32
- #: src/django_api_usage/admin.py:136 src/django_api_usage/models.py:100
33
+ #: src/django_api_usage/admin.py:192 src/django_api_usage/admin.py:299
34
+ #: src/django_api_usage/admin.py:363
35
+ msgid "Site"
36
+ msgstr "Webgunea"
37
+
38
+ #: src/django_api_usage/admin.py:232 src/django_api_usage/models.py:100
33
39
  msgid "Client application"
34
40
  msgstr "Bezero-aplikazioa"
35
41
 
36
- #: src/django_api_usage/admin.py:152
42
+ #: src/django_api_usage/admin.py:248
37
43
  msgid "Not attributed"
38
44
  msgstr "Esleitu gabea"
39
45
 
40
- #: src/django_api_usage/admin.py:244
46
+ #: src/django_api_usage/admin.py:352
41
47
  msgid ""
42
48
  "Search by endpoint path, route name, app, method or replacement (for example "
43
49
  "'herriak' or 'artikuluak')."
@@ -45,12 +51,16 @@ msgstr ""
45
51
  "Bilatu endpointaren bidearen, route-izenaren, aplikazioaren, metodoaren edo "
46
52
  "ordezkoaren arabera (adibidez 'herriak' edo 'artikuluak')."
47
53
 
48
- #: src/django_api_usage/admin.py:299
54
+ #: src/django_api_usage/admin.py:371
55
+ msgid "Endpoint"
56
+ msgstr "Endpointa"
57
+
58
+ #: src/django_api_usage/admin.py:427
49
59
  #, python-brace-format
50
60
  msgid "Flushed {count} buffered counter(s)."
51
61
  msgstr "{count} kontagailu eraman dira bufferretik datu-basera."
52
62
 
53
- #: src/django_api_usage/admin.py:306
63
+ #: src/django_api_usage/admin.py:434
54
64
  msgid ""
55
65
  "Nothing was buffered: with BUFFER_BACKEND='db' counters are already in the "
56
66
  "database."
@@ -58,22 +68,22 @@ msgstr ""
58
68
  "Ez zegoen ezer bufferrean: BUFFER_BACKEND='db' denean kontagailuak jada datu-"
59
69
  "basean daude."
60
70
 
61
- #: src/django_api_usage/admin.py:318
71
+ #: src/django_api_usage/admin.py:446
62
72
  #: src/django_api_usage/templates/admin/django_api_usage/endpointstat/change_list.html:12
63
73
  #: src/django_api_usage/templates/admin/django_api_usage/endpointstat/confirm_clear.html:8
64
74
  msgid "Clear statistics"
65
75
  msgstr "Estatistikak hustu"
66
76
 
67
- #: src/django_api_usage/admin.py:330
77
+ #: src/django_api_usage/admin.py:458
68
78
  #, python-brace-format
69
79
  msgid "Deleted {count} statistic row(s)."
70
80
  msgstr "{count} estatistika-errenkada ezabatu dira."
71
81
 
72
- #: src/django_api_usage/admin.py:394
82
+ #: src/django_api_usage/admin.py:522
73
83
  msgid "Matching rules"
74
84
  msgstr "Bat-etortze arauak"
75
85
 
76
- #: src/django_api_usage/admin.py:398
86
+ #: src/django_api_usage/admin.py:526
77
87
  msgid ""
78
88
  "Checked in this order: account, request host, client network, user agent. "
79
89
  "Between applications, the lowest priority wins. Accounts are assigned from "
@@ -83,15 +93,15 @@ msgstr ""
83
93
  "sarea eta user agent-a. Aplikazioen artean, lehentasun txikiena duenak "
84
94
  "irabazten du. Kontuak 'Bezero-aplikazioko kontuak' taulatik esleitzen dira."
85
95
 
86
- #: src/django_api_usage/admin.py:422
96
+ #: src/django_api_usage/admin.py:550
87
97
  msgid "Accounts"
88
98
  msgstr "Kontuak"
89
99
 
90
- #: src/django_api_usage/admin.py:426
100
+ #: src/django_api_usage/admin.py:554
91
101
  msgid "Counters"
92
102
  msgstr "Kontagailuak"
93
103
 
94
- #: src/django_api_usage/admin.py:454 src/django_api_usage/models.py:134
104
+ #: src/django_api_usage/admin.py:582 src/django_api_usage/models.py:134
95
105
  msgid "Account"
96
106
  msgstr "Kontua"
97
107
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-api-usage
3
- Version: 0.1.8
3
+ Version: 0.1.9
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
@@ -42,4 +42,5 @@ tests/test_csv_export.py
42
42
  tests/test_drf.py
43
43
  tests/test_filters.py
44
44
  tests/test_middleware.py
45
- tests/test_models.py
45
+ tests/test_models.py
46
+ tests/test_sites.py
@@ -0,0 +1,92 @@
1
+ """Tests for the Site column and filter.
2
+
3
+ Several front ends (several sites) can call the same backend, so the endpoint's
4
+ site is how they are told apart.
5
+ """
6
+
7
+ import re
8
+
9
+ from django.contrib.auth.models import User
10
+ from django.contrib.sites.models import Site
11
+ from django.urls import reverse
12
+
13
+ from django_api_usage.admin import reset_site_labels_cache
14
+ from django_api_usage.models import Endpoint, EndpointStat
15
+
16
+ from .base import UsageTestCase
17
+
18
+ STAT_CHANGELIST = "admin:django_api_usage_endpointstat_changelist"
19
+ ENDPOINT_CHANGELIST = "admin:django_api_usage_endpoint_changelist"
20
+
21
+
22
+ class SiteColumnTest(UsageTestCase):
23
+ def setUp(self):
24
+ super().setUp()
25
+ # The labels are cached per process, so every test starts from scratch.
26
+ reset_site_labels_cache()
27
+ self.admin = User.objects.create_superuser("admin", "a@example.com", "pw")
28
+ self.client.force_login(self.admin)
29
+
30
+ self.goiena = Site.objects.create(domain="goiena.eus", name="Goiena")
31
+ self.arrasate = Site.objects.create(domain="arrasate.eus", name="Arrasate")
32
+
33
+ self.endpoint_a = Endpoint.objects.create(
34
+ site_id=self.goiena.pk,
35
+ app_label="api",
36
+ route_path="api/3.0/a/",
37
+ method="GET",
38
+ )
39
+ self.endpoint_b = Endpoint.objects.create(
40
+ site_id=self.arrasate.pk,
41
+ app_label="api",
42
+ route_path="api/3.0/b/",
43
+ method="GET",
44
+ )
45
+ self.stat_a = EndpointStat.objects.create(
46
+ endpoint=self.endpoint_a, date="2026-10-08", client_type="anon", count=1
47
+ )
48
+ self.stat_b = EndpointStat.objects.create(
49
+ endpoint=self.endpoint_b, date="2026-10-08", client_type="anon", count=2
50
+ )
51
+
52
+ def cells(self, changelist, **params):
53
+ html = self.client.get(reverse(changelist), params).content.decode()
54
+ return re.findall(r'<td class="field-site">([^<]*)</td>', html)
55
+
56
+ def rows(self, **params):
57
+ response = self.client.get(reverse(STAT_CHANGELIST), params)
58
+ self.assertEqual(response.status_code, 200)
59
+ return set(response.context["cl"].queryset)
60
+
61
+ # -- column -------------------------------------------------------------
62
+
63
+ def test_the_stats_column_shows_the_domain(self):
64
+ self.assertCountEqual(
65
+ self.cells(STAT_CHANGELIST), ["goiena.eus", "arrasate.eus"]
66
+ )
67
+
68
+ def test_the_endpoints_column_shows_the_domain(self):
69
+ self.assertCountEqual(
70
+ self.cells(ENDPOINT_CHANGELIST), ["goiena.eus", "arrasate.eus"]
71
+ )
72
+
73
+ # -- filter -------------------------------------------------------------
74
+
75
+ def test_the_stats_can_be_filtered_by_site(self):
76
+ self.assertEqual(self.rows(site=self.goiena.pk), {self.stat_a})
77
+ self.assertEqual(self.rows(site=self.arrasate.pk), {self.stat_b})
78
+
79
+ def test_the_filter_offers_every_site_by_domain(self):
80
+ html = self.client.get(reverse(STAT_CHANGELIST)).content.decode()
81
+
82
+ self.assertIn(f"?site={self.goiena.pk}", html)
83
+ self.assertIn(">goiena.eus</a>", html)
84
+ self.assertIn(">arrasate.eus</a>", html)
85
+
86
+ def test_the_endpoints_can_be_filtered_by_site(self):
87
+ response = self.client.get(
88
+ reverse(ENDPOINT_CHANGELIST), {"site": self.arrasate.pk}
89
+ )
90
+
91
+ self.assertEqual(response.status_code, 200)
92
+ self.assertEqual(set(response.context["cl"].queryset), {self.endpoint_b})