diff --git a/.github/workflows/test-src.yml b/.github/workflows/test-src.yml index fda11d5d..f945ea9d 100644 --- a/.github/workflows/test-src.yml +++ b/.github/workflows/test-src.yml @@ -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: diff --git a/CHANGELOG.md b/CHANGELOG.md index 142e162b..c27809b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` diff --git a/docs/python/configure-urls.py b/docs/python/configure-urls.py index d619b8a1..49ae5ec1 100644 --- a/docs/python/configure-urls.py +++ b/docs/python/configure-urls.py @@ -2,6 +2,6 @@ urlpatterns = [ - path("reactpy/", include("reactpy_django.http.urls")), ..., + path("reactpy/", include("reactpy_django.http.urls")), ] diff --git a/docs/python/settings.py b/docs/python/settings.py index 9a719299..56fc037d 100644 --- a/docs/python/settings.py +++ b/docs/python/settings.py @@ -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 diff --git a/docs/src/dictionary.txt b/docs/src/dictionary.txt index 4f63c420..2dbbc4e6 100644 --- a/docs/src/dictionary.txt +++ b/docs/src/dictionary.txt @@ -35,4 +35,6 @@ backends backend frontend frontends +misconfiguration +misconfigurations backhaul diff --git a/docs/src/get-started/installation.md b/docs/src/get-started/installation.md index 7aa2ea65..d9763992 100644 --- a/docs/src/get-started/installation.md +++ b/docs/src/get-started/installation.md @@ -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`. diff --git a/src/reactpy_django/__init__.py b/src/reactpy_django/__init__.py index 8d026baa..fc4c15d4 100644 --- a/src/reactpy_django/__init__.py +++ b/src/reactpy_django/__init__.py @@ -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 @@ -10,4 +10,5 @@ "decorators", "types", "utils", + "checks", ] diff --git a/src/reactpy_django/checks.py b/src/reactpy_django/checks.py new file mode 100644 index 00000000..3feb4f18 --- /dev/null +++ b/src/reactpy_django/checks.py @@ -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 diff --git a/src/reactpy_django/config.py b/src/reactpy_django/config.py index 5a642b57..6d5b1130 100644 --- a/src/reactpy_django/config.py +++ b/src/reactpy_django/config.py @@ -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] = {} @@ -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( diff --git a/src/reactpy_django/database.py b/src/reactpy_django/database.py new file mode 100644 index 00000000..736ffdc8 --- /dev/null +++ b/src/reactpy_django/database.py @@ -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 diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py index d1ce87e5..20e9b0de 100644 --- a/src/reactpy_django/templatetags/reactpy.py +++ b/src/reactpy_django/templatetags/reactpy.py @@ -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, @@ -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): diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index 219f5c3e..f41c09db 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -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() @@ -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 " @@ -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 diff --git a/src/reactpy_django/websocket/consumer.py b/src/reactpy_django/websocket/consumer.py index 74d54beb..279d0f0d 100644 --- a/src/reactpy_django/websocket/consumer.py +++ b/src/reactpy_django/websocket/consumer.py @@ -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, ) @@ -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), diff --git a/tests/test_app/settings.py b/tests/test_app/settings.py index f846b22b..f1b0a69e 100644 --- a/tests/test_app/settings.py +++ b/tests/test_app/settings.py @@ -41,7 +41,7 @@ "django.contrib.messages", "django.contrib.staticfiles", "channels", # Websocket library - "reactpy_django", # Django compatible ReactPy client + "reactpy_django", # Django compatiblity layer for ReactPy "test_app", # This test application ] MIDDLEWARE = [ @@ -80,10 +80,10 @@ DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", - # Changing NAME is needed due to a bug related to `manage.py test` migrations - "NAME": os.path.join(BASE_DIR, "test_db.sqlite3") - if "test" in sys.argv - else os.path.join(BASE_DIR, "db.sqlite3"), + # Changing NAME is needed due to a bug related to `manage.py test` + "NAME": os.path.join( + BASE_DIR, "test_db.sqlite3" if "test" in sys.argv else "db.sqlite3" + ), "TEST": { "NAME": os.path.join(BASE_DIR, "test_db.sqlite3"), "OPTIONS": {"timeout": 20}, @@ -95,10 +95,10 @@ if "test" in sys.argv: DATABASES["reactpy"] = { "ENGINE": "django.db.backends.sqlite3", - # Changing NAME is needed due to a bug related to `manage.py test` migrations - "NAME": os.path.join(BASE_DIR, "test_db_2.sqlite3") - if "test" in sys.argv - else os.path.join(BASE_DIR, "db_2.sqlite3"), + # Changing NAME is needed due to a bug related to `manage.py test` + "NAME": os.path.join( + BASE_DIR, "test_db_2.sqlite3" if "test" in sys.argv else "db_2.sqlite3" + ), "TEST": { "NAME": os.path.join(BASE_DIR, "test_db_2.sqlite3"), "OPTIONS": {"timeout": 20}, @@ -107,6 +107,8 @@ "OPTIONS": {"timeout": 20}, } REACTPY_DATABASE = "reactpy" +DATABASE_ROUTERS = ["reactpy_django.database.Router"] + # Cache CACHES = { diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index fc4ef799..0801e28e 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -272,28 +272,24 @@ def test_component_param_error(self): def test_component_session_exists(self): """Session should exist for components with args/kwargs.""" - from reactpy_django.config import REACTPY_DATABASE - component = self.page.locator("#parametrized-component") component.wait_for() parent = component.locator("..") session_id = parent.get_attribute("id") os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true" - query = ComponentSession.objects.filter(uuid=session_id).using(REACTPY_DATABASE) + query = ComponentSession.objects.filter(uuid=session_id) query_exists = query.exists() os.environ.pop("DJANGO_ALLOW_ASYNC_UNSAFE") self.assertTrue(query_exists) def test_component_session_missing(self): """No session should exist for components that don't have args/kwargs.""" - from reactpy_django.config import REACTPY_DATABASE - component = self.page.locator("#simple-button") component.wait_for() parent = component.locator("..") session_id = parent.get_attribute("id") os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true" - query = ComponentSession.objects.filter(uuid=session_id).using(REACTPY_DATABASE) + query = ComponentSession.objects.filter(uuid=session_id) query_exists = query.exists() os.environ.pop("DJANGO_ALLOW_ASYNC_UNSAFE") self.assertFalse(query_exists) diff --git a/tests/test_app/tests/test_database.py b/tests/test_app/tests/test_database.py index 4b30d58a..d6bd9059 100644 --- a/tests/test_app/tests/test_database.py +++ b/tests/test_app/tests/test_database.py @@ -14,15 +14,13 @@ class RoutedDatabaseTests(TransactionTestCase): databases = {"reactpy"} def test_component_params(self): - db = list(self.databases)[0] - # Make sure the ComponentParams table is empty - self.assertEqual(ComponentSession.objects.using(db).count(), 0) + self.assertEqual(ComponentSession.objects.count(), 0) params_1 = self._save_params_to_db(1) # Check if a component params are in the database - self.assertEqual(ComponentSession.objects.using(db).count(), 1) - self.assertEqual(pickle.loads(ComponentSession.objects.using(db).first().params), params_1) # type: ignore + self.assertEqual(ComponentSession.objects.count(), 1) + self.assertEqual(pickle.loads(ComponentSession.objects.first().params), params_1) # type: ignore # Force `params_1` to expire from reactpy_django import config @@ -32,14 +30,14 @@ def test_component_params(self): # Create a new, non-expired component params params_2 = self._save_params_to_db(2) - self.assertEqual(ComponentSession.objects.using(db).count(), 2) + self.assertEqual(ComponentSession.objects.count(), 2) # Delete the first component params based on expiration time utils.db_cleanup() # Don't use `immediate` to test cache timestamping logic # Make sure `params_1` has expired - self.assertEqual(ComponentSession.objects.using(db).count(), 1) - self.assertEqual(pickle.loads(ComponentSession.objects.using(db).first().params), params_2) # type: ignore + self.assertEqual(ComponentSession.objects.count(), 1) + self.assertEqual(pickle.loads(ComponentSession.objects.first().params), params_2) # type: ignore def _save_params_to_db(self, value: Any) -> ComponentParamData: db = list(self.databases)[0] @@ -50,19 +48,3 @@ def _save_params_to_db(self, value: Any) -> ComponentParamData: model.save(using=db) return param_data - - -class DefaultDatabaseTests(RoutedDatabaseTests): - databases = {"default"} - - def setUp(self) -> None: - from reactpy_django import config - - config.REACTPY_DATABASE = "default" - return super().setUp() - - def tearDown(self) -> None: - from reactpy_django import config - - config.REACTPY_DATABASE = "reactpy" - return super().tearDown()