|
1 | 1 | import contextlib |
| 2 | +import functools |
2 | 3 | import json |
3 | 4 | from collections import defaultdict, deque |
4 | 5 | from collections.abc import Iterable |
5 | 6 | from typing import Any |
6 | 7 |
|
| 8 | +from django.core.cache import caches |
7 | 9 | from django.core.serializers.json import DjangoJSONEncoder |
8 | 10 | from django.db import transaction |
9 | 11 | from django.utils.module_loading import import_string |
@@ -219,5 +221,175 @@ def panels(cls, request_id: str) -> Any: |
219 | 221 | return {} |
220 | 222 |
|
221 | 223 |
|
| 224 | +class _UntrackedCache: |
| 225 | + """ |
| 226 | + Wrapper around a Django cache backend that suppresses debug toolbar tracking. |
| 227 | +
|
| 228 | + The cache panel's monkey-patched methods check ``cache._djdt_panel`` and skip |
| 229 | + recording when it is ``None``. This proxy temporarily sets that attribute to |
| 230 | + ``None`` around every call so the toolbar's own cache operations are invisible. |
| 231 | + """ |
| 232 | + |
| 233 | + def __init__(self, cache): |
| 234 | + self._cache = cache |
| 235 | + |
| 236 | + def __getattr__(self, name): |
| 237 | + attr = getattr(self._cache, name) |
| 238 | + if not callable(attr): |
| 239 | + return attr |
| 240 | + |
| 241 | + @functools.wraps(attr) |
| 242 | + def untracked(*args, **kwargs): |
| 243 | + panel = getattr(self._cache, "_djdt_panel", None) |
| 244 | + self._cache._djdt_panel = None |
| 245 | + try: |
| 246 | + return attr(*args, **kwargs) |
| 247 | + finally: |
| 248 | + self._cache._djdt_panel = panel |
| 249 | + |
| 250 | + return untracked |
| 251 | + |
| 252 | + |
| 253 | +class CacheStore(BaseStore): |
| 254 | + """ |
| 255 | + Store that uses Django's cache framework to persist debug toolbar data. |
| 256 | + """ |
| 257 | + |
| 258 | + _cache_table_registered = False |
| 259 | + |
| 260 | + @classmethod |
| 261 | + def _get_cache(cls): |
| 262 | + """Get the Django cache backend, wrapped to bypass toolbar tracking.""" |
| 263 | + cache = _UntrackedCache(caches[dt_settings.get_config()["CACHE_BACKEND"]]) |
| 264 | + |
| 265 | + # Register the cache table with DDT_MODELS to filter SQL queries |
| 266 | + if not cls._cache_table_registered: |
| 267 | + cls._register_cache_table_for_sql_filtering(cache._cache) |
| 268 | + cls._cache_table_registered = True |
| 269 | + |
| 270 | + return cache |
| 271 | + |
| 272 | + @classmethod |
| 273 | + def _register_cache_table_for_sql_filtering(cls, cache): |
| 274 | + """ |
| 275 | + Add the cache table to DDT_MODELS. |
| 276 | +
|
| 277 | + This ensures that when using DatabaseCache, the cache table's SQL queries |
| 278 | + don't appear in the SQLPanel. |
| 279 | + """ |
| 280 | + # Only proceed if this is a DatabaseCache backend |
| 281 | + if cache.__class__.__name__ != "DatabaseCache": |
| 282 | + return |
| 283 | + |
| 284 | + # Get the cache table name |
| 285 | + cache_table = getattr(cache, "_table", None) |
| 286 | + if cache_table: |
| 287 | + # Import here to avoid circular dependency: |
| 288 | + # store.py -> panels/sql/tracking.py -> panels/sql/forms.py -> toolbar.py -> store.py |
| 289 | + from debug_toolbar.panels.sql import tracking |
| 290 | + |
| 291 | + tracking.DDT_MODELS.add(cache_table) |
| 292 | + |
| 293 | + @classmethod |
| 294 | + def _key_prefix(cls) -> str: |
| 295 | + """Get the cache key prefix from settings.""" |
| 296 | + return dt_settings.get_config()["CACHE_KEY_PREFIX"] |
| 297 | + |
| 298 | + @classmethod |
| 299 | + def _request_ids_key(cls) -> str: |
| 300 | + """Return the cache key for the request IDs list.""" |
| 301 | + return f"{cls._key_prefix()}request_ids" |
| 302 | + |
| 303 | + @classmethod |
| 304 | + def _request_key(cls, request_id: str) -> str: |
| 305 | + """Return the cache key for a specific request's data.""" |
| 306 | + return f"{cls._key_prefix()}req:{request_id}" |
| 307 | + |
| 308 | + @classmethod |
| 309 | + def request_ids(cls) -> Iterable: |
| 310 | + """The stored request ids.""" |
| 311 | + return cls._get_cache().get(cls._request_ids_key(), []) |
| 312 | + |
| 313 | + @classmethod |
| 314 | + def exists(cls, request_id: str) -> bool: |
| 315 | + """Does the given request_id exist in the store.""" |
| 316 | + return request_id in cls.request_ids() |
| 317 | + |
| 318 | + @classmethod |
| 319 | + def set(cls, request_id: str): |
| 320 | + """Set a request_id in the store.""" |
| 321 | + cache = cls._get_cache() |
| 322 | + ids_key = cls._request_ids_key() |
| 323 | + request_ids = deque(cache.get(ids_key, [])) |
| 324 | + |
| 325 | + if request_id not in request_ids: |
| 326 | + request_ids.append(request_id) |
| 327 | + |
| 328 | + # Enforce RESULTS_CACHE_SIZE limit |
| 329 | + max_size = dt_settings.get_config()["RESULTS_CACHE_SIZE"] |
| 330 | + while len(request_ids) > max_size: |
| 331 | + removed_id = request_ids.popleft() |
| 332 | + cache.delete(cls._request_key(removed_id)) |
| 333 | + |
| 334 | + cache.set(ids_key, list(request_ids), None) |
| 335 | + |
| 336 | + @classmethod |
| 337 | + def clear(cls): |
| 338 | + """Remove all requests from the request store.""" |
| 339 | + cache = cls._get_cache() |
| 340 | + ids_key = cls._request_ids_key() |
| 341 | + request_ids = cache.get(ids_key, []) |
| 342 | + |
| 343 | + # Delete all request data |
| 344 | + if request_ids: |
| 345 | + cache.delete_many([cls._request_key(_id) for _id in request_ids]) |
| 346 | + |
| 347 | + # Clear the request IDs list |
| 348 | + cache.delete(ids_key) |
| 349 | + |
| 350 | + @classmethod |
| 351 | + def delete(cls, request_id: str): |
| 352 | + """Delete the stored request for the given request_id.""" |
| 353 | + cache = cls._get_cache() |
| 354 | + ids_key = cls._request_ids_key() |
| 355 | + request_ids = list(cache.get(ids_key, [])) |
| 356 | + |
| 357 | + # Remove from the list if present |
| 358 | + if request_id in request_ids: |
| 359 | + request_ids.remove(request_id) |
| 360 | + cache.set(ids_key, request_ids, None) |
| 361 | + |
| 362 | + # Delete the request data |
| 363 | + cache.delete(cls._request_key(request_id)) |
| 364 | + |
| 365 | + @classmethod |
| 366 | + def save_panel(cls, request_id: str, panel_id: str, data: Any = None): |
| 367 | + """Save the panel data for the given request_id.""" |
| 368 | + cls.set(request_id) |
| 369 | + cache = cls._get_cache() |
| 370 | + request_key = cls._request_key(request_id) |
| 371 | + request_data = cache.get(request_key, {}) |
| 372 | + request_data[panel_id] = serialize(data) |
| 373 | + cache.set(request_key, request_data, None) |
| 374 | + |
| 375 | + @classmethod |
| 376 | + def panel(cls, request_id: str, panel_id: str) -> Any: |
| 377 | + """Fetch the panel data for the given request_id.""" |
| 378 | + cache = cls._get_cache() |
| 379 | + request_data = cache.get(cls._request_key(request_id), {}) |
| 380 | + panel_data = request_data.get(panel_id) |
| 381 | + if panel_data is None: |
| 382 | + return {} |
| 383 | + return deserialize(panel_data) |
| 384 | + |
| 385 | + @classmethod |
| 386 | + def panels(cls, request_id: str) -> Any: |
| 387 | + """Fetch all the panel data for the given request_id.""" |
| 388 | + cache = cls._get_cache() |
| 389 | + request_data = cache.get(cls._request_key(request_id), {}) |
| 390 | + for panel_id, panel_data in request_data.items(): |
| 391 | + yield panel_id, deserialize(panel_data) |
| 392 | + |
| 393 | + |
222 | 394 | def get_store() -> BaseStore: |
223 | 395 | return import_string(dt_settings.get_config()["TOOLBAR_STORE_CLASS"]) |
0 commit comments