From dfc0443504ff03e5d8740a0445749d43019bd97a Mon Sep 17 00:00:00 2001 From: Urtzi Odriozola <1741252+urtzai@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:13:13 +0200 Subject: [PATCH 1/6] refactor!: assign accounts one by one instead of a many-to-many ClientApp.accounts (a many-to-many holding every account) forced the resolver to load the whole list into memory on every process. Accounts are now assigned one row at a time (ClientAppAccount), so the resolver does a single indexed point lookup and the table only holds real integrations. - ClientAppAccount: OneToOne on the account (one application per token, so the assignment is unambiguous) + notes - resolver: the account rule is checked first, then host, network and user agent - admin: ClientAppAccount changelist (search, filter by application, raw id for the account); ClientApp keeps the rule fields and shows an accounts count - migration 0004 copies the assignments made through the removed field - 71 tests BREAKING CHANGE: ClientApp.accounts is gone; assignments live in ClientAppAccount. Version 0.1.7, which shipped the many-to-many, is being yanked and never reached production. --- README.md | 33 ++++---- src/django_api_usage/admin.py | 41 ++++++++-- src/django_api_usage/apps.py | 13 ++- .../locale/eu/LC_MESSAGES/django.mo | Bin 3512 -> 3825 bytes .../locale/eu/LC_MESSAGES/django.po | 62 +++++++++----- ...ove_clientapp_accounts_clientappaccount.py | 77 ++++++++++++++++++ src/django_api_usage/models.py | 37 +++++++-- src/django_api_usage/resolvers.py | 20 +++-- tests/test_client_app.py | 38 ++++++++- 9 files changed, 257 insertions(+), 64 deletions(-) create mode 100644 src/django_api_usage/migrations/0004_remove_clientapp_accounts_clientappaccount.py diff --git a/README.md b/README.md index 28e7a4d..97f38f4 100644 --- a/README.md +++ b/README.md @@ -106,20 +106,25 @@ similar): ## 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/src/django_api_usage/admin.py b/src/django_api_usage/admin.py index 6cabeda..d86be46 100644 --- a/src/django_api_usage/admin.py +++ b/src/django_api_usage/admin.py @@ -25,7 +25,7 @@ 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: @@ -388,22 +388,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 +426,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 c11f3c1132960bd844bf30db5e6d5a44bd9843e6..7f61e9087b6e2a78e7e24de38662a8a3b7884334 100644 GIT binary patch delta 1054 zcmaLVO=#3W6u|M>e%Wtpt8MLC9qZyEwum24r6|<3R1xvwsUSGpO}8;=QZ|Xm3Km4_ zO%JOV>!H$15fNn(s^Cq$ds8Xi1VIpbDx!%0m+eX~I%M*jWF~Lk%w#3G)<5^Tv+jYS z?56IbwkDK1fWJ`6VvSNi@G)+|g<7Q=aS2=SDZarK?7(YvN_FBL?8Aqc#FyBG?@_-0 zf_s&kQ{QQ{^P;1^GH?$z@jQYl979RyD$2O~*nmqL&rk3Y&#!PFw$ph6kKhQpcodg$ z8dvcYjl{T^s+c1ecj|5Bkt@)S*2_>N2<0`IidLfkaLrjNwV^<5H}74 z@!5;j!fC`w%TaDF@(;*vN#U0osI<7tR?GJFQ)PR)sZx^V%Qf|h(nRgOj2@kw^dm0} zv?*BH1c9CRtel=I_%k}3wsbn<+Ljk;Gdt_rlP0u%Pn(TT(mFI**D8IjJ)Ww*RR0-Y zZmXM09NQde)+$s#9Mpjy&lviSpgdE5I8px4aI>a#vFZ6qect!N z$T&KE+A3NFf5?!WV~V!#_}Z!TNpg`(!j85A*RsPRQFC^|lzul2r~Zyp|Bg^jHNQ?| zMysK9)`XSdv>aaoI{u)J(>1ziT;qjBQ((Tl$y!F+MZ<(9XM5$MpWEM72M25`Wf7~b5#Wwf(t>Bg1GU2(o!$n{N}#zxhFr5z8`L`4`iPTWte(| z`kOjJ{owFJS~i`b9zsAt{9ogz(n z#K%cG9%BJtqfY#YJ@{p-{SB|v-oO)hxm)BkhIk6!Vg*0r8JuPD0c_zkzQq0b3Fipz z2M#$+ktxE;F>rI=QYNnFG8_zQLY z1cQ3s9QJX(T;=07-b0OS4KLw3&f+MiJ;4y?aRYUunZC{On>a-K6>8S>vYm^gzJOki zU^{8ji|bq-NuOUVRam!ONT2Y+og5v)-Bj)&q`y-%HJV?I;9#dmWZ#yDks!B={$C`0 zk?D5YZ@OCTdUi7Xnq6?xT2IlLz7e}oY{qO*Hc7>e+y`Ognwe`CO%nQU5EtmRiCJ>1 zVX#aeO)oaKRvS;B<(iqc>0NE-@)K#4Uv<*Oz7@xwtyMkWHoVYO!oV|SyBs!rFAR(u z*(I-xSX?I$+&T16E9BK(l?cQoYdtXoD+1| 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/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_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}) From 3f45e46589b2a04aa3370a6675987593af343c64 Mon Sep 17 00:00:00 2001 From: Urtzi Odriozola <1741252+urtzai@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:19:58 +0200 Subject: [PATCH 2/6] chore: release 0.1.8 Version bump for the account-assignment redesign. The release itself (GitHub release -> PyPI) is created separately, after review. --- pyproject.toml | 2 +- src/django_api_usage/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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" From e3e080565dd9cdeffee3a1b4a549a68e824e9843 Mon Sep 17 00:00:00 2001 From: Urtzi Odriozola <1741252+urtzai@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:30:27 +0200 Subject: [PATCH 3/6] feat: show the application and a coloured method badge as columns - Endpoint stats: Application (endpoint.app_label) and Method columns, so the risky verbs can be spotted without reading the endpoint string - The verb is rendered as a pill (reads green/blue, writes amber, deletes red) - Same badge in the Endpoints changelist, for consistency - 73 tests --- README.md | 4 +++- src/django_api_usage/admin.py | 44 ++++++++++++++++++++++++++++++++++- tests/test_filters.py | 19 +++++++++++++++ 3 files changed, 65 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 97f38f4..c96c9d7 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 diff --git a/src/django_api_usage/admin.py b/src/django_api_usage/admin.py index d86be46..d4a4063 100644 --- a/src/django_api_usage/admin.py +++ b/src/django_api_usage/admin.py @@ -21,6 +21,7 @@ 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 _ @@ -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,6 +246,8 @@ class EndpointStatAdmin(CsvExportMixin, admin.ModelAdmin): ) list_display = ( "date", + "application", + "method_badge", "endpoint", "client_type", "client_app", @@ -246,6 +278,16 @@ 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) + def has_add_permission(self, request): return False diff --git a/tests/test_filters.py b/tests/test_filters.py index b5dd409..d287c44 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -88,6 +88,25 @@ 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) + # -- filter presentation ------------------------------------------------ def test_titles_are_readable_instead_of_raw_field_names(self): From 663d12f6d2db06e19ce6e1410dc8c4c22dc207b1 Mon Sep 17 00:00:00 2001 From: Urtzi Odriozola <1741252+urtzai@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:37:02 +0200 Subject: [PATCH 4/6] feat: show only the path in the stats endpoint column The verb and the application now have their own columns, so repeating them inside the endpoint cell ("GET api/3.0/herriak/ (api)") was noise. The cell shows the route path, with the Django regex anchors stripped for readability, and falls back to the route name for endpoints whose path is not known yet. 75 tests. --- src/django_api_usage/admin.py | 8 +++++++- tests/test_filters.py | 25 +++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/django_api_usage/admin.py b/src/django_api_usage/admin.py index d4a4063..0caca33 100644 --- a/src/django_api_usage/admin.py +++ b/src/django_api_usage/admin.py @@ -248,7 +248,7 @@ class EndpointStatAdmin(CsvExportMixin, admin.ModelAdmin): "date", "application", "method_badge", - "endpoint", + "endpoint_path", "client_type", "client_app", "status_class", @@ -288,6 +288,12 @@ def application(self, obj): 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 diff --git a/tests/test_filters.py b/tests/test_filters.py index d287c44..b2010ed 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -107,6 +107,31 @@ def test_the_method_is_rendered_as_a_coloured_badge(self): 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() + + self.assertIn('class="field-endpoint_path"', html) + self.assertIn(">api/3.0/gida/", html) + # The verb and the app have their own columns, so they are not repeated. + self.assertNotIn("DELETE api/3.0/gida/", html) + self.assertNotIn("api/3.0/gida/ (gida)", html) + + 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): From e3271f3abfcb1bb1f81485593b769d2a595ba32f Mon Sep 17 00:00:00 2001 From: Urtzi Odriozola <1741252+urtzai@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:39:48 +0200 Subject: [PATCH 5/6] feat: resolve the path of endpoints recorded before it was captured Endpoint.route_path only exists since 0.1.3, so older rows carry just their route name and the stats column showed 'kirolak_berriak-list'. The new api_usage_backfill_paths command resolves the name back to its path and leaves (and reports) whatever it cannot resolve. - works from the admin? no: it is a one-off management command, --dry-run first - 80 tests --- README.md | 5 ++ .../commands/api_usage_backfill_paths.py | 59 +++++++++++++++++ tests/test_backfill_paths.py | 66 +++++++++++++++++++ 3 files changed, 130 insertions(+) create mode 100644 src/django_api_usage/management/commands/api_usage_backfill_paths.py create mode 100644 tests/test_backfill_paths.py diff --git a/README.md b/README.md index c96c9d7..b71a4a4 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,11 @@ 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 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/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/") From 6822af61fd4468dfd17c5be5ac73a793f231c5f0 Mon Sep 17 00:00:00 2001 From: Urtzi Odriozola <1741252+urtzai@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:43:41 +0200 Subject: [PATCH 6/6] test: assert the endpoint cells, not the whole page Django 5.1+ repeats str(obj) in the action checkbox aria-label, and EndpointStat.__str__ contains the endpoint ('DELETE api/3.0/gida/ (gida)'), so the page-wide assertNotIn failed on Django 5.2 while passing on 4.2. The test now checks the cells of the column. Verified on Django 4.2 and 5.2. --- tests/test_filters.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/test_filters.py b/tests/test_filters.py index b2010ed..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 @@ -110,11 +112,12 @@ def test_the_method_is_rendered_as_a_coloured_badge(self): def test_the_endpoint_column_shows_only_the_path(self): html = self.client.get(reverse(STAT_CHANGELIST)).content.decode() - self.assertIn('class="field-endpoint_path"', html) - self.assertIn(">api/3.0/gida/", html) - # The verb and the app have their own columns, so they are not repeated. - self.assertNotIn("DELETE api/3.0/gida/", html) - self.assertNotIn("api/3.0/gida/ (gida)", html) + # 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(