Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/django_api_usage/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""django-api-usage: usage metering for Django APIs, with deprecation support."""

__version__ = "0.1.8"
__version__ = "0.1.9"
90 changes: 88 additions & 2 deletions src/django_api_usage/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"""

import csv
import time

from django.contrib import admin, messages
from django.db.models import Count, OuterRef, Subquery, Sum
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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__"

Expand Down Expand Up @@ -207,7 +283,7 @@ class EndpointAdmin(CsvExportMixin, admin.ModelAdmin):
"route_path",
"route_name",
"method_badge",
"site_id",
"site",
"deprecated",
"sunset_date",
"replacement",
Expand All @@ -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")
Expand All @@ -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):
Expand All @@ -246,6 +326,7 @@ class EndpointStatAdmin(CsvExportMixin, admin.ModelAdmin):
)
list_display = (
"date",
"site",
"application",
"method_badge",
"endpoint_path",
Expand All @@ -259,6 +340,7 @@ class EndpointStatAdmin(CsvExportMixin, admin.ModelAdmin):
"client_type",
ClientAppListFilter,
"status_class",
StatSiteListFilter,
StatAppLabelListFilter,
StatMethodListFilter,
)
Expand All @@ -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)
Expand Down
Binary file modified src/django_api_usage/locale/eu/LC_MESSAGES/django.mo
Binary file not shown.
42 changes: 26 additions & 16 deletions src/django_api_usage/locale/eu/LC_MESSAGES/django.po
Original file line number Diff line number Diff line change
Expand Up @@ -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 <uodriozola@codesyntax.com>\n"
"Language-Team: Basque\n"
Expand All @@ -17,63 +17,73 @@ 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')."
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."
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 "
Expand All @@ -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"

Expand Down
3 changes: 3 additions & 0 deletions tests/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
92 changes: 92 additions & 0 deletions tests/test_sites.py
Original file line number Diff line number Diff line change
@@ -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'<td class="field-site">([^<]*)</td>', 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</a>", html)
self.assertIn(">arrasate.eus</a>", 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})
Loading