django-api-usage 0.1.0__py3-none-any.whl

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.
@@ -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
@@ -0,0 +1,81 @@
1
+ """Configuration for django-api-usage.
2
+
3
+ Every option is read from the ``API_USAGE`` dictionary in Django settings::
4
+
5
+ API_USAGE = {
6
+ "ENABLED": True,
7
+ "TRACK_CONSUMERS": False,
8
+ }
9
+
10
+ Resolver options accept either a dotted path to a callable or the callable
11
+ itself. Nothing in this module depends on the host project.
12
+ """
13
+
14
+ from django.utils.module_loading import import_string
15
+
16
+ DEFAULTS = {
17
+ # Master switch: when False the middleware becomes a no-op.
18
+ "ENABLED": True,
19
+ # Metering must never break a request.
20
+ "FAIL_OPEN": True,
21
+ # "cache" buffers counters in the cache and writes them in batches;
22
+ # "db" writes one aggregate update per request (simpler, slower).
23
+ "BUFFER_BACKEND": "cache",
24
+ "CACHE_ALIAS": "default",
25
+ "CACHE_PREFIX": "api_usage",
26
+ # Raw per-day aggregates older than this are pruned. 0 keeps them forever.
27
+ "RETENTION_DAYS": 90,
28
+ # Opt-in consumer attribution. Only salted hashes are stored; see the docs.
29
+ "TRACK_CONSUMERS": False,
30
+ # Salt used to hash consumer identifiers. Keep it secret and rotatable.
31
+ "CONSUMER_SALT": "",
32
+ # Resolvers (dotted path or callable).
33
+ "APP_LABEL_RESOLVER": "django_api_usage.resolvers.default_app_label",
34
+ "ROUTE_NAME_RESOLVER": "django_api_usage.resolvers.default_route_name",
35
+ "CLIENT_TYPE_RESOLVER": "django_api_usage.resolvers.default_client_type",
36
+ "SITE_RESOLVER": "django_api_usage.resolvers.default_site_id",
37
+ "CONSUMER_RESOLVER": "django_api_usage.resolvers.default_consumer",
38
+ "ROLE_SCOPES_RESOLVER": "django_api_usage.drf.default_role_scopes",
39
+ # Regexes matched against request.path; matching requests are not recorded.
40
+ "IGNORE_PATHS": (
41
+ r"^/static/",
42
+ r"^/media/",
43
+ r"^/favicon\.ico$",
44
+ ),
45
+ }
46
+
47
+
48
+ class ApiUsageSettings:
49
+ """Lazy accessor over ``settings.API_USAGE`` with sane defaults."""
50
+
51
+ def __init__(self, user_settings=None):
52
+ self._user = user_settings
53
+ self._resolved = {}
54
+
55
+ @property
56
+ def user_settings(self):
57
+ if self._user is None:
58
+ from django.conf import settings as django_settings
59
+
60
+ self._user = getattr(django_settings, "API_USAGE", {}) or {}
61
+ return self._user
62
+
63
+ def __getattr__(self, attr):
64
+ if attr not in DEFAULTS:
65
+ raise AttributeError(f"Invalid API_USAGE setting: {attr!r}")
66
+ if attr in self._resolved:
67
+ return self._resolved[attr]
68
+ value = self.user_settings.get(attr, DEFAULTS[attr])
69
+ if attr.endswith("_RESOLVER"):
70
+ if isinstance(value, str):
71
+ value = import_string(value)
72
+ self._resolved[attr] = value
73
+ return value
74
+
75
+ def reload(self):
76
+ """Forget cached settings and resolved callables (used in tests)."""
77
+ self._user = None
78
+ self._resolved = {}
79
+
80
+
81
+ api_settings = ApiUsageSettings()
@@ -0,0 +1,34 @@
1
+ """Deprecation lifecycle helpers built on top of the usage counters."""
2
+
3
+ from datetime import timedelta
4
+
5
+ from django.db.models import Sum
6
+ from django.utils import timezone
7
+
8
+ from .models import Endpoint, EndpointStat
9
+
10
+
11
+ def usage(period_days=90, site_id=None):
12
+ """Return ``[(endpoint, calls), ...]`` for every known endpoint."""
13
+ since = timezone.localdate() - timedelta(days=period_days)
14
+ endpoints = Endpoint.objects.all()
15
+ if site_id is not None:
16
+ endpoints = endpoints.filter(site_id=site_id)
17
+ totals = (
18
+ EndpointStat.objects.filter(date__gte=since)
19
+ .values("endpoint_id")
20
+ .annotate(total=Sum("count"))
21
+ )
22
+ by_id = {row["endpoint_id"]: row["total"] for row in totals}
23
+ return [(endpoint, by_id.get(endpoint.pk, 0)) for endpoint in endpoints]
24
+
25
+
26
+ def in_use(period_days=90, site_id=None):
27
+ """Endpoints with traffic in the period, most used first."""
28
+ rows = [(ep, total) for ep, total in usage(period_days, site_id) if total > 0]
29
+ return sorted(rows, key=lambda row: row[1], reverse=True)
30
+
31
+
32
+ def candidates_for_deprecation(period_days=90, site_id=None):
33
+ """Endpoints with no traffic in the period: the safe ones to deprecate."""
34
+ return [ep for ep, total in usage(period_days, site_id) if total == 0]
@@ -0,0 +1,71 @@
1
+ """Optional DRF integration: scope-based permissions with a shadow mode.
2
+
3
+ Importing this module requires ``djangorestframework`` to be installed.
4
+
5
+ The point of the shadow mode is to let a new permission be **deployed and
6
+ measured before it is enforced**: with ``API_ENFORCEMENT = "report"`` nothing is
7
+ blocked, and the middleware adds an ``X-Api-Would-Deny`` header so you can see
8
+ what the permission would have rejected.
9
+ """
10
+
11
+ from django.conf import settings
12
+ from rest_framework.permissions import BasePermission
13
+
14
+ from .conf import api_settings
15
+
16
+ REPORT = "report"
17
+ WARN = "warn"
18
+ ENFORCE = "enforce"
19
+
20
+
21
+ def enforcement_mode(view=None):
22
+ """Resolve the enforcement mode, honouring per-view/per-endpoint overrides."""
23
+ overrides = getattr(settings, "API_ENFORCEMENT_OVERRIDES", {}) or {}
24
+ if view is not None:
25
+ key = getattr(view, "enforcement_key", None) or getattr(view, "basename", None)
26
+ if key and key in overrides:
27
+ return overrides[key]
28
+ return getattr(settings, "API_ENFORCEMENT", REPORT)
29
+
30
+
31
+ def default_role_scopes(user):
32
+ """Scopes granted to a user.
33
+
34
+ Uses ``user.get_api_scopes()`` when the project exposes it, otherwise maps
35
+ Django groups named ``api:<scope>`` (e.g. ``api:content:write``).
36
+ """
37
+ getter = getattr(user, "get_api_scopes", None)
38
+ if callable(getter):
39
+ return set(getter())
40
+ return {
41
+ group.name[4:] for group in user.groups.all() if group.name.startswith("api:")
42
+ }
43
+
44
+
45
+ def granted_scopes(request):
46
+ """Scopes granted to the caller: client key scopes, or role-derived scopes."""
47
+ scopes = set()
48
+ api_key = getattr(request, "api_key", None)
49
+ if api_key is not None:
50
+ scopes.update(api_key.scopes.values_list("code", flat=True))
51
+ return scopes
52
+ user = getattr(request, "user", None)
53
+ if user is not None and getattr(user, "is_authenticated", False):
54
+ scopes.update(api_settings.ROLE_SCOPES_RESOLVER(user))
55
+ return scopes
56
+
57
+
58
+ class HasAPIScope(BasePermission):
59
+ """Require the scopes declared in ``view.required_scopes``.
60
+
61
+ Views that declare no scopes are denied (fail closed) once enforcing.
62
+ """
63
+
64
+ def has_permission(self, request, view):
65
+ required = set(getattr(view, "required_scopes", ()) or ())
66
+ if required and required <= granted_scopes(request):
67
+ return True
68
+ if enforcement_mode(view) in (REPORT, WARN):
69
+ request._api_usage_would_deny = sorted(required)
70
+ return True
71
+ return False
@@ -0,0 +1,21 @@
1
+ """Maintenance helpers (retention/pruning)."""
2
+
3
+ from datetime import timedelta
4
+
5
+ from django.utils import timezone
6
+
7
+ from .conf import api_settings
8
+ from .models import EndpointStat
9
+
10
+
11
+ def prune(retention_days=None):
12
+ """Delete raw counters older than the retention window.
13
+
14
+ Returns the number of deleted rows. ``RETENTION_DAYS = 0`` disables pruning.
15
+ """
16
+ days = api_settings.RETENTION_DAYS if retention_days is None else retention_days
17
+ if not days:
18
+ return 0
19
+ cutoff = timezone.localdate() - timedelta(days=days)
20
+ deleted, _ = EndpointStat.objects.filter(date__lt=cutoff).delete()
21
+ return deleted
File without changes
File without changes
@@ -0,0 +1,27 @@
1
+ """Flush buffered counters and apply the retention window.
2
+
3
+ Run from cron or a Celery beat schedule:
4
+
5
+ python manage.py api_usage_flush
6
+ """
7
+
8
+ from django.core.management.base import BaseCommand
9
+
10
+ from django_api_usage.buffers import flush
11
+ from django_api_usage.maintenance import prune
12
+
13
+
14
+ class Command(BaseCommand):
15
+ help = "Move buffered counters to the database and prune old rows."
16
+
17
+ def add_arguments(self, parser):
18
+ parser.add_argument(
19
+ "--no-prune", action="store_true", help="Skip the retention/pruning step."
20
+ )
21
+
22
+ def handle(self, *args, **options):
23
+ written = flush()
24
+ self.stdout.write(f"Flushed {written} counter bucket(s).")
25
+ if not options["no_prune"]:
26
+ deleted = prune()
27
+ self.stdout.write(f"Pruned {deleted} old stat row(s).")
@@ -0,0 +1,60 @@
1
+ """Report API usage per endpoint and per application.
2
+
3
+ This is the command that feeds a deprecation decision:
4
+
5
+ python manage.py api_usage_report --days 90
6
+ python manage.py api_usage_report --days 90 --sunset-candidates
7
+ python manage.py api_usage_report --app gida
8
+ """
9
+
10
+ from django.core.management.base import BaseCommand
11
+
12
+ from django_api_usage.deprecation import candidates_for_deprecation, in_use
13
+
14
+
15
+ class Command(BaseCommand):
16
+ help = "Report API usage per endpoint and app, to support deprecation decisions."
17
+
18
+ def add_arguments(self, parser):
19
+ parser.add_argument(
20
+ "--days",
21
+ type=int,
22
+ default=90,
23
+ help="Look-back window in days (default: 90).",
24
+ )
25
+ parser.add_argument(
26
+ "--app", dest="app_label", default=None, help="Filter by app."
27
+ )
28
+ parser.add_argument("--site", type=int, default=None, help="Filter by site id.")
29
+ parser.add_argument(
30
+ "--sunset-candidates",
31
+ action="store_true",
32
+ help="List endpoints with no traffic in the window instead of usage.",
33
+ )
34
+
35
+ def handle(self, *args, **options):
36
+ days = options["days"]
37
+ site_id = options["site"]
38
+ app_label = options["app_label"]
39
+
40
+ if options["sunset_candidates"]:
41
+ endpoints = candidates_for_deprecation(days, site_id)
42
+ if app_label:
43
+ endpoints = [ep for ep in endpoints if ep.app_label == app_label]
44
+ self.stdout.write(f"Endpoints without traffic in the last {days} day(s):")
45
+ for endpoint in endpoints:
46
+ self.stdout.write(
47
+ f" {endpoint.method} {endpoint.route_name} ({endpoint.app_label})"
48
+ )
49
+ self.stdout.write(f"Total: {len(endpoints)}")
50
+ return
51
+
52
+ rows = in_use(days, site_id)
53
+ if app_label:
54
+ rows = [(ep, total) for ep, total in rows if ep.app_label == app_label]
55
+ self.stdout.write("{:<12} {:<40} {:>10}".format("APP", "ENDPOINT", "CALLS"))
56
+ for endpoint, total in rows:
57
+ self.stdout.write(
58
+ f"{endpoint.app_label:<12} {endpoint.route_name:<40} {total:>10}"
59
+ )
60
+ self.stdout.write(f"Endpoints with traffic: {len(rows)}")
@@ -0,0 +1,60 @@
1
+ """Fail-open middleware that meters every Django view, DRF or not."""
2
+
3
+ import logging
4
+ import re
5
+
6
+ from .buffers import record_hit
7
+ from .conf import api_settings
8
+
9
+ logger = logging.getLogger("django_api_usage")
10
+
11
+
12
+ class ApiUsageMiddleware:
13
+ """Record one aggregated hit per request and expose the shadow-mode header.
14
+
15
+ Placed anywhere in ``MIDDLEWARE``; it only needs the final response.
16
+ """
17
+
18
+ def __init__(self, get_response):
19
+ self.get_response = get_response
20
+
21
+ def __call__(self, request):
22
+ response = self.get_response(request)
23
+ if api_settings.ENABLED:
24
+ self._record(request, response)
25
+ self._add_shadow_header(request, response)
26
+ return response
27
+
28
+ def _record(self, request, response):
29
+ try:
30
+ if self._is_ignored(request):
31
+ return
32
+ dimensions = {
33
+ "site_id": api_settings.SITE_RESOLVER(request),
34
+ "app_label": api_settings.APP_LABEL_RESOLVER(request),
35
+ "route_name": api_settings.ROUTE_NAME_RESOLVER(request),
36
+ "method": request.method,
37
+ "client_type": api_settings.CLIENT_TYPE_RESOLVER(request),
38
+ }
39
+ consumer = None
40
+ if api_settings.TRACK_CONSUMERS:
41
+ consumer = api_settings.CONSUMER_RESOLVER(request)
42
+ record_hit(dimensions, response.status_code, consumer)
43
+ except Exception:
44
+ logger.exception("django-api-usage: could not record request")
45
+ if not api_settings.FAIL_OPEN:
46
+ raise
47
+
48
+ @staticmethod
49
+ def _add_shadow_header(request, response):
50
+ would_deny = getattr(request, "_api_usage_would_deny", None)
51
+ if would_deny:
52
+ response["X-Api-Would-Deny"] = ",".join(sorted(would_deny))
53
+
54
+ @staticmethod
55
+ def _is_ignored(request):
56
+ path = request.path or ""
57
+ for pattern in api_settings.IGNORE_PATHS:
58
+ if re.search(pattern, path):
59
+ return True
60
+ return False
@@ -0,0 +1,118 @@
1
+ # Generated by Django 4.2.30 on 2026-10-07 15:10
2
+
3
+ from django.db import migrations, models
4
+ import django.db.models.deletion
5
+ import django.utils.timezone
6
+
7
+
8
+ class Migration(migrations.Migration):
9
+
10
+ initial = True
11
+
12
+ dependencies = []
13
+
14
+ operations = [
15
+ migrations.CreateModel(
16
+ name="Endpoint",
17
+ fields=[
18
+ (
19
+ "id",
20
+ models.BigAutoField(
21
+ auto_created=True,
22
+ primary_key=True,
23
+ serialize=False,
24
+ verbose_name="ID",
25
+ ),
26
+ ),
27
+ (
28
+ "site_id",
29
+ models.PositiveIntegerField(blank=True, db_index=True, null=True),
30
+ ),
31
+ ("app_label", models.CharField(db_index=True, max_length=64)),
32
+ ("route_name", models.CharField(max_length=160)),
33
+ ("method", models.CharField(max_length=8)),
34
+ ("deprecated", models.BooleanField(default=False)),
35
+ ("sunset_date", models.DateField(blank=True, null=True)),
36
+ ("replacement", models.CharField(blank=True, max_length=160)),
37
+ ("owner", models.CharField(blank=True, max_length=160)),
38
+ ("notes", models.TextField(blank=True)),
39
+ ],
40
+ options={
41
+ "ordering": ("app_label", "route_name", "method"),
42
+ "unique_together": {("site_id", "app_label", "route_name", "method")},
43
+ },
44
+ ),
45
+ migrations.CreateModel(
46
+ name="Consumer",
47
+ fields=[
48
+ (
49
+ "id",
50
+ models.BigAutoField(
51
+ auto_created=True,
52
+ primary_key=True,
53
+ serialize=False,
54
+ verbose_name="ID",
55
+ ),
56
+ ),
57
+ (
58
+ "kind",
59
+ models.CharField(
60
+ choices=[
61
+ ("api_key", "API key"),
62
+ ("user", "User"),
63
+ ("ip_hash", "IP hash"),
64
+ ],
65
+ max_length=16,
66
+ ),
67
+ ),
68
+ ("ref_hash", models.CharField(db_index=True, max_length=64)),
69
+ ("user_agent_family", models.CharField(blank=True, max_length=64)),
70
+ ("first_seen", models.DateTimeField(default=django.utils.timezone.now)),
71
+ ("last_seen", models.DateTimeField(default=django.utils.timezone.now)),
72
+ ("request_count", models.PositiveIntegerField(default=0)),
73
+ ("contact", models.CharField(blank=True, max_length=200)),
74
+ ("notes", models.TextField(blank=True)),
75
+ ],
76
+ options={
77
+ "ordering": ("-last_seen",),
78
+ "unique_together": {("kind", "ref_hash")},
79
+ },
80
+ ),
81
+ migrations.CreateModel(
82
+ name="EndpointStat",
83
+ fields=[
84
+ (
85
+ "id",
86
+ models.BigAutoField(
87
+ auto_created=True,
88
+ primary_key=True,
89
+ serialize=False,
90
+ verbose_name="ID",
91
+ ),
92
+ ),
93
+ ("date", models.DateField(db_index=True)),
94
+ ("client_type", models.CharField(default="anon", max_length=16)),
95
+ ("status_class", models.CharField(default="2xx", max_length=4)),
96
+ ("count", models.PositiveIntegerField(default=0)),
97
+ (
98
+ "endpoint",
99
+ models.ForeignKey(
100
+ on_delete=django.db.models.deletion.CASCADE,
101
+ related_name="stats",
102
+ to="django_api_usage.endpoint",
103
+ ),
104
+ ),
105
+ ],
106
+ options={
107
+ "ordering": ("-date",),
108
+ "indexes": [
109
+ models.Index(
110
+ fields=["date", "endpoint"], name="django_api__date_fd01a8_idx"
111
+ )
112
+ ],
113
+ "unique_together": {
114
+ ("endpoint", "date", "client_type", "status_class")
115
+ },
116
+ },
117
+ ),
118
+ ]
File without changes
@@ -0,0 +1,95 @@
1
+ """Data model.
2
+
3
+ Two layers live here on purpose:
4
+
5
+ * **Usage metering** (:class:`Endpoint`, :class:`EndpointStat`) is the neutral
6
+ core: how much is each endpoint of each app used.
7
+ * **Deprecation lifecycle** (the extra fields on :class:`Endpoint`) is the
8
+ optional layer built on top of the same data.
9
+ """
10
+
11
+ from django.db import models
12
+ from django.utils import timezone
13
+
14
+
15
+ class Endpoint(models.Model):
16
+ """One distinct (site, app, route, method) combination that was called."""
17
+
18
+ # Plain integer instead of a FK to sites.Site: keeps the package usable in
19
+ # projects without django.contrib.sites.
20
+ site_id = models.PositiveIntegerField(null=True, blank=True, db_index=True)
21
+ app_label = models.CharField(max_length=64, db_index=True)
22
+ route_name = models.CharField(max_length=160)
23
+ method = models.CharField(max_length=8)
24
+
25
+ # Deprecation lifecycle (optional layer).
26
+ deprecated = models.BooleanField(default=False)
27
+ sunset_date = models.DateField(null=True, blank=True)
28
+ replacement = models.CharField(max_length=160, blank=True)
29
+ owner = models.CharField(max_length=160, blank=True)
30
+ notes = models.TextField(blank=True)
31
+
32
+ class Meta:
33
+ unique_together = ("site_id", "app_label", "route_name", "method")
34
+ ordering = ("app_label", "route_name", "method")
35
+
36
+ def __str__(self):
37
+ return f"{self.method} {self.route_name} ({self.app_label})"
38
+
39
+ @property
40
+ def is_sunset(self):
41
+ """True when the sunset date has been reached."""
42
+ return bool(self.sunset_date and self.sunset_date <= timezone.localdate())
43
+
44
+ @property
45
+ def days_until_sunset(self):
46
+ if not self.sunset_date:
47
+ return None
48
+ return (self.sunset_date - timezone.localdate()).days
49
+
50
+
51
+ class EndpointStat(models.Model):
52
+ """Aggregated counter per endpoint, day, client type and status class."""
53
+
54
+ endpoint = models.ForeignKey(
55
+ Endpoint, on_delete=models.CASCADE, related_name="stats"
56
+ )
57
+ date = models.DateField(db_index=True)
58
+ client_type = models.CharField(max_length=16, default="anon")
59
+ status_class = models.CharField(max_length=4, default="2xx")
60
+ count = models.PositiveIntegerField(default=0)
61
+
62
+ class Meta:
63
+ unique_together = ("endpoint", "date", "client_type", "status_class")
64
+ ordering = ("-date",)
65
+ indexes = [models.Index(fields=["date", "endpoint"])]
66
+
67
+ def __str__(self):
68
+ return f"{self.date} {self.endpoint} {self.client_type} {self.count}"
69
+
70
+
71
+ class Consumer(models.Model):
72
+ """An observed caller, identified only by a salted hash. Opt-in."""
73
+
74
+ KIND_CHOICES = (
75
+ ("api_key", "API key"),
76
+ ("user", "User"),
77
+ ("ip_hash", "IP hash"),
78
+ )
79
+
80
+ kind = models.CharField(max_length=16, choices=KIND_CHOICES)
81
+ ref_hash = models.CharField(max_length=64, db_index=True)
82
+ user_agent_family = models.CharField(max_length=64, blank=True)
83
+ first_seen = models.DateTimeField(default=timezone.now)
84
+ last_seen = models.DateTimeField(default=timezone.now)
85
+ request_count = models.PositiveIntegerField(default=0)
86
+ # Manually maintained: who to contact before deprecating something.
87
+ contact = models.CharField(max_length=200, blank=True)
88
+ notes = models.TextField(blank=True)
89
+
90
+ class Meta:
91
+ unique_together = ("kind", "ref_hash")
92
+ ordering = ("-last_seen",)
93
+
94
+ def __str__(self):
95
+ return f"{self.kind} {self.ref_hash[:12]}"
@@ -0,0 +1,104 @@
1
+ """Default resolvers describing a request.
2
+
3
+ Everything here is overridable from ``API_USAGE`` so the package stays free of
4
+ project-specific knowledge. Resolvers must be cheap: they run on every request.
5
+ """
6
+
7
+ import hashlib
8
+
9
+ from django.conf import settings as django_settings
10
+
11
+
12
+ def _view_module(match):
13
+ """Best-effort module name of the callable that handled the request."""
14
+ func = getattr(match, "func", None)
15
+ view_class = getattr(func, "cls", None) # class-based views and DRF viewsets
16
+ if view_class is not None:
17
+ return getattr(view_class, "__module__", "") or ""
18
+ return getattr(func, "__module__", "") or ""
19
+
20
+
21
+ def default_app_label(request):
22
+ """Return a short application label for the resolved view.
23
+
24
+ ``tokikom.gida.views`` -> ``gida``, ``myproject.api.rest_views`` -> ``api``.
25
+ Override with ``API_USAGE["APP_LABEL_RESOLVER"]`` when your layout differs.
26
+ """
27
+ match = getattr(request, "resolver_match", None)
28
+ if match is None:
29
+ return "unknown"
30
+ if getattr(match, "app_name", None):
31
+ return match.app_name
32
+ parts = [part for part in _view_module(match).split(".") if part]
33
+ if not parts:
34
+ return "unknown"
35
+ suffixes = {"views", "viewsets", "rest_views", "api"}
36
+ if len(parts) >= 2 and parts[-1] in suffixes:
37
+ return parts[-2]
38
+ return parts[-1]
39
+
40
+
41
+ def default_route_name(request):
42
+ """Return the named URL pattern, never the raw path (cardinality!)."""
43
+ match = getattr(request, "resolver_match", None)
44
+ if match is None:
45
+ return "unknown"
46
+ return (
47
+ getattr(match, "url_name", None) or getattr(match, "route", None) or "unknown"
48
+ )
49
+
50
+
51
+ def default_client_type(request):
52
+ """Classify the caller as ``client``, ``user`` or ``anon``."""
53
+ if getattr(request, "api_client", None) is not None:
54
+ return "client"
55
+ user = getattr(request, "user", None)
56
+ if user is not None and getattr(user, "is_authenticated", False):
57
+ return "user"
58
+ return "anon"
59
+
60
+
61
+ def default_site_id(request):
62
+ """Return ``settings.SITE_ID`` as an int, or ``None`` when not configured."""
63
+ site_id = getattr(django_settings, "SITE_ID", None)
64
+ if site_id is None:
65
+ return None
66
+ try:
67
+ return int(site_id)
68
+ except (TypeError, ValueError):
69
+ return None
70
+
71
+
72
+ def _hash(value, salt):
73
+ return hashlib.sha256(f"{salt}:{value}".encode()).hexdigest()
74
+
75
+
76
+ def _user_agent_family(request):
77
+ user_agent = request.META.get("HTTP_USER_AGENT", "") or ""
78
+ return user_agent.split(" ")[0][:64]
79
+
80
+
81
+ def default_consumer(request):
82
+ """Return ``(kind, ref_hash, user_agent_family)`` or ``None``.
83
+
84
+ Raw identifiers are never stored: only a salted SHA-256 digest. Set
85
+ ``API_USAGE["CONSUMER_SALT"]`` to a secret, rotatable value.
86
+ """
87
+ from .conf import api_settings
88
+
89
+ salt = api_settings.CONSUMER_SALT or django_settings.SECRET_KEY
90
+ user_agent_family = _user_agent_family(request)
91
+
92
+ api_key = getattr(request, "api_key", None)
93
+ if api_key is not None:
94
+ return ("api_key", _hash(str(api_key.pk), salt), user_agent_family)
95
+
96
+ user = getattr(request, "user", None)
97
+ if user is not None and getattr(user, "is_authenticated", False):
98
+ return ("user", _hash(str(user.pk), salt), user_agent_family)
99
+
100
+ forwarded = request.META.get("HTTP_X_FORWARDED_FOR", "")
101
+ ip = forwarded.split(",")[0].strip() or request.META.get("REMOTE_ADDR", "")
102
+ if not ip:
103
+ return None
104
+ return ("ip_hash", _hash(ip, salt), user_agent_family)
@@ -0,0 +1,22 @@
1
+ """Optional Celery tasks. Requires the ``celery`` extra."""
2
+
3
+ from .buffers import flush
4
+ from .maintenance import prune
5
+
6
+ try:
7
+ from celery import shared_task
8
+ except ImportError: # pragma: no cover - celery is an optional dependency
9
+ shared_task = None
10
+
11
+
12
+ if shared_task is not None:
13
+
14
+ @shared_task
15
+ def flush_api_usage():
16
+ """Move buffered counters to the database."""
17
+ return flush()
18
+
19
+ @shared_task
20
+ def prune_api_usage():
21
+ """Apply the retention window to raw counters."""
22
+ return prune()
@@ -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,24 @@
1
+ django_api_usage/__init__.py,sha256=Rnl9TTnoJUF0gwudzH71y7EEq2lqCnSLy0AoqP53S7M,105
2
+ django_api_usage/admin.py,sha256=Gs8bTO_4GJCtoRM6Jp94oGkC1Xivt_buz4wmEMxfqY0,1515
3
+ django_api_usage/apps.py,sha256=IP6SBrHpuyHuVvLwIjvgRhV8LAQxp82WkyLO2SRGYZI,342
4
+ django_api_usage/buffers.py,sha256=QInMzFZZHgstuVmSYklH38uLrsiIvD5IrOqM2qjNXQA,4292
5
+ django_api_usage/checks.py,sha256=HT4xVAXMx89faygH7WflfmCfLOMaxEFuPsh8tmriCbA,2509
6
+ django_api_usage/conf.py,sha256=yGBigS_pGqg-N2Ud-trHQWqXfVuAUcZlc5ZXJk3yja0,2872
7
+ django_api_usage/deprecation.py,sha256=e_QSzCL_jHMBcu4XIH4rrjUPqB8APsNhka_pKhZgC_g,1272
8
+ django_api_usage/drf.py,sha256=VucHwwQqA9ZA66XKWcIhfea8qy0q37aDyu2Z_HVI8RA,2499
9
+ django_api_usage/maintenance.py,sha256=z3--j65dWyNDJN5THkSDe3g5u5LcCptjHjfoEvsv8Mc,629
10
+ django_api_usage/middleware.py,sha256=pCunQ5dj8VIAc2PJSm2Ab5WhCuVKH4e7cRyjFUj26bI,2027
11
+ django_api_usage/models.py,sha256=skV4tumzZ2m1AC70TZzlR3t54TOB1-uVohDDg2kUESk,3429
12
+ django_api_usage/resolvers.py,sha256=euoj73k_fP0ypFc3a1OImdUX8c9Cds1TsVZ5X8rbqLI,3509
13
+ django_api_usage/tasks.py,sha256=gGUfvNvv_qvsKGcpER1Q7cGZ8vFodTxItT00w5-JVOY,536
14
+ django_api_usage/management/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
+ django_api_usage/management/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
16
+ django_api_usage/management/commands/api_usage_flush.py,sha256=51TVn5wiVdM3Vr1Li5pRcEiofpTFRAqrglpgTKxngaA,821
17
+ django_api_usage/management/commands/api_usage_report.py,sha256=fbiXCK2NlEhlE_8bdy7a_zfnTPqlddb885i7WZ2l4GI,2290
18
+ django_api_usage/migrations/0001_initial.py,sha256=0mt6BMYDmHedbQpdd_nChFWb7BxhdH6J1iaKmM6oniw,4432
19
+ django_api_usage/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
20
+ django_api_usage-0.1.0.dist-info/licenses/LICENSE,sha256=wtaPGB5yvwDIUqTgIMOnQDpMc4xjG7qnhvAtTPlEggM,1067
21
+ django_api_usage-0.1.0.dist-info/METADATA,sha256=gf2phpPt2fgiwjAQOnptStsOGpby32Ki3F_DcrnERj0,5291
22
+ django_api_usage-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
23
+ django_api_usage-0.1.0.dist-info/top_level.txt,sha256=M0Lfi57fatFaVtRXWLvyuHRbYp3UsnHy-f1Xqa-0z8A,17
24
+ django_api_usage-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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 @@
1
+ django_api_usage