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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 27 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "django-api-usage"
version = "0.1.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"
Expand Down
2 changes: 1 addition & 1 deletion src/django_api_usage/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""django-api-usage: usage metering for Django APIs, with deprecation support."""

__version__ = "0.1.7"
__version__ = "0.1.8"
93 changes: 82 additions & 11 deletions src/django_api_usage/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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(
'<span style="background:{};color:{};padding:1px 7px;border-radius:9px;'
'font-size:11px;font-weight:600;white-space:nowrap">{}</span>',
background,
color,
verb,
)


class AppLabelListFilter(admin.SimpleListFilter):
"""Distinct application labels, under a readable title."""
Expand Down Expand Up @@ -180,7 +206,7 @@ class EndpointAdmin(CsvExportMixin, admin.ModelAdmin):
"app_label",
"route_path",
"route_name",
"method",
"method_badge",
"site_id",
"deprecated",
"sunset_date",
Expand All @@ -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):
Expand All @@ -216,7 +246,9 @@ class EndpointStatAdmin(CsvExportMixin, admin.ModelAdmin):
)
list_display = (
"date",
"endpoint",
"application",
"method_badge",
"endpoint_path",
"client_type",
"client_app",
"status_class",
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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."
),
},
),
Expand Down Expand Up @@ -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
13 changes: 6 additions & 7 deletions src/django_api_usage/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
)
Binary file modified src/django_api_usage/locale/eu/LC_MESSAGES/django.mo
Binary file not shown.
62 changes: 41 additions & 21 deletions src/django_api_usage/locale/eu/LC_MESSAGES/django.po
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: django-api-usage\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-10-08 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 <uodriozola@codesyntax.com>\n"
"Language-Team: Basque\n"
Expand All @@ -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"

Expand All @@ -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
Expand All @@ -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 ""
Expand All @@ -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"
Expand Down Expand Up @@ -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)."
Loading
Loading