django-api-usage 0.1.0__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 (34) hide show
  1. django_api_usage-0.1.0/LICENSE +21 -0
  2. django_api_usage-0.1.0/PKG-INFO +155 -0
  3. django_api_usage-0.1.0/README.md +116 -0
  4. django_api_usage-0.1.0/pyproject.toml +51 -0
  5. django_api_usage-0.1.0/setup.cfg +4 -0
  6. django_api_usage-0.1.0/src/django_api_usage/__init__.py +3 -0
  7. django_api_usage-0.1.0/src/django_api_usage/admin.py +56 -0
  8. django_api_usage-0.1.0/src/django_api_usage/apps.py +11 -0
  9. django_api_usage-0.1.0/src/django_api_usage/buffers.py +140 -0
  10. django_api_usage-0.1.0/src/django_api_usage/checks.py +80 -0
  11. django_api_usage-0.1.0/src/django_api_usage/conf.py +81 -0
  12. django_api_usage-0.1.0/src/django_api_usage/deprecation.py +34 -0
  13. django_api_usage-0.1.0/src/django_api_usage/drf.py +71 -0
  14. django_api_usage-0.1.0/src/django_api_usage/maintenance.py +21 -0
  15. django_api_usage-0.1.0/src/django_api_usage/management/__init__.py +0 -0
  16. django_api_usage-0.1.0/src/django_api_usage/management/commands/__init__.py +0 -0
  17. django_api_usage-0.1.0/src/django_api_usage/management/commands/api_usage_flush.py +27 -0
  18. django_api_usage-0.1.0/src/django_api_usage/management/commands/api_usage_report.py +60 -0
  19. django_api_usage-0.1.0/src/django_api_usage/middleware.py +60 -0
  20. django_api_usage-0.1.0/src/django_api_usage/migrations/0001_initial.py +118 -0
  21. django_api_usage-0.1.0/src/django_api_usage/migrations/__init__.py +0 -0
  22. django_api_usage-0.1.0/src/django_api_usage/models.py +95 -0
  23. django_api_usage-0.1.0/src/django_api_usage/resolvers.py +104 -0
  24. django_api_usage-0.1.0/src/django_api_usage/tasks.py +22 -0
  25. django_api_usage-0.1.0/src/django_api_usage.egg-info/PKG-INFO +155 -0
  26. django_api_usage-0.1.0/src/django_api_usage.egg-info/SOURCES.txt +32 -0
  27. django_api_usage-0.1.0/src/django_api_usage.egg-info/dependency_links.txt +1 -0
  28. django_api_usage-0.1.0/src/django_api_usage.egg-info/requires.txt +17 -0
  29. django_api_usage-0.1.0/src/django_api_usage.egg-info/top_level.txt +1 -0
  30. django_api_usage-0.1.0/tests/test_checks.py +29 -0
  31. django_api_usage-0.1.0/tests/test_commands.py +51 -0
  32. django_api_usage-0.1.0/tests/test_drf.py +60 -0
  33. django_api_usage-0.1.0/tests/test_middleware.py +45 -0
  34. django_api_usage-0.1.0/tests/test_models.py +35 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 CodeSyntax
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,155 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-api-usage
3
+ Version: 0.1.0
4
+ Summary: Lightweight, privacy-first usage metering for Django APIs, with an optional deprecation lifecycle layer.
5
+ Author-email: CodeSyntax <teknika@codesyntax.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/codesyntax/django-api-usage
8
+ Project-URL: Source, https://github.com/codesyntax/django-api-usage
9
+ Project-URL: Issues, https://github.com/codesyntax/django-api-usage/issues
10
+ Keywords: django,api,usage,metrics,deprecation,drf
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Framework :: Django
13
+ Classifier: Framework :: Django :: 4.2
14
+ Classifier: Framework :: Django :: 5.0
15
+ Classifier: Framework :: Django :: 5.1
16
+ Classifier: Framework :: Django :: 5.2
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Topic :: Internet :: WWW/HTTP
22
+ Requires-Python: >=3.10
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: Django>=4.2
26
+ Provides-Extra: drf
27
+ Requires-Dist: djangorestframework>=3.14; extra == "drf"
28
+ Provides-Extra: celery
29
+ Requires-Dist: celery>=5.2; extra == "celery"
30
+ Provides-Extra: redis
31
+ Requires-Dist: redis>=4.0; extra == "redis"
32
+ Provides-Extra: dev
33
+ Requires-Dist: pytest; extra == "dev"
34
+ Requires-Dist: pytest-django; extra == "dev"
35
+ Requires-Dist: djangorestframework>=3.14; extra == "dev"
36
+ Requires-Dist: black==26.5.1; extra == "dev"
37
+ Requires-Dist: ruff==0.16.10; extra == "dev"
38
+ Dynamic: license-file
39
+
40
+ ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/django-api-usage)
41
+ ![Django versions](https://img.shields.io/badge/django-4.2%20%7C%205.2-0C4B33)
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)
45
+
46
+ # django-api-usage
47
+
48
+ Lightweight, privacy-first **usage metering for Django APIs**, with an optional
49
+ **deprecation lifecycle** layer on top.
50
+
51
+ It answers two questions that decide whether an endpoint can be changed or removed:
52
+
53
+ 1. **How much is this endpoint (or this whole Django app) used?**
54
+ 2. **Who is calling it, so they can be contacted before it changes?**
55
+
56
+ The package is deliberately generic: the core is plain Django middleware (works
57
+ with or without Django REST Framework), it never breaks a request because
58
+ metering failed, and it stores no raw personal data by default.
59
+
60
+ ## Why not just `drf-api-tracking`?
61
+
62
+ `drf-api-tracking` stores one database row per request (including bodies), which
63
+ is heavy and privacy-sensitive. `django-api-usage` accumulates **counters** and
64
+ keeps consumer attribution **hashed and opt-in**. That makes it suitable both for
65
+ continuous usage analytics and for the concrete decision of deprecating an
66
+ endpoint.
67
+
68
+ ## Install
69
+
70
+ ```bash
71
+ pip install django-api-usage
72
+ ```
73
+
74
+ ```python
75
+ INSTALLED_APPS = [
76
+ # ...
77
+ "django_api_usage",
78
+ ]
79
+
80
+ MIDDLEWARE = [
81
+ # ...
82
+ "django_api_usage.middleware.ApiUsageMiddleware",
83
+ ]
84
+ ```
85
+
86
+ Metering starts immediately. Counters are buffered in the cache and written to
87
+ the database in batches:
88
+
89
+ ```bash
90
+ python manage.py api_usage_flush # from cron, or a Celery beat task
91
+ python manage.py api_usage_report --days 30
92
+ python manage.py api_usage_report --days 90 --sunset-candidates
93
+ ```
94
+
95
+ With Celery (and the `celery` extra) two tasks are provided:
96
+ `django_api_usage.tasks.flush_api_usage` and
97
+ `django_api_usage.tasks.prune_api_usage`.
98
+
99
+ ## Configuration
100
+
101
+ ```python
102
+ API_USAGE = {
103
+ "ENABLED": True,
104
+ "FAIL_OPEN": True,
105
+ "BUFFER_BACKEND": "cache", # "cache" (batched) or "db" (write per request)
106
+ "RETENTION_DAYS": 90,
107
+ "TRACK_CONSUMERS": False, # opt-in; stores hashed callers only
108
+ "CONSUMER_SALT": "a-rotatable-secret",
109
+ }
110
+ ```
111
+
112
+ All resolvers are overridable, so no project-specific knowledge leaks into the
113
+ package:
114
+
115
+ ```python
116
+ API_USAGE = {
117
+ "APP_LABEL_RESOLVER": "myproject.api.resolvers.app_label",
118
+ "SITE_RESOLVER": "myproject.api.resolvers.site_id",
119
+ "ROLE_SCOPES_RESOLVER": "myproject.api.resolvers.role_scopes",
120
+ }
121
+ ```
122
+
123
+ ## Deprecation lifecycle
124
+
125
+ `Endpoint` carries `deprecated`, `sunset_date`, `replacement` and `owner`, so the
126
+ same data that measures usage also drives a deprecation plan. See the consuming
127
+ project's migration guide for the full workflow (measure → notify → enforce).
128
+
129
+ ## DRF shadow mode
130
+
131
+ `django_api_usage.drf.HasAPIScope` enforces scopes declared on a view:
132
+
133
+ ```python
134
+ class AllUserViewSet(viewsets.ReadOnlyModelViewSet):
135
+ permission_classes = [HasAPIScope]
136
+ required_scopes = ["user:read_all"]
137
+ ```
138
+
139
+ With `API_ENFORCEMENT = "report"` (the default) nothing is blocked yet: a
140
+ `X-Api-Would-Deny` header is returned instead, so you can deploy a permission and
141
+ measure what it *would* have denied before enforcing it.
142
+
143
+ ## Status
144
+
145
+ **Alpha.** The core is implemented: metering middleware, models and migrations,
146
+ management commands, the deprecation layer and a DRF shadow-mode permission.
147
+ Tested on Python 3.10-3.12 and Django 4.2/5.2. MIT licensed.
148
+
149
+ ## Development
150
+
151
+ ```bash
152
+ python -m django test tests --settings=tests.settings
153
+ ruff check .
154
+ black --check .
155
+ ```
@@ -0,0 +1,116 @@
1
+ ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/django-api-usage)
2
+ ![Django versions](https://img.shields.io/badge/django-4.2%20%7C%205.2-0C4B33)
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)
6
+
7
+ # django-api-usage
8
+
9
+ Lightweight, privacy-first **usage metering for Django APIs**, with an optional
10
+ **deprecation lifecycle** layer on top.
11
+
12
+ It answers two questions that decide whether an endpoint can be changed or removed:
13
+
14
+ 1. **How much is this endpoint (or this whole Django app) used?**
15
+ 2. **Who is calling it, so they can be contacted before it changes?**
16
+
17
+ The package is deliberately generic: the core is plain Django middleware (works
18
+ with or without Django REST Framework), it never breaks a request because
19
+ metering failed, and it stores no raw personal data by default.
20
+
21
+ ## Why not just `drf-api-tracking`?
22
+
23
+ `drf-api-tracking` stores one database row per request (including bodies), which
24
+ is heavy and privacy-sensitive. `django-api-usage` accumulates **counters** and
25
+ keeps consumer attribution **hashed and opt-in**. That makes it suitable both for
26
+ continuous usage analytics and for the concrete decision of deprecating an
27
+ endpoint.
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ pip install django-api-usage
33
+ ```
34
+
35
+ ```python
36
+ INSTALLED_APPS = [
37
+ # ...
38
+ "django_api_usage",
39
+ ]
40
+
41
+ MIDDLEWARE = [
42
+ # ...
43
+ "django_api_usage.middleware.ApiUsageMiddleware",
44
+ ]
45
+ ```
46
+
47
+ Metering starts immediately. Counters are buffered in the cache and written to
48
+ the database in batches:
49
+
50
+ ```bash
51
+ python manage.py api_usage_flush # from cron, or a Celery beat task
52
+ python manage.py api_usage_report --days 30
53
+ python manage.py api_usage_report --days 90 --sunset-candidates
54
+ ```
55
+
56
+ With Celery (and the `celery` extra) two tasks are provided:
57
+ `django_api_usage.tasks.flush_api_usage` and
58
+ `django_api_usage.tasks.prune_api_usage`.
59
+
60
+ ## Configuration
61
+
62
+ ```python
63
+ API_USAGE = {
64
+ "ENABLED": True,
65
+ "FAIL_OPEN": True,
66
+ "BUFFER_BACKEND": "cache", # "cache" (batched) or "db" (write per request)
67
+ "RETENTION_DAYS": 90,
68
+ "TRACK_CONSUMERS": False, # opt-in; stores hashed callers only
69
+ "CONSUMER_SALT": "a-rotatable-secret",
70
+ }
71
+ ```
72
+
73
+ All resolvers are overridable, so no project-specific knowledge leaks into the
74
+ package:
75
+
76
+ ```python
77
+ API_USAGE = {
78
+ "APP_LABEL_RESOLVER": "myproject.api.resolvers.app_label",
79
+ "SITE_RESOLVER": "myproject.api.resolvers.site_id",
80
+ "ROLE_SCOPES_RESOLVER": "myproject.api.resolvers.role_scopes",
81
+ }
82
+ ```
83
+
84
+ ## Deprecation lifecycle
85
+
86
+ `Endpoint` carries `deprecated`, `sunset_date`, `replacement` and `owner`, so the
87
+ same data that measures usage also drives a deprecation plan. See the consuming
88
+ project's migration guide for the full workflow (measure → notify → enforce).
89
+
90
+ ## DRF shadow mode
91
+
92
+ `django_api_usage.drf.HasAPIScope` enforces scopes declared on a view:
93
+
94
+ ```python
95
+ class AllUserViewSet(viewsets.ReadOnlyModelViewSet):
96
+ permission_classes = [HasAPIScope]
97
+ required_scopes = ["user:read_all"]
98
+ ```
99
+
100
+ With `API_ENFORCEMENT = "report"` (the default) nothing is blocked yet: a
101
+ `X-Api-Would-Deny` header is returned instead, so you can deploy a permission and
102
+ measure what it *would* have denied before enforcing it.
103
+
104
+ ## Status
105
+
106
+ **Alpha.** The core is implemented: metering middleware, models and migrations,
107
+ management commands, the deprecation layer and a DRF shadow-mode permission.
108
+ Tested on Python 3.10-3.12 and Django 4.2/5.2. MIT licensed.
109
+
110
+ ## Development
111
+
112
+ ```bash
113
+ python -m django test tests --settings=tests.settings
114
+ ruff check .
115
+ black --check .
116
+ ```
@@ -0,0 +1,51 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "django-api-usage"
7
+ version = "0.1.0"
8
+ description = "Lightweight, privacy-first usage metering for Django APIs, with an optional deprecation lifecycle layer."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.10"
13
+ authors = [{ name = "CodeSyntax", email = "teknika@codesyntax.com" }]
14
+ keywords = ["django", "api", "usage", "metrics", "deprecation", "drf"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Framework :: Django",
18
+ "Framework :: Django :: 4.2",
19
+ "Framework :: Django :: 5.0",
20
+ "Framework :: Django :: 5.1",
21
+ "Framework :: Django :: 5.2",
22
+ "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python :: 3.10",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Topic :: Internet :: WWW/HTTP",
27
+ ]
28
+ dependencies = ["Django>=4.2"]
29
+
30
+ [project.optional-dependencies]
31
+ drf = ["djangorestframework>=3.14"]
32
+ celery = ["celery>=5.2"]
33
+ redis = ["redis>=4.0"]
34
+ dev = ["pytest", "pytest-django", "djangorestframework>=3.14", "black==26.5.1", "ruff==0.16.10"]
35
+
36
+ [project.urls]
37
+ Homepage = "https://github.com/codesyntax/django-api-usage"
38
+ Source = "https://github.com/codesyntax/django-api-usage"
39
+ Issues = "https://github.com/codesyntax/django-api-usage/issues"
40
+
41
+ [tool.setuptools.packages.find]
42
+ where = ["src"]
43
+
44
+ [tool.black]
45
+ line-length = 88
46
+ target-version = ["py310"]
47
+
48
+ [tool.pytest.ini_options]
49
+ DJANGO_SETTINGS_MODULE = "tests.settings"
50
+ pythonpath = ["src", "."]
51
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """django-api-usage: usage metering for Django APIs, with deprecation support."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,56 @@
1
+ """Read-only admin for usage counters, plus editable deprecation metadata."""
2
+
3
+ from django.contrib import admin
4
+
5
+ from .models import Consumer, Endpoint, EndpointStat
6
+
7
+
8
+ @admin.register(Endpoint)
9
+ class EndpointAdmin(admin.ModelAdmin):
10
+ list_display = (
11
+ "app_label",
12
+ "route_name",
13
+ "method",
14
+ "site_id",
15
+ "deprecated",
16
+ "sunset_date",
17
+ "replacement",
18
+ "owner",
19
+ )
20
+ list_filter = ("deprecated", "app_label", "method", "site_id")
21
+ search_fields = ("route_name", "replacement", "owner", "notes")
22
+ list_editable = ("deprecated", "sunset_date", "replacement", "owner")
23
+
24
+
25
+ @admin.register(EndpointStat)
26
+ class EndpointStatAdmin(admin.ModelAdmin):
27
+ list_display = ("date", "endpoint", "client_type", "status_class", "count")
28
+ list_filter = ("date", "client_type", "status_class")
29
+ list_select_related = ("endpoint",)
30
+ date_hierarchy = "date"
31
+
32
+ def has_add_permission(self, request):
33
+ return False
34
+
35
+ def has_change_permission(self, request, obj=None):
36
+ return False
37
+
38
+
39
+ @admin.register(Consumer)
40
+ class ConsumerAdmin(admin.ModelAdmin):
41
+ list_display = (
42
+ "kind",
43
+ "ref_short",
44
+ "user_agent_family",
45
+ "request_count",
46
+ "first_seen",
47
+ "last_seen",
48
+ "contact",
49
+ )
50
+ list_filter = ("kind",)
51
+ search_fields = ("contact", "notes")
52
+ list_editable = ("contact",)
53
+
54
+ @admin.display(description="Reference")
55
+ def ref_short(self, obj):
56
+ return obj.ref_hash[:12]
@@ -0,0 +1,11 @@
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
@@ -0,0 +1,140 @@
1
+ """Recording backends.
2
+
3
+ The default backend accumulates counters in a single cache key and flushes them
4
+ to the database in batches (``api_usage_flush``), so a request never pays a
5
+ database write. With ``FAIL_OPEN`` metering can never break a request.
6
+
7
+ Note: the cache backend trades perfect accuracy under high concurrency for
8
+ throughput. If you need per-request exactness, set ``BUFFER_BACKEND = "db"``.
9
+ """
10
+
11
+ import json
12
+ import logging
13
+
14
+ from django.core.cache import caches
15
+ from django.db import transaction
16
+ from django.db.models import F
17
+ from django.utils import timezone
18
+
19
+ from .conf import api_settings
20
+ from .models import Consumer, Endpoint, EndpointStat
21
+
22
+ logger = logging.getLogger("django_api_usage")
23
+
24
+ # Order matters: it is how a buffered bucket is encoded into a cache key.
25
+ _FIELDS = ("site_id", "app_label", "route_name", "method", "client_type")
26
+
27
+
28
+ def _cache():
29
+ return caches[api_settings.CACHE_ALIAS]
30
+
31
+
32
+ def _buffer_key():
33
+ return f"{api_settings.CACHE_PREFIX}:buffer"
34
+
35
+
36
+ def status_class(status_code):
37
+ """``204`` -> ``2xx``."""
38
+ return f"{int(status_code) // 100}xx"
39
+
40
+
41
+ def record_hit(dimensions, status_code, consumer=None):
42
+ """Record a single hit.
43
+
44
+ ``dimensions`` is a mapping with the keys in :data:`_FIELDS`.
45
+ """
46
+ if api_settings.BUFFER_BACKEND == "db":
47
+ _write_to_db(dimensions, status_class(status_code), 1)
48
+ else:
49
+ _buffer(dimensions, status_class(status_code))
50
+ if consumer and api_settings.TRACK_CONSUMERS:
51
+ _touch_consumer(consumer)
52
+
53
+
54
+ def _buffer(dimensions, status_code_class):
55
+ cache = _cache()
56
+ key = _buffer_key()
57
+ raw = cache.get(key)
58
+ counters = json.loads(raw) if raw else {}
59
+ bucket = "{}|{}".format(
60
+ "|".join(str(dimensions.get(field, "")) for field in _FIELDS),
61
+ status_code_class,
62
+ )
63
+ counters[bucket] = counters.get(bucket, 0) + 1
64
+ cache.set(key, json.dumps(counters), None)
65
+
66
+
67
+ def flush():
68
+ """Move buffered counters from the cache into the database.
69
+
70
+ Returns the number of counter buckets written.
71
+ """
72
+ cache = _cache()
73
+ key = _buffer_key()
74
+ raw = cache.get(key)
75
+ if not raw:
76
+ return 0
77
+ cache.delete(key)
78
+ counters = json.loads(raw) if isinstance(raw, str) else raw
79
+ written = 0
80
+ for bucket, count in counters.items():
81
+ dimension_key, _, status_code_class = bucket.rpartition("|")
82
+ values = dimension_key.split("|")
83
+ if len(values) != len(_FIELDS):
84
+ logger.warning("django-api-usage: skipping malformed bucket %r", bucket)
85
+ continue
86
+ dimensions = dict(zip(_FIELDS, values))
87
+ dimensions["site_id"] = _as_int(dimensions.get("site_id"))
88
+ _write_to_db(dimensions, status_code_class, count)
89
+ written += 1
90
+ return written
91
+
92
+
93
+ def _as_int(value):
94
+ if value in (None, "", "None"):
95
+ return None
96
+ try:
97
+ return int(value)
98
+ except (TypeError, ValueError):
99
+ return None
100
+
101
+
102
+ def _write_to_db(dimensions, status_code_class, count):
103
+ with transaction.atomic():
104
+ endpoint, _ = Endpoint.objects.get_or_create(
105
+ site_id=dimensions.get("site_id"),
106
+ app_label=dimensions.get("app_label") or "unknown",
107
+ route_name=dimensions.get("route_name") or "unknown",
108
+ method=dimensions.get("method") or "GET",
109
+ )
110
+ stat, created = EndpointStat.objects.get_or_create(
111
+ endpoint=endpoint,
112
+ date=timezone.localdate(),
113
+ client_type=dimensions.get("client_type") or "anon",
114
+ status_class=status_code_class,
115
+ defaults={"count": count},
116
+ )
117
+ if not created:
118
+ EndpointStat.objects.filter(pk=stat.pk).update(count=F("count") + count)
119
+
120
+
121
+ def _touch_consumer(consumer):
122
+ kind, ref_hash, user_agent_family = consumer
123
+ now = timezone.now()
124
+ obj, created = Consumer.objects.get_or_create(
125
+ kind=kind,
126
+ ref_hash=ref_hash,
127
+ defaults={
128
+ "user_agent_family": user_agent_family,
129
+ "first_seen": now,
130
+ "last_seen": now,
131
+ "request_count": 1,
132
+ },
133
+ )
134
+ if created:
135
+ return
136
+ Consumer.objects.filter(pk=obj.pk).update(
137
+ last_seen=now,
138
+ request_count=F("request_count") + 1,
139
+ user_agent_family=user_agent_family,
140
+ )
@@ -0,0 +1,80 @@
1
+ """Django system checks: catch misconfiguration early."""
2
+
3
+ from django.conf import settings
4
+ from django.core.checks import Warning, register
5
+ from django.utils.module_loading import import_string
6
+
7
+ from .conf import api_settings
8
+
9
+ W001 = Warning(
10
+ "django-api-usage is enabled but no subclass of "
11
+ "'django_api_usage.middleware.ApiUsageMiddleware' is in MIDDLEWARE; no usage "
12
+ "will be recorded.",
13
+ id="api_usage.W001",
14
+ )
15
+ W002 = Warning(
16
+ "API_USAGE['TRACK_CONSUMERS'] is enabled but API_USAGE['CONSUMER_SALT'] is empty; "
17
+ "SECRET_KEY will be used as the salt. Set an explicit, rotatable salt.",
18
+ id="api_usage.W002",
19
+ )
20
+ W003 = Warning(
21
+ "API_USAGE['BUFFER_BACKEND'] is 'cache' but the configured cache backend is "
22
+ "DummyCache, which stores nothing: no usage will be recorded. Configure a real "
23
+ "cache or set BUFFER_BACKEND='db'.",
24
+ id="api_usage.W003",
25
+ )
26
+
27
+ DUMMY_CACHE_BACKEND = "django.core.cache.backends.dummy.DummyCache"
28
+
29
+
30
+ def _middleware_installed():
31
+ """True when some MIDDLEWARE entry is ApiUsageMiddleware or a subclass.
32
+
33
+ Subclasses are accepted on purpose: projects are encouraged to subclass the
34
+ middleware to narrow what gets metered (for example, only ``/api/``).
35
+ """
36
+ from .middleware import ApiUsageMiddleware
37
+
38
+ for entry in getattr(settings, "MIDDLEWARE", []) or []:
39
+ if not isinstance(entry, str):
40
+ continue
41
+ try:
42
+ obj = import_string(entry)
43
+ except Exception: # pragma: no cover - unresolvable entry
44
+ continue
45
+ if isinstance(obj, type) and issubclass(obj, ApiUsageMiddleware):
46
+ return True
47
+ return False
48
+
49
+
50
+ def _cache_backend_path():
51
+ """Dotted path of the configured cache backend, or '' if unavailable."""
52
+ from django.core.cache import caches
53
+
54
+ try:
55
+ backend = caches[api_settings.CACHE_ALIAS]
56
+ except Exception: # pragma: no cover - misconfigured CACHES
57
+ return ""
58
+ backend_class = type(backend)
59
+ return f"{backend_class.__module__}.{backend_class.__name__}"
60
+
61
+
62
+ @register()
63
+ def api_usage_checks(app_configs, **kwargs):
64
+ errors = []
65
+ if not api_settings.ENABLED:
66
+ return errors
67
+
68
+ if not _middleware_installed():
69
+ errors.append(W001)
70
+
71
+ if api_settings.TRACK_CONSUMERS and not api_settings.CONSUMER_SALT:
72
+ errors.append(W002)
73
+
74
+ if (
75
+ api_settings.BUFFER_BACKEND == "cache"
76
+ and _cache_backend_path() == DUMMY_CACHE_BACKEND
77
+ ):
78
+ errors.append(W003)
79
+
80
+ return errors