Skip to content

Use Django database router API #162

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 14 commits into from
Aug 4, 2023
Merged
3 changes: 1 addition & 2 deletions .github/workflows/test-src.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,9 @@ jobs:
python-version: ["3.9", "3.10", "3.11"]
steps:
- uses: actions/checkout@v3
- uses: nanasess/setup-chromedriver@master
- uses: actions/setup-node@v3
with:
node-version: "14"
node-version: "14.x"
- name: Use Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,12 @@ Using the following categories, list your changes in this order:

### Added

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

### Changed

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

Expand Down
2 changes: 1 addition & 1 deletion docs/python/configure-urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@


urlpatterns = [
path("reactpy/", include("reactpy_django.http.urls")),
...,
path("reactpy/", include("reactpy_django.http.urls")),
]
14 changes: 10 additions & 4 deletions docs/python/settings.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,31 @@
# Cache used to store ReactPy web modules.
# ReactPy requires a multiprocessing-safe and thread-safe cache.
# We recommend redis or python-diskcache.
REACTPY_CACHE = "default"

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

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

# The URL for ReactPy to serve the component rendering websocket
# The URL for ReactPy to serve the component rendering websocket.
REACTPY_WEBSOCKET_URL = "reactpy/"

# Dotted path to the default `reactpy_django.hooks.use_query` postprocessor function, or `None`
# Dotted path to the default `reactpy_django.hooks.use_query` postprocessor function,
# or `None`.
REACTPY_DEFAULT_QUERY_POSTPROCESSOR = "reactpy_django.utils.django_query_postprocessor"

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

# Whether to enable rendering ReactPy via a dedicated backhaul thread
# This allows the webserver to process traffic while during ReactPy rendering
Expand Down
2 changes: 2 additions & 0 deletions docs/src/dictionary.txt
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,6 @@ backends
backend
frontend
frontends
misconfiguration
misconfigurations
backhaul
2 changes: 1 addition & 1 deletion docs/src/get-started/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ Register ReactPy's Websocket using `REACTPY_WEBSOCKET_PATH`.

1. Access the `User` that is currently logged in
2. Login or logout the current `User`
3. Access Django's `Sesssion` object
3. Access Django's `Session` object

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

Expand Down
3 changes: 2 additions & 1 deletion src/reactpy_django/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from reactpy_django import components, decorators, hooks, types, utils
from reactpy_django import checks, components, decorators, hooks, types, utils
from reactpy_django.websocket.paths import REACTPY_WEBSOCKET_PATH


Expand All @@ -10,4 +10,5 @@
"decorators",
"types",
"utils",
"checks",
]
153 changes: 153 additions & 0 deletions src/reactpy_django/checks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
from django.core.checks import Error, Tags, Warning, register


@register(Tags.compatibility)
def reactpy_warnings(app_configs, **kwargs):
from django.conf import settings
from django.urls import reverse

warnings = []

# REACTPY_DATABASE is not an in-memory database.
if (
getattr(settings, "DATABASES", {})
.get(getattr(settings, "REACTPY_DATABASE", "default"), {})
.get("NAME", None)
== ":memory:"
):
warnings.append(
Warning(
"Using ReactPy with an in-memory database can cause unexpected "
"behaviors.",
hint="Configure settings.py:DATABASES[REACTPY_DATABASE], to use a "
"multiprocessing and thread safe database.",
id="reactpy_django.W001",
)
)

# REACTPY_CACHE is not an in-memory cache.
if getattr(settings, "CACHES", {}).get(
getattr(settings, "REACTPY_CACHE", "default"), {}
).get("BACKEND", None) in {
"django.core.cache.backends.dummy.DummyCache",
"django.core.cache.backends.locmem.LocMemCache",
}:
warnings.append(
Warning(
"Using ReactPy with an in-memory cache can cause unexpected "
"behaviors.",
hint="Configure settings.py:CACHES[REACTPY_CACHE], to use a "
"multiprocessing and thread safe cache.",
id="reactpy_django.W002",
)
)

# ReactPy URLs exist
try:
reverse("reactpy:web_modules", kwargs={"file": "example"})
reverse("reactpy:view_to_component", kwargs={"view_path": "example"})
except Exception:
warnings.append(
Warning(
"ReactPy URLs have not been registered.",
hint="""Add 'path("reactpy/", include("reactpy_django.http.urls"))' """
"to your application's urlpatterns.",
id="reactpy_django.W003",
)
)

return warnings


@register(Tags.compatibility)
def reactpy_errors(app_configs, **kwargs):
from django.conf import settings

errors = []

# Make sure ASGI is enabled
if not getattr(settings, "ASGI_APPLICATION", None):
errors.append(
Error(
"ASGI_APPLICATION is not defined."
" ReactPy requires ASGI to be enabled.",
hint="Add ASGI_APPLICATION to settings.py.",
id="reactpy_django.E001",
)
)

# DATABASE_ROUTERS is properly configured when REACTPY_DATABASE is defined
if getattr(
settings, "REACTPY_DATABASE", None
) and "reactpy_django.database.Router" not in getattr(
settings, "DATABASE_ROUTERS", []
):
errors.append(
Error(
"ReactPy database has been changed but the database router is "
"not configured.",
hint="Set settings.py:DATABASE_ROUTERS to "
"['reactpy_django.database.Router', ...]",
id="reactpy_django.E002",
)
)

# All settings in reactpy_django.conf are the correct data type
if not isinstance(getattr(settings, "REACTPY_WEBSOCKET_URL", ""), str):
errors.append(
Error(
"Invalid type for REACTPY_WEBSOCKET_URL.",
hint="REACTPY_WEBSOCKET_URL should be a string.",
obj=settings.REACTPY_WEBSOCKET_URL,
id="reactpy_django.E003",
)
)
if not isinstance(getattr(settings, "REACTPY_RECONNECT_MAX", 0), int):
errors.append(
Error(
"Invalid type for REACTPY_RECONNECT_MAX.",
hint="REACTPY_RECONNECT_MAX should be an integer.",
obj=settings.REACTPY_RECONNECT_MAX,
id="reactpy_django.E004",
)
)
if not isinstance(getattr(settings, "REACTPY_CACHE", ""), str):
errors.append(
Error(
"Invalid type for REACTPY_CACHE.",
hint="REACTPY_CACHE should be a string.",
obj=settings.REACTPY_CACHE,
id="reactpy_django.E005",
)
)
if not isinstance(getattr(settings, "REACTPY_DATABASE", ""), str):
errors.append(
Error(
"Invalid type for REACTPY_DATABASE.",
hint="REACTPY_DATABASE should be a string.",
obj=settings.REACTPY_DATABASE,
id="reactpy_django.E006",
)
)
if not isinstance(
getattr(settings, "REACTPY_DEFAULT_QUERY_POSTPROCESSOR", ""), str
):
errors.append(
Error(
"Invalid type for REACTPY_DEFAULT_QUERY_POSTPROCESSOR.",
hint="REACTPY_DEFAULT_QUERY_POSTPROCESSOR should be a string.",
obj=settings.REACTPY_DEFAULT_QUERY_POSTPROCESSOR,
id="reactpy_django.E007",
)
)
if not isinstance(getattr(settings, "REACTPY_AUTH_BACKEND", ""), str):
errors.append(
Error(
"Invalid type for REACTPY_AUTH_BACKEND.",
hint="REACTPY_AUTH_BACKEND should be a string.",
obj=settings.REACTPY_AUTH_BACKEND,
id="reactpy_django.E008",
)
)

return errors
16 changes: 9 additions & 7 deletions src/reactpy_django/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,10 @@
)
from reactpy_django.utils import import_dotted_path


_logger = logging.getLogger(__name__)


# Not user configurable settings
# Non-configurable values
REACTPY_DEBUG_MODE.set_current(getattr(settings, "DEBUG"))
REACTPY_REGISTERED_COMPONENTS: dict[str, ComponentConstructor] = {}
REACTPY_VIEW_COMPONENT_IFRAMES: dict[str, ViewComponentIframe] = {}
Expand All @@ -47,13 +46,16 @@
"REACTPY_DATABASE",
DEFAULT_DB_ALIAS,
)
_default_query_postprocessor = getattr(
settings,
"REACTPY_DEFAULT_QUERY_POSTPROCESSOR",
None,
)
REACTPY_DEFAULT_QUERY_POSTPROCESSOR: AsyncPostprocessor | SyncPostprocessor | None = (
import_dotted_path(
getattr(
settings,
"REACTPY_DEFAULT_QUERY_POSTPROCESSOR",
"reactpy_django.utils.django_query_postprocessor",
)
_default_query_postprocessor
if isinstance(_default_query_postprocessor, str)
else "reactpy_django.utils.django_query_postprocessor",
)
)
REACTPY_AUTH_BACKEND: str | None = getattr(
Expand Down
29 changes: 29 additions & 0 deletions src/reactpy_django/database.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from reactpy_django.config import REACTPY_DATABASE


class Router:
"""
A router to control all database operations on models in the
auth and contenttypes applications.
"""

route_app_labels = {"reactpy_django"}

def db_for_read(self, model, **hints):
"""Attempts to read go to REACTPY_DATABASE."""
if model._meta.app_label in self.route_app_labels:
return REACTPY_DATABASE

def db_for_write(self, model, **hints):
"""Attempts to write go to REACTPY_DATABASE."""
if model._meta.app_label in self.route_app_labels:
return REACTPY_DATABASE

def allow_relation(self, obj1, obj2, **hints):
"""Only relations within the same database are allowed (default behavior)."""
return None

def allow_migrate(self, db, app_label, model_name=None, **hints):
"""Make sure ReactPy models only appear in REACTPY_DATABASE."""
if app_label in self.route_app_labels:
return db == REACTPY_DATABASE
3 changes: 1 addition & 2 deletions src/reactpy_django/templatetags/reactpy.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@

from reactpy_django import models
from reactpy_django.config import (
REACTPY_DATABASE,
REACTPY_DEBUG_MODE,
REACTPY_RECONNECT_MAX,
REACTPY_WEBSOCKET_URL,
Expand Down Expand Up @@ -73,7 +72,7 @@ def component(dotted_path: str, *args, **kwargs):
params = ComponentParamData(args, kwargs)
model = models.ComponentSession(uuid=uuid, params=pickle.dumps(params))
model.full_clean()
model.save(using=REACTPY_DATABASE)
model.save()

except Exception as e:
if isinstance(e, ComponentParamError):
Expand Down
13 changes: 3 additions & 10 deletions src/reactpy_django/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -332,12 +332,7 @@ def create_cache_key(*args):
def db_cleanup(immediate: bool = False):
"""Deletes expired component sessions from the database.
This function may be expanded in the future to include additional cleanup tasks."""
from .config import (
REACTPY_CACHE,
REACTPY_DATABASE,
REACTPY_DEBUG_MODE,
REACTPY_RECONNECT_MAX,
)
from .config import REACTPY_CACHE, REACTPY_DEBUG_MODE, REACTPY_RECONNECT_MAX
from .models import ComponentSession

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

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

# Check if cleaning took abnormally long
Expand Down
5 changes: 1 addition & 4 deletions src/reactpy_django/websocket/consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,6 @@ async def run_dispatcher(self):
"""Runs the main loop that performs component rendering tasks."""
from reactpy_django import models
from reactpy_django.config import (
REACTPY_DATABASE,
REACTPY_RECONNECT_MAX,
REACTPY_REGISTERED_COMPONENTS,
)
Expand Down Expand Up @@ -149,9 +148,7 @@ async def run_dispatcher(self):
await database_sync_to_async(db_cleanup, thread_sensitive=False)()

# Get the queries from a DB
params_query = await models.ComponentSession.objects.using(
REACTPY_DATABASE
).aget(
params_query = await models.ComponentSession.objects.aget(
uuid=uuid,
last_accessed__gt=now
- timedelta(seconds=REACTPY_RECONNECT_MAX),
Expand Down
Loading