diff --git a/README.md b/README.md
index 28e7a4d..b71a4a4 100644
--- a/README.md
+++ b/README.md
@@ -91,7 +91,9 @@ similar):
method or replacement), filters by date, client type, status class, and
**Application** / **Method**, plus the **sum of the `count` column** for the
rows matching the current filters. The Method filter lists the riskiest verbs
- first (`DELETE`, `POST`, ...), so write and destructive calls stand out.
+ first (`DELETE`, `POST`, ...), and the list shows **Application** and
+ **Method** as columns — the verb as a coloured badge (writes amber, deletes
+ red), so the dangerous traffic is obvious at a glance.
* **Endpoint** changelist: the same Application / Method filters, plus
deprecation state, the site and the CSV export.
* **Flush now**: moves counters buffered in the cache into the database, so they
@@ -103,23 +105,33 @@ similar):
and method), so the file can be fed straight to pandas or a spreadsheet. Use
the "select all" link to export every row matching the current filters.
+If you upgraded from an earlier release, run
+`manage.py api_usage_backfill_paths` once: endpoints recorded before the path was
+captured only have their route name, and the command resolves it back to the
+path (`--dry-run` first if you want to see what it would do).
+
## Client applications
Not every caller is a person: ERPs, partners, your own web front end and the
-native mobile apps also hit the API. `ClientApp` is an **editable table**, so a
-new consumer is recognised from the admin, without a deploy:
-
-| Rule | Matched against | Use it for |
-|---|---|---|
-| `accounts` | the authenticated user (and therefore its DRF token) | one dedicated token per integration (ERP, partner...) |
-| `domains` | the `Origin`/`Referer` host, subdomains included | your own web front end |
-| `ip_networks` | the client address, against a CIDR (one per line) | internal networks and servers |
-| `user_agent_patterns` | a case-insensitive substring of `User-Agent` | native mobile apps |
-
-Rules are checked in that order, and `priority` decides between applications
-(lower wins). A caller matching nothing stays unattributed, so the dimension
-cannot grow out of control: `EndpointStat.client_app` only ever holds a
-`ClientApp.slug`.
+native mobile apps also hit the API. `ClientApp` is an **editable table**, and
+the rules are checked in this order:
+
+| # | Rule | Matched against | Use it for |
+|---|---|---|---|
+| 1 | `ClientAppAccount` | the authenticated account (and therefore its DRF token) | one dedicated token per integration (ERP, partner...) |
+| 2 | `domains` | the `Origin`/`Referer` host, subdomains included | your own web front end |
+| 3 | `ip_networks` | the client address, against a CIDR (one per line) | internal networks and servers |
+| 4 | `user_agent_patterns` | a case-insensitive substring of `User-Agent` | native mobile apps |
+
+An explicit assignment always wins: if the token's account belongs to an
+application, that is the answer. Accounts without an assignment (an app user
+reading the news) fall through to the user agent, which is what identifies the
+native apps. `priority` decides between applications (lower wins), and a caller
+matching nothing stays unattributed.
+
+Assignments are one row per account (a point lookup, indexed) instead of a
+many-to-many list holding every account, so nothing grows with the number of
+users: `EndpointStat.client_app` only ever holds a `ClientApp.slug`.
Counters, the CSV export and the admin filters all carry the application, so
"which application calls this endpoint?" is one filter away. The **Endpoint
diff --git a/pyproject.toml b/pyproject.toml
index b12ae9a..34acf27 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "django-api-usage"
-version = "0.1.7"
+version = "0.1.8"
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 aa4e579..d4902b4 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.7"
+__version__ = "0.1.8"
diff --git a/src/django_api_usage/admin.py b/src/django_api_usage/admin.py
index 6cabeda..0caca33 100644
--- a/src/django_api_usage/admin.py
+++ b/src/django_api_usage/admin.py
@@ -21,11 +21,12 @@
from django.template.response import TemplateResponse
from django.urls import path, reverse
from django.utils import timezone
+from django.utils.html import format_html
from django.utils.text import format_lazy
from django.utils.translation import gettext_lazy as _
from .buffers import flush
-from .models import ClientApp, Consumer, Endpoint, EndpointStat
+from .models import ClientApp, ClientAppAccount, Consumer, Endpoint, EndpointStat
class CsvExportMixin:
@@ -64,6 +65,31 @@ def export_as_csv(self, request, queryset):
#: write/destructive calls (POST, DELETE, ...) instead of the read ones.
METHOD_ORDER = ("DELETE", "POST", "PUT", "PATCH", "GET", "HEAD", "OPTIONS")
+#: Pill colours per verb: reads green/blue, writes amber, deletes red.
+METHOD_COLORS = {
+ "GET": ("#d1e7dd", "#0f5132"),
+ "POST": ("#cfe2ff", "#084298"),
+ "PUT": ("#fff3cd", "#664d03"),
+ "PATCH": ("#fff3cd", "#664d03"),
+ "DELETE": ("#f8d7da", "#842029"),
+ "HEAD": ("#e2e3e5", "#41464b"),
+ "OPTIONS": ("#e2e3e5", "#41464b"),
+}
+DEFAULT_METHOD_COLORS = ("#e2e3e5", "#41464b")
+
+
+def _method_badge_html(method):
+ """Coloured pill for an HTTP verb, so writes stand out at a glance."""
+ verb = (method or "").upper()
+ background, color = METHOD_COLORS.get(verb, DEFAULT_METHOD_COLORS)
+ return format_html(
+ '{}',
+ background,
+ color,
+ verb,
+ )
+
class AppLabelListFilter(admin.SimpleListFilter):
"""Distinct application labels, under a readable title."""
@@ -180,7 +206,7 @@ class EndpointAdmin(CsvExportMixin, admin.ModelAdmin):
"app_label",
"route_path",
"route_name",
- "method",
+ "method_badge",
"site_id",
"deprecated",
"sunset_date",
@@ -196,6 +222,10 @@ class EndpointAdmin(CsvExportMixin, admin.ModelAdmin):
search_fields = ("route_path", "route_name", "replacement", "owner", "notes")
list_editable = ("deprecated", "sunset_date", "replacement", "owner")
+ @admin.display(description=_("Method"), ordering="method")
+ def method_badge(self, obj):
+ return _method_badge_html(obj.method)
+
@admin.register(EndpointStat)
class EndpointStatAdmin(CsvExportMixin, admin.ModelAdmin):
@@ -216,7 +246,9 @@ class EndpointStatAdmin(CsvExportMixin, admin.ModelAdmin):
)
list_display = (
"date",
- "endpoint",
+ "application",
+ "method_badge",
+ "endpoint_path",
"client_type",
"client_app",
"status_class",
@@ -246,6 +278,22 @@ class EndpointStatAdmin(CsvExportMixin, admin.ModelAdmin):
)
change_list_template = "admin/django_api_usage/endpointstat/change_list.html"
+ # -- columns ------------------------------------------------------------
+
+ @admin.display(description=_("Application"), ordering="endpoint__app_label")
+ def application(self, obj):
+ return obj.endpoint.app_label
+
+ @admin.display(description=_("Method"), ordering="endpoint__method")
+ def method_badge(self, obj):
+ return _method_badge_html(obj.endpoint.method)
+
+ @admin.display(description=_("Endpoint"), ordering="endpoint__route_path")
+ def endpoint_path(self, obj):
+ """Just the path: the verb and the app already have their own columns."""
+ endpoint = obj.endpoint
+ return (endpoint.route_path or endpoint.route_name or "").strip("^$")
+
def has_add_permission(self, request):
return False
@@ -388,22 +436,17 @@ class ClientAppAdmin(CsvExportMixin, admin.ModelAdmin):
)
list_filter = ("is_active",)
search_fields = ("slug", "name", "description")
- filter_horizontal = ("accounts",)
fieldsets = (
(None, {"fields": ("slug", "name", "description", "priority", "is_active")}),
(
_("Matching rules"),
{
- "fields": (
- "accounts",
- "domains",
- "ip_networks",
- "user_agent_patterns",
- ),
+ "fields": ("domains", "ip_networks", "user_agent_patterns"),
"description": _(
"Checked in this order: account, request host, client "
"network, user agent. Between applications, the lowest "
- "priority wins."
+ "priority wins. Accounts are assigned from the "
+ "'Client application accounts' table."
),
},
),
@@ -431,3 +474,31 @@ def accounts_total(self, obj):
@admin.display(description=_("Counters"), ordering="counters_total")
def counters_total(self, obj):
return obj.counters_total or 0
+
+
+@admin.register(ClientAppAccount)
+class ClientAppAccountAdmin(CsvExportMixin, admin.ModelAdmin):
+ """One row per assigned account: search, do not scroll a giant list."""
+
+ actions = ("export_as_csv",)
+ csv_columns = (
+ ("client_app", "client_app__slug"),
+ ("username", "user__username"),
+ ("email", "user__email"),
+ ("notes", "notes"),
+ )
+ list_display = ("client_app", "account", "notes")
+ list_filter = ("client_app",)
+ search_fields = (
+ "user__username",
+ "user__email",
+ "user__first_name",
+ "user__last_name",
+ "notes",
+ )
+ raw_id_fields = ("user",)
+ list_select_related = ("user", "client_app")
+
+ @admin.display(description=_("Account"), ordering="user__username")
+ def account(self, obj):
+ return obj.user
diff --git a/src/django_api_usage/apps.py b/src/django_api_usage/apps.py
index 71e421c..25d7261 100644
--- a/src/django_api_usage/apps.py
+++ b/src/django_api_usage/apps.py
@@ -13,8 +13,12 @@ def ready(self):
self._connect_client_app_signals()
def _connect_client_app_signals(self):
- """Drop the cached ClientApp rules as soon as the table changes."""
- from django.db.models.signals import m2m_changed, post_delete, post_save
+ """Drop the cached ClientApp rules as soon as the table changes.
+
+ Account assignments are not cached (they are a single indexed lookup),
+ so only the rule table needs to invalidate anything.
+ """
+ from django.db.models.signals import post_delete, post_save
from .models import ClientApp
from .resolvers import reset_client_app_cache
@@ -27,8 +31,3 @@ def _connect_client_app_signals(self):
sender=ClientApp,
dispatch_uid="api_usage_app_delete",
)
- m2m_changed.connect(
- reset_client_app_cache,
- sender=ClientApp.accounts.through,
- dispatch_uid="api_usage_app_accounts",
- )
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 c11f3c1..7f61e90 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 4d2aba0..5c67acc 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 06:45-0500\n"
+"POT-Creation-Date: 2026-10-08 07:11-0500\n"
"PO-Revision-Date: 2026-10-08 06:55-0500\n"
"Last-Translator: Urtzi Odriozola \n"
"Language-Team: Basque\n"
@@ -29,7 +29,7 @@ msgstr "Aplikazioa"
msgid "Method"
msgstr "Metodoa"
-#: src/django_api_usage/admin.py:136 src/django_api_usage/models.py:106
+#: src/django_api_usage/admin.py:136 src/django_api_usage/models.py:100
msgid "Client application"
msgstr "Bezero-aplikazioa"
@@ -55,8 +55,8 @@ 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."
+"Ez zegoen ezer bufferrean: BUFFER_BACKEND='db' denean kontagailuak jada datu-"
+"basean daude."
#: src/django_api_usage/admin.py:318
#: src/django_api_usage/templates/admin/django_api_usage/endpointstat/change_list.html:12
@@ -69,26 +69,32 @@ msgstr "Estatistikak hustu"
msgid "Deleted {count} statistic row(s)."
msgstr "{count} estatistika-errenkada ezabatu dira."
-#: src/django_api_usage/admin.py:395
+#: src/django_api_usage/admin.py:394
msgid "Matching rules"
msgstr "Bat-etortze arauak"
-#: src/django_api_usage/admin.py:404
+#: src/django_api_usage/admin.py:398
msgid ""
"Checked in this order: account, request host, client network, user agent. "
-"Between applications, the lowest priority wins."
+"Between applications, the lowest priority wins. Accounts are assigned from "
+"the 'Client application accounts' table."
msgstr ""
-"Ordena honetan egiaztatzen da: kontua, eskaeraren ostalaria, bezeroaren sarea "
-"eta user agent-a. Aplikazioen artean, lehentasun txikiena duenak irabazten du."
+"Ordena honetan egiaztatzen da: kontua, eskaeraren ostalaria, bezeroaren "
+"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:427
+#: src/django_api_usage/admin.py:422
msgid "Accounts"
msgstr "Kontuak"
-#: src/django_api_usage/admin.py:431
+#: src/django_api_usage/admin.py:426
msgid "Counters"
msgstr "Kontagailuak"
+#: src/django_api_usage/admin.py:454 src/django_api_usage/models.py:134
+msgid "Account"
+msgstr "Kontua"
+
#: src/django_api_usage/models.py:79
msgid "Short, stable label stored in the counters (e.g. 'mugikorra')."
msgstr ""
@@ -98,26 +104,36 @@ msgstr ""
msgid "Lower wins when several apps could match."
msgstr "Hainbat aplikaziok bat etorri ahalketenean, txikienak irabazten du."
-#: src/django_api_usage/models.py:91
-msgid "Users (and therefore DRF tokens) that belong to this app."
-msgstr "Aplikazio honi dagozkion erabiltzaileak (eta, beraz, haien DRF tokenak)."
-
-#: src/django_api_usage/models.py:95
+#: src/django_api_usage/models.py:89
msgid "One host per line; matched against the Origin/Referer host."
-msgstr "Ostalari bat lerroko; Origin/Referer ostalariaren aurka egiaztatzen da."
+msgstr ""
+"Ostalari bat lerroko; Origin/Referer ostalariaren aurka egiaztatzen da."
-#: src/django_api_usage/models.py:98
+#: src/django_api_usage/models.py:92
msgid "One CIDR per line, e.g. 10.0.0.0/8."
msgstr "CIDR bat lerroko, adib. 10.0.0.0/8."
-#: src/django_api_usage/models.py:102
+#: src/django_api_usage/models.py:96
msgid "One substring per line, matched case-insensitively."
-msgstr "Azpikate bat lerroko; maiuskulak/minuskulak bereizi gabe egiaztatzen da."
+msgstr ""
+"Azpikate bat lerroko; maiuskulak/minuskulak bereizi gabe egiaztatzen da."
-#: src/django_api_usage/models.py:107
+#: src/django_api_usage/models.py:101
msgid "Client applications"
msgstr "Bezero-aplikazioak"
+#: src/django_api_usage/models.py:135
+msgid "One application per account, so the token is unambiguous."
+msgstr "Aplikazio bat kontu bakoitzeko, tokena zalantzarik gabea izan dadin."
+
+#: src/django_api_usage/models.py:140
+msgid "Client application account"
+msgstr "Bezero-aplikazioko kontua"
+
+#: src/django_api_usage/models.py:141
+msgid "Client application accounts"
+msgstr "Bezero-aplikazioko kontuak"
+
#: src/django_api_usage/templates/admin/django_api_usage/endpointstat/change_list.html:8
msgid "Flush now"
msgstr "Flush orain"
@@ -167,3 +183,7 @@ msgstr "Bai, estatistikak hustu"
#: src/django_api_usage/templates/admin/django_api_usage/endpointstat/confirm_clear.html:25
msgid "Cancel"
msgstr ""
+
+#~ msgid "Users (and therefore DRF tokens) that belong to this app."
+#~ msgstr ""
+#~ "Aplikazio honi dagozkion erabiltzaileak (eta, beraz, haien DRF tokenak)."
diff --git a/src/django_api_usage/management/commands/api_usage_backfill_paths.py b/src/django_api_usage/management/commands/api_usage_backfill_paths.py
new file mode 100644
index 0000000..db540b6
--- /dev/null
+++ b/src/django_api_usage/management/commands/api_usage_backfill_paths.py
@@ -0,0 +1,59 @@
+"""Fill in the route path of endpoints recorded before it was captured.
+
+``Endpoint.route_path`` was added in 0.1.3, so endpoints recorded earlier only
+have their route *name* (``town-list``). Running this once resolves the name
+back to its path (``api/3.0/herriak/``), which is what the admin shows.
+
+ python manage.py api_usage_backfill_paths --dry-run
+ python manage.py api_usage_backfill_paths
+"""
+
+from django.core.management.base import BaseCommand
+from django.urls import NoReverseMatch, reverse
+
+from django_api_usage.models import Endpoint
+
+
+class Command(BaseCommand):
+ help = "Resolve the route path of endpoints that do not have one yet."
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ "--dry-run",
+ action="store_true",
+ help="Report what would change without writing anything.",
+ )
+
+ def handle(self, *args, **options):
+ dry_run = options["dry_run"]
+ resolved = unresolved = 0
+
+ for endpoint in Endpoint.objects.filter(route_path="").order_by("pk"):
+ path = self.resolve(endpoint.route_name)
+ if not path:
+ unresolved += 1
+ self.stderr.write(
+ f" could not resolve {endpoint.route_name!r} (left as it is)"
+ )
+ continue
+ resolved += 1
+ if not dry_run:
+ Endpoint.objects.filter(pk=endpoint.pk).update(route_path=path)
+
+ verb = "would be filled in" if dry_run else "filled in"
+ self.stdout.write(
+ f"{resolved} route path(s) {verb}; {unresolved} left without a path."
+ )
+
+ @staticmethod
+ def resolve(route_name):
+ """Path for a named route; a route that already is a pattern is reused."""
+ if not route_name or route_name == "unknown":
+ return ""
+ if route_name.startswith("^"):
+ # No url name: the captured "name" is the pattern itself.
+ return route_name.strip("^$")
+ try:
+ return reverse(route_name).lstrip("/")
+ except NoReverseMatch:
+ return ""
diff --git a/src/django_api_usage/migrations/0004_remove_clientapp_accounts_clientappaccount.py b/src/django_api_usage/migrations/0004_remove_clientapp_accounts_clientappaccount.py
new file mode 100644
index 0000000..ef4c8c1
--- /dev/null
+++ b/src/django_api_usage/migrations/0004_remove_clientapp_accounts_clientappaccount.py
@@ -0,0 +1,77 @@
+# Generated by Django 4.2.30 on 2026-10-08 12:08
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+def accounts_to_rows(apps, schema_editor):
+ """Keep the assignments made through the removed many-to-many field.
+
+ ``ClientApp.accounts`` (0003) held every assigned account in a join table.
+ It was replaced by one row per account, which the resolver can read with a
+ single indexed lookup instead of loading the whole list into memory.
+ """
+ ClientApp = apps.get_model("django_api_usage", "ClientApp")
+ ClientAppAccount = apps.get_model("django_api_usage", "ClientAppAccount")
+
+ rows = [
+ ClientAppAccount(client_app_id=app.pk, user_id=user_id)
+ for app in ClientApp.objects.all()
+ for user_id in app.accounts.values_list("pk", flat=True)
+ ]
+ ClientAppAccount.objects.bulk_create(rows, ignore_conflicts=True)
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ("django_api_usage", "0003_alter_endpointstat_unique_together_and_more"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="ClientAppAccount",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("notes", models.CharField(blank=True, max_length=200)),
+ (
+ "client_app",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="accounts",
+ to="django_api_usage.clientapp",
+ ),
+ ),
+ (
+ "user",
+ models.OneToOneField(
+ help_text="One application per account, so the token is unambiguous.",
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="api_usage_client_app",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Account",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Client application account",
+ "verbose_name_plural": "Client application accounts",
+ "ordering": ("client_app", "user"),
+ },
+ ),
+ migrations.RunPython(accounts_to_rows, migrations.RunPython.noop),
+ migrations.RemoveField(
+ model_name="clientapp",
+ name="accounts",
+ ),
+ ]
diff --git a/src/django_api_usage/models.py b/src/django_api_usage/models.py
index d6884d0..bf1e076 100644
--- a/src/django_api_usage/models.py
+++ b/src/django_api_usage/models.py
@@ -84,12 +84,6 @@ class ClientApp(models.Model):
priority = models.PositiveSmallIntegerField(
default=100, help_text=_("Lower wins when several apps could match.")
)
- accounts = models.ManyToManyField(
- settings.AUTH_USER_MODEL,
- blank=True,
- related_name="+",
- help_text=_("Users (and therefore DRF tokens) that belong to this app."),
- )
domains = models.TextField(
blank=True,
help_text=_("One host per line; matched against the Origin/Referer host."),
@@ -120,6 +114,37 @@ def user_agent_list(self):
return [pattern.lower() for pattern in _lines(self.user_agent_patterns)]
+class ClientAppAccount(models.Model):
+ """Assigns one account (and therefore its DRF token) to an application.
+
+ One row per account, indexed, instead of a many-to-many holding the account
+ list of every application: the resolver does a single point lookup and
+ nothing has to be loaded into memory. Only real integrations belong here
+ (an ERP, a partner); ordinary readers of the mobile apps are matched by
+ their user agent, not by account.
+ """
+
+ client_app = models.ForeignKey(
+ ClientApp, on_delete=models.CASCADE, related_name="accounts"
+ )
+ user = models.OneToOneField(
+ settings.AUTH_USER_MODEL,
+ on_delete=models.CASCADE,
+ related_name="api_usage_client_app",
+ verbose_name=_("Account"),
+ help_text=_("One application per account, so the token is unambiguous."),
+ )
+ notes = models.CharField(max_length=200, blank=True)
+
+ class Meta:
+ verbose_name = _("Client application account")
+ verbose_name_plural = _("Client application accounts")
+ ordering = ("client_app", "user")
+
+ def __str__(self):
+ return f"{self.client_app} ← {self.user}"
+
+
class EndpointStat(models.Model):
"""Aggregated counter per endpoint, day, client and status class."""
diff --git a/src/django_api_usage/resolvers.py b/src/django_api_usage/resolvers.py
index d7644c6..bc01a80 100644
--- a/src/django_api_usage/resolvers.py
+++ b/src/django_api_usage/resolvers.py
@@ -138,11 +138,9 @@ def reset_client_app_cache(**kwargs):
def _load_client_app_rules():
from .models import ClientApp
- rules = {"by_user": {}, "hosts": [], "networks": [], "patterns": []}
- apps = ClientApp.objects.filter(is_active=True).prefetch_related("accounts")
+ rules = {"hosts": [], "networks": [], "patterns": []}
+ apps = ClientApp.objects.filter(is_active=True)
for app in apps: # ordered by priority, then slug
- for user_id in app.accounts.values_list("pk", flat=True):
- rules["by_user"].setdefault(user_id, app.slug)
rules["hosts"].extend((host.lower(), app.slug) for host in app.domain_list())
for cidr in app.network_list():
try:
@@ -159,6 +157,18 @@ def _load_client_app_rules():
return rules
+def _client_app_for_account(user_id):
+ """Application assigned to an account, with a single indexed lookup."""
+ from .models import ClientAppAccount
+
+ return (
+ ClientAppAccount.objects.filter(user_id=user_id, client_app__is_active=True)
+ .values_list("client_app__slug", flat=True)
+ .first()
+ or ""
+ )
+
+
def _client_app_rules():
now = time.monotonic()
if (
@@ -199,7 +209,7 @@ def client_app_from_rules(request):
user = getattr(request, "user", None)
if user is not None and getattr(user, "is_authenticated", False):
- slug = rules["by_user"].get(user.pk)
+ slug = _client_app_for_account(user.pk)
if slug:
return slug
diff --git a/tests/test_backfill_paths.py b/tests/test_backfill_paths.py
new file mode 100644
index 0000000..7e25528
--- /dev/null
+++ b/tests/test_backfill_paths.py
@@ -0,0 +1,66 @@
+"""Tests for the api_usage_backfill_paths command."""
+
+from io import StringIO
+
+from django.core.management import call_command
+from django.test import TestCase
+
+from django_api_usage.models import Endpoint
+
+
+class BackfillPathsTest(TestCase):
+ def make(self, route_name, route_path=""):
+ return Endpoint.objects.create(
+ app_label="api",
+ route_name=route_name,
+ route_path=route_path,
+ method="GET",
+ )
+
+ def run_command(self, *args):
+ out, err = StringIO(), StringIO()
+ call_command("api_usage_backfill_paths", *args, stdout=out, stderr=err)
+ return out.getvalue(), err.getvalue()
+
+ def test_a_named_route_is_resolved_to_its_path(self):
+ endpoint = self.make("ping")
+
+ self.run_command()
+
+ endpoint.refresh_from_db()
+ self.assertEqual(endpoint.route_path, "ping/")
+
+ def test_a_route_without_a_name_keeps_its_pattern(self):
+ endpoint = self.make("^api/3.0/agenda/")
+
+ self.run_command()
+
+ endpoint.refresh_from_db()
+ self.assertEqual(endpoint.route_path, "api/3.0/agenda/")
+
+ def test_an_unresolvable_route_is_left_alone_and_reported(self):
+ endpoint = self.make("artikuluak-detail")
+
+ out, err = self.run_command()
+
+ endpoint.refresh_from_db()
+ self.assertEqual(endpoint.route_path, "")
+ self.assertIn("could not resolve", err)
+ self.assertIn("0 route path(s) filled in", out)
+
+ def test_dry_run_does_not_write(self):
+ endpoint = self.make("ping")
+
+ out, _ = self.run_command("--dry-run")
+
+ endpoint.refresh_from_db()
+ self.assertEqual(endpoint.route_path, "")
+ self.assertIn("would be filled in", out)
+
+ def test_endpoints_that_already_have_a_path_are_untouched(self):
+ endpoint = self.make("ping", route_path="api/3.0/ping/")
+
+ self.run_command()
+
+ endpoint.refresh_from_db()
+ self.assertEqual(endpoint.route_path, "api/3.0/ping/")
diff --git a/tests/test_client_app.py b/tests/test_client_app.py
index b61275a..450f762 100644
--- a/tests/test_client_app.py
+++ b/tests/test_client_app.py
@@ -5,7 +5,7 @@
from django.urls import reverse
from django_api_usage.buffers import flush
-from django_api_usage.models import ClientApp, Endpoint, EndpointStat
+from django_api_usage.models import ClientApp, ClientAppAccount, Endpoint, EndpointStat
from django_api_usage.resolvers import client_app_from_rules, reset_client_app_cache
from .base import UsageTestCase
@@ -29,10 +29,27 @@ def test_a_dedicated_account_wins(self):
"""A dedicated token (its user) identifies the integration."""
user = User.objects.create_user("zoho", "zoho@example.com", "pw")
app = ClientApp.objects.create(slug="zoho", priority=10)
- app.accounts.add(user)
+ ClientAppAccount.objects.create(client_app=app, user=user)
self.assertEqual(client_app_from_rules(self.request(user=user)), "zoho")
+ def test_an_account_of_an_inactive_application_is_ignored(self):
+ user = User.objects.create_user("zaharra", "z@example.com", "pw")
+ app = ClientApp.objects.create(slug="zaharra", is_active=False)
+ ClientAppAccount.objects.create(client_app=app, user=user)
+
+ self.assertEqual(client_app_from_rules(self.request(user=user)), "")
+
+ def test_the_account_rule_wins_over_the_user_agent(self):
+ user = User.objects.create_user("erp", "erp@example.com", "pw")
+ erp = ClientApp.objects.create(slug="goiena_erp")
+ ClientAppAccount.objects.create(client_app=erp, user=user)
+ ClientApp.objects.create(slug="mugikorra", user_agent_patterns="tokio")
+
+ request = self.request(user=user, HTTP_USER_AGENT="tokio/1")
+
+ self.assertEqual(client_app_from_rules(request), "goiena_erp")
+
def test_the_request_host_matches_subdomains(self):
ClientApp.objects.create(slug="elhuyar", domains="elhuyar.eus")
@@ -144,6 +161,23 @@ def test_the_table_is_editable_in_the_admin(self):
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Matching rules")
+ def test_account_assignments_are_editable_in_the_admin(self):
+ app = ClientApp.objects.create(slug="zoho", name="Zoho")
+ account = User.objects.create_user("zoho_erabiltzailea", "z@example.com", "pw")
+ ClientAppAccount.objects.create(client_app=app, user=account)
+
+ listing = self.client.get(
+ reverse("admin:django_api_usage_clientappaccount_changelist")
+ )
+
+ self.assertEqual(listing.status_code, 200)
+ self.assertContains(listing, "zoho_erabiltzailea")
+
+ add_page = self.client.get(
+ reverse("admin:django_api_usage_clientappaccount_add")
+ )
+ self.assertEqual(add_page.status_code, 200)
+
def test_stats_can_be_filtered_by_application(self):
self.assertEqual(self.rows(client_app="mugikorra"), {self.attributed})
diff --git a/tests/test_filters.py b/tests/test_filters.py
index b5dd409..97d5b19 100644
--- a/tests/test_filters.py
+++ b/tests/test_filters.py
@@ -1,5 +1,7 @@
"""Tests for the Application / Method changelist filters."""
+import re
+
from django.contrib import admin as django_admin
from django.contrib.auth.models import User
from django.urls import reverse
@@ -88,6 +90,51 @@ def test_stats_admin_filters_by_application(self):
rows = self.stats(app_label="api")
self.assertEqual([row.endpoint for row in rows], [self.post_endpoint])
+ # -- columns ------------------------------------------------------------
+
+ def test_stats_show_the_application_and_the_method_as_columns(self):
+ html = self.client.get(reverse(STAT_CHANGELIST)).content.decode()
+
+ # The columns exist and carry the endpoint's data (not just the titles).
+ self.assertIn('class="field-application"', html)
+ self.assertIn('class="field-method_badge"', html)
+ self.assertIn(">gida", html)
+
+ def test_the_method_is_rendered_as_a_coloured_badge(self):
+ html = self.client.get(reverse(STAT_CHANGELIST)).content.decode()
+
+ # DELETE in red, POST in blue; the verb lives inside the pill.
+ self.assertIn("background:#f8d7da", html)
+ self.assertIn(">DELETE", html)
+ self.assertIn("background:#cfe2ff", html)
+ self.assertIn(">POST", html)
+
+ def test_the_endpoint_column_shows_only_the_path(self):
+ html = self.client.get(reverse(STAT_CHANGELIST)).content.decode()
+
+ # The verb and the app have their own columns, so the cell carries just
+ # the path. Asserted on the cells: from Django 5.1 on, the action
+ # checkbox aria-label repeats str(obj), which includes the endpoint.
+ cells = re.findall(r'| ([^<]*) | ', html)
+
+ self.assertCountEqual(cells, ["api/3.0/artikuluak/", "api/3.0/gida/"])
+
+ def test_the_endpoint_column_strips_the_regex_anchors(self):
+ endpoint = Endpoint.objects.create(
+ app_label="api", route_path="^api/3.0/eskelak/$", method="GET"
+ )
+ EndpointStat.objects.create(
+ endpoint=endpoint,
+ date="2026-10-08",
+ client_type="anon",
+ status_class="2xx",
+ count=1,
+ )
+
+ html = self.client.get(reverse(STAT_CHANGELIST)).content.decode()
+
+ self.assertIn(">api/3.0/eskelak/", html)
+
# -- filter presentation ------------------------------------------------
def test_titles_are_readable_instead_of_raw_field_names(self):