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):