django-api-usage 0.1.6__tar.gz → 0.1.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. {django_api_usage-0.1.6/src/django_api_usage.egg-info → django_api_usage-0.1.7}/PKG-INFO +23 -1
  2. {django_api_usage-0.1.6 → django_api_usage-0.1.7}/README.md +22 -0
  3. {django_api_usage-0.1.6 → django_api_usage-0.1.7}/pyproject.toml +1 -1
  4. {django_api_usage-0.1.6 → django_api_usage-0.1.7}/src/django_api_usage/__init__.py +1 -1
  5. {django_api_usage-0.1.6 → django_api_usage-0.1.7}/src/django_api_usage/admin.py +120 -3
  6. django_api_usage-0.1.7/src/django_api_usage/apps.py +34 -0
  7. {django_api_usage-0.1.6 → django_api_usage-0.1.7}/src/django_api_usage/buffers.py +2 -0
  8. {django_api_usage-0.1.6 → django_api_usage-0.1.7}/src/django_api_usage/conf.py +1 -0
  9. django_api_usage-0.1.7/src/django_api_usage/locale/eu/LC_MESSAGES/django.mo +0 -0
  10. {django_api_usage-0.1.6 → django_api_usage-0.1.7}/src/django_api_usage/locale/eu/LC_MESSAGES/django.po +67 -10
  11. {django_api_usage-0.1.6 → django_api_usage-0.1.7}/src/django_api_usage/middleware.py +1 -0
  12. django_api_usage-0.1.7/src/django_api_usage/migrations/0003_alter_endpointstat_unique_together_and_more.py +96 -0
  13. {django_api_usage-0.1.6 → django_api_usage-0.1.7}/src/django_api_usage/models.py +80 -4
  14. {django_api_usage-0.1.6 → django_api_usage-0.1.7}/src/django_api_usage/resolvers.py +116 -0
  15. {django_api_usage-0.1.6 → django_api_usage-0.1.7/src/django_api_usage.egg-info}/PKG-INFO +23 -1
  16. {django_api_usage-0.1.6 → django_api_usage-0.1.7}/src/django_api_usage.egg-info/SOURCES.txt +2 -0
  17. django_api_usage-0.1.7/tests/test_client_app.py +159 -0
  18. {django_api_usage-0.1.6 → django_api_usage-0.1.7}/tests/test_csv_export.py +5 -3
  19. {django_api_usage-0.1.6 → django_api_usage-0.1.7}/tests/test_filters.py +4 -2
  20. django_api_usage-0.1.6/src/django_api_usage/apps.py +0 -11
  21. django_api_usage-0.1.6/src/django_api_usage/locale/eu/LC_MESSAGES/django.mo +0 -0
  22. {django_api_usage-0.1.6 → django_api_usage-0.1.7}/LICENSE +0 -0
  23. {django_api_usage-0.1.6 → django_api_usage-0.1.7}/setup.cfg +0 -0
  24. {django_api_usage-0.1.6 → django_api_usage-0.1.7}/src/django_api_usage/checks.py +0 -0
  25. {django_api_usage-0.1.6 → django_api_usage-0.1.7}/src/django_api_usage/deprecation.py +0 -0
  26. {django_api_usage-0.1.6 → django_api_usage-0.1.7}/src/django_api_usage/drf.py +0 -0
  27. {django_api_usage-0.1.6 → django_api_usage-0.1.7}/src/django_api_usage/maintenance.py +0 -0
  28. {django_api_usage-0.1.6 → django_api_usage-0.1.7}/src/django_api_usage/management/__init__.py +0 -0
  29. {django_api_usage-0.1.6 → django_api_usage-0.1.7}/src/django_api_usage/management/commands/__init__.py +0 -0
  30. {django_api_usage-0.1.6 → django_api_usage-0.1.7}/src/django_api_usage/management/commands/api_usage_flush.py +0 -0
  31. {django_api_usage-0.1.6 → django_api_usage-0.1.7}/src/django_api_usage/management/commands/api_usage_report.py +0 -0
  32. {django_api_usage-0.1.6 → django_api_usage-0.1.7}/src/django_api_usage/migrations/0001_initial.py +0 -0
  33. {django_api_usage-0.1.6 → django_api_usage-0.1.7}/src/django_api_usage/migrations/0002_endpoint_route_path.py +0 -0
  34. {django_api_usage-0.1.6 → django_api_usage-0.1.7}/src/django_api_usage/migrations/__init__.py +0 -0
  35. {django_api_usage-0.1.6 → django_api_usage-0.1.7}/src/django_api_usage/tasks.py +0 -0
  36. {django_api_usage-0.1.6 → django_api_usage-0.1.7}/src/django_api_usage/templates/admin/django_api_usage/endpointstat/change_list.html +0 -0
  37. {django_api_usage-0.1.6 → django_api_usage-0.1.7}/src/django_api_usage/templates/admin/django_api_usage/endpointstat/confirm_clear.html +0 -0
  38. {django_api_usage-0.1.6 → django_api_usage-0.1.7}/src/django_api_usage.egg-info/dependency_links.txt +0 -0
  39. {django_api_usage-0.1.6 → django_api_usage-0.1.7}/src/django_api_usage.egg-info/requires.txt +0 -0
  40. {django_api_usage-0.1.6 → django_api_usage-0.1.7}/src/django_api_usage.egg-info/top_level.txt +0 -0
  41. {django_api_usage-0.1.6 → django_api_usage-0.1.7}/tests/test_admin.py +0 -0
  42. {django_api_usage-0.1.6 → django_api_usage-0.1.7}/tests/test_checks.py +0 -0
  43. {django_api_usage-0.1.6 → django_api_usage-0.1.7}/tests/test_commands.py +0 -0
  44. {django_api_usage-0.1.6 → django_api_usage-0.1.7}/tests/test_drf.py +0 -0
  45. {django_api_usage-0.1.6 → django_api_usage-0.1.7}/tests/test_middleware.py +0 -0
  46. {django_api_usage-0.1.6 → django_api_usage-0.1.7}/tests/test_models.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-api-usage
3
- Version: 0.1.6
3
+ Version: 0.1.7
4
4
  Summary: Lightweight, privacy-first usage metering for Django APIs, with an optional deprecation lifecycle layer.
5
5
  Author-email: CodeSyntax <teknika@codesyntax.com>
6
6
  License-Expression: MIT
@@ -142,6 +142,28 @@ similar):
142
142
  and method), so the file can be fed straight to pandas or a spreadsheet. Use
143
143
  the "select all" link to export every row matching the current filters.
144
144
 
145
+ ## Client applications
146
+
147
+ Not every caller is a person: ERPs, partners, your own web front end and the
148
+ native mobile apps also hit the API. `ClientApp` is an **editable table**, so a
149
+ new consumer is recognised from the admin, without a deploy:
150
+
151
+ | Rule | Matched against | Use it for |
152
+ |---|---|---|
153
+ | `accounts` | the authenticated user (and therefore its DRF token) | one dedicated token per integration (ERP, partner...) |
154
+ | `domains` | the `Origin`/`Referer` host, subdomains included | your own web front end |
155
+ | `ip_networks` | the client address, against a CIDR (one per line) | internal networks and servers |
156
+ | `user_agent_patterns` | a case-insensitive substring of `User-Agent` | native mobile apps |
157
+
158
+ Rules are checked in that order, and `priority` decides between applications
159
+ (lower wins). A caller matching nothing stays unattributed, so the dimension
160
+ cannot grow out of control: `EndpointStat.client_app` only ever holds a
161
+ `ClientApp.slug`.
162
+
163
+ Counters, the CSV export and the admin filters all carry the application, so
164
+ "which application calls this endpoint?" is one filter away. The **Endpoint
165
+ stats** changelist also offers *Not attributed*: the callers still to classify.
166
+
145
167
  ## Deprecation lifecycle
146
168
 
147
169
  `Endpoint` carries `deprecated`, `sunset_date`, `replacement` and `owner`, so the
@@ -103,6 +103,28 @@ similar):
103
103
  and method), so the file can be fed straight to pandas or a spreadsheet. Use
104
104
  the "select all" link to export every row matching the current filters.
105
105
 
106
+ ## Client applications
107
+
108
+ Not every caller is a person: ERPs, partners, your own web front end and the
109
+ native mobile apps also hit the API. `ClientApp` is an **editable table**, so a
110
+ new consumer is recognised from the admin, without a deploy:
111
+
112
+ | Rule | Matched against | Use it for |
113
+ |---|---|---|
114
+ | `accounts` | the authenticated user (and therefore its DRF token) | one dedicated token per integration (ERP, partner...) |
115
+ | `domains` | the `Origin`/`Referer` host, subdomains included | your own web front end |
116
+ | `ip_networks` | the client address, against a CIDR (one per line) | internal networks and servers |
117
+ | `user_agent_patterns` | a case-insensitive substring of `User-Agent` | native mobile apps |
118
+
119
+ Rules are checked in that order, and `priority` decides between applications
120
+ (lower wins). A caller matching nothing stays unattributed, so the dimension
121
+ cannot grow out of control: `EndpointStat.client_app` only ever holds a
122
+ `ClientApp.slug`.
123
+
124
+ Counters, the CSV export and the admin filters all carry the application, so
125
+ "which application calls this endpoint?" is one filter away. The **Endpoint
126
+ stats** changelist also offers *Not attributed*: the callers still to classify.
127
+
106
128
  ## Deprecation lifecycle
107
129
 
108
130
  `Endpoint` carries `deprecated`, `sunset_date`, `replacement` and `owner`, so the
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "django-api-usage"
7
- version = "0.1.6"
7
+ version = "0.1.7"
8
8
  description = "Lightweight, privacy-first usage metering for Django APIs, with an optional deprecation lifecycle layer."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,3 +1,3 @@
1
1
  """django-api-usage: usage metering for Django APIs, with deprecation support."""
2
2
 
3
- __version__ = "0.1.6"
3
+ __version__ = "0.1.7"
@@ -16,7 +16,7 @@ Everything is plain Django: no extra dependency beyond Django itself.
16
16
  import csv
17
17
 
18
18
  from django.contrib import admin, messages
19
- from django.db.models import Sum
19
+ from django.db.models import Count, OuterRef, Subquery, Sum
20
20
  from django.http import HttpResponse, HttpResponseRedirect
21
21
  from django.template.response import TemplateResponse
22
22
  from django.urls import path, reverse
@@ -25,7 +25,7 @@ from django.utils.text import format_lazy
25
25
  from django.utils.translation import gettext_lazy as _
26
26
 
27
27
  from .buffers import flush
28
- from .models import Consumer, Endpoint, EndpointStat
28
+ from .models import ClientApp, Consumer, Endpoint, EndpointStat
29
29
 
30
30
 
31
31
  class CsvExportMixin:
@@ -122,6 +122,45 @@ class StatMethodListFilter(MethodListFilter):
122
122
  field_path = "endpoint__method"
123
123
 
124
124
 
125
+ #: Sentinel for the calls that could not be attributed to any application.
126
+ UNATTRIBUTED = "__none__"
127
+
128
+
129
+ class ClientAppListFilter(admin.SimpleListFilter):
130
+ """Client applications, plus the calls that were not attributed.
131
+
132
+ Applications defined in the table are listed even before they have traffic,
133
+ which is what you want while checking that a new rule works.
134
+ """
135
+
136
+ title = _("Client application")
137
+ parameter_name = "client_app"
138
+
139
+ def lookups(self, request, model_admin):
140
+ used = set(
141
+ model_admin.model.objects.order_by()
142
+ .values_list("client_app", flat=True)
143
+ .distinct()
144
+ )
145
+ names = dict(ClientApp.objects.values_list("slug", "name"))
146
+ defined = set(
147
+ ClientApp.objects.filter(is_active=True).values_list("slug", flat=True)
148
+ )
149
+ choices = [
150
+ (slug, names.get(slug) or slug) for slug in sorted((used | defined) - {""})
151
+ ]
152
+ choices.append((UNATTRIBUTED, _("Not attributed")))
153
+ return choices
154
+
155
+ def queryset(self, request, queryset):
156
+ value = self.value()
157
+ if value == UNATTRIBUTED:
158
+ return queryset.filter(client_app="")
159
+ if value:
160
+ return queryset.filter(client_app=value)
161
+ return queryset
162
+
163
+
125
164
  @admin.register(Endpoint)
126
165
  class EndpointAdmin(CsvExportMixin, admin.ModelAdmin):
127
166
  actions = ("export_as_csv",)
@@ -171,13 +210,22 @@ class EndpointStatAdmin(CsvExportMixin, admin.ModelAdmin):
171
210
  ("method", "endpoint__method"),
172
211
  ("site_id", "endpoint__site_id"),
173
212
  ("client_type", "client_type"),
213
+ ("client_app", "client_app"),
174
214
  ("status_class", "status_class"),
175
215
  ("count", "count"),
176
216
  )
177
- list_display = ("date", "endpoint", "client_type", "status_class", "count")
217
+ list_display = (
218
+ "date",
219
+ "endpoint",
220
+ "client_type",
221
+ "client_app",
222
+ "status_class",
223
+ "count",
224
+ )
178
225
  list_filter = (
179
226
  "date",
180
227
  "client_type",
228
+ ClientAppListFilter,
181
229
  "status_class",
182
230
  StatAppLabelListFilter,
183
231
  StatMethodListFilter,
@@ -314,3 +362,72 @@ class ConsumerAdmin(CsvExportMixin, admin.ModelAdmin):
314
362
  @admin.display(description="Reference")
315
363
  def ref_short(self, obj):
316
364
  return obj.ref_hash[:12]
365
+
366
+
367
+ @admin.register(ClientApp)
368
+ class ClientAppAdmin(CsvExportMixin, admin.ModelAdmin):
369
+ """Editable table: recognise a new consumer without deploying anything."""
370
+
371
+ actions = ("export_as_csv",)
372
+ csv_columns = (
373
+ ("slug", "slug"),
374
+ ("name", "name"),
375
+ ("priority", "priority"),
376
+ ("is_active", "is_active"),
377
+ ("domains", "domains"),
378
+ ("ip_networks", "ip_networks"),
379
+ ("user_agent_patterns", "user_agent_patterns"),
380
+ )
381
+ list_display = (
382
+ "slug",
383
+ "name",
384
+ "priority",
385
+ "is_active",
386
+ "accounts_total",
387
+ "counters_total",
388
+ )
389
+ list_filter = ("is_active",)
390
+ search_fields = ("slug", "name", "description")
391
+ filter_horizontal = ("accounts",)
392
+ fieldsets = (
393
+ (None, {"fields": ("slug", "name", "description", "priority", "is_active")}),
394
+ (
395
+ _("Matching rules"),
396
+ {
397
+ "fields": (
398
+ "accounts",
399
+ "domains",
400
+ "ip_networks",
401
+ "user_agent_patterns",
402
+ ),
403
+ "description": _(
404
+ "Checked in this order: account, request host, client "
405
+ "network, user agent. Between applications, the lowest "
406
+ "priority wins."
407
+ ),
408
+ },
409
+ ),
410
+ )
411
+
412
+ def get_queryset(self, request):
413
+ counters = (
414
+ EndpointStat.objects.filter(client_app=OuterRef("slug"))
415
+ .order_by()
416
+ .values("client_app")
417
+ .annotate(total=Sum("count"))
418
+ .values("total")
419
+ )
420
+ return (
421
+ super()
422
+ .get_queryset(request)
423
+ .annotate(accounts_total=Count("accounts", distinct=True))
424
+ .annotate(counters_total=Subquery(counters))
425
+ )
426
+
427
+ @admin.display(description=_("Accounts"), ordering="accounts_total")
428
+ def accounts_total(self, obj):
429
+ return obj.accounts_total
430
+
431
+ @admin.display(description=_("Counters"), ordering="counters_total")
432
+ def counters_total(self, obj):
433
+ return obj.counters_total or 0
@@ -0,0 +1,34 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class DjangoApiUsageConfig(AppConfig):
5
+ name = "django_api_usage"
6
+ verbose_name = "Django API usage"
7
+ default_auto_field = "django.db.models.BigAutoField"
8
+
9
+ def ready(self):
10
+ # Register system checks (middleware installed, consumer salt, ...).
11
+ from . import checks # noqa: F401
12
+
13
+ self._connect_client_app_signals()
14
+
15
+ def _connect_client_app_signals(self):
16
+ """Drop the cached ClientApp rules as soon as the table changes."""
17
+ from django.db.models.signals import m2m_changed, post_delete, post_save
18
+
19
+ from .models import ClientApp
20
+ from .resolvers import reset_client_app_cache
21
+
22
+ post_save.connect(
23
+ reset_client_app_cache, sender=ClientApp, dispatch_uid="api_usage_app_save"
24
+ )
25
+ post_delete.connect(
26
+ reset_client_app_cache,
27
+ sender=ClientApp,
28
+ dispatch_uid="api_usage_app_delete",
29
+ )
30
+ m2m_changed.connect(
31
+ reset_client_app_cache,
32
+ sender=ClientApp.accounts.through,
33
+ dispatch_uid="api_usage_app_accounts",
34
+ )
@@ -30,6 +30,7 @@ _FIELDS = (
30
30
  "route_path",
31
31
  "method",
32
32
  "client_type",
33
+ "client_app",
33
34
  )
34
35
 
35
36
 
@@ -126,6 +127,7 @@ def _write_to_db(dimensions, status_code_class, count):
126
127
  endpoint=endpoint,
127
128
  date=timezone.localdate(),
128
129
  client_type=dimensions.get("client_type") or "anon",
130
+ client_app=dimensions.get("client_app") or "",
129
131
  status_class=status_code_class,
130
132
  defaults={"count": count},
131
133
  )
@@ -33,6 +33,7 @@ DEFAULTS = {
33
33
  "APP_LABEL_RESOLVER": "django_api_usage.resolvers.default_app_label",
34
34
  "ROUTE_NAME_RESOLVER": "django_api_usage.resolvers.default_route_name",
35
35
  "ROUTE_PATH_RESOLVER": "django_api_usage.resolvers.default_route_path",
36
+ "CLIENT_APP_RESOLVER": "django_api_usage.resolvers.client_app_from_rules",
36
37
  "CLIENT_TYPE_RESOLVER": "django_api_usage.resolvers.default_client_type",
37
38
  "SITE_RESOLVER": "django_api_usage.resolvers.default_site_id",
38
39
  "CONSUMER_RESOLVER": "django_api_usage.resolvers.default_consumer",
@@ -7,8 +7,8 @@ msgid ""
7
7
  msgstr ""
8
8
  "Project-Id-Version: django-api-usage\n"
9
9
  "Report-Msgid-Bugs-To: \n"
10
- "POT-Creation-Date: 2026-10-08 06:10-0500\n"
11
- "PO-Revision-Date: 2026-10-08 06:20-0500\n"
10
+ "POT-Creation-Date: 2026-10-08 06:45-0500\n"
11
+ "PO-Revision-Date: 2026-10-08 06:55-0500\n"
12
12
  "Last-Translator: Urtzi Odriozola <uodriozola@codesyntax.com>\n"
13
13
  "Language-Team: Basque\n"
14
14
  "Language: eu\n"
@@ -29,7 +29,15 @@ msgstr "Aplikazioa"
29
29
  msgid "Method"
30
30
  msgstr "Metodoa"
31
31
 
32
- #: src/django_api_usage/admin.py:196
32
+ #: src/django_api_usage/admin.py:136 src/django_api_usage/models.py:106
33
+ msgid "Client application"
34
+ msgstr "Bezero-aplikazioa"
35
+
36
+ #: src/django_api_usage/admin.py:152
37
+ msgid "Not attributed"
38
+ msgstr "Esleitu gabea"
39
+
40
+ #: src/django_api_usage/admin.py:244
33
41
  msgid ""
34
42
  "Search by endpoint path, route name, app, method or replacement (for example "
35
43
  "'herriak' or 'artikuluak')."
@@ -37,30 +45,79 @@ msgstr ""
37
45
  "Bilatu endpointaren bidearen, route-izenaren, aplikazioaren, metodoaren edo "
38
46
  "ordezkoaren arabera (adibidez 'herriak' edo 'artikuluak')."
39
47
 
40
- #: src/django_api_usage/admin.py:251
48
+ #: src/django_api_usage/admin.py:299
41
49
  #, python-brace-format
42
50
  msgid "Flushed {count} buffered counter(s)."
43
51
  msgstr "{count} kontagailu eraman dira bufferretik datu-basera."
44
52
 
45
- #: src/django_api_usage/admin.py:258
53
+ #: src/django_api_usage/admin.py:306
46
54
  msgid ""
47
55
  "Nothing was buffered: with BUFFER_BACKEND='db' counters are already in the "
48
56
  "database."
49
57
  msgstr ""
50
- "Ez zegoen ezer bufferrean: BUFFER_BACKEND='db' denean kontagailuak jada datu-"
51
- "basean daude."
58
+ "Ez zegoen ezer bufferrean: BUFFER_BACKEND='db' denean kontagailuak jada "
59
+ "datu-basean daude."
52
60
 
53
- #: src/django_api_usage/admin.py:270
61
+ #: src/django_api_usage/admin.py:318
54
62
  #: src/django_api_usage/templates/admin/django_api_usage/endpointstat/change_list.html:12
55
63
  #: src/django_api_usage/templates/admin/django_api_usage/endpointstat/confirm_clear.html:8
56
64
  msgid "Clear statistics"
57
65
  msgstr "Estatistikak hustu"
58
66
 
59
- #: src/django_api_usage/admin.py:282
67
+ #: src/django_api_usage/admin.py:330
60
68
  #, python-brace-format
61
69
  msgid "Deleted {count} statistic row(s)."
62
70
  msgstr "{count} estatistika-errenkada ezabatu dira."
63
71
 
72
+ #: src/django_api_usage/admin.py:395
73
+ msgid "Matching rules"
74
+ msgstr "Bat-etortze arauak"
75
+
76
+ #: src/django_api_usage/admin.py:404
77
+ msgid ""
78
+ "Checked in this order: account, request host, client network, user agent. "
79
+ "Between applications, the lowest priority wins."
80
+ msgstr ""
81
+ "Ordena honetan egiaztatzen da: kontua, eskaeraren ostalaria, bezeroaren sarea "
82
+ "eta user agent-a. Aplikazioen artean, lehentasun txikiena duenak irabazten du."
83
+
84
+ #: src/django_api_usage/admin.py:427
85
+ msgid "Accounts"
86
+ msgstr "Kontuak"
87
+
88
+ #: src/django_api_usage/admin.py:431
89
+ msgid "Counters"
90
+ msgstr "Kontagailuak"
91
+
92
+ #: src/django_api_usage/models.py:79
93
+ msgid "Short, stable label stored in the counters (e.g. 'mugikorra')."
94
+ msgstr ""
95
+ "Kontagailuetan gordetzen den etiketa labur eta egonkorra (adib. 'mugikorra')."
96
+
97
+ #: src/django_api_usage/models.py:85
98
+ msgid "Lower wins when several apps could match."
99
+ msgstr "Hainbat aplikaziok bat etorri ahalketenean, txikienak irabazten du."
100
+
101
+ #: src/django_api_usage/models.py:91
102
+ msgid "Users (and therefore DRF tokens) that belong to this app."
103
+ msgstr "Aplikazio honi dagozkion erabiltzaileak (eta, beraz, haien DRF tokenak)."
104
+
105
+ #: src/django_api_usage/models.py:95
106
+ msgid "One host per line; matched against the Origin/Referer host."
107
+ msgstr "Ostalari bat lerroko; Origin/Referer ostalariaren aurka egiaztatzen da."
108
+
109
+ #: src/django_api_usage/models.py:98
110
+ msgid "One CIDR per line, e.g. 10.0.0.0/8."
111
+ msgstr "CIDR bat lerroko, adib. 10.0.0.0/8."
112
+
113
+ #: src/django_api_usage/models.py:102
114
+ msgid "One substring per line, matched case-insensitively."
115
+ msgstr "Azpikate bat lerroko; maiuskulak/minuskulak bereizi gabe egiaztatzen da."
116
+
117
+ #: src/django_api_usage/models.py:107
118
+ msgid "Client applications"
119
+ msgstr "Bezero-aplikazioak"
120
+
64
121
  #: src/django_api_usage/templates/admin/django_api_usage/endpointstat/change_list.html:8
65
122
  msgid "Flush now"
66
123
  msgstr "Flush orain"
@@ -68,7 +125,7 @@ msgstr "Flush orain"
68
125
  #: src/django_api_usage/templates/admin/django_api_usage/endpointstat/change_list.html:21
69
126
  #, python-format
70
127
  msgid "TOTAL COUNT: %(total)s"
71
- msgstr ""
128
+ msgstr "GUZTIRA: %(total)s"
72
129
 
73
130
  #: src/django_api_usage/templates/admin/django_api_usage/endpointstat/confirm_clear.html:6
74
131
  msgid "Home"
@@ -36,6 +36,7 @@ class ApiUsageMiddleware:
36
36
  "route_path": api_settings.ROUTE_PATH_RESOLVER(request),
37
37
  "method": request.method,
38
38
  "client_type": api_settings.CLIENT_TYPE_RESOLVER(request),
39
+ "client_app": api_settings.CLIENT_APP_RESOLVER(request),
39
40
  }
40
41
  consumer = None
41
42
  if api_settings.TRACK_CONSUMERS:
@@ -0,0 +1,96 @@
1
+ # Generated by Django 4.2.30 on 2026-10-08 11:42
2
+
3
+ from django.conf import settings
4
+ from django.db import migrations, models
5
+
6
+
7
+ class Migration(migrations.Migration):
8
+
9
+ dependencies = [
10
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
11
+ ("django_api_usage", "0002_endpoint_route_path"),
12
+ ]
13
+
14
+ operations = [
15
+ migrations.AlterUniqueTogether(
16
+ name="endpointstat",
17
+ unique_together=set(),
18
+ ),
19
+ migrations.AddField(
20
+ model_name="endpointstat",
21
+ name="client_app",
22
+ field=models.CharField(blank=True, max_length=64),
23
+ ),
24
+ migrations.AlterUniqueTogether(
25
+ name="endpointstat",
26
+ unique_together={
27
+ ("endpoint", "date", "client_type", "client_app", "status_class")
28
+ },
29
+ ),
30
+ migrations.CreateModel(
31
+ name="ClientApp",
32
+ fields=[
33
+ (
34
+ "id",
35
+ models.BigAutoField(
36
+ auto_created=True,
37
+ primary_key=True,
38
+ serialize=False,
39
+ verbose_name="ID",
40
+ ),
41
+ ),
42
+ (
43
+ "slug",
44
+ models.SlugField(
45
+ help_text="Short, stable label stored in the counters (e.g. 'mugikorra').",
46
+ max_length=64,
47
+ unique=True,
48
+ ),
49
+ ),
50
+ ("name", models.CharField(blank=True, max_length=160)),
51
+ ("description", models.TextField(blank=True)),
52
+ ("is_active", models.BooleanField(default=True)),
53
+ (
54
+ "priority",
55
+ models.PositiveSmallIntegerField(
56
+ default=100,
57
+ help_text="Lower wins when several apps could match.",
58
+ ),
59
+ ),
60
+ (
61
+ "domains",
62
+ models.TextField(
63
+ blank=True,
64
+ help_text="One host per line; matched against the Origin/Referer host.",
65
+ ),
66
+ ),
67
+ (
68
+ "ip_networks",
69
+ models.TextField(
70
+ blank=True, help_text="One CIDR per line, e.g. 10.0.0.0/8."
71
+ ),
72
+ ),
73
+ (
74
+ "user_agent_patterns",
75
+ models.TextField(
76
+ blank=True,
77
+ help_text="One substring per line, matched case-insensitively.",
78
+ ),
79
+ ),
80
+ (
81
+ "accounts",
82
+ models.ManyToManyField(
83
+ blank=True,
84
+ help_text="Users (and therefore DRF tokens) that belong to this app.",
85
+ related_name="+",
86
+ to=settings.AUTH_USER_MODEL,
87
+ ),
88
+ ),
89
+ ],
90
+ options={
91
+ "verbose_name": "Client application",
92
+ "verbose_name_plural": "Client applications",
93
+ "ordering": ("priority", "slug"),
94
+ },
95
+ ),
96
+ ]
@@ -1,15 +1,25 @@
1
1
  """Data model.
2
2
 
3
- Two layers live here on purpose:
3
+ Three layers live here on purpose:
4
4
 
5
5
  * **Usage metering** (:class:`Endpoint`, :class:`EndpointStat`) is the neutral
6
- core: how much is each endpoint of each app used.
6
+ core: how much is each endpoint of each app used, and by which client
7
+ application (:class:`ClientApp`).
8
+ * **Client attribution** (:class:`ClientApp`) is editable data, so new consumers
9
+ can be recognised from the admin without a deploy.
7
10
  * **Deprecation lifecycle** (the extra fields on :class:`Endpoint`) is the
8
11
  optional layer built on top of the same data.
9
12
  """
10
13
 
14
+ from django.conf import settings
11
15
  from django.db import models
12
16
  from django.utils import timezone
17
+ from django.utils.translation import gettext_lazy as _
18
+
19
+
20
+ def _lines(value):
21
+ """Split a textarea into non-empty, stripped lines."""
22
+ return [line.strip() for line in (value or "").splitlines() if line.strip()]
13
23
 
14
24
 
15
25
  class Endpoint(models.Model):
@@ -52,19 +62,85 @@ class Endpoint(models.Model):
52
62
  return (self.sunset_date - timezone.localdate()).days
53
63
 
54
64
 
65
+ class ClientApp(models.Model):
66
+ """An application that consumes the API (mobile app, ERP, partner, ...).
67
+
68
+ Editable on purpose: the mapping is data, not code. A blank ``slug``-less
69
+ request is attributed to no app at all, so the rules can grow without a
70
+ deploy.
71
+
72
+ Resolution order is: authenticated account, request host, client network,
73
+ then user agent. ``priority`` breaks ties (lower wins).
74
+ """
75
+
76
+ slug = models.SlugField(
77
+ max_length=64,
78
+ unique=True,
79
+ help_text=_("Short, stable label stored in the counters (e.g. 'mugikorra')."),
80
+ )
81
+ name = models.CharField(max_length=160, blank=True)
82
+ description = models.TextField(blank=True)
83
+ is_active = models.BooleanField(default=True)
84
+ priority = models.PositiveSmallIntegerField(
85
+ default=100, help_text=_("Lower wins when several apps could match.")
86
+ )
87
+ accounts = models.ManyToManyField(
88
+ settings.AUTH_USER_MODEL,
89
+ blank=True,
90
+ related_name="+",
91
+ help_text=_("Users (and therefore DRF tokens) that belong to this app."),
92
+ )
93
+ domains = models.TextField(
94
+ blank=True,
95
+ help_text=_("One host per line; matched against the Origin/Referer host."),
96
+ )
97
+ ip_networks = models.TextField(
98
+ blank=True, help_text=_("One CIDR per line, e.g. 10.0.0.0/8.")
99
+ )
100
+ user_agent_patterns = models.TextField(
101
+ blank=True,
102
+ help_text=_("One substring per line, matched case-insensitively."),
103
+ )
104
+
105
+ class Meta:
106
+ verbose_name = _("Client application")
107
+ verbose_name_plural = _("Client applications")
108
+ ordering = ("priority", "slug")
109
+
110
+ def __str__(self):
111
+ return self.name or self.slug
112
+
113
+ def domain_list(self):
114
+ return _lines(self.domains)
115
+
116
+ def network_list(self):
117
+ return _lines(self.ip_networks)
118
+
119
+ def user_agent_list(self):
120
+ return [pattern.lower() for pattern in _lines(self.user_agent_patterns)]
121
+
122
+
55
123
  class EndpointStat(models.Model):
56
- """Aggregated counter per endpoint, day, client type and status class."""
124
+ """Aggregated counter per endpoint, day, client and status class."""
57
125
 
58
126
  endpoint = models.ForeignKey(
59
127
  Endpoint, on_delete=models.CASCADE, related_name="stats"
60
128
  )
61
129
  date = models.DateField(db_index=True)
62
130
  client_type = models.CharField(max_length=16, default="anon")
131
+ #: ``ClientApp.slug``; blank when the caller could not be attributed.
132
+ client_app = models.CharField(max_length=64, blank=True)
63
133
  status_class = models.CharField(max_length=4, default="2xx")
64
134
  count = models.PositiveIntegerField(default=0)
65
135
 
66
136
  class Meta:
67
- unique_together = ("endpoint", "date", "client_type", "status_class")
137
+ unique_together = (
138
+ "endpoint",
139
+ "date",
140
+ "client_type",
141
+ "client_app",
142
+ "status_class",
143
+ )
68
144
  ordering = ("-date",)
69
145
  indexes = [models.Index(fields=["date", "endpoint"])]
70
146
 
@@ -5,9 +5,15 @@ project-specific knowledge. Resolvers must be cheap: they run on every request.
5
5
  """
6
6
 
7
7
  import hashlib
8
+ import ipaddress
9
+ import logging
10
+ import time
11
+ from urllib.parse import urlsplit
8
12
 
9
13
  from django.conf import settings as django_settings
10
14
 
15
+ logger = logging.getLogger("django_api_usage")
16
+
11
17
 
12
18
  def _view_module(match):
13
19
  """Best-effort module name of the callable that handled the request."""
@@ -114,3 +120,113 @@ def default_consumer(request):
114
120
  if not ip:
115
121
  return None
116
122
  return ("ip_hash", _hash(ip, salt), user_agent_family)
123
+
124
+
125
+ # -- client application attribution -----------------------------------------
126
+
127
+ #: Safety net for processes that did not see the signal (see ``apps.py``).
128
+ CLIENT_APP_RULES_TTL = 60
129
+ _rules_cache = {"loaded_at": 0.0, "rules": None}
130
+
131
+
132
+ def reset_client_app_cache(**kwargs):
133
+ """Forget the cached :class:`~django_api_usage.models.ClientApp` rules."""
134
+ _rules_cache["rules"] = None
135
+ _rules_cache["loaded_at"] = 0.0
136
+
137
+
138
+ def _load_client_app_rules():
139
+ from .models import ClientApp
140
+
141
+ rules = {"by_user": {}, "hosts": [], "networks": [], "patterns": []}
142
+ apps = ClientApp.objects.filter(is_active=True).prefetch_related("accounts")
143
+ for app in apps: # ordered by priority, then slug
144
+ for user_id in app.accounts.values_list("pk", flat=True):
145
+ rules["by_user"].setdefault(user_id, app.slug)
146
+ rules["hosts"].extend((host.lower(), app.slug) for host in app.domain_list())
147
+ for cidr in app.network_list():
148
+ try:
149
+ network = ipaddress.ip_network(cidr, strict=False)
150
+ except ValueError:
151
+ logger.warning(
152
+ "django-api-usage: ignoring invalid network %r on %s", cidr, app
153
+ )
154
+ continue
155
+ rules["networks"].append((network, app.slug))
156
+ rules["patterns"].extend(
157
+ (pattern, app.slug) for pattern in app.user_agent_list()
158
+ )
159
+ return rules
160
+
161
+
162
+ def _client_app_rules():
163
+ now = time.monotonic()
164
+ if (
165
+ _rules_cache["rules"] is None
166
+ or now - _rules_cache["loaded_at"] > CLIENT_APP_RULES_TTL
167
+ ):
168
+ _rules_cache["rules"] = _load_client_app_rules()
169
+ _rules_cache["loaded_at"] = now
170
+ return _rules_cache["rules"]
171
+
172
+
173
+ def _request_host(request):
174
+ """Host of the ``Origin`` (or ``Referer``) header, lowercased."""
175
+ for header in ("HTTP_ORIGIN", "HTTP_REFERER"):
176
+ value = request.META.get(header)
177
+ if value:
178
+ host = urlsplit(value).hostname
179
+ if host:
180
+ return host.lower()
181
+ return ""
182
+
183
+
184
+ def client_ip(request):
185
+ """Client address, honouring the first ``X-Forwarded-For`` entry."""
186
+ forwarded = request.META.get("HTTP_X_FORWARDED_FOR", "")
187
+ return forwarded.split(",")[0].strip() or request.META.get("REMOTE_ADDR", "")
188
+
189
+
190
+ def client_app_from_rules(request):
191
+ """Attribute the caller to a :class:`ClientApp`, or return ``""``.
192
+
193
+ The rules live in the database and are editable from the admin, so a new
194
+ consumer can be recognised without a deploy. Monitored order: the caller's
195
+ account (a dedicated token), then the request host, then the client network,
196
+ then the user agent. ``ClientApp.priority`` decides the order between apps.
197
+ """
198
+ rules = _client_app_rules()
199
+
200
+ user = getattr(request, "user", None)
201
+ if user is not None and getattr(user, "is_authenticated", False):
202
+ slug = rules["by_user"].get(user.pk)
203
+ if slug:
204
+ return slug
205
+
206
+ host = _request_host(request)
207
+ if host:
208
+ for pattern, slug in rules["hosts"]:
209
+ if host == pattern or host.endswith("." + pattern):
210
+ return slug
211
+
212
+ address = _as_address(client_ip(request))
213
+ if address is not None:
214
+ for network, slug in rules["networks"]:
215
+ if address.version == network.version and address in network:
216
+ return slug
217
+
218
+ user_agent = request.META.get("HTTP_USER_AGENT", "").lower()
219
+ if user_agent:
220
+ for pattern, slug in rules["patterns"]:
221
+ if pattern in user_agent:
222
+ return slug
223
+ return ""
224
+
225
+
226
+ def _as_address(value):
227
+ if not value:
228
+ return None
229
+ try:
230
+ return ipaddress.ip_address(value)
231
+ except ValueError:
232
+ return None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-api-usage
3
- Version: 0.1.6
3
+ Version: 0.1.7
4
4
  Summary: Lightweight, privacy-first usage metering for Django APIs, with an optional deprecation lifecycle layer.
5
5
  Author-email: CodeSyntax <teknika@codesyntax.com>
6
6
  License-Expression: MIT
@@ -142,6 +142,28 @@ similar):
142
142
  and method), so the file can be fed straight to pandas or a spreadsheet. Use
143
143
  the "select all" link to export every row matching the current filters.
144
144
 
145
+ ## Client applications
146
+
147
+ Not every caller is a person: ERPs, partners, your own web front end and the
148
+ native mobile apps also hit the API. `ClientApp` is an **editable table**, so a
149
+ new consumer is recognised from the admin, without a deploy:
150
+
151
+ | Rule | Matched against | Use it for |
152
+ |---|---|---|
153
+ | `accounts` | the authenticated user (and therefore its DRF token) | one dedicated token per integration (ERP, partner...) |
154
+ | `domains` | the `Origin`/`Referer` host, subdomains included | your own web front end |
155
+ | `ip_networks` | the client address, against a CIDR (one per line) | internal networks and servers |
156
+ | `user_agent_patterns` | a case-insensitive substring of `User-Agent` | native mobile apps |
157
+
158
+ Rules are checked in that order, and `priority` decides between applications
159
+ (lower wins). A caller matching nothing stays unattributed, so the dimension
160
+ cannot grow out of control: `EndpointStat.client_app` only ever holds a
161
+ `ClientApp.slug`.
162
+
163
+ Counters, the CSV export and the admin filters all carry the application, so
164
+ "which application calls this endpoint?" is one filter away. The **Endpoint
165
+ stats** changelist also offers *Not attributed*: the callers still to classify.
166
+
145
167
  ## Deprecation lifecycle
146
168
 
147
169
  `Endpoint` carries `deprecated`, `sunset_date`, `replacement` and `owner`, so the
@@ -27,11 +27,13 @@ src/django_api_usage/management/commands/api_usage_flush.py
27
27
  src/django_api_usage/management/commands/api_usage_report.py
28
28
  src/django_api_usage/migrations/0001_initial.py
29
29
  src/django_api_usage/migrations/0002_endpoint_route_path.py
30
+ src/django_api_usage/migrations/0003_alter_endpointstat_unique_together_and_more.py
30
31
  src/django_api_usage/migrations/__init__.py
31
32
  src/django_api_usage/templates/admin/django_api_usage/endpointstat/change_list.html
32
33
  src/django_api_usage/templates/admin/django_api_usage/endpointstat/confirm_clear.html
33
34
  tests/test_admin.py
34
35
  tests/test_checks.py
36
+ tests/test_client_app.py
35
37
  tests/test_commands.py
36
38
  tests/test_csv_export.py
37
39
  tests/test_drf.py
@@ -0,0 +1,159 @@
1
+ """Tests for the editable ClientApp table and its matching rules."""
2
+
3
+ from django.contrib.auth.models import User
4
+ from django.test import RequestFactory
5
+ from django.urls import reverse
6
+
7
+ from django_api_usage.buffers import flush
8
+ from django_api_usage.models import ClientApp, Endpoint, EndpointStat
9
+ from django_api_usage.resolvers import client_app_from_rules, reset_client_app_cache
10
+
11
+ from .base import UsageTestCase
12
+
13
+ STAT_CHANGELIST = "admin:django_api_usage_endpointstat_changelist"
14
+
15
+
16
+ class ClientAppResolverTest(UsageTestCase):
17
+ def setUp(self):
18
+ super().setUp()
19
+ reset_client_app_cache()
20
+ self.factory = RequestFactory()
21
+
22
+ def request(self, user=None, **meta):
23
+ request = self.factory.get("/api/3.0/artikuluak/", **meta)
24
+ if user is not None:
25
+ request.user = user
26
+ return request
27
+
28
+ def test_a_dedicated_account_wins(self):
29
+ """A dedicated token (its user) identifies the integration."""
30
+ user = User.objects.create_user("zoho", "zoho@example.com", "pw")
31
+ app = ClientApp.objects.create(slug="zoho", priority=10)
32
+ app.accounts.add(user)
33
+
34
+ self.assertEqual(client_app_from_rules(self.request(user=user)), "zoho")
35
+
36
+ def test_the_request_host_matches_subdomains(self):
37
+ ClientApp.objects.create(slug="elhuyar", domains="elhuyar.eus")
38
+
39
+ request = self.request(HTTP_ORIGIN="https://www.elhuyar.eus/tresnak")
40
+
41
+ self.assertEqual(client_app_from_rules(request), "elhuyar")
42
+
43
+ def test_the_client_network_matches(self):
44
+ ClientApp.objects.create(
45
+ slug="goiena_erp", ip_networks="10.0.0.0/8\n192.168.1.0/24"
46
+ )
47
+
48
+ self.assertEqual(
49
+ client_app_from_rules(self.request(REMOTE_ADDR="10.1.2.3")), "goiena_erp"
50
+ )
51
+
52
+ def test_the_user_agent_matches_for_anonymous_readers(self):
53
+ """The bulk of the traffic: readers on the native mobile apps."""
54
+ ClientApp.objects.create(slug="mugikorra", user_agent_patterns="TokikomApp")
55
+
56
+ request = self.request(HTTP_USER_AGENT="TokikomApp/2.1 (Android 15)")
57
+
58
+ self.assertEqual(client_app_from_rules(request), "mugikorra")
59
+
60
+ def test_everything_else_is_not_attributed(self):
61
+ ClientApp.objects.create(slug="mugikorra", user_agent_patterns="TokikomApp")
62
+
63
+ self.assertEqual(client_app_from_rules(self.request()), "")
64
+
65
+ def test_the_lowest_priority_wins(self):
66
+ ClientApp.objects.create(
67
+ slug="orokorra", user_agent_patterns="tokio", priority=100
68
+ )
69
+ ClientApp.objects.create(
70
+ slug="mugikorra", user_agent_patterns="tokio", priority=10
71
+ )
72
+
73
+ self.assertEqual(
74
+ client_app_from_rules(self.request(HTTP_USER_AGENT="tokio/1")), "mugikorra"
75
+ )
76
+
77
+ def test_inactive_applications_are_ignored(self):
78
+ ClientApp.objects.create(
79
+ slug="zaharra", user_agent_patterns="tokio", is_active=False
80
+ )
81
+
82
+ self.assertEqual(
83
+ client_app_from_rules(self.request(HTTP_USER_AGENT="tokio/1")), ""
84
+ )
85
+
86
+ def test_invalid_networks_are_skipped(self):
87
+ ClientApp.objects.create(slug="gaizki", ip_networks="not-a-network")
88
+
89
+ self.assertEqual(
90
+ client_app_from_rules(self.request(REMOTE_ADDR="10.1.2.3")), ""
91
+ )
92
+
93
+ def test_saving_a_rule_invalidates_the_cache(self):
94
+ self.assertEqual(client_app_from_rules(self.request()), "")
95
+
96
+ ClientApp.objects.create(slug="berria", domains="berria.eus")
97
+
98
+ request = self.request(HTTP_ORIGIN="https://berria.eus")
99
+ self.assertEqual(client_app_from_rules(request), "berria")
100
+
101
+ def test_middleware_stores_the_application_in_the_counters(self):
102
+ ClientApp.objects.create(slug="mugikorra", user_agent_patterns="TokikomApp")
103
+
104
+ self.client.get("/ping/", HTTP_USER_AGENT="TokikomApp/2.1")
105
+ flush()
106
+
107
+ self.assertEqual(EndpointStat.objects.get().client_app, "mugikorra")
108
+
109
+
110
+ class ClientAppAdminTest(UsageTestCase):
111
+ def setUp(self):
112
+ super().setUp()
113
+ self.admin = User.objects.create_superuser("admin", "a@example.com", "pw")
114
+ self.client.force_login(self.admin)
115
+ self.endpoint = Endpoint.objects.create(
116
+ app_label="api", route_path="api/3.0/artikuluak/", method="GET"
117
+ )
118
+ self.attributed = EndpointStat.objects.create(
119
+ endpoint=self.endpoint,
120
+ date="2026-10-08",
121
+ client_app="mugikorra",
122
+ count=2,
123
+ )
124
+ self.unattributed = EndpointStat.objects.create(
125
+ endpoint=self.endpoint,
126
+ date="2026-10-08",
127
+ client_type="user",
128
+ client_app="",
129
+ count=5,
130
+ )
131
+
132
+ def rows(self, **params):
133
+ response = self.client.get(reverse(STAT_CHANGELIST), params)
134
+ self.assertEqual(response.status_code, 200)
135
+ return set(response.context["cl"].queryset)
136
+
137
+ def test_the_table_is_editable_in_the_admin(self):
138
+ app = ClientApp.objects.create(slug="mugikorra", name="Mugikorra")
139
+
140
+ response = self.client.get(
141
+ reverse("admin:django_api_usage_clientapp_change", args=[app.pk])
142
+ )
143
+
144
+ self.assertEqual(response.status_code, 200)
145
+ self.assertContains(response, "Matching rules")
146
+
147
+ def test_stats_can_be_filtered_by_application(self):
148
+ self.assertEqual(self.rows(client_app="mugikorra"), {self.attributed})
149
+
150
+ def test_stats_can_be_filtered_by_unattributed_calls(self):
151
+ self.assertEqual(self.rows(client_app="__none__"), {self.unattributed})
152
+
153
+ def test_applications_without_traffic_are_still_offered(self):
154
+ ClientApp.objects.create(slug="erp", name="ERP")
155
+
156
+ response = self.client.get(reverse(STAT_CHANGELIST))
157
+
158
+ self.assertContains(response, "?client_app=erp")
159
+ self.assertContains(response, ">ERP</a>")
@@ -103,6 +103,7 @@ class CsvExportTest(UsageTestCase):
103
103
  "method",
104
104
  "site_id",
105
105
  "client_type",
106
+ "client_app",
106
107
  "status_class",
107
108
  "count",
108
109
  ],
@@ -121,8 +122,9 @@ class CsvExportTest(UsageTestCase):
121
122
  self.assertEqual(body[3], "town-list")
122
123
  self.assertEqual(body[4], "GET")
123
124
  self.assertEqual(body[6], "anon")
124
- self.assertEqual(body[7], "2xx")
125
- self.assertEqual(body[8], "7")
125
+ self.assertEqual(body[7], "")
126
+ self.assertEqual(body[8], "2xx")
127
+ self.assertEqual(body[9], "7")
126
128
 
127
129
  def test_stats_export_only_includes_the_selected_rows(self):
128
130
  endpoint = self.make_endpoint()
@@ -133,7 +135,7 @@ class CsvExportTest(UsageTestCase):
133
135
 
134
136
  rows = self.rows(response)
135
137
  self.assertEqual(len(rows), 2) # header + one row
136
- self.assertEqual(rows[1][8], "1")
138
+ self.assertEqual(rows[1][9], "1")
137
139
 
138
140
  def test_stats_export_filename_contains_the_date(self):
139
141
  endpoint = self.make_endpoint()
@@ -114,9 +114,11 @@ class FilterTest(UsageTestCase):
114
114
  self.assertIn("Aplikazioa", html)
115
115
  self.assertIn("Metodoa", html)
116
116
 
117
- def test_total_count_label_is_left_untranslated(self):
118
- """The label was requested verbatim, so the eu catalog keeps it English."""
117
+ def test_total_count_follows_the_active_language(self):
119
118
  with translation.override("eu"):
120
119
  html = self.client.get(reverse(STAT_CHANGELIST)).content.decode()
120
+ self.assertIn("GUZTIRA", html)
121
121
 
122
+ with translation.override("en"):
123
+ html = self.client.get(reverse(STAT_CHANGELIST)).content.decode()
122
124
  self.assertIn("TOTAL COUNT", html)
@@ -1,11 +0,0 @@
1
- from django.apps import AppConfig
2
-
3
-
4
- class DjangoApiUsageConfig(AppConfig):
5
- name = "django_api_usage"
6
- verbose_name = "Django API usage"
7
- default_auto_field = "django.db.models.BigAutoField"
8
-
9
- def ready(self):
10
- # Register system checks (middleware installed, consumer salt, ...).
11
- from . import checks # noqa: F401