diff --git a/pyproject.toml b/pyproject.toml index 34acf27..12d83c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "django-api-usage" -version = "0.1.8" +version = "0.1.9" description = "Lightweight, privacy-first usage metering for Django APIs, with an optional deprecation lifecycle layer." readme = "README.md" license = "MIT" diff --git a/src/django_api_usage/__init__.py b/src/django_api_usage/__init__.py index d4902b4..f27a55e 100644 --- a/src/django_api_usage/__init__.py +++ b/src/django_api_usage/__init__.py @@ -1,3 +1,3 @@ """django-api-usage: usage metering for Django APIs, with deprecation support.""" -__version__ = "0.1.8" +__version__ = "0.1.9" diff --git a/src/django_api_usage/admin.py b/src/django_api_usage/admin.py index 0caca33..b8ac56c 100644 --- a/src/django_api_usage/admin.py +++ b/src/django_api_usage/admin.py @@ -14,6 +14,7 @@ """ import csv +import time from django.contrib import admin, messages from django.db.models import Count, OuterRef, Subquery, Sum @@ -78,6 +79,45 @@ def export_as_csv(self, request, queryset): DEFAULT_METHOD_COLORS = ("#e2e3e5", "#41464b") +#: Site labels are read for every row of the changelist, so they are cached for +#: a short while (the table changes very rarely). +SITE_LABELS_TTL = 60 +_site_labels_cache = {"loaded_at": 0.0, "value": None} + + +def _load_site_labels(): + """``{pk: domain}`` when django.contrib.sites is installed, else ``{}``.""" + try: + from django.contrib.sites.models import Site + except (ImportError, RuntimeError): + return {} + return dict(Site.objects.values_list("pk", "domain")) + + +def _site_labels(): + now = time.monotonic() + if ( + _site_labels_cache["value"] is None + or now - _site_labels_cache["loaded_at"] > SITE_LABELS_TTL + ): + _site_labels_cache["value"] = _load_site_labels() + _site_labels_cache["loaded_at"] = now + return _site_labels_cache["value"] + + +def reset_site_labels_cache(**kwargs): + """Forget the cached site domains (used by tests and after editing sites).""" + _site_labels_cache["value"] = None + _site_labels_cache["loaded_at"] = 0.0 + + +def _site_label(site_id): + """Domain of a site, falling back to the raw id (or ``None``).""" + if site_id is None: + return None + return _site_labels().get(site_id, str(site_id)) + + def _method_badge_html(method): """Coloured pill for an HTTP verb, so writes stand out at a glance.""" verb = (method or "").upper() @@ -148,6 +188,42 @@ class StatMethodListFilter(MethodListFilter): field_path = "endpoint__method" +class SiteListFilter(admin.SimpleListFilter): + """Sites, shown by domain when django.contrib.sites is available. + + Useful when several front ends (several sites) call the same backend: the + endpoint's site tells them apart. + """ + + title = _("Site") + parameter_name = "site" + field_path = "site_id" + + def lookups(self, request, model_admin): + labels = _site_labels() + if labels: + return [(pk, domain) for pk, domain in sorted(labels.items())] + used = ( + model_admin.model.objects.order_by() + .values_list(self.field_path, flat=True) + .distinct() + ) + return [ + (value, str(value)) for value in sorted(v for v in used if v is not None) + ] + + def queryset(self, request, queryset): + if not self.value(): + return queryset + return queryset.filter(**{self.field_path: self.value()}) + + +class StatSiteListFilter(SiteListFilter): + """Site filter for the stats changelist (through the endpoint).""" + + field_path = "endpoint__site_id" + + #: Sentinel for the calls that could not be attributed to any application. UNATTRIBUTED = "__none__" @@ -207,7 +283,7 @@ class EndpointAdmin(CsvExportMixin, admin.ModelAdmin): "route_path", "route_name", "method_badge", - "site_id", + "site", "deprecated", "sunset_date", "replacement", @@ -217,7 +293,7 @@ class EndpointAdmin(CsvExportMixin, admin.ModelAdmin): "deprecated", AppLabelListFilter, MethodListFilter, - "site_id", + SiteListFilter, ) search_fields = ("route_path", "route_name", "replacement", "owner", "notes") list_editable = ("deprecated", "sunset_date", "replacement", "owner") @@ -226,6 +302,10 @@ class EndpointAdmin(CsvExportMixin, admin.ModelAdmin): def method_badge(self, obj): return _method_badge_html(obj.method) + @admin.display(description=_("Site"), ordering="site_id") + def site(self, obj): + return _site_label(obj.site_id) + @admin.register(EndpointStat) class EndpointStatAdmin(CsvExportMixin, admin.ModelAdmin): @@ -246,6 +326,7 @@ class EndpointStatAdmin(CsvExportMixin, admin.ModelAdmin): ) list_display = ( "date", + "site", "application", "method_badge", "endpoint_path", @@ -259,6 +340,7 @@ class EndpointStatAdmin(CsvExportMixin, admin.ModelAdmin): "client_type", ClientAppListFilter, "status_class", + StatSiteListFilter, StatAppLabelListFilter, StatMethodListFilter, ) @@ -284,6 +366,10 @@ class EndpointStatAdmin(CsvExportMixin, admin.ModelAdmin): def application(self, obj): return obj.endpoint.app_label + @admin.display(description=_("Site"), ordering="endpoint__site_id") + def site(self, obj): + return _site_label(obj.endpoint.site_id) + @admin.display(description=_("Method"), ordering="endpoint__method") def method_badge(self, obj): return _method_badge_html(obj.endpoint.method) diff --git a/src/django_api_usage/locale/eu/LC_MESSAGES/django.mo b/src/django_api_usage/locale/eu/LC_MESSAGES/django.mo index 7f61e90..1ae167a 100644 Binary files a/src/django_api_usage/locale/eu/LC_MESSAGES/django.mo and b/src/django_api_usage/locale/eu/LC_MESSAGES/django.mo differ diff --git a/src/django_api_usage/locale/eu/LC_MESSAGES/django.po b/src/django_api_usage/locale/eu/LC_MESSAGES/django.po index 5c67acc..a87c20e 100644 --- a/src/django_api_usage/locale/eu/LC_MESSAGES/django.po +++ b/src/django_api_usage/locale/eu/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: django-api-usage\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-10-08 07:11-0500\n" +"POT-Creation-Date: 2026-10-08 08:11-0500\n" "PO-Revision-Date: 2026-10-08 06:55-0500\n" "Last-Translator: Urtzi Odriozola \n" "Language-Team: Basque\n" @@ -17,27 +17,33 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: src/django_api_usage/admin.py:46 +#: src/django_api_usage/admin.py:48 msgid "Export selected rows to CSV" msgstr "Esportatu hautatutako errenkadak CSVra" -#: src/django_api_usage/admin.py:71 +#: src/django_api_usage/admin.py:131 src/django_api_usage/admin.py:359 msgid "Application" msgstr "Aplikazioa" -#: src/django_api_usage/admin.py:93 +#: src/django_api_usage/admin.py:153 src/django_api_usage/admin.py:295 +#: src/django_api_usage/admin.py:367 msgid "Method" msgstr "Metodoa" -#: src/django_api_usage/admin.py:136 src/django_api_usage/models.py:100 +#: src/django_api_usage/admin.py:192 src/django_api_usage/admin.py:299 +#: src/django_api_usage/admin.py:363 +msgid "Site" +msgstr "Webgunea" + +#: src/django_api_usage/admin.py:232 src/django_api_usage/models.py:100 msgid "Client application" msgstr "Bezero-aplikazioa" -#: src/django_api_usage/admin.py:152 +#: src/django_api_usage/admin.py:248 msgid "Not attributed" msgstr "Esleitu gabea" -#: src/django_api_usage/admin.py:244 +#: src/django_api_usage/admin.py:352 msgid "" "Search by endpoint path, route name, app, method or replacement (for example " "'herriak' or 'artikuluak')." @@ -45,12 +51,16 @@ msgstr "" "Bilatu endpointaren bidearen, route-izenaren, aplikazioaren, metodoaren edo " "ordezkoaren arabera (adibidez 'herriak' edo 'artikuluak')." -#: src/django_api_usage/admin.py:299 +#: src/django_api_usage/admin.py:371 +msgid "Endpoint" +msgstr "Endpointa" + +#: src/django_api_usage/admin.py:427 #, python-brace-format msgid "Flushed {count} buffered counter(s)." msgstr "{count} kontagailu eraman dira bufferretik datu-basera." -#: src/django_api_usage/admin.py:306 +#: src/django_api_usage/admin.py:434 msgid "" "Nothing was buffered: with BUFFER_BACKEND='db' counters are already in the " "database." @@ -58,22 +68,22 @@ msgstr "" "Ez zegoen ezer bufferrean: BUFFER_BACKEND='db' denean kontagailuak jada datu-" "basean daude." -#: src/django_api_usage/admin.py:318 +#: src/django_api_usage/admin.py:446 #: src/django_api_usage/templates/admin/django_api_usage/endpointstat/change_list.html:12 #: src/django_api_usage/templates/admin/django_api_usage/endpointstat/confirm_clear.html:8 msgid "Clear statistics" msgstr "Estatistikak hustu" -#: src/django_api_usage/admin.py:330 +#: src/django_api_usage/admin.py:458 #, python-brace-format msgid "Deleted {count} statistic row(s)." msgstr "{count} estatistika-errenkada ezabatu dira." -#: src/django_api_usage/admin.py:394 +#: src/django_api_usage/admin.py:522 msgid "Matching rules" msgstr "Bat-etortze arauak" -#: src/django_api_usage/admin.py:398 +#: src/django_api_usage/admin.py:526 msgid "" "Checked in this order: account, request host, client network, user agent. " "Between applications, the lowest priority wins. Accounts are assigned from " @@ -83,15 +93,15 @@ msgstr "" "sarea eta user agent-a. Aplikazioen artean, lehentasun txikiena duenak " "irabazten du. Kontuak 'Bezero-aplikazioko kontuak' taulatik esleitzen dira." -#: src/django_api_usage/admin.py:422 +#: src/django_api_usage/admin.py:550 msgid "Accounts" msgstr "Kontuak" -#: src/django_api_usage/admin.py:426 +#: src/django_api_usage/admin.py:554 msgid "Counters" msgstr "Kontagailuak" -#: src/django_api_usage/admin.py:454 src/django_api_usage/models.py:134 +#: src/django_api_usage/admin.py:582 src/django_api_usage/models.py:134 msgid "Account" msgstr "Kontua" diff --git a/tests/settings.py b/tests/settings.py index 0d95cce..8e0a4e3 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -12,9 +12,12 @@ "django.contrib.auth", "django.contrib.sessions", "django.contrib.messages", + "django.contrib.sites", "django_api_usage", ] +SITE_ID = 1 + MIDDLEWARE = [ "django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", diff --git a/tests/test_sites.py b/tests/test_sites.py new file mode 100644 index 0000000..d06aeb2 --- /dev/null +++ b/tests/test_sites.py @@ -0,0 +1,92 @@ +"""Tests for the Site column and filter. + +Several front ends (several sites) can call the same backend, so the endpoint's +site is how they are told apart. +""" + +import re + +from django.contrib.auth.models import User +from django.contrib.sites.models import Site +from django.urls import reverse + +from django_api_usage.admin import reset_site_labels_cache +from django_api_usage.models import Endpoint, EndpointStat + +from .base import UsageTestCase + +STAT_CHANGELIST = "admin:django_api_usage_endpointstat_changelist" +ENDPOINT_CHANGELIST = "admin:django_api_usage_endpoint_changelist" + + +class SiteColumnTest(UsageTestCase): + def setUp(self): + super().setUp() + # The labels are cached per process, so every test starts from scratch. + reset_site_labels_cache() + self.admin = User.objects.create_superuser("admin", "a@example.com", "pw") + self.client.force_login(self.admin) + + self.goiena = Site.objects.create(domain="goiena.eus", name="Goiena") + self.arrasate = Site.objects.create(domain="arrasate.eus", name="Arrasate") + + self.endpoint_a = Endpoint.objects.create( + site_id=self.goiena.pk, + app_label="api", + route_path="api/3.0/a/", + method="GET", + ) + self.endpoint_b = Endpoint.objects.create( + site_id=self.arrasate.pk, + app_label="api", + route_path="api/3.0/b/", + method="GET", + ) + self.stat_a = EndpointStat.objects.create( + endpoint=self.endpoint_a, date="2026-10-08", client_type="anon", count=1 + ) + self.stat_b = EndpointStat.objects.create( + endpoint=self.endpoint_b, date="2026-10-08", client_type="anon", count=2 + ) + + def cells(self, changelist, **params): + html = self.client.get(reverse(changelist), params).content.decode() + return re.findall(r'([^<]*)', html) + + def rows(self, **params): + response = self.client.get(reverse(STAT_CHANGELIST), params) + self.assertEqual(response.status_code, 200) + return set(response.context["cl"].queryset) + + # -- column ------------------------------------------------------------- + + def test_the_stats_column_shows_the_domain(self): + self.assertCountEqual( + self.cells(STAT_CHANGELIST), ["goiena.eus", "arrasate.eus"] + ) + + def test_the_endpoints_column_shows_the_domain(self): + self.assertCountEqual( + self.cells(ENDPOINT_CHANGELIST), ["goiena.eus", "arrasate.eus"] + ) + + # -- filter ------------------------------------------------------------- + + def test_the_stats_can_be_filtered_by_site(self): + self.assertEqual(self.rows(site=self.goiena.pk), {self.stat_a}) + self.assertEqual(self.rows(site=self.arrasate.pk), {self.stat_b}) + + def test_the_filter_offers_every_site_by_domain(self): + html = self.client.get(reverse(STAT_CHANGELIST)).content.decode() + + self.assertIn(f"?site={self.goiena.pk}", html) + self.assertIn(">goiena.eus", html) + self.assertIn(">arrasate.eus", html) + + def test_the_endpoints_can_be_filtered_by_site(self): + response = self.client.get( + reverse(ENDPOINT_CHANGELIST), {"site": self.arrasate.pk} + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(set(response.context["cl"].queryset), {self.endpoint_b})