Coverage for apps / ai / services / quota.py: 91%
81 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-12 10:49 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-12 10:49 +0000
1"""AI quota checking and tracking service.
3Uses Django's database cache backend to enforce per-profile, per-feature
4daily limits. Quotas only apply in passkey auth mode — home mode is unlimited.
5"""
7from datetime import datetime, timedelta, UTC
9from django.conf import settings
10from django.core.cache import cache
12from apps.core.models import AppSettings
14ALL_FEATURES = (
15 "remix",
16 "remix_suggestions",
17 "scale",
18 "tips",
19 "discover",
20 "timer",
21)
23FEATURE_LIMIT_FIELDS = {
24 "remix": "daily_limit_remix",
25 "remix_suggestions": "daily_limit_remix_suggestions",
26 "scale": "daily_limit_scale",
27 "tips": "daily_limit_tips",
28 "discover": "daily_limit_discover",
29 "timer": "daily_limit_timer",
30}
33def _cache_key(profile_id: int, feature: str) -> str:
34 """Build the daily cache key for a profile/feature pair."""
35 today = datetime.now(UTC).date()
36 return f"ai_quota:{profile_id}:{feature}:{today.isoformat()}"
39def _seconds_until_midnight_utc() -> int:
40 """Return seconds remaining until the next UTC midnight."""
41 now = datetime.now(UTC)
42 tomorrow = datetime(now.year, now.month, now.day, tzinfo=UTC) + timedelta(days=1)
43 return int((tomorrow - now).total_seconds())
46def _next_midnight_utc_iso() -> str:
47 """Return next UTC midnight as an ISO 8601 string."""
48 now = datetime.now(UTC)
49 tomorrow = datetime(now.year, now.month, now.day, tzinfo=UTC) + timedelta(days=1)
50 return tomorrow.isoformat()
53def check_quota(profile, feature: str) -> tuple[bool, dict]:
54 """Check whether *profile* may use *feature* right now (read-only).
56 Returns (allowed, info_dict). Info is empty when allowed;
57 contains remaining/limit/used/resets_at when denied.
59 NOTE: For atomic enforcement under concurrent load, use
60 reserve_quota() instead of check_quota() + increment_quota().
61 """
62 if getattr(settings, "AUTH_MODE", "home") != "passkey":
63 return (True, {})
65 if profile.user and profile.user.is_staff:
66 return (True, {})
68 if profile.unlimited_ai:
69 return (True, {})
71 if feature not in FEATURE_LIMIT_FIELDS:
72 raise ValueError(f"Unknown quota feature: {feature}")
74 app = AppSettings.get()
75 limit_field = FEATURE_LIMIT_FIELDS[feature]
76 limit = getattr(app, limit_field)
78 key = _cache_key(profile.pk, feature)
79 used = cache.get(key, 0)
81 if used >= limit:
82 return (
83 False,
84 {
85 "remaining": 0,
86 "limit": limit,
87 "used": used,
88 "resets_at": _next_midnight_utc_iso(),
89 },
90 )
92 return (True, {})
95def reserve_quota(profile, feature: str) -> tuple[bool, dict]:
96 """Atomically reserve a quota slot BEFORE executing the AI operation.
98 Uses cache.incr() which is atomic at the database level, preventing
99 concurrent requests from bypassing the quota limit.
101 Returns (allowed, info_dict). If allowed, the counter is already
102 incremented. On AI failure, call release_quota() to roll back.
103 """
104 if getattr(settings, "AUTH_MODE", "home") != "passkey":
105 return (True, {})
107 if profile.user and profile.user.is_staff:
108 return (True, {})
110 if profile.unlimited_ai:
111 return (True, {})
113 if feature not in FEATURE_LIMIT_FIELDS:
114 raise ValueError(f"Unknown quota feature: {feature}")
116 app = AppSettings.get()
117 limit_field = FEATURE_LIMIT_FIELDS[feature]
118 limit = getattr(app, limit_field)
120 key = _cache_key(profile.pk, feature)
121 ttl = _seconds_until_midnight_utc()
123 # Atomic increment — if key doesn't exist, set to 1
124 try:
125 new_count = cache.incr(key)
126 except ValueError:
127 cache.set(key, 1, timeout=ttl)
128 new_count = 1
130 if new_count > limit:
131 # Over limit — roll back the increment we just did
132 try:
133 cache.decr(key)
134 except ValueError:
135 pass
136 return (
137 False,
138 {
139 "remaining": 0,
140 "limit": limit,
141 "used": new_count - 1,
142 "resets_at": _next_midnight_utc_iso(),
143 },
144 )
146 return (True, {})
149def release_quota(profile, feature: str) -> None:
150 """Roll back a reserved quota slot on AI operation failure (FR-009)."""
151 if getattr(settings, "AUTH_MODE", "home") != "passkey":
152 return
153 if profile.user and profile.user.is_staff:
154 return
155 if profile.unlimited_ai:
156 return
158 key = _cache_key(profile.pk, feature)
159 try:
160 cache.decr(key)
161 except ValueError:
162 pass
165def increment_quota(profile, feature: str) -> None:
166 """Increment the daily counter after a successful AI operation.
168 DEPRECATED: Use reserve_quota() / release_quota() instead for
169 atomic enforcement under concurrent load.
170 """
171 key = _cache_key(profile.pk, feature)
172 ttl = _seconds_until_midnight_utc()
174 try:
175 cache.incr(key)
176 except ValueError:
177 cache.set(key, 1, timeout=ttl)
180def get_usage(profile_id: int) -> dict:
181 """Return {feature: count} for all features for today."""
182 return {feature: cache.get(_cache_key(profile_id, feature), 0) for feature in ALL_FEATURES}