Skip to content

Commit 01946d5

Browse files
authored
Use Django database router API (#162)
- `reactpy_django.database.Router` must now be registered in `settings.py:DATABASE_ROUTERS` whenever `settings.py:REACTPY_DATABASE` is set. - Added system checks for a variety of common ReactPy misconfiguration
1 parent 5c1301b commit 01946d5

File tree

16 files changed

+234
-71
lines changed

16 files changed

+234
-71
lines changed

.github/workflows/test-src.yml

+1-2
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,9 @@ jobs:
1818
python-version: ["3.9", "3.10", "3.11"]
1919
steps:
2020
- uses: actions/checkout@v3
21-
- uses: nanasess/setup-chromedriver@master
2221
- uses: actions/setup-node@v3
2322
with:
24-
node-version: "14"
23+
node-version: "14.x"
2524
- name: Use Python ${{ matrix.python-version }}
2625
uses: actions/setup-python@v4
2726
with:

CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,12 @@ Using the following categories, list your changes in this order:
3636

3737
### Added
3838

39+
- Added system checks for a variety of common ReactPy misconfigurations.
3940
- `REACTPY_BACKHAUL_THREAD` setting to enable/disable threading behavior.
4041

4142
### Changed
4243

44+
- If using `settings.py:REACTPY_DATABASE`, `reactpy_django.database.Router` must now be registered in `settings.py:DATABASE_ROUTERS`.
4345
- By default, ReactPy will now use a backhaul thread to increase performance.
4446
- Minimum Python version required is now `3.9`
4547

docs/python/configure-urls.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@
22

33

44
urlpatterns = [
5-
path("reactpy/", include("reactpy_django.http.urls")),
65
...,
6+
path("reactpy/", include("reactpy_django.http.urls")),
77
]

docs/python/settings.py

+10-4
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,31 @@
1+
# Cache used to store ReactPy web modules.
12
# ReactPy requires a multiprocessing-safe and thread-safe cache.
3+
# We recommend redis or python-diskcache.
24
REACTPY_CACHE = "default"
35

6+
# Database ReactPy uses to store session data.
47
# ReactPy requires a multiprocessing-safe and thread-safe database.
8+
# DATABASE_ROUTERS is mandatory if REACTPY_DATABASE is configured.
59
REACTPY_DATABASE = "default"
10+
DATABASE_ROUTERS = ["reactpy_django.database.Router", ...]
611

712
# Maximum seconds between reconnection attempts before giving up.
813
# Use `0` to prevent component reconnection.
914
REACTPY_RECONNECT_MAX = 259200
1015

11-
# The URL for ReactPy to serve the component rendering websocket
16+
# The URL for ReactPy to serve the component rendering websocket.
1217
REACTPY_WEBSOCKET_URL = "reactpy/"
1318

14-
# Dotted path to the default `reactpy_django.hooks.use_query` postprocessor function, or `None`
19+
# Dotted path to the default `reactpy_django.hooks.use_query` postprocessor function,
20+
# or `None`.
1521
REACTPY_DEFAULT_QUERY_POSTPROCESSOR = "reactpy_django.utils.django_query_postprocessor"
1622

17-
# Dotted path to the Django authentication backend to use for ReactPy components
23+
# Dotted path to the Django authentication backend to use for ReactPy components.
1824
# This is only needed if:
1925
# 1. You are using `AuthMiddlewareStack` and...
2026
# 2. You are using Django's `AUTHENTICATION_BACKENDS` setting and...
2127
# 3. Your Django user model does not define a `backend` attribute
22-
REACTPY_AUTH_BACKEND = None
28+
REACTPY_AUTH_BACKEND = "django.contrib.auth.backends.ModelBackend"
2329

2430
# Whether to enable rendering ReactPy via a dedicated backhaul thread
2531
# This allows the webserver to process traffic while during ReactPy rendering

docs/src/dictionary.txt

+2
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,6 @@ backends
3535
backend
3636
frontend
3737
frontends
38+
misconfiguration
39+
misconfigurations
3840
backhaul

docs/src/get-started/installation.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ Register ReactPy's Websocket using `REACTPY_WEBSOCKET_PATH`.
7676

7777
1. Access the `User` that is currently logged in
7878
2. Login or logout the current `User`
79-
3. Access Django's `Sesssion` object
79+
3. Access Django's `Session` object
8080

8181
In these situations will need to ensure you are using `AuthMiddlewareStack` and/or `SessionMiddlewareStack`.
8282

src/reactpy_django/__init__.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from reactpy_django import components, decorators, hooks, types, utils
1+
from reactpy_django import checks, components, decorators, hooks, types, utils
22
from reactpy_django.websocket.paths import REACTPY_WEBSOCKET_PATH
33

44

@@ -10,4 +10,5 @@
1010
"decorators",
1111
"types",
1212
"utils",
13+
"checks",
1314
]

src/reactpy_django/checks.py

+153
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
from django.core.checks import Error, Tags, Warning, register
2+
3+
4+
@register(Tags.compatibility)
5+
def reactpy_warnings(app_configs, **kwargs):
6+
from django.conf import settings
7+
from django.urls import reverse
8+
9+
warnings = []
10+
11+
# REACTPY_DATABASE is not an in-memory database.
12+
if (
13+
getattr(settings, "DATABASES", {})
14+
.get(getattr(settings, "REACTPY_DATABASE", "default"), {})
15+
.get("NAME", None)
16+
== ":memory:"
17+
):
18+
warnings.append(
19+
Warning(
20+
"Using ReactPy with an in-memory database can cause unexpected "
21+
"behaviors.",
22+
hint="Configure settings.py:DATABASES[REACTPY_DATABASE], to use a "
23+
"multiprocessing and thread safe database.",
24+
id="reactpy_django.W001",
25+
)
26+
)
27+
28+
# REACTPY_CACHE is not an in-memory cache.
29+
if getattr(settings, "CACHES", {}).get(
30+
getattr(settings, "REACTPY_CACHE", "default"), {}
31+
).get("BACKEND", None) in {
32+
"django.core.cache.backends.dummy.DummyCache",
33+
"django.core.cache.backends.locmem.LocMemCache",
34+
}:
35+
warnings.append(
36+
Warning(
37+
"Using ReactPy with an in-memory cache can cause unexpected "
38+
"behaviors.",
39+
hint="Configure settings.py:CACHES[REACTPY_CACHE], to use a "
40+
"multiprocessing and thread safe cache.",
41+
id="reactpy_django.W002",
42+
)
43+
)
44+
45+
# ReactPy URLs exist
46+
try:
47+
reverse("reactpy:web_modules", kwargs={"file": "example"})
48+
reverse("reactpy:view_to_component", kwargs={"view_path": "example"})
49+
except Exception:
50+
warnings.append(
51+
Warning(
52+
"ReactPy URLs have not been registered.",
53+
hint="""Add 'path("reactpy/", include("reactpy_django.http.urls"))' """
54+
"to your application's urlpatterns.",
55+
id="reactpy_django.W003",
56+
)
57+
)
58+
59+
return warnings
60+
61+
62+
@register(Tags.compatibility)
63+
def reactpy_errors(app_configs, **kwargs):
64+
from django.conf import settings
65+
66+
errors = []
67+
68+
# Make sure ASGI is enabled
69+
if not getattr(settings, "ASGI_APPLICATION", None):
70+
errors.append(
71+
Error(
72+
"ASGI_APPLICATION is not defined."
73+
" ReactPy requires ASGI to be enabled.",
74+
hint="Add ASGI_APPLICATION to settings.py.",
75+
id="reactpy_django.E001",
76+
)
77+
)
78+
79+
# DATABASE_ROUTERS is properly configured when REACTPY_DATABASE is defined
80+
if getattr(
81+
settings, "REACTPY_DATABASE", None
82+
) and "reactpy_django.database.Router" not in getattr(
83+
settings, "DATABASE_ROUTERS", []
84+
):
85+
errors.append(
86+
Error(
87+
"ReactPy database has been changed but the database router is "
88+
"not configured.",
89+
hint="Set settings.py:DATABASE_ROUTERS to "
90+
"['reactpy_django.database.Router', ...]",
91+
id="reactpy_django.E002",
92+
)
93+
)
94+
95+
# All settings in reactpy_django.conf are the correct data type
96+
if not isinstance(getattr(settings, "REACTPY_WEBSOCKET_URL", ""), str):
97+
errors.append(
98+
Error(
99+
"Invalid type for REACTPY_WEBSOCKET_URL.",
100+
hint="REACTPY_WEBSOCKET_URL should be a string.",
101+
obj=settings.REACTPY_WEBSOCKET_URL,
102+
id="reactpy_django.E003",
103+
)
104+
)
105+
if not isinstance(getattr(settings, "REACTPY_RECONNECT_MAX", 0), int):
106+
errors.append(
107+
Error(
108+
"Invalid type for REACTPY_RECONNECT_MAX.",
109+
hint="REACTPY_RECONNECT_MAX should be an integer.",
110+
obj=settings.REACTPY_RECONNECT_MAX,
111+
id="reactpy_django.E004",
112+
)
113+
)
114+
if not isinstance(getattr(settings, "REACTPY_CACHE", ""), str):
115+
errors.append(
116+
Error(
117+
"Invalid type for REACTPY_CACHE.",
118+
hint="REACTPY_CACHE should be a string.",
119+
obj=settings.REACTPY_CACHE,
120+
id="reactpy_django.E005",
121+
)
122+
)
123+
if not isinstance(getattr(settings, "REACTPY_DATABASE", ""), str):
124+
errors.append(
125+
Error(
126+
"Invalid type for REACTPY_DATABASE.",
127+
hint="REACTPY_DATABASE should be a string.",
128+
obj=settings.REACTPY_DATABASE,
129+
id="reactpy_django.E006",
130+
)
131+
)
132+
if not isinstance(
133+
getattr(settings, "REACTPY_DEFAULT_QUERY_POSTPROCESSOR", ""), str
134+
):
135+
errors.append(
136+
Error(
137+
"Invalid type for REACTPY_DEFAULT_QUERY_POSTPROCESSOR.",
138+
hint="REACTPY_DEFAULT_QUERY_POSTPROCESSOR should be a string.",
139+
obj=settings.REACTPY_DEFAULT_QUERY_POSTPROCESSOR,
140+
id="reactpy_django.E007",
141+
)
142+
)
143+
if not isinstance(getattr(settings, "REACTPY_AUTH_BACKEND", ""), str):
144+
errors.append(
145+
Error(
146+
"Invalid type for REACTPY_AUTH_BACKEND.",
147+
hint="REACTPY_AUTH_BACKEND should be a string.",
148+
obj=settings.REACTPY_AUTH_BACKEND,
149+
id="reactpy_django.E008",
150+
)
151+
)
152+
153+
return errors

src/reactpy_django/config.py

+9-7
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,10 @@
1616
)
1717
from reactpy_django.utils import import_dotted_path
1818

19-
2019
_logger = logging.getLogger(__name__)
2120

2221

23-
# Not user configurable settings
22+
# Non-configurable values
2423
REACTPY_DEBUG_MODE.set_current(getattr(settings, "DEBUG"))
2524
REACTPY_REGISTERED_COMPONENTS: dict[str, ComponentConstructor] = {}
2625
REACTPY_VIEW_COMPONENT_IFRAMES: dict[str, ViewComponentIframe] = {}
@@ -47,13 +46,16 @@
4746
"REACTPY_DATABASE",
4847
DEFAULT_DB_ALIAS,
4948
)
49+
_default_query_postprocessor = getattr(
50+
settings,
51+
"REACTPY_DEFAULT_QUERY_POSTPROCESSOR",
52+
None,
53+
)
5054
REACTPY_DEFAULT_QUERY_POSTPROCESSOR: AsyncPostprocessor | SyncPostprocessor | None = (
5155
import_dotted_path(
52-
getattr(
53-
settings,
54-
"REACTPY_DEFAULT_QUERY_POSTPROCESSOR",
55-
"reactpy_django.utils.django_query_postprocessor",
56-
)
56+
_default_query_postprocessor
57+
if isinstance(_default_query_postprocessor, str)
58+
else "reactpy_django.utils.django_query_postprocessor",
5759
)
5860
)
5961
REACTPY_AUTH_BACKEND: str | None = getattr(

src/reactpy_django/database.py

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from reactpy_django.config import REACTPY_DATABASE
2+
3+
4+
class Router:
5+
"""
6+
A router to control all database operations on models in the
7+
auth and contenttypes applications.
8+
"""
9+
10+
route_app_labels = {"reactpy_django"}
11+
12+
def db_for_read(self, model, **hints):
13+
"""Attempts to read go to REACTPY_DATABASE."""
14+
if model._meta.app_label in self.route_app_labels:
15+
return REACTPY_DATABASE
16+
17+
def db_for_write(self, model, **hints):
18+
"""Attempts to write go to REACTPY_DATABASE."""
19+
if model._meta.app_label in self.route_app_labels:
20+
return REACTPY_DATABASE
21+
22+
def allow_relation(self, obj1, obj2, **hints):
23+
"""Only relations within the same database are allowed (default behavior)."""
24+
return None
25+
26+
def allow_migrate(self, db, app_label, model_name=None, **hints):
27+
"""Make sure ReactPy models only appear in REACTPY_DATABASE."""
28+
if app_label in self.route_app_labels:
29+
return db == REACTPY_DATABASE

src/reactpy_django/templatetags/reactpy.py

+1-2
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77

88
from reactpy_django import models
99
from reactpy_django.config import (
10-
REACTPY_DATABASE,
1110
REACTPY_DEBUG_MODE,
1211
REACTPY_RECONNECT_MAX,
1312
REACTPY_WEBSOCKET_URL,
@@ -73,7 +72,7 @@ def component(dotted_path: str, *args, **kwargs):
7372
params = ComponentParamData(args, kwargs)
7473
model = models.ComponentSession(uuid=uuid, params=pickle.dumps(params))
7574
model.full_clean()
76-
model.save(using=REACTPY_DATABASE)
75+
model.save()
7776

7877
except Exception as e:
7978
if isinstance(e, ComponentParamError):

src/reactpy_django/utils.py

+3-10
Original file line numberDiff line numberDiff line change
@@ -332,12 +332,7 @@ def create_cache_key(*args):
332332
def db_cleanup(immediate: bool = False):
333333
"""Deletes expired component sessions from the database.
334334
This function may be expanded in the future to include additional cleanup tasks."""
335-
from .config import (
336-
REACTPY_CACHE,
337-
REACTPY_DATABASE,
338-
REACTPY_DEBUG_MODE,
339-
REACTPY_RECONNECT_MAX,
340-
)
335+
from .config import REACTPY_CACHE, REACTPY_DEBUG_MODE, REACTPY_RECONNECT_MAX
341336
from .models import ComponentSession
342337

343338
clean_started_at = datetime.now()
@@ -351,7 +346,7 @@ def db_cleanup(immediate: bool = False):
351346
expires_by: datetime = timezone.now() - timedelta(seconds=REACTPY_RECONNECT_MAX)
352347

353348
# Component params exist in the DB, but we don't know when they were last cleaned
354-
if not cleaned_at_str and ComponentSession.objects.using(REACTPY_DATABASE).all():
349+
if not cleaned_at_str and ComponentSession.objects.all():
355350
_logger.warning(
356351
"ReactPy has detected component sessions in the database, "
357352
"but no timestamp was found in cache. This may indicate that "
@@ -361,9 +356,7 @@ def db_cleanup(immediate: bool = False):
361356
# Delete expired component parameters
362357
# Use timestamps in cache (`cleaned_at_str`) as a no-dependency rate limiter
363358
if immediate or not cleaned_at_str or timezone.now() >= clean_needed_by:
364-
ComponentSession.objects.using(REACTPY_DATABASE).filter(
365-
last_accessed__lte=expires_by
366-
).delete()
359+
ComponentSession.objects.filter(last_accessed__lte=expires_by).delete()
367360
caches[REACTPY_CACHE].set(cache_key, now_str, timeout=None)
368361

369362
# Check if cleaning took abnormally long

src/reactpy_django/websocket/consumer.py

+1-4
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,6 @@ async def run_dispatcher(self):
109109
"""Runs the main loop that performs component rendering tasks."""
110110
from reactpy_django import models
111111
from reactpy_django.config import (
112-
REACTPY_DATABASE,
113112
REACTPY_RECONNECT_MAX,
114113
REACTPY_REGISTERED_COMPONENTS,
115114
)
@@ -149,9 +148,7 @@ async def run_dispatcher(self):
149148
await database_sync_to_async(db_cleanup, thread_sensitive=False)()
150149

151150
# Get the queries from a DB
152-
params_query = await models.ComponentSession.objects.using(
153-
REACTPY_DATABASE
154-
).aget(
151+
params_query = await models.ComponentSession.objects.aget(
155152
uuid=uuid,
156153
last_accessed__gt=now
157154
- timedelta(seconds=REACTPY_RECONNECT_MAX),

0 commit comments

Comments
 (0)