django-api-usage 0.1.3__tar.gz → 0.1.5__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 (39) hide show
  1. {django_api_usage-0.1.3/src/django_api_usage.egg-info → django_api_usage-0.1.5}/PKG-INFO +22 -4
  2. {django_api_usage-0.1.3 → django_api_usage-0.1.5}/README.md +21 -3
  3. {django_api_usage-0.1.3 → django_api_usage-0.1.5}/pyproject.toml +1 -1
  4. {django_api_usage-0.1.3 → django_api_usage-0.1.5}/src/django_api_usage/__init__.py +1 -1
  5. {django_api_usage-0.1.3 → django_api_usage-0.1.5}/src/django_api_usage/admin.py +82 -5
  6. {django_api_usage-0.1.3 → django_api_usage-0.1.5/src/django_api_usage.egg-info}/PKG-INFO +22 -4
  7. {django_api_usage-0.1.3 → django_api_usage-0.1.5}/src/django_api_usage.egg-info/SOURCES.txt +1 -0
  8. django_api_usage-0.1.5/tests/test_csv_export.py +209 -0
  9. {django_api_usage-0.1.3 → django_api_usage-0.1.5}/LICENSE +0 -0
  10. {django_api_usage-0.1.3 → django_api_usage-0.1.5}/setup.cfg +0 -0
  11. {django_api_usage-0.1.3 → django_api_usage-0.1.5}/src/django_api_usage/apps.py +0 -0
  12. {django_api_usage-0.1.3 → django_api_usage-0.1.5}/src/django_api_usage/buffers.py +0 -0
  13. {django_api_usage-0.1.3 → django_api_usage-0.1.5}/src/django_api_usage/checks.py +0 -0
  14. {django_api_usage-0.1.3 → django_api_usage-0.1.5}/src/django_api_usage/conf.py +0 -0
  15. {django_api_usage-0.1.3 → django_api_usage-0.1.5}/src/django_api_usage/deprecation.py +0 -0
  16. {django_api_usage-0.1.3 → django_api_usage-0.1.5}/src/django_api_usage/drf.py +0 -0
  17. {django_api_usage-0.1.3 → django_api_usage-0.1.5}/src/django_api_usage/maintenance.py +0 -0
  18. {django_api_usage-0.1.3 → django_api_usage-0.1.5}/src/django_api_usage/management/__init__.py +0 -0
  19. {django_api_usage-0.1.3 → django_api_usage-0.1.5}/src/django_api_usage/management/commands/__init__.py +0 -0
  20. {django_api_usage-0.1.3 → django_api_usage-0.1.5}/src/django_api_usage/management/commands/api_usage_flush.py +0 -0
  21. {django_api_usage-0.1.3 → django_api_usage-0.1.5}/src/django_api_usage/management/commands/api_usage_report.py +0 -0
  22. {django_api_usage-0.1.3 → django_api_usage-0.1.5}/src/django_api_usage/middleware.py +0 -0
  23. {django_api_usage-0.1.3 → django_api_usage-0.1.5}/src/django_api_usage/migrations/0001_initial.py +0 -0
  24. {django_api_usage-0.1.3 → django_api_usage-0.1.5}/src/django_api_usage/migrations/0002_endpoint_route_path.py +0 -0
  25. {django_api_usage-0.1.3 → django_api_usage-0.1.5}/src/django_api_usage/migrations/__init__.py +0 -0
  26. {django_api_usage-0.1.3 → django_api_usage-0.1.5}/src/django_api_usage/models.py +0 -0
  27. {django_api_usage-0.1.3 → django_api_usage-0.1.5}/src/django_api_usage/resolvers.py +0 -0
  28. {django_api_usage-0.1.3 → django_api_usage-0.1.5}/src/django_api_usage/tasks.py +0 -0
  29. {django_api_usage-0.1.3 → django_api_usage-0.1.5}/src/django_api_usage/templates/admin/django_api_usage/endpointstat/change_list.html +0 -0
  30. {django_api_usage-0.1.3 → django_api_usage-0.1.5}/src/django_api_usage/templates/admin/django_api_usage/endpointstat/confirm_clear.html +0 -0
  31. {django_api_usage-0.1.3 → django_api_usage-0.1.5}/src/django_api_usage.egg-info/dependency_links.txt +0 -0
  32. {django_api_usage-0.1.3 → django_api_usage-0.1.5}/src/django_api_usage.egg-info/requires.txt +0 -0
  33. {django_api_usage-0.1.3 → django_api_usage-0.1.5}/src/django_api_usage.egg-info/top_level.txt +0 -0
  34. {django_api_usage-0.1.3 → django_api_usage-0.1.5}/tests/test_admin.py +0 -0
  35. {django_api_usage-0.1.3 → django_api_usage-0.1.5}/tests/test_checks.py +0 -0
  36. {django_api_usage-0.1.3 → django_api_usage-0.1.5}/tests/test_commands.py +0 -0
  37. {django_api_usage-0.1.3 → django_api_usage-0.1.5}/tests/test_drf.py +0 -0
  38. {django_api_usage-0.1.3 → django_api_usage-0.1.5}/tests/test_middleware.py +0 -0
  39. {django_api_usage-0.1.3 → django_api_usage-0.1.5}/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.3
3
+ Version: 0.1.5
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
@@ -37,11 +37,11 @@ Requires-Dist: black==26.5.1; extra == "dev"
37
37
  Requires-Dist: ruff==0.16.10; extra == "dev"
38
38
  Dynamic: license-file
39
39
 
40
- ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/django-api-usage)
40
+ ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/django-api-usage?logo=pypi)
41
41
  ![Django versions](https://img.shields.io/badge/django-4.2%20%7C%205.2-0C4B33)
42
42
  ![Status](https://img.shields.io/badge/status-alpha-orange)
43
- ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/codesyntax/django-api-usage/ci.yml)
44
- ![PyPI - Version](https://img.shields.io/pypi/v/django-api-usage)
43
+ ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/codesyntax/django-api-usage/ci.yml?logo=github)
44
+ ![PyPI - Version](https://img.shields.io/pypi/v/django-api-usage?logo=pypi)
45
45
 
46
46
  # django-api-usage
47
47
 
@@ -120,6 +120,24 @@ API_USAGE = {
120
120
  }
121
121
  ```
122
122
 
123
+ ## Admin
124
+
125
+ The package ships a read-only admin for the three models, built with plain
126
+ Django — **no dependency beyond Django itself** (no `django-object-actions` or
127
+ similar):
128
+
129
+ * **Endpoint stats** changelist: a search box (by endpoint path, route name, app,
130
+ method or replacement), filters by date, profile and role, and the **sum of
131
+ the `count` column** for the rows matching the current filters.
132
+ * **Flush now**: moves counters buffered in the cache into the database, so they
133
+ appear immediately when you run with `BUFFER_BACKEND = "cache"`.
134
+ * **Clear statistics**: empties the aggregated counters behind a confirmation
135
+ page (endpoints and their deprecation metadata are kept).
136
+ * **Export selected rows to CSV** on all three admins. Relations are flattened
137
+ into one analysis-friendly table (a stat row carries its endpoint's path, app
138
+ and method), so the file can be fed straight to pandas or a spreadsheet. Use
139
+ the "select all" link to export every row matching the current filters.
140
+
123
141
  ## Deprecation lifecycle
124
142
 
125
143
  `Endpoint` carries `deprecated`, `sunset_date`, `replacement` and `owner`, so the
@@ -1,8 +1,8 @@
1
- ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/django-api-usage)
1
+ ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/django-api-usage?logo=pypi)
2
2
  ![Django versions](https://img.shields.io/badge/django-4.2%20%7C%205.2-0C4B33)
3
3
  ![Status](https://img.shields.io/badge/status-alpha-orange)
4
- ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/codesyntax/django-api-usage/ci.yml)
5
- ![PyPI - Version](https://img.shields.io/pypi/v/django-api-usage)
4
+ ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/codesyntax/django-api-usage/ci.yml?logo=github)
5
+ ![PyPI - Version](https://img.shields.io/pypi/v/django-api-usage?logo=pypi)
6
6
 
7
7
  # django-api-usage
8
8
 
@@ -81,6 +81,24 @@ API_USAGE = {
81
81
  }
82
82
  ```
83
83
 
84
+ ## Admin
85
+
86
+ The package ships a read-only admin for the three models, built with plain
87
+ Django — **no dependency beyond Django itself** (no `django-object-actions` or
88
+ similar):
89
+
90
+ * **Endpoint stats** changelist: a search box (by endpoint path, route name, app,
91
+ method or replacement), filters by date, profile and role, and the **sum of
92
+ the `count` column** for the rows matching the current filters.
93
+ * **Flush now**: moves counters buffered in the cache into the database, so they
94
+ appear immediately when you run with `BUFFER_BACKEND = "cache"`.
95
+ * **Clear statistics**: empties the aggregated counters behind a confirmation
96
+ page (endpoints and their deprecation metadata are kept).
97
+ * **Export selected rows to CSV** on all three admins. Relations are flattened
98
+ into one analysis-friendly table (a stat row carries its endpoint's path, app
99
+ and method), so the file can be fed straight to pandas or a spreadsheet. Use
100
+ the "select all" link to export every row matching the current filters.
101
+
84
102
  ## Deprecation lifecycle
85
103
 
86
104
  `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.3"
7
+ version = "0.1.5"
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.3"
3
+ __version__ = "0.1.5"
@@ -6,14 +6,21 @@
6
6
  deprecation metadata) are kept on purpose.
7
7
 
8
8
  The changelist also shows the **sum of the ``count`` column** for the rows that
9
- match the current filters.
9
+ match the current filters, and every admin ships an **export to CSV** action for
10
+ the selected rows (all the rows matching the filters can be selected with the
11
+ "select all" link).
12
+
13
+ Everything is plain Django: no extra dependency beyond Django itself.
10
14
  """
11
15
 
16
+ import csv
17
+
12
18
  from django.contrib import admin, messages
13
19
  from django.db.models import Sum
14
- from django.http import HttpResponseRedirect
20
+ from django.http import HttpResponse, HttpResponseRedirect
15
21
  from django.template.response import TemplateResponse
16
22
  from django.urls import path, reverse
23
+ from django.utils import timezone
17
24
  from django.utils.text import format_lazy
18
25
  from django.utils.translation import gettext_lazy as _
19
26
 
@@ -21,8 +28,53 @@ from .buffers import flush
21
28
  from .models import Consumer, Endpoint, EndpointStat
22
29
 
23
30
 
31
+ class CsvExportMixin:
32
+ """Admin action to download the selected rows as CSV.
33
+
34
+ ``csv_columns`` is an iterable of ``(header, queryset lookup)`` pairs. The
35
+ lookups are resolved by the database in a single query, so related rows can
36
+ be flattened into one analysis-friendly table (for example a stat row plus
37
+ its endpoint's path and app label).
38
+ """
39
+
40
+ #: ``(header, lookup)`` pairs. The header is written as-is (keep it stable
41
+ #: and ASCII: the file is meant to be read by pandas or a spreadsheet).
42
+ csv_columns = ()
43
+ #: Prefix of the downloaded filename; the date is appended.
44
+ csv_filename_prefix = "django-api-usage"
45
+
46
+ @admin.action(description=_("Export selected rows to CSV"))
47
+ def export_as_csv(self, request, queryset):
48
+ columns = list(self.csv_columns)
49
+ rows = queryset.values_list(*(lookup for _, lookup in columns))
50
+
51
+ filename = f"{self.csv_filename_prefix}-{timezone.localdate():%Y-%m-%d}.csv"
52
+ response = HttpResponse(content_type="text/csv; charset=utf-8")
53
+ response["Content-Disposition"] = f'attachment; filename="{filename}"'
54
+ # Byte order mark, so Excel detects UTF-8 (content may be Basque).
55
+ response.write("\ufeff")
56
+
57
+ writer = csv.writer(response)
58
+ writer.writerow([header for header, _ in columns])
59
+ writer.writerows(rows)
60
+ return response
61
+
62
+
24
63
  @admin.register(Endpoint)
25
- class EndpointAdmin(admin.ModelAdmin):
64
+ class EndpointAdmin(CsvExportMixin, admin.ModelAdmin):
65
+ actions = ("export_as_csv",)
66
+ csv_columns = (
67
+ ("app_label", "app_label"),
68
+ ("route_path", "route_path"),
69
+ ("route_name", "route_name"),
70
+ ("method", "method"),
71
+ ("site_id", "site_id"),
72
+ ("deprecated", "deprecated"),
73
+ ("sunset_date", "sunset_date"),
74
+ ("replacement", "replacement"),
75
+ ("owner", "owner"),
76
+ ("notes", "notes"),
77
+ )
26
78
  list_display = (
27
79
  "app_label",
28
80
  "route_path",
@@ -40,7 +92,21 @@ class EndpointAdmin(admin.ModelAdmin):
40
92
 
41
93
 
42
94
  @admin.register(EndpointStat)
43
- class EndpointStatAdmin(admin.ModelAdmin):
95
+ class EndpointStatAdmin(CsvExportMixin, admin.ModelAdmin):
96
+ actions = ("export_as_csv",)
97
+ # Flattened on purpose: the CSV is meant for analysis (pandas, spreadsheet)
98
+ # without having to join endpoints by hand.
99
+ csv_columns = (
100
+ ("date", "date"),
101
+ ("app_label", "endpoint__app_label"),
102
+ ("route_path", "endpoint__route_path"),
103
+ ("route_name", "endpoint__route_name"),
104
+ ("method", "endpoint__method"),
105
+ ("site_id", "endpoint__site_id"),
106
+ ("client_type", "client_type"),
107
+ ("status_class", "status_class"),
108
+ ("count", "count"),
109
+ )
44
110
  list_display = ("date", "endpoint", "client_type", "status_class", "count")
45
111
  list_filter = ("date", "client_type", "status_class")
46
112
  list_select_related = ("endpoint",)
@@ -147,7 +213,18 @@ class EndpointStatAdmin(admin.ModelAdmin):
147
213
 
148
214
 
149
215
  @admin.register(Consumer)
150
- class ConsumerAdmin(admin.ModelAdmin):
216
+ class ConsumerAdmin(CsvExportMixin, admin.ModelAdmin):
217
+ actions = ("export_as_csv",)
218
+ csv_columns = (
219
+ ("kind", "kind"),
220
+ ("ref_hash", "ref_hash"),
221
+ ("user_agent_family", "user_agent_family"),
222
+ ("request_count", "request_count"),
223
+ ("first_seen", "first_seen"),
224
+ ("last_seen", "last_seen"),
225
+ ("contact", "contact"),
226
+ ("notes", "notes"),
227
+ )
151
228
  list_display = (
152
229
  "kind",
153
230
  "ref_short",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-api-usage
3
- Version: 0.1.3
3
+ Version: 0.1.5
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
@@ -37,11 +37,11 @@ Requires-Dist: black==26.5.1; extra == "dev"
37
37
  Requires-Dist: ruff==0.16.10; extra == "dev"
38
38
  Dynamic: license-file
39
39
 
40
- ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/django-api-usage)
40
+ ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/django-api-usage?logo=pypi)
41
41
  ![Django versions](https://img.shields.io/badge/django-4.2%20%7C%205.2-0C4B33)
42
42
  ![Status](https://img.shields.io/badge/status-alpha-orange)
43
- ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/codesyntax/django-api-usage/ci.yml)
44
- ![PyPI - Version](https://img.shields.io/pypi/v/django-api-usage)
43
+ ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/codesyntax/django-api-usage/ci.yml?logo=github)
44
+ ![PyPI - Version](https://img.shields.io/pypi/v/django-api-usage?logo=pypi)
45
45
 
46
46
  # django-api-usage
47
47
 
@@ -120,6 +120,24 @@ API_USAGE = {
120
120
  }
121
121
  ```
122
122
 
123
+ ## Admin
124
+
125
+ The package ships a read-only admin for the three models, built with plain
126
+ Django — **no dependency beyond Django itself** (no `django-object-actions` or
127
+ similar):
128
+
129
+ * **Endpoint stats** changelist: a search box (by endpoint path, route name, app,
130
+ method or replacement), filters by date, profile and role, and the **sum of
131
+ the `count` column** for the rows matching the current filters.
132
+ * **Flush now**: moves counters buffered in the cache into the database, so they
133
+ appear immediately when you run with `BUFFER_BACKEND = "cache"`.
134
+ * **Clear statistics**: empties the aggregated counters behind a confirmation
135
+ page (endpoints and their deprecation metadata are kept).
136
+ * **Export selected rows to CSV** on all three admins. Relations are flattened
137
+ into one analysis-friendly table (a stat row carries its endpoint's path, app
138
+ and method), so the file can be fed straight to pandas or a spreadsheet. Use
139
+ the "select all" link to export every row matching the current filters.
140
+
123
141
  ## Deprecation lifecycle
124
142
 
125
143
  `Endpoint` carries `deprecated`, `sunset_date`, `replacement` and `owner`, so the
@@ -31,6 +31,7 @@ src/django_api_usage/templates/admin/django_api_usage/endpointstat/confirm_clear
31
31
  tests/test_admin.py
32
32
  tests/test_checks.py
33
33
  tests/test_commands.py
34
+ tests/test_csv_export.py
34
35
  tests/test_drf.py
35
36
  tests/test_middleware.py
36
37
  tests/test_models.py
@@ -0,0 +1,209 @@
1
+ """Tests for the "export selected rows to CSV" admin action."""
2
+
3
+ import csv
4
+ import io
5
+
6
+ from django.contrib.auth.models import User
7
+ from django.urls import reverse
8
+
9
+ from django_api_usage.models import Consumer, Endpoint, EndpointStat
10
+
11
+ from .base import UsageTestCase
12
+
13
+ ENDPOINT_CHANGELIST = "admin:django_api_usage_endpoint_changelist"
14
+ STAT_CHANGELIST = "admin:django_api_usage_endpointstat_changelist"
15
+ CONSUMER_CHANGELIST = "admin:django_api_usage_consumer_changelist"
16
+
17
+ BOM = b"\xef\xbb\xbf"
18
+
19
+
20
+ class CsvExportTest(UsageTestCase):
21
+ def setUp(self):
22
+ super().setUp()
23
+ self.admin = User.objects.create_superuser("admin", "a@example.com", "pw")
24
+ self.client.force_login(self.admin)
25
+
26
+ # -- helpers ------------------------------------------------------------
27
+
28
+ def export(self, changelist, objects, action="export_as_csv"):
29
+ return self.client.post(
30
+ reverse(changelist),
31
+ {
32
+ "action": action,
33
+ "select_across": "0",
34
+ "index": "0",
35
+ "_selected_action": [str(obj.pk) for obj in objects],
36
+ },
37
+ )
38
+
39
+ def rows(self, response):
40
+ """Return the CSV as a list of rows (BOM stripped)."""
41
+ content = response.content.decode("utf-8-sig")
42
+ return list(csv.reader(io.StringIO(content)))
43
+
44
+ def make_endpoint(self, route_path="api/3.0/herriak/", app_label="api"):
45
+ return Endpoint.objects.create(
46
+ app_label=app_label,
47
+ route_path=route_path,
48
+ route_name="town-list",
49
+ method="GET",
50
+ )
51
+
52
+ def make_stat(self, endpoint, count=1, client_type="anon"):
53
+ return EndpointStat.objects.create(
54
+ endpoint=endpoint,
55
+ date="2026-10-08",
56
+ client_type=client_type,
57
+ status_class="2xx",
58
+ count=count,
59
+ )
60
+
61
+ def assert_csv_response(self, response):
62
+ self.assertEqual(response.status_code, 200)
63
+ self.assertEqual(response["Content-Type"], "text/csv; charset=utf-8")
64
+ self.assertIn("attachment;", response["Content-Disposition"])
65
+ self.assertIn(".csv", response["Content-Disposition"])
66
+ # The BOM makes Excel detect UTF-8.
67
+ self.assertTrue(response.content.startswith(BOM))
68
+
69
+ # -- action availability ------------------------------------------------
70
+
71
+ def test_action_is_offered_in_the_changelist(self):
72
+ endpoint = self.make_endpoint()
73
+ self.make_stat(endpoint)
74
+
75
+ response = self.client.get(reverse(STAT_CHANGELIST))
76
+
77
+ self.assertContains(response, "Export selected rows to CSV")
78
+ self.assertContains(response, 'value="export_as_csv"')
79
+
80
+ def test_delete_selected_is_still_available(self):
81
+ self.make_endpoint()
82
+
83
+ changelist = self.client.get(reverse(ENDPOINT_CHANGELIST))
84
+
85
+ self.assertContains(changelist, "delete_selected")
86
+
87
+ # -- stats export -------------------------------------------------------
88
+
89
+ def test_stats_export_header_is_stable(self):
90
+ endpoint = self.make_endpoint()
91
+ stat = self.make_stat(endpoint)
92
+
93
+ response = self.export(STAT_CHANGELIST, [stat])
94
+
95
+ self.assert_csv_response(response)
96
+ self.assertEqual(
97
+ self.rows(response)[0],
98
+ [
99
+ "date",
100
+ "app_label",
101
+ "route_path",
102
+ "route_name",
103
+ "method",
104
+ "site_id",
105
+ "client_type",
106
+ "status_class",
107
+ "count",
108
+ ],
109
+ )
110
+
111
+ def test_stats_export_flattens_the_endpoint(self):
112
+ endpoint = self.make_endpoint(route_path="^api/3.0/herriak/$")
113
+ stat = self.make_stat(endpoint, count=7)
114
+
115
+ response = self.export(STAT_CHANGELIST, [stat])
116
+
117
+ body = self.rows(response)[1]
118
+ self.assertEqual(body[0], "2026-10-08")
119
+ self.assertEqual(body[1], "api")
120
+ self.assertEqual(body[2], "^api/3.0/herriak/$")
121
+ self.assertEqual(body[3], "town-list")
122
+ self.assertEqual(body[4], "GET")
123
+ self.assertEqual(body[6], "anon")
124
+ self.assertEqual(body[7], "2xx")
125
+ self.assertEqual(body[8], "7")
126
+
127
+ def test_stats_export_only_includes_the_selected_rows(self):
128
+ endpoint = self.make_endpoint()
129
+ selected = self.make_stat(endpoint, count=1)
130
+ self.make_stat(endpoint, count=99, client_type="user")
131
+
132
+ response = self.export(STAT_CHANGELIST, [selected])
133
+
134
+ rows = self.rows(response)
135
+ self.assertEqual(len(rows), 2) # header + one row
136
+ self.assertEqual(rows[1][8], "1")
137
+
138
+ def test_stats_export_filename_contains_the_date(self):
139
+ endpoint = self.make_endpoint()
140
+ stat = self.make_stat(endpoint)
141
+
142
+ response = self.export(STAT_CHANGELIST, [stat])
143
+
144
+ self.assertIn('filename="django-api-usage-', response["Content-Disposition"])
145
+
146
+ # -- endpoints and consumers export -------------------------------------
147
+
148
+ def test_endpoints_export(self):
149
+ endpoint = self.make_endpoint()
150
+ Endpoint.objects.filter(pk=endpoint.pk).update(
151
+ deprecated=True, replacement="api/4.0/herriak/"
152
+ )
153
+
154
+ response = self.export(ENDPOINT_CHANGELIST, [endpoint])
155
+
156
+ self.assert_csv_response(response)
157
+ rows = self.rows(response)
158
+ self.assertIn("deprecated", rows[0])
159
+ self.assertIn("replacement", rows[0])
160
+ self.assertIn("True", rows[1])
161
+ self.assertIn("api/4.0/herriak/", rows[1])
162
+
163
+ def test_consumers_export(self):
164
+ consumer = Consumer.objects.create(
165
+ kind="ip_hash",
166
+ ref_hash="a" * 64,
167
+ user_agent_family="firefox",
168
+ request_count=12,
169
+ )
170
+
171
+ response = self.export(CONSUMER_CHANGELIST, [consumer])
172
+
173
+ self.assert_csv_response(response)
174
+ rows = self.rows(response)
175
+ self.assertEqual(rows[0][0], "kind")
176
+ self.assertEqual(rows[0][1], "ref_hash")
177
+ self.assertEqual(rows[1][0], "ip_hash")
178
+ self.assertEqual(rows[1][1], "a" * 64)
179
+
180
+ # -- edge cases ---------------------------------------------------------
181
+
182
+ def test_export_without_selection_is_not_a_csv(self):
183
+ """Django warns and redirects instead of downloading an empty file."""
184
+ response = self.client.post(
185
+ reverse(STAT_CHANGELIST),
186
+ {"action": "export_as_csv", "select_across": "0", "index": "0"},
187
+ )
188
+
189
+ self.assertEqual(response.status_code, 302)
190
+ self.assertNotEqual(response.get("Content-Type"), "text/csv; charset=utf-8")
191
+
192
+ def test_export_all_matching_rows_with_select_across(self):
193
+ """One checked row + "select all" exports every row matching the filters."""
194
+ endpoint = self.make_endpoint()
195
+ first = self.make_stat(endpoint, count=1)
196
+ self.make_stat(endpoint, count=2, client_type="user")
197
+
198
+ response = self.client.post(
199
+ reverse(STAT_CHANGELIST),
200
+ {
201
+ "action": "export_as_csv",
202
+ "select_across": "1",
203
+ "index": "0",
204
+ "_selected_action": [str(first.pk)],
205
+ },
206
+ )
207
+
208
+ self.assert_csv_response(response)
209
+ self.assertEqual(len(self.rows(response)), 3) # header + two rows