packages/django-postgres-cache: Initial implementation of postgres cache (#16653)

* start db cache

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* update codeowners

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* handle db error in keys

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* implement rest of the methods

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix unrelated warning on startup for cache

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix migrations?

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add readme

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* dynamic dependency...?

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* types

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* rip out django_redis

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* format

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix tests?

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix get default

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* some cleanup

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* simplify to use ORM

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* remove old migrations that use cache instead of doing dynamic things

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix migration

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* Update packages/django-postgres-cache/django_postgres_cache/models.py

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>

* Update packages/django-postgres-cache/django_postgres_cache/migrations/0001_initial.py

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>

* fix redis imports

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>

* more redis removal

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>

* lint

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
This commit is contained in:
Jens L.
2025-10-02 16:01:28 +02:00
committed by GitHub
parent 8f644c3d3a
commit 986f082b59
24 changed files with 215 additions and 141 deletions
+1
View File
@@ -24,6 +24,7 @@ Makefile @goauthentik/infrastructure
.editorconfig @goauthentik/infrastructure
CODEOWNERS @goauthentik/infrastructure
# Backend packages
packages/django-postgres-cache @goauthentik/backend
packages/django-dramatiq-postgres @goauthentik/backend
# Web packages
packages/docusaurus-config @goauthentik/frontend
+9
View File
@@ -0,0 +1,9 @@
from django.dispatch import receiver
from authentik.admin.tasks import _set_prom_info
from authentik.root.signals import post_startup
@receiver(post_startup)
def post_startup_admin_metrics(sender, **_):
_set_prom_info()
-3
View File
@@ -71,6 +71,3 @@ def update_latest_version():
except (RequestException, IndexError) as exc:
cache.set(VERSION_CACHE_KEY, VERSION_NULL, VERSION_CACHE_TIMEOUT)
raise exc
_set_prom_info()
@@ -13,14 +13,6 @@ import authentik.core.models
import authentik.lib.models
def migrate_sessions(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
from django.contrib.sessions.backends.cache import KEY_PREFIX
from django.core.cache import cache
session_keys = cache.keys(KEY_PREFIX + "*")
cache.delete_many(session_keys)
def fix_duplicates(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
db_alias = schema_editor.connection.alias
Token = apps.get_model("authentik_core", "token")
@@ -151,9 +143,6 @@ class Migration(migrations.Migration):
"abstract": False,
},
),
migrations.RunPython(
code=migrate_sessions,
),
migrations.AlterField(
model_name="application",
name="meta_launch_url",
@@ -7,15 +7,10 @@ from django.contrib.auth import BACKEND_SESSION_KEY, HASH_SESSION_KEY, SESSION_K
from django.db import migrations, models
import django.db.models.deletion
from django.conf import settings
from django.contrib.sessions.backends.cache import KEY_PREFIX
from django.utils.timezone import now, timedelta
from authentik.lib.migrations import progress_bar
from authentik.root.middleware import ClientIPMiddleware
SESSION_CACHE_ALIAS = "default"
class PickleSerializer:
"""
Simple wrapper around pickle to be used in signing.dumps()/loads() and
@@ -83,27 +78,6 @@ def _migrate_session(
)
def migrate_redis_sessions(apps, schema_editor):
from django.core.cache import caches
db_alias = schema_editor.connection.alias
cache = caches[SESSION_CACHE_ALIAS]
# Not a redis cache, skipping
if not hasattr(cache, "keys"):
return
print("\nMigrating Redis sessions to database, this might take a couple of minutes...")
for key, session_data in progress_bar(cache.get_many(cache.keys(f"{KEY_PREFIX}*")).items()):
_migrate_session(
apps=apps,
db_alias=db_alias,
session_key=key.removeprefix(KEY_PREFIX),
session_data=session_data,
expires=now() + timedelta(seconds=cache.ttl(key)),
)
def migrate_database_sessions(apps, schema_editor):
DjangoSession = apps.get_model("sessions", "Session")
db_alias = schema_editor.connection.alias
@@ -231,10 +205,6 @@ class Migration(migrations.Migration):
"verbose_name_plural": "Authenticated Sessions",
},
),
migrations.RunPython(
code=migrate_redis_sessions,
reverse_code=migrations.RunPython.noop,
),
migrations.RunPython(
code=migrate_database_sessions,
reverse_code=migrations.RunPython.noop,
+2
View File
@@ -4,6 +4,7 @@ from datetime import datetime, timedelta
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from django_postgres_cache.tasks import clear_expired_cache
from dramatiq.actor import actor
from structlog.stdlib import get_logger
@@ -32,6 +33,7 @@ def clean_expired_models():
obj.expire_action()
LOGGER.debug("Expired models", model=cls, amount=amount)
self.info(f"Expired {amount} {cls._meta.verbose_name_plural}")
clear_expired_cache()
@actor(description=_("Remove temporary users created by SAML Sources."))
-12
View File
@@ -7,14 +7,11 @@ from django.conf import settings
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation, ValidationError
from django.db import DatabaseError, InternalError, OperationalError, ProgrammingError
from django.http.response import Http404
from django_redis.exceptions import ConnectionInterrupted
from docker.errors import DockerException
from dramatiq.errors import Retry
from h11 import LocalProtocolError
from ldap3.core.exceptions import LDAPException
from psycopg.errors import Error
from redis.exceptions import ConnectionError as RedisConnectionError
from redis.exceptions import RedisError, ResponseError
from rest_framework.exceptions import APIException
from sentry_sdk import HttpTransport, get_current_scope
from sentry_sdk import init as sentry_sdk_init
@@ -22,7 +19,6 @@ from sentry_sdk.api import set_tag
from sentry_sdk.integrations.argv import ArgvIntegration
from sentry_sdk.integrations.django import DjangoIntegration
from sentry_sdk.integrations.dramatiq import DramatiqIntegration
from sentry_sdk.integrations.redis import RedisIntegration
from sentry_sdk.integrations.socket import SocketIntegration
from sentry_sdk.integrations.stdlib import StdlibIntegration
from sentry_sdk.integrations.threading import ThreadingIntegration
@@ -58,11 +54,6 @@ ignored_classes = (
ProgrammingError,
SuspiciousOperation,
ValidationError,
# Redis errors
RedisConnectionError,
ConnectionInterrupted,
RedisError,
ResponseError,
# websocket errors
WebSocketException,
LocalProtocolError,
@@ -110,7 +101,6 @@ def sentry_init(**sentry_init_kwargs):
ArgvIntegration(),
DjangoIntegration(transaction_style="function_name", cache_spans=True),
DramatiqIntegration(),
RedisIntegration(),
SocketIntegration(),
StdlibIntegration(),
ThreadingIntegration(propagate_hub=True),
@@ -157,9 +147,7 @@ def before_send(event: dict, hint: dict) -> dict | None:
if event["logger"] in [
"asyncio",
"multiprocessing",
"django_redis",
"django.security.DisallowedHost",
"django_redis.cache",
"paramiko.transport",
]:
return None
+8 -12
View File
@@ -11,8 +11,6 @@ from django.dispatch import Signal
from django.http import HttpRequest, HttpResponse
from django.views import View
from django_prometheus.exports import ExportToDjangoView
from django_redis import get_redis_connection
from redis.exceptions import RedisError
monitoring_set = Signal()
@@ -44,19 +42,17 @@ class LiveView(View):
class ReadyView(View):
"""View for readiness probe, always returns Http 200, unless sql or redis is down"""
"""View for readiness probe, always returns Http 200, unless sql is down"""
def check_db(self):
for db_conn in connections.all():
# Force connection reload
db_conn.connect()
_ = db_conn.cursor()
def dispatch(self, request: HttpRequest) -> HttpResponse:
try:
for db_conn in connections.all():
# Force connection reload
db_conn.connect()
_ = db_conn.cursor()
self.check_db()
except OperationalError: # pragma: no cover
return HttpResponse(status=503)
try:
redis_conn = get_redis_connection()
redis_conn.ping()
except RedisError: # pragma: no cover
return HttpResponse(status=503)
return HttpResponse(status=200)
+3 -11
View File
@@ -10,7 +10,7 @@ from sentry_sdk import set_tag
from xmlsec import enable_debug_trace
from authentik import authentik_version
from authentik.lib.config import CONFIG, django_db_config, redis_url
from authentik.lib.config import CONFIG, django_db_config
from authentik.lib.logging import get_logger_config, structlog_configure
from authentik.lib.sentry import sentry_init
from authentik.lib.utils.reflection import get_env
@@ -73,6 +73,7 @@ TENANT_APPS = [
"django.contrib.contenttypes",
"django.contrib.sessions",
"pgtrigger",
"django_postgres_cache",
"authentik.admin",
"authentik.api",
"authentik.core",
@@ -227,20 +228,11 @@ REST_FRAMEWORK = {
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": CONFIG.get("cache.url") or redis_url(CONFIG.get("redis.db")),
"TIMEOUT": CONFIG.get_int("cache.timeout", 300),
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
},
"KEY_PREFIX": "authentik_cache",
"BACKEND": "django_postgres_cache.backend.DatabaseCache",
"KEY_FUNCTION": "django_tenants.cache.make_key",
"REVERSE_KEY_FUNCTION": "django_tenants.cache.reverse_key",
}
}
DJANGO_REDIS_SCAN_ITERSIZE = 1000
DJANGO_REDIS_IGNORE_EXCEPTIONS = True
DJANGO_REDIS_LOG_IGNORED_EXCEPTIONS = True
SESSION_ENGINE = "authentik.core.sessions"
# Configured via custom SessionMiddleware
# SESSION_COOKIE_SAMESITE = "None"
+1 -5
View File
@@ -13,12 +13,10 @@ from django_dramatiq_postgres.middleware import HTTPServer
from django_dramatiq_postgres.middleware import (
MetricsMiddleware as BaseMetricsMiddleware,
)
from django_redis import get_redis_connection
from dramatiq.broker import Broker
from dramatiq.message import Message
from dramatiq.middleware import Middleware
from psycopg.errors import Error
from redis.exceptions import RedisError
from structlog.stdlib import get_logger
from authentik import authentik_full_version
@@ -31,7 +29,7 @@ from authentik.tenants.utils import get_current_tenant
LOGGER = get_logger()
HEALTHCHECK_LOGGER = get_logger("authentik.worker").bind()
DB_ERRORS = (OperationalError, Error, RedisError)
DB_ERRORS = (OperationalError, Error)
class CurrentTask(BaseCurrentTask):
@@ -188,8 +186,6 @@ class _healthcheck_handler(BaseHTTPRequestHandler):
# Force connection reload
db_conn.connect()
_ = db_conn.cursor()
redis_conn = get_redis_connection()
redis_conn.ping()
self.send_response(200)
except DB_ERRORS: # pragma: no cover
self.send_response(503)
@@ -1,7 +1,5 @@
# flake8: noqa
from redis import Redis
from authentik.lib.config import CONFIG
from lifecycle.migrate import BaseMigration
SQL_STATEMENT = """BEGIN TRANSACTION;
@@ -106,17 +104,3 @@ class Migration(BaseMigration):
def run(self):
with self.con.transaction():
self.cur.execute(SQL_STATEMENT)
# We also need to clean the cache to make sure no pickeled objects still exist
for db in [
CONFIG.get("redis.message_queue_db"),
CONFIG.get("redis.cache_db"),
CONFIG.get("redis.ws_db"),
]:
redis = Redis(
host=CONFIG.get("redis.host"),
port=6379,
db=db,
username=CONFIG.get("redis.username"),
password=CONFIG.get("redis.password"),
)
redis.flushall()
+2 -22
View File
@@ -1,14 +1,13 @@
#!/usr/bin/env python
"""This file needs to be run from the root of the project to correctly
import authentik. This is done by the dockerfile."""
from sys import exit as sysexit
from time import sleep
from psycopg import OperationalError, connect
from redis import Redis
from redis.exceptions import RedisError
from authentik.lib.config import CONFIG, redis_url
from authentik.lib.config import CONFIG
CHECK_THRESHOLD = 30
@@ -40,24 +39,6 @@ def check_postgres():
CONFIG.log("info", "PostgreSQL connection successful")
def check_redis():
url = CONFIG.get("cache.url") or redis_url(CONFIG.get("redis.db"))
attempt = 0
while True:
if attempt >= CHECK_THRESHOLD:
sysexit(1)
try:
redis = Redis.from_url(url)
redis.ping()
break
except RedisError as exc:
sleep(1)
CONFIG.log("info", f"Redis Connection failed, retrying... ({exc})")
finally:
attempt += 1
CONFIG.log("info", "Redis Connection successful")
def wait_for_db():
CONFIG.log("info", "Starting authentik bootstrap")
# Sanity check, ensure SECRET_KEY is set before we even check for database connectivity
@@ -69,7 +50,6 @@ def wait_for_db():
CONFIG.log("info", "----------------------------------------------------------------------")
sysexit(1)
check_postgres()
check_redis()
CONFIG.log("info", "Finished authentik bootstrap")
@@ -12,9 +12,12 @@ class DjangoDramatiqPostgres(AppConfig):
verbose_name = "Django Dramatiq postgres"
def ready(self) -> None:
old_broker = dramatiq.get_broker()
try:
old_broker = dramatiq.get_broker()
except ModuleNotFoundError:
old_broker = None
if len(old_broker.actors) != 0:
if old_broker is not None and len(old_broker.actors) != 0:
raise ImproperlyConfigured(
"Actors were previously registered. "
"Make sure your actors are not imported too early."
+12
View File
@@ -0,0 +1,12 @@
# django-postgres-cache
### Use in migrations
Migrations that use the cache with this installed need to depend on the migration to create the cache entry table:
```python
dependencies = [
# ...other requirements
("django_postgres_cache", "0001_initial"),
]
```
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class DjangoPostgresCache(AppConfig):
name = "django_postgres_cache"
verbose_name = "Django Postgres cache"
@@ -0,0 +1,51 @@
from typing import Any
from django.core.cache.backends.db import DatabaseCache as BaseDatabaseCache
from django.db.utils import ProgrammingError
from django.utils.module_loading import import_string
from django.utils.timezone import now
from django_postgres_cache.models import CacheEntry
class DatabaseCache(BaseDatabaseCache):
def __init__(self, table: str, params: dict[str, Any]) -> None:
super().__init__(table, params)
self.reverse_key_func = import_string(params["REVERSE_KEY_FUNCTION"])
self._table = CacheEntry._meta.db_table
self.cache_model_class = CacheEntry
def _cull(self, *args: Any, **kwargs: Any) -> None:
"""Stubbed out cull method as we cull in a background task"""
pass
def get(self, key: str, default: Any | None = None, version: int | None = None) -> Any:
try:
return super().get(key, default=default, version=version)
except ProgrammingError:
return default
def keys(self, keys_pattern: str, version: int | None = None) -> list[str]:
try:
return self._keys(keys_pattern, version=version)
except ProgrammingError:
return []
def _keys(self, keys_pattern: str, version: int | None = None) -> list[str]:
keys_pattern = self.make_key(keys_pattern.replace("*", ".*"), version=version)
return [
self.reverse_key_func(key)
for key in CacheEntry.objects.filter(cache_key__regex=keys_pattern).values_list(
"cache_key", flat=True
)
]
def ttl(self, key: str, version: int | None = None) -> int | None:
"""Get TTL left for a given key and version"""
key = self.make_and_validate_key(key, version=version)
entry = CacheEntry.objects.filter(cache_key=key).first()
if not entry:
return None
return int((entry.expires - now()).total_seconds())
@@ -0,0 +1,24 @@
# Generated by Django 5.1.12 on 2025-09-06 16:16
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="CacheEntry",
fields=[
("cache_key", models.TextField(primary_key=True, serialize=False)),
("value", models.TextField()),
("expires", models.DateTimeField(db_index=True)),
],
options={
"default_permissions": [],
},
),
]
@@ -0,0 +1,14 @@
from django.db import models
class CacheEntry(models.Model):
cache_key = models.TextField(primary_key=True)
value = models.TextField()
expires = models.DateTimeField(db_index=True)
class Meta:
default_permissions = []
def __str__(self) -> str:
return f"Cache entry '{self.cache_key}'"
@@ -0,0 +1,11 @@
from django.utils.timezone import now
from authentik.lib.utils.db import chunked_queryset
from django_postgres_cache.models import CacheEntry
def clear_expired_cache() -> None:
# FIXME: this is currently imported from the main project,
# do we copy it here to make it independent?
for obj in chunked_queryset(CacheEntry.objects.filter(expires__lt=now())):
obj.delete()
@@ -0,0 +1,46 @@
[project]
name = "django-postgres-cache"
version = "0.1.0"
description = "Improved Django Postgres Cache"
requires-python = ">=3.9,<3.14"
readme = "README.md"
license = "MIT"
authors = [{ name = "Authentik Security Inc.", email = "hello@goauthentik.io" }]
keywords = ["django", "cache", "postgres"]
classifiers = [
"Development Status :: 3 - Alpha",
"Environment :: Web Environment",
"Framework :: Django",
"Framework :: Django :: 4.2",
"Framework :: Django :: 5.0",
"Framework :: Django :: 5.1",
"Framework :: Django :: 5.2",
"Intended Audience :: Developers",
"Operating System :: MacOS",
"Operating System :: POSIX",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python",
"Topic :: Software Development :: Libraries :: Python Modules",
]
dependencies = [
"django >=4.2,<6.0",
]
[project.urls]
Homepage = "https://github.com/goauthentik/authentik/tree/main/packages/django-postgres-cache"
Documentation = "https://github.com/goauthentik/authentik/tree/main/packages/django-postgres-cache"
Repository = "https://github.com/goauthentik/authentik/tree/main/packages/django-postgres-cache"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.setuptools.packages]
find = {}
+6 -2
View File
@@ -16,13 +16,13 @@ dependencies = [
"django-countries==7.6.1",
"django-cte==2.0.0",
"django-dramatiq-postgres",
"django-postgres-cache",
"django-filter==25.1",
"django-guardian==3.0.3",
"django-model-utils==5.0.0",
"django-pglock==1.7.2",
"django-pgtrigger==4.15.2",
"django-prometheus==2.4.1",
"django-redis==6.0.0",
"django-storages[s3]==1.14.6",
"django-tenants==3.8.0",
"djangoql==0.18.1",
@@ -120,11 +120,15 @@ no-binary-package = [
[tool.uv.sources]
djangorestframework = { git = "https://github.com/goauthentik/django-rest-framework", rev = "896722bab969fabc74a08b827da59409cf9f1a4e" }
django-dramatiq-postgres = { workspace = true }
django-postgres-cache = { workspace = true }
opencontainers = { git = "https://github.com/vsoch/oci-python", rev = "ceb4fcc090851717a3069d78e85ceb1e86c2740c" }
channels-postgres = { git = "https://github.com/rissson/channels_postgres", rev = "93ed24e3c5317d7ccf7e8b1ce913c0f365d1728f" }
[tool.uv.workspace]
members = ["packages/django-dramatiq-postgres"]
members = [
"packages/django-dramatiq-postgres",
"packages/django-postgres-cache",
]
[project.scripts]
ak = "lifecycle.ak:main"
Generated
+14 -15
View File
@@ -6,6 +6,7 @@ requires-python = "==3.13.*"
members = [
"authentik",
"django-dramatiq-postgres",
"django-postgres-cache",
]
[[package]]
@@ -178,8 +179,8 @@ dependencies = [
{ name = "django-model-utils" },
{ name = "django-pglock" },
{ name = "django-pgtrigger" },
{ name = "django-postgres-cache" },
{ name = "django-prometheus" },
{ name = "django-redis" },
{ name = "django-storages", extra = ["s3"] },
{ name = "django-tenants" },
{ name = "djangoql" },
@@ -280,8 +281,8 @@ requires-dist = [
{ name = "django-model-utils", specifier = "==5.0.0" },
{ name = "django-pglock", specifier = "==1.7.2" },
{ name = "django-pgtrigger", specifier = "==4.15.2" },
{ name = "django-postgres-cache", editable = "packages/django-postgres-cache" },
{ name = "django-prometheus", specifier = "==2.4.1" },
{ name = "django-redis", specifier = "==6.0.0" },
{ name = "django-storages", extras = ["s3"], specifier = "==1.14.6" },
{ name = "django-tenants", specifier = "==3.8.0" },
{ name = "djangoql", specifier = "==0.18.1" },
@@ -1041,6 +1042,17 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/40/97/81a4779e73f09d347d006b866b85b4be7774543d9f6eb6fe49a1303802af/django_pgtrigger-4.15.2-py3-none-any.whl", hash = "sha256:77ac6bd44fe5df0307e2630e3294cc68dcb25c9ec1a6bb523667546d28c80bea", size = 36434, upload-time = "2025-04-29T20:38:20.7Z" },
]
[[package]]
name = "django-postgres-cache"
version = "0.1.0"
source = { editable = "packages/django-postgres-cache" }
dependencies = [
{ name = "django" },
]
[package.metadata]
requires-dist = [{ name = "django", specifier = ">=4.2,<6.0" }]
[[package]]
name = "django-prometheus"
version = "2.4.1"
@@ -1054,19 +1066,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/01/50/9c5e022fa92574e5d20606687f15a2aa255e10512a17d11a8216fa117f72/django_prometheus-2.4.1-py2.py3-none-any.whl", hash = "sha256:7fe5af7f7c9ad9cd8a429fe0f3f1bf651f0e244f77162147869eab7ec09cc5e7", size = 29541, upload-time = "2025-06-25T15:45:35.433Z" },
]
[[package]]
name = "django-redis"
version = "6.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
{ name = "redis" },
]
sdist = { url = "https://files.pythonhosted.org/packages/08/53/dbcfa1e528e0d6c39947092625b2c89274b5d88f14d357cee53c4d6dbbd4/django_redis-6.0.0.tar.gz", hash = "sha256:2d9cb12a20424a4c4dde082c6122f486628bae2d9c2bee4c0126a4de7fda00dd", size = 56904, upload-time = "2025-06-17T18:15:46.376Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/79/055dfcc508cfe9f439d9f453741188d633efa9eab90fc78a67b0ab50b137/django_redis-6.0.0-py3-none-any.whl", hash = "sha256:20bf0063a8abee567eb5f77f375143c32810c8700c0674ced34737f8de4e36c0", size = 33687, upload-time = "2025-06-17T18:15:34.165Z" },
]
[[package]]
name = "django-storages"
version = "1.14.6"