From 6257c02d5094b06173e1ff6b445e86e2630eb565 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 17 Sep 2023 19:10:03 -0700 Subject: [PATCH 01/63] first draft of `use_user` and `use_user_data` --- docs/src/reference/hooks.md | 2 +- src/reactpy_django/exceptions.py | 6 +- src/reactpy_django/hooks.py | 75 ++++++++++++++++++- .../migrations/0006_userdatamodel.py | 37 +++++++++ src/reactpy_django/models.py | 10 ++- src/reactpy_django/types.py | 8 ++ src/reactpy_django/websocket/consumer.py | 9 ++- 7 files changed, 139 insertions(+), 8 deletions(-) create mode 100644 src/reactpy_django/migrations/0006_userdatamodel.py diff --git a/docs/src/reference/hooks.md b/docs/src/reference/hooks.md index 3233a5bb..79f368a3 100644 --- a/docs/src/reference/hooks.md +++ b/docs/src/reference/hooks.md @@ -169,7 +169,7 @@ The mutation function you provide should have no return value. | Name | Type | Description | Default | | --- | --- | --- | --- | | `#!python mutate` | `#!python Callable[_Params, bool | None]` | A callable that performs Django ORM create, update, or delete functionality. If this function returns `#!python False`, then your `#!python refetch` function will not be used. | N/A | - | `#!python refetch` | `#!python Callable[..., Any] | Sequence[Callable[..., Any]] | None` | A `#!python query` function (used by the `#!python use_query` hook) or a sequence of `#!python query` functions that will be called if the mutation succeeds. This is useful for refetching data after a mutation has been performed. | `#!python None` | + | `#!python refetch` | `#!python Callable[..., Any] | Sequence[Callable[..., Any]] | None` | A query function (the function typically provided to your `#!python use_query` hook) or a sequence of query functions that need a `refetch` if the mutation succeeds. This is useful for refetching data after a mutation has been performed. | `#!python None` | **Returns** diff --git a/src/reactpy_django/exceptions.py b/src/reactpy_django/exceptions.py index 49c6fef3..fd394c46 100644 --- a/src/reactpy_django/exceptions.py +++ b/src/reactpy_django/exceptions.py @@ -10,5 +10,9 @@ class InvalidHostError(ValueError): ... -class ComponentCarrierError(ValueError): +class ComponentCarrierError(Exception): + ... + + +class UserNotFoundError(Exception): ... diff --git a/src/reactpy_django/hooks.py b/src/reactpy_django/hooks.py index 8115de56..1525e626 100644 --- a/src/reactpy_django/hooks.py +++ b/src/reactpy_django/hooks.py @@ -3,35 +3,46 @@ import asyncio import logging from typing import ( + TYPE_CHECKING, Any, Awaitable, Callable, + Coroutine, DefaultDict, + Generic, Sequence, + Type, Union, cast, overload, ) +import dill as pickle from channels.db import database_sync_to_async -from reactpy import use_callback, use_ref +from reactpy import use_callback, use_effect, use_ref, use_state from reactpy.backend.hooks import use_connection as _use_connection from reactpy.backend.hooks import use_location as _use_location from reactpy.backend.hooks import use_scope as _use_scope from reactpy.backend.types import Location -from reactpy.core.hooks import use_effect, use_state +from reactpy_django.exceptions import UserNotFoundError from reactpy_django.types import ( Connection, Mutation, MutationOptions, Query, QueryOptions, + UserData, _Params, _Result, + _Type, ) from reactpy_django.utils import generate_obj_name +if TYPE_CHECKING: + from django.contrib.auth.models import AbstractUser + + _logger = logging.getLogger(__name__) _REFETCH_CALLBACKS: DefaultDict[ Callable[..., Any], set[Callable[[], None]] @@ -335,3 +346,63 @@ def _use_mutation_args_2( refetch: Callable[..., Any] | Sequence[Callable[..., Any]] | None = None, ): return MutationOptions(), mutation, refetch + + +def use_user() -> AbstractUser | None: + """Get the current `User` object from either the WebSocket or HTTP request.""" + connection = use_connection() + return connection.scope.get("user") or getattr(connection.carrier, "user", None) + + +async def get_user_data(user: AbstractUser | None, default: Any, user_data_model): + """Get the current user's `UserState` query.""" + from reactpy_django.models import UserDataModel + + if user is None: + raise ValueError("No user is available.") + + user_data_model = user_data_model or UserDataModel + + model, _ = await user_data_model.objects.aget_or_create(user=user) + + if not model.data: + if asyncio.iscoroutinefunction(default): + default = await default() + elif callable(default): + default = default() + model.data = pickle.dumps(default) + model.save() + + return pickle.loads(model.data) + + +def set_user_data(user: AbstractUser, user_data_model): + from reactpy_django.models import UserDataModel + + user_data_model = user_data_model or UserDataModel + + async def mutation(data: Any): + """Set the current user's `UserState` query.""" + model, _ = await user_data_model.objects.aget_or_create(user=user) + model.data = pickle.dumps(data) + model.save() + + return mutation + + +# TODO: Make user_data_model generic construct + + +def use_user_data( + default_data: _Type | Callable[[], _Type] | Callable[[], Awaitable[_Type]], + user_data_model, +) -> UserData[_Type]: + user = use_user() + + if user is None: + raise UserNotFoundError("No user is available.") + + data = use_query(get_user_data, user, default_data, user_data_model) + set_data = use_mutation(set_user_data(user, user_data_model), refetch=get_user_data) + + return UserData(cast(Query[_Type | None], data), set_data) diff --git a/src/reactpy_django/migrations/0006_userdatamodel.py b/src/reactpy_django/migrations/0006_userdatamodel.py new file mode 100644 index 00000000..69e4ec31 --- /dev/null +++ b/src/reactpy_django/migrations/0006_userdatamodel.py @@ -0,0 +1,37 @@ +# Generated by Django 4.2.5 on 2023-09-18 01:45 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("reactpy_django", "0005_alter_componentsession_last_accessed"), + ] + + operations = [ + migrations.CreateModel( + name="UserDataModel", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("data", models.BinaryField(blank=True)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/src/reactpy_django/models.py b/src/reactpy_django/models.py index 1fa69d2c..5a1f9cfd 100644 --- a/src/reactpy_django/models.py +++ b/src/reactpy_django/models.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.db import models @@ -23,5 +24,12 @@ def save(self, *args, **kwargs): @classmethod def load(cls): - obj, created = cls.objects.get_or_create(pk=1) + obj, _ = cls.objects.get_or_create(pk=1) return obj + + +class UserDataModel(models.Model): + """A model for storing `user_state` data.""" + + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) # type: ignore + data = models.BinaryField(blank=True) # type: ignore diff --git a/src/reactpy_django/types.py b/src/reactpy_django/types.py index 3c77a1eb..015526d9 100644 --- a/src/reactpy_django/types.py +++ b/src/reactpy_django/types.py @@ -7,6 +7,7 @@ Callable, Generic, MutableMapping, + NamedTuple, Optional, Protocol, Sequence, @@ -40,6 +41,7 @@ _Result = TypeVar("_Result", bound=Union[Model, QuerySet[Any]]) _Params = ParamSpec("_Params") _Data = TypeVar("_Data") +_Type = TypeVar("_Type") @dataclass @@ -134,3 +136,9 @@ class ComponentParams: args: Sequence kwargs: MutableMapping[str, Any] + + +@dataclass +class UserData(Generic[_Type]): + data: Query[_Type | None] + set_data: Mutation[_Type] diff --git a/src/reactpy_django/websocket/consumer.py b/src/reactpy_django/websocket/consumer.py index c6a47c27..ec0e0810 100644 --- a/src/reactpy_django/websocket/consumer.py +++ b/src/reactpy_django/websocket/consumer.py @@ -8,7 +8,7 @@ from concurrent.futures import Future from datetime import timedelta from threading import Thread -from typing import Any, MutableMapping, Sequence +from typing import TYPE_CHECKING, Any, MutableMapping, Sequence import dill as pickle import orjson @@ -24,6 +24,9 @@ from reactpy_django.types import ComponentParams, ComponentWebsocket from reactpy_django.utils import delete_expired_sessions +if TYPE_CHECKING: + from django.contrib.auth.models import AbstractUser + _logger = logging.getLogger(__name__) backhaul_loop = asyncio.new_event_loop() @@ -48,8 +51,8 @@ async def connect(self) -> None: await super().connect() # Authenticate the user, if possible - user = self.scope.get("user") - if user and user.is_authenticated: + user: AbstractUser | None = self.scope.get("user") + if user and getattr(user, "is_authenticated", False): try: await login(self.scope, user, backend=REACTPY_AUTH_BACKEND) except Exception: From 27623453e35445ffbea4d79fd97a4b655485aa4e Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 17 Sep 2023 20:18:14 -0700 Subject: [PATCH 02/63] misc --- src/reactpy_django/hooks.py | 2 +- src/reactpy_django/types.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/reactpy_django/hooks.py b/src/reactpy_django/hooks.py index 1525e626..b00eebd7 100644 --- a/src/reactpy_django/hooks.py +++ b/src/reactpy_django/hooks.py @@ -390,7 +390,7 @@ async def mutation(data: Any): return mutation -# TODO: Make user_data_model generic construct +# TODO: Make user_data_model generic protocol def use_user_data( diff --git a/src/reactpy_django/types.py b/src/reactpy_django/types.py index 015526d9..27362b53 100644 --- a/src/reactpy_django/types.py +++ b/src/reactpy_django/types.py @@ -7,7 +7,6 @@ Callable, Generic, MutableMapping, - NamedTuple, Optional, Protocol, Sequence, From 72055e25054bc4bd669520c58fcd2a3b587671b7 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 18 Sep 2023 01:10:46 -0700 Subject: [PATCH 03/63] remove unneeded imports --- src/reactpy_django/hooks.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/reactpy_django/hooks.py b/src/reactpy_django/hooks.py index b00eebd7..97b65cf9 100644 --- a/src/reactpy_django/hooks.py +++ b/src/reactpy_django/hooks.py @@ -7,11 +7,8 @@ Any, Awaitable, Callable, - Coroutine, DefaultDict, - Generic, Sequence, - Type, Union, cast, overload, From cb1c17252d6f0217da75836e1d8b8d7aff190b03 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 18 Sep 2023 23:23:31 -0700 Subject: [PATCH 04/63] auth_required -> user_passes_test --- CHANGELOG.md | 4 ++++ src/reactpy_django/decorators.py | 40 ++++++++++++++++++++++++++++++-- src/reactpy_django/hooks.py | 11 ++++----- 3 files changed, 47 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8f0ec63..6da46500 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,10 @@ Using the following categories, list your changes in this order: - Renamed undocumented utility function `reactpy_django.utils.ComponentPreloader` to `reactpy_django.utils.RootComponentFinder`. +### Deprecated + +- `reactpy_django.decorators.auth_required` is deprecated. An equivalent to this decorator's default is `@reactpy_django.decorators.user_passes_test(lambda user: user.is_active)`. + ## [3.5.1] - 2023-09-07 ### Added diff --git a/src/reactpy_django/decorators.py b/src/reactpy_django/decorators.py index fc24fdfd..bd406e46 100644 --- a/src/reactpy_django/decorators.py +++ b/src/reactpy_django/decorators.py @@ -1,11 +1,12 @@ from __future__ import annotations from functools import wraps -from typing import Callable +from typing import Any, Callable +from warnings import warn from reactpy.core.types import ComponentType, VdomDict -from reactpy_django.hooks import use_scope +from reactpy_django.hooks import use_scope, use_user def auth_required( @@ -24,6 +25,12 @@ def auth_required( fallback: The component or VDOM (`reactpy.html` snippet) to render if the user is not authenticated. """ + warn( + "auth_required is deprecated and will be removed in the next major version. " + "An equivalent to this decorator's default is @user_passes_test('is_active').", + DeprecationWarning, + ) + def decorator(component): @wraps(component) def _wrapped_func(*args, **kwargs): @@ -37,3 +44,32 @@ def _wrapped_func(*args, **kwargs): # Return for @authenticated(...) and @authenticated respectively return decorator if component is None else decorator(component) + + +def user_passes_test( + test_func: Callable[[Any], bool], + fallback: ComponentType | Callable | VdomDict | None = None, +) -> Callable: + """Check the attribute on the current `UserModel`. If the attribute passes as a conditional, + then decorated component will be rendered. Otherwise, the fallback component will be rendered. + + Args: + test_func: The function that returns a boolean. + fallback: The component or VDOM (`reactpy.html` snippet) to render if the user is not authenticated. + """ + + def decorator(component): + @wraps(component) + def _wrapper(*args, **kwargs): + user = use_user() + + # Run the test and render the component if it passes. + if test_func(user): + return component(*args, **kwargs) + + # Render the fallback component. + return fallback(*args, **kwargs) if callable(fallback) else fallback + + return _wrapper + + return decorator diff --git a/src/reactpy_django/hooks.py b/src/reactpy_django/hooks.py index 97b65cf9..6ceda7f6 100644 --- a/src/reactpy_django/hooks.py +++ b/src/reactpy_django/hooks.py @@ -345,10 +345,13 @@ def _use_mutation_args_2( return MutationOptions(), mutation, refetch -def use_user() -> AbstractUser | None: +def use_user() -> AbstractUser: """Get the current `User` object from either the WebSocket or HTTP request.""" connection = use_connection() - return connection.scope.get("user") or getattr(connection.carrier, "user", None) + user = connection.scope.get("user") or getattr(connection.carrier, "user", None) + if user is None: + raise UserNotFoundError("No user is available in the current environment.") + return user async def get_user_data(user: AbstractUser | None, default: Any, user_data_model): @@ -395,10 +398,6 @@ def use_user_data( user_data_model, ) -> UserData[_Type]: user = use_user() - - if user is None: - raise UserNotFoundError("No user is available.") - data = use_query(get_user_data, user, default_data, user_data_model) set_data = use_mutation(set_user_data(user, user_data_model), refetch=get_user_data) From 6815927482df5c0f3344d1d5110daf1b83fbe5a9 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 18 Sep 2023 23:30:24 -0700 Subject: [PATCH 05/63] revert docs mods --- docs/src/reference/hooks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/reference/hooks.md b/docs/src/reference/hooks.md index 79f368a3..3233a5bb 100644 --- a/docs/src/reference/hooks.md +++ b/docs/src/reference/hooks.md @@ -169,7 +169,7 @@ The mutation function you provide should have no return value. | Name | Type | Description | Default | | --- | --- | --- | --- | | `#!python mutate` | `#!python Callable[_Params, bool | None]` | A callable that performs Django ORM create, update, or delete functionality. If this function returns `#!python False`, then your `#!python refetch` function will not be used. | N/A | - | `#!python refetch` | `#!python Callable[..., Any] | Sequence[Callable[..., Any]] | None` | A query function (the function typically provided to your `#!python use_query` hook) or a sequence of query functions that need a `refetch` if the mutation succeeds. This is useful for refetching data after a mutation has been performed. | `#!python None` | + | `#!python refetch` | `#!python Callable[..., Any] | Sequence[Callable[..., Any]] | None` | A `#!python query` function (used by the `#!python use_query` hook) or a sequence of `#!python query` functions that will be called if the mutation succeeds. This is useful for refetching data after a mutation has been performed. | `#!python None` | **Returns** From a1809d1622bfe6e998b0a5e1b6c9fa6699939369 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 19 Sep 2023 00:44:40 -0700 Subject: [PATCH 06/63] remove ComponentWebsocket --- src/reactpy_django/types.py | 16 +++------------- src/reactpy_django/websocket/consumer.py | 6 +++--- 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/src/reactpy_django/types.py b/src/reactpy_django/types.py index 27362b53..75455066 100644 --- a/src/reactpy_django/types.py +++ b/src/reactpy_django/types.py @@ -3,11 +3,9 @@ from dataclasses import dataclass, field from typing import ( Any, - Awaitable, Callable, Generic, MutableMapping, - Optional, Protocol, Sequence, TypeVar, @@ -21,11 +19,12 @@ from reactpy.types import Connection as _Connection from typing_extensions import ParamSpec +from reactpy_django.websocket.consumer import ReactpyAsyncWebsocketConsumer + __all__ = [ "_Result", "_Params", "_Data", - "ComponentWebsocket", "Query", "Mutation", "Connection", @@ -43,16 +42,7 @@ _Type = TypeVar("_Type") -@dataclass -class ComponentWebsocket: - """Carrier type for the `use_connection` hook.""" - - close: Callable[[Optional[int]], Awaitable[None]] - disconnect: Callable[[int], Awaitable[None]] - dotted_path: str - - -Connection = _Connection[Union[ComponentWebsocket, HttpRequest]] +Connection = _Connection[Union[ReactpyAsyncWebsocketConsumer, HttpRequest]] @dataclass diff --git a/src/reactpy_django/websocket/consumer.py b/src/reactpy_django/websocket/consumer.py index ec0e0810..0b0e5896 100644 --- a/src/reactpy_django/websocket/consumer.py +++ b/src/reactpy_django/websocket/consumer.py @@ -21,7 +21,7 @@ from reactpy.core.layout import Layout from reactpy.core.serve import serve_layout -from reactpy_django.types import ComponentParams, ComponentWebsocket +from reactpy_django.types import ComponentParams from reactpy_django.utils import delete_expired_sessions if TYPE_CHECKING: @@ -150,7 +150,7 @@ async def run_dispatcher(self): ) scope = self.scope - dotted_path = scope["url_route"]["kwargs"]["dotted_path"] + self.dotted_path = dotted_path = scope["url_route"]["kwargs"]["dotted_path"] uuid = scope["url_route"]["kwargs"].get("uuid") search = scope["query_string"].decode() self.recv_queue: asyncio.Queue = asyncio.Queue() @@ -160,7 +160,7 @@ async def run_dispatcher(self): pathname=scope["path"], search=f"?{search}" if (search and (search != "undefined")) else "", ), - carrier=ComponentWebsocket(self.close, self.disconnect, dotted_path), + carrier=self, ) now = timezone.now() component_session_args: Sequence[Any] = () From 2f0735ec692d4df1fe59d08d9258c0e03b1385f0 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 24 Sep 2023 05:49:30 -0700 Subject: [PATCH 07/63] Simplify middleware requirements --- docs/python/configure-asgi-middleware.py | 9 ++--- .../learn/add-reactpy-to-a-django-project.md | 6 +-- src/reactpy_django/websocket/consumer.py | 39 +++++-------------- tests/test_app/asgi.py | 5 +-- 4 files changed, 16 insertions(+), 43 deletions(-) diff --git a/docs/python/configure-asgi-middleware.py b/docs/python/configure-asgi-middleware.py index e817ee48..1895d651 100644 --- a/docs/python/configure-asgi-middleware.py +++ b/docs/python/configure-asgi-middleware.py @@ -7,16 +7,13 @@ # start from channels.auth import AuthMiddlewareStack # noqa: E402 -from channels.sessions import SessionMiddlewareStack # noqa: E402 application = ProtocolTypeRouter( { "http": django_asgi_app, - "websocket": SessionMiddlewareStack( - AuthMiddlewareStack( - URLRouter( - [REACTPY_WEBSOCKET_ROUTE], - ) + "websocket": AuthMiddlewareStack( + URLRouter( + [REACTPY_WEBSOCKET_ROUTE], ) ), } diff --git a/docs/src/learn/add-reactpy-to-a-django-project.md b/docs/src/learn/add-reactpy-to-a-django-project.md index 5a22b8f6..71b174c0 100644 --- a/docs/src/learn/add-reactpy-to-a-django-project.md +++ b/docs/src/learn/add-reactpy-to-a-django-project.md @@ -77,15 +77,15 @@ Register ReactPy's WebSocket using `#!python REACTPY_WEBSOCKET_ROUTE` in your [` {% include "../../python/configure-asgi.py" %} ``` -??? info "Add `#!python AuthMiddlewareStack` and `#!python SessionMiddlewareStack` (Optional)" +??? info "Add `#!python AuthMiddlewareStack` (Optional)" There are many situations where you need to access the Django `#!python User` or `#!python Session` objects within ReactPy components. For example, if you want to: 1. Access the `#!python User` that is currently logged in - 2. Login or logout the current `#!python User` 3. Access Django's `#!python Session` object + 2. Login or logout the current `#!python User` - In these situations will need to ensure you are using `#!python AuthMiddlewareStack` and/or `#!python SessionMiddlewareStack`. + In these situations will need to ensure you are using `#!python AuthMiddlewareStack`. ```python linenums="0" {% include "../../python/configure-asgi-middleware.py" start="# start" %} diff --git a/src/reactpy_django/websocket/consumer.py b/src/reactpy_django/websocket/consumer.py index 0b0e5896..3dd60ffd 100644 --- a/src/reactpy_django/websocket/consumer.py +++ b/src/reactpy_django/websocket/consumer.py @@ -8,11 +8,10 @@ from concurrent.futures import Future from datetime import timedelta from threading import Thread -from typing import TYPE_CHECKING, Any, MutableMapping, Sequence +from typing import Any, MutableMapping, Sequence import dill as pickle import orjson -from channels.auth import login from channels.db import database_sync_to_async from channels.generic.websocket import AsyncJsonWebsocketConsumer from django.utils import timezone @@ -24,9 +23,6 @@ from reactpy_django.types import ComponentParams from reactpy_django.utils import delete_expired_sessions -if TYPE_CHECKING: - from django.contrib.auth.models import AbstractUser - _logger = logging.getLogger(__name__) backhaul_loop = asyncio.new_event_loop() @@ -46,39 +42,22 @@ class ReactpyAsyncWebsocketConsumer(AsyncJsonWebsocketConsumer): async def connect(self) -> None: """The browser has connected.""" from reactpy_django import models - from reactpy_django.config import REACTPY_AUTH_BACKEND, REACTPY_BACKHAUL_THREAD + from reactpy_django.config import REACTPY_BACKHAUL_THREAD await super().connect() - # Authenticate the user, if possible - user: AbstractUser | None = self.scope.get("user") - if user and getattr(user, "is_authenticated", False): - try: - await login(self.scope, user, backend=REACTPY_AUTH_BACKEND) - except Exception: - await asyncio.to_thread( - _logger.exception, "ReactPy websocket authentication has failed!" - ) - elif user is None: + # Warn for missing features + if self.scope.get("user") is None: await asyncio.to_thread( _logger.debug, - "ReactPy websocket is missing AuthMiddlewareStack! " - "Users will not be accessible within `use_scope` or `use_websocket`!", + "ReactPy websocket is missing Auth Middleware! " + "User will not be accessible within hooks!", ) - - # Save the session, if possible - if self.scope.get("session"): - try: - await database_sync_to_async(self.scope["session"].save)() - except Exception: - await asyncio.to_thread( - _logger.exception, "ReactPy has failed to save scope['session']!" - ) - else: + if not self.scope.get("session"): await asyncio.to_thread( _logger.debug, - "ReactPy websocket is missing SessionMiddlewareStack! " - "Sessions will not be accessible within `use_scope` or `use_websocket`!", + "ReactPy websocket is missing Session Middleware! " + "Sessions will not be accessible within hooks!", ) # Start the component dispatcher diff --git a/tests/test_app/asgi.py b/tests/test_app/asgi.py index 044dee20..50a9865f 100644 --- a/tests/test_app/asgi.py +++ b/tests/test_app/asgi.py @@ -15,14 +15,11 @@ from channels.auth import AuthMiddlewareStack # noqa: E402 from channels.routing import ProtocolTypeRouter, URLRouter # noqa: E402 -from channels.sessions import SessionMiddlewareStack # noqa: E402 from reactpy_django import REACTPY_WEBSOCKET_ROUTE # noqa: E402 application = ProtocolTypeRouter( { "http": http_asgi_app, - "websocket": SessionMiddlewareStack( - AuthMiddlewareStack(URLRouter([REACTPY_WEBSOCKET_ROUTE])) - ), + "websocket": AuthMiddlewareStack(URLRouter([REACTPY_WEBSOCKET_ROUTE])), } ) From 65119e311281fc4c134410d5b3dca76aabafb50f Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 24 Sep 2023 23:40:53 -0700 Subject: [PATCH 08/63] fix changelog merge issue --- CHANGELOG.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2cb92b1..8a505aaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,9 +52,6 @@ Using the following categories, list your changes in this order: - The `compatibility` argument on `reactpy_django.components.view_to_component` is deprecated. Use `reactpy_django.components.view_to_iframe` instead. - Using `reactpy_django.components.view_to_component` as a decorator is deprecated. Check the docs on the new suggested usage. - -### Deprecated - - `reactpy_django.decorators.auth_required` is deprecated. An equivalent to this decorator's default is `@reactpy_django.decorators.user_passes_test(lambda user: user.is_active)`. ## [3.5.1] - 2023-09-07 From b32ca1ab03d7a1796ddaba265946afe176513b30 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 24 Sep 2023 23:43:28 -0700 Subject: [PATCH 09/63] fix circular import --- src/reactpy_django/types.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/reactpy_django/types.py b/src/reactpy_django/types.py index 4c90b938..e41aee57 100644 --- a/src/reactpy_django/types.py +++ b/src/reactpy_django/types.py @@ -2,6 +2,7 @@ from dataclasses import dataclass, field from typing import ( + TYPE_CHECKING, Any, Callable, Generic, @@ -18,7 +19,8 @@ from reactpy.types import Connection as _Connection from typing_extensions import ParamSpec -from reactpy_django.websocket.consumer import ReactpyAsyncWebsocketConsumer +if TYPE_CHECKING: + from reactpy_django.websocket.consumer import ReactpyAsyncWebsocketConsumer __all__ = [ "_Result", @@ -40,7 +42,7 @@ _Type = TypeVar("_Type") -Connection = _Connection[Union[ReactpyAsyncWebsocketConsumer, HttpRequest]] +Connection = _Connection[Union["ReactpyAsyncWebsocketConsumer", HttpRequest]] @dataclass From 4d3b538dbf860e99d3eb62f19d14ac90bbee54e8 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 24 Sep 2023 23:56:01 -0700 Subject: [PATCH 10/63] _UserDataType --- src/reactpy_django/hooks.py | 32 +++++++++++++++++++------------- src/reactpy_django/types.py | 8 ++++---- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/src/reactpy_django/hooks.py b/src/reactpy_django/hooks.py index 50f81e8a..ac6a535d 100644 --- a/src/reactpy_django/hooks.py +++ b/src/reactpy_django/hooks.py @@ -32,12 +32,13 @@ UserData, _Params, _Result, - _Type, + _UserDataType, ) from reactpy_django.utils import generate_obj_name if TYPE_CHECKING: from django.contrib.auth.models import AbstractUser + from django.db.models import Model _logger = logging.getLogger(__name__) @@ -355,7 +356,9 @@ def use_user() -> AbstractUser: return user -async def get_user_data(user: AbstractUser | None, default: Any, user_data_model): +async def _get_user_data( + user: AbstractUser | None, default: Any, user_data_model: Model +): """Get the current user's `UserState` query.""" from reactpy_django.models import UserDataModel @@ -377,7 +380,7 @@ async def get_user_data(user: AbstractUser | None, default: Any, user_data_model return pickle.loads(model.data) -def set_user_data(user: AbstractUser, user_data_model): +def _set_user_data(user: AbstractUser, user_data_model: Model): from reactpy_django.models import UserDataModel user_data_model = user_data_model or UserDataModel @@ -391,15 +394,18 @@ async def mutation(data: Any): return mutation -# TODO: Make user_data_model generic protocol - - def use_user_data( - default_data: _Type | Callable[[], _Type] | Callable[[], Awaitable[_Type]], - user_data_model, -) -> UserData[_Type]: + default_data: _UserDataType + | Callable[[], _UserDataType] + | Callable[[], Awaitable[_UserDataType]], + user_data_model: Model, +) -> UserData[_UserDataType]: user = use_user() - data = use_query(get_user_data, user, default_data, user_data_model) - set_data = use_mutation(set_user_data(user, user_data_model), refetch=get_user_data) - - return UserData(cast(Query[_Type | None], data), set_data) + data = use_query(_get_user_data, user, default_data, user_data_model) + set_data = use_mutation( + _set_user_data(user, user_data_model), refetch=_get_user_data + ) + + return UserData( + cast(Query[_UserDataType | None], data), cast(Mutation[_UserDataType], set_data) + ) diff --git a/src/reactpy_django/types.py b/src/reactpy_django/types.py index e41aee57..5ab02398 100644 --- a/src/reactpy_django/types.py +++ b/src/reactpy_django/types.py @@ -39,7 +39,7 @@ _Result = TypeVar("_Result", bound=Union[Model, QuerySet[Any]]) _Params = ParamSpec("_Params") _Data = TypeVar("_Data") -_Type = TypeVar("_Type") +_UserDataType = TypeVar("_UserDataType") Connection = _Connection[Union["ReactpyAsyncWebsocketConsumer", HttpRequest]] @@ -121,6 +121,6 @@ class ComponentParams: @dataclass -class UserData(Generic[_Type]): - data: Query[_Type | None] - set_data: Mutation[_Type] +class UserData(Generic[_UserDataType]): + data: Query[_UserDataType | None] + set_data: Mutation[_UserDataType] From e2ae7415c88f019b8ca436f937927ba38851c3d1 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 24 Sep 2023 23:56:07 -0700 Subject: [PATCH 11/63] user_passes_test docstring --- src/reactpy_django/decorators.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/reactpy_django/decorators.py b/src/reactpy_django/decorators.py index 1adc3219..710691f4 100644 --- a/src/reactpy_django/decorators.py +++ b/src/reactpy_django/decorators.py @@ -48,14 +48,16 @@ def _wrapped_func(*args, **kwargs): def user_passes_test( test_func: Callable[[Any], bool], - fallback: ComponentType | Callable | VdomDict | None = None, + fallback: Any | None = None, ) -> Callable: - """Check the attribute on the current `UserModel`. If the attribute passes as a conditional, + """Imitation of Django's `user_passes_test` decorator that works with components. + This decorator runs your test function on the websocket connection's `user`. If the test passes, then decorated component will be rendered. Otherwise, the fallback component will be rendered. Args: test_func: The function that returns a boolean. - fallback: The component or VDOM (`reactpy.html` snippet) to render if the user is not authenticated. + fallback: The content to be rendered if the test fails. Typically is a ReactPy component or \ + VDOM (`reactpy.html` snippet). """ def decorator(component): From b312ff685bead66b0e51230f6d180cf1d6627369 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 25 Sep 2023 00:11:02 -0700 Subject: [PATCH 12/63] fix docstrings for mutation and query hooks --- src/reactpy_django/hooks.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/reactpy_django/hooks.py b/src/reactpy_django/hooks.py index ac6a535d..9dfbbd43 100644 --- a/src/reactpy_django/hooks.py +++ b/src/reactpy_django/hooks.py @@ -121,7 +121,8 @@ def use_query( *args: Any, **kwargs: Any, ) -> Query[_Result | None]: - """Hook to fetch a Django ORM query. + """This hook is used to execute functions in the background and return the result, \ + typically to read data the Django ORM. Args: options: An optional `QueryOptions` object that can modify how the query is executed. @@ -235,7 +236,11 @@ def use_mutation( def use_mutation(*args: Any, **kwargs: Any) -> Mutation[_Params]: - """Hook to create, update, or delete Django ORM objects. + """This hook is used to modify data in the background, typically to create/update/delete \ + data from the Django ORM. \n + + Mutation functions can `return False` to prevent executing your `refetch` function. All \ + other returns are ignored. Mutation functions can be sync or async. Args: mutation: A callable that performs Django ORM create, update, or delete \ From 70c03b8b2c40f9743827b5fc0dfff25443d813ca Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 25 Sep 2023 00:24:44 -0700 Subject: [PATCH 13/63] remove user_data_model type --- src/reactpy_django/hooks.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/reactpy_django/hooks.py b/src/reactpy_django/hooks.py index 9dfbbd43..da7f6f1a 100644 --- a/src/reactpy_django/hooks.py +++ b/src/reactpy_django/hooks.py @@ -362,7 +362,7 @@ def use_user() -> AbstractUser: async def _get_user_data( - user: AbstractUser | None, default: Any, user_data_model: Model + user: AbstractUser | None, default: Any, user_data_model: Any | None ): """Get the current user's `UserState` query.""" from reactpy_django.models import UserDataModel @@ -385,7 +385,7 @@ async def _get_user_data( return pickle.loads(model.data) -def _set_user_data(user: AbstractUser, user_data_model: Model): +def _set_user_data(user: AbstractUser, user_data_model: Any | None): from reactpy_django.models import UserDataModel user_data_model = user_data_model or UserDataModel @@ -403,7 +403,7 @@ def use_user_data( default_data: _UserDataType | Callable[[], _UserDataType] | Callable[[], Awaitable[_UserDataType]], - user_data_model: Model, + user_data_model: Any | None, ) -> UserData[_UserDataType]: user = use_user() data = use_query(_get_user_data, user, default_data, user_data_model) From 510f6a95d929904f1b3d2b83e8c4762eea0ebaf1 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 25 Sep 2023 02:04:12 -0700 Subject: [PATCH 14/63] fix new mkdocs font color --- docs/src/assets/css/main.css | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/src/assets/css/main.css b/docs/src/assets/css/main.css index da5a74c4..9b51f683 100644 --- a/docs/src/assets/css/main.css +++ b/docs/src/assets/css/main.css @@ -17,6 +17,7 @@ --md-default-bg-color--lighter: hsla(var(--md-hue), 15%, 16%, 0.26); --md-default-bg-color--lightest: hsla(var(--md-hue), 15%, 16%, 0.07); --md-primary-fg-color: var(--md-default-bg-color); + --md-default-fg-color: hsla(var(--md-hue), 75%, 95%, 1); --md-default-fg-color--light: #fff; --md-typeset-a-color: var(--reactpy-color); --md-accent-fg-color: var(--reactpy-color-dark); From 299f330d5c88f722b6d3dc2fcbeaaaeefab6fa3e Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 25 Sep 2023 02:10:11 -0700 Subject: [PATCH 15/63] add warning to utils page --- docs/src/reference/utils.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/src/reference/utils.md b/docs/src/reference/utils.md index d9ca6409..4617eaa9 100644 --- a/docs/src/reference/utils.md +++ b/docs/src/reference/utils.md @@ -2,10 +2,14 @@

-Utility functions provide various miscellaneous functionality. These are typically not used, but are available for advanced use cases. +Utility functions provide various miscellaneous functionality for advanced use cases.

+!!! warning "Pitfall" + + Any utility functions not documented here are not considered part of the public API and may change without notice. + --- ## Register Iframe From a5b2a18a82af531bd3bc9f3bf9b8f98e28ddc665 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 25 Sep 2023 18:30:03 -0700 Subject: [PATCH 16/63] functional type hints --- src/reactpy_django/hooks.py | 187 +++++++++++++++-------------------- src/reactpy_django/types.py | 41 +++----- tests/test_app/components.py | 4 +- 3 files changed, 95 insertions(+), 137 deletions(-) diff --git a/src/reactpy_django/hooks.py b/src/reactpy_django/hooks.py index da7f6f1a..3dcb2955 100644 --- a/src/reactpy_django/hooks.py +++ b/src/reactpy_django/hooks.py @@ -24,21 +24,19 @@ from reactpy_django.exceptions import UserNotFoundError from reactpy_django.types import ( - Connection, + ConnectionType, + FuncParams, + Inferred, Mutation, MutationOptions, Query, QueryOptions, UserData, - _Params, - _Result, - _UserDataType, ) from reactpy_django.utils import generate_obj_name if TYPE_CHECKING: from django.contrib.auth.models import AbstractUser - from django.db.models import Model _logger = logging.getLogger(__name__) @@ -90,7 +88,7 @@ def use_scope() -> dict[str, Any]: raise TypeError(f"Expected scope to be a dict, got {type(scope)}") -def use_connection() -> Connection: +def use_connection() -> ConnectionType: """Get the current `Connection` object""" return _use_connection() @@ -99,28 +97,23 @@ def use_connection() -> Connection: def use_query( options: QueryOptions, /, - query: Callable[_Params, _Result | None] - | Callable[_Params, Awaitable[_Result | None]], - *args: _Params.args, - **kwargs: _Params.kwargs, -) -> Query[_Result | None]: + query: Callable[FuncParams, Awaitable[Inferred]] | Callable[FuncParams, Inferred], + *args: FuncParams.args, + **kwargs: FuncParams.kwargs, +) -> Query[Inferred]: ... @overload def use_query( - query: Callable[_Params, _Result | None] - | Callable[_Params, Awaitable[_Result | None]], - *args: _Params.args, - **kwargs: _Params.kwargs, -) -> Query[_Result | None]: + query: Callable[FuncParams, Awaitable[Inferred]] | Callable[FuncParams, Inferred], + *args: FuncParams.args, + **kwargs: FuncParams.kwargs, +) -> Query[Inferred]: ... -def use_query( - *args: Any, - **kwargs: Any, -) -> Query[_Result | None]: +def use_query(*args, **kwargs) -> Query[Inferred]: """This hook is used to execute functions in the background and return the result, \ typically to read data the Django ORM. @@ -133,7 +126,7 @@ def use_query( **kwargs: Keyword arguments to pass into `query`.""" should_execute, set_should_execute = use_state(True) - data, set_data = use_state(cast(Union[_Result, None], None)) + data, set_data = use_state(cast(Inferred, None)) loading, set_loading = use_state(True) error, set_error = use_state(cast(Union[Exception, None], None)) if isinstance(args[0], QueryOptions): @@ -174,7 +167,7 @@ async def execute_query() -> None: # Log any errors and set the error state except Exception as e: - set_data(None) + set_data(cast(Inferred, None)) set_loading(False) set_error(e) _logger.exception(f"Failed to execute query: {generate_obj_name(query)}") @@ -219,23 +212,23 @@ def add_refetch_callback() -> Callable[[], None]: @overload def use_mutation( options: MutationOptions, - mutation: Callable[_Params, bool | None] - | Callable[_Params, Awaitable[bool | None]], + mutation: Callable[FuncParams, bool | None] + | Callable[FuncParams, Awaitable[bool | None]], refetch: Callable[..., Any] | Sequence[Callable[..., Any]] | None = None, -) -> Mutation[_Params]: +) -> Mutation[FuncParams]: ... @overload def use_mutation( - mutation: Callable[_Params, bool | None] - | Callable[_Params, Awaitable[bool | None]], + mutation: Callable[FuncParams, bool | None] + | Callable[FuncParams, Awaitable[bool | None]], refetch: Callable[..., Any] | Sequence[Callable[..., Any]] | None = None, -) -> Mutation[_Params]: +) -> Mutation[FuncParams]: ... -def use_mutation(*args: Any, **kwargs: Any) -> Mutation[_Params]: +def use_mutation(*args: Any, **kwargs: Any) -> Mutation[FuncParams]: """This hook is used to modify data in the background, typically to create/update/delete \ data from the Django ORM. \n @@ -293,7 +286,7 @@ async def execute_mutation(exec_args, exec_kwargs) -> None: # Schedule the mutation to be run when needed @use_callback def schedule_mutation( - *exec_args: _Params.args, **exec_kwargs: _Params.kwargs + *exec_args: FuncParams.args, **exec_kwargs: FuncParams.kwargs ) -> None: # Set the loading state. # It's okay to re-execute the mutation if we're told to. The user @@ -315,43 +308,6 @@ def reset() -> None: return Mutation(schedule_mutation, loading, error, reset) -def _use_query_args_1( - options: QueryOptions, - /, - query: Callable[_Params, _Result | None] - | Callable[_Params, Awaitable[_Result | None]], - *args: _Params.args, - **kwargs: _Params.kwargs, -): - return options, query, args, kwargs - - -def _use_query_args_2( - query: Callable[_Params, _Result | None] - | Callable[_Params, Awaitable[_Result | None]], - *args: _Params.args, - **kwargs: _Params.kwargs, -): - return QueryOptions(), query, args, kwargs - - -def _use_mutation_args_1( - options: MutationOptions, - mutation: Callable[_Params, bool | None] - | Callable[_Params, Awaitable[bool | None]], - refetch: Callable[..., Any] | Sequence[Callable[..., Any]] | None = None, -): - return options, mutation, refetch - - -def _use_mutation_args_2( - mutation: Callable[_Params, bool | None] - | Callable[_Params, Awaitable[bool | None]], - refetch: Callable[..., Any] | Sequence[Callable[..., Any]] | None = None, -): - return MutationOptions(), mutation, refetch - - def use_user() -> AbstractUser: """Get the current `User` object from either the WebSocket or HTTP request.""" connection = use_connection() @@ -361,56 +317,77 @@ def use_user() -> AbstractUser: return user -async def _get_user_data( - user: AbstractUser | None, default: Any, user_data_model: Any | None -): - """Get the current user's `UserState` query.""" +def use_user_data( + default_data: None + | dict[str, Callable[[], Any] | Callable[[], Awaitable[Any]] | Any] = None, +) -> UserData[dict]: from reactpy_django.models import UserDataModel - if user is None: - raise ValueError("No user is available.") - - user_data_model = user_data_model or UserDataModel + user = use_user() - model, _ = await user_data_model.objects.aget_or_create(user=user) + async def _set_user_data(data): + if not isinstance(data, dict): + raise TypeError(f"Expected dict while setting user data, got {type(data)}") - if not model.data: - if asyncio.iscoroutinefunction(default): - default = await default() - elif callable(default): - default = default() - model.data = pickle.dumps(default) + model, _ = await UserDataModel.objects.aget_or_create(user=user) + model.data = pickle.dumps(data) model.save() - return pickle.loads(model.data) + data: Query[dict] = use_query( + QueryOptions(postprocessor=None), + _get_user_data, + user=user, + defaults=default_data, + ) + set_data = use_mutation(_set_user_data, refetch=_get_user_data) + return UserData(data, set_data) -def _set_user_data(user: AbstractUser, user_data_model: Any | None): - from reactpy_django.models import UserDataModel - user_data_model = user_data_model or UserDataModel +def _use_query_args_1(options: QueryOptions, /, query: Query, *args, **kwargs): + return options, query, args, kwargs - async def mutation(data: Any): - """Set the current user's `UserState` query.""" - model, _ = await user_data_model.objects.aget_or_create(user=user) - model.data = pickle.dumps(data) - model.save() - return mutation +def _use_query_args_2(query: Query, *args, **kwargs): + return QueryOptions(), query, args, kwargs -def use_user_data( - default_data: _UserDataType - | Callable[[], _UserDataType] - | Callable[[], Awaitable[_UserDataType]], - user_data_model: Any | None, -) -> UserData[_UserDataType]: - user = use_user() - data = use_query(_get_user_data, user, default_data, user_data_model) - set_data = use_mutation( - _set_user_data(user, user_data_model), refetch=_get_user_data - ) +def _use_mutation_args_1(options: MutationOptions, mutation: Mutation, refetch=None): + return options, mutation, refetch + + +def _use_mutation_args_2(mutation, refetch=None): + return MutationOptions(), mutation, refetch + + +async def _get_user_data(user: AbstractUser, defaults: None | dict) -> dict: + from reactpy_django.models import UserDataModel + + if user is None: + raise ValueError("No user is available.") - return UserData( - cast(Query[_UserDataType | None], data), cast(Mutation[_UserDataType], set_data) + model, _ = await UserDataModel.objects.aget_or_create( + user=user, data=pickle.dumps({}) ) + data = pickle.loads(model.data) + + if not isinstance(data, dict): + raise TypeError(f"Expected dict while loading user data, got {type(data)}") + + # Set default values, if needed + if defaults: + changed = False + for key, value in defaults.items(): + if key not in data: + new_value: Any = value + if asyncio.iscoroutinefunction(value): + new_value = await value() + elif callable(value): + new_value = value() + data[key] = new_value + changed = True + if changed: + model.data = pickle.dumps(data) + model.save() + + return data diff --git a/src/reactpy_django/types.py b/src/reactpy_django/types.py index 5ab02398..43a76988 100644 --- a/src/reactpy_django/types.py +++ b/src/reactpy_django/types.py @@ -13,53 +13,34 @@ Union, ) -from django.db.models.base import Model -from django.db.models.query import QuerySet from django.http import HttpRequest -from reactpy.types import Connection as _Connection +from reactpy.types import Connection from typing_extensions import ParamSpec if TYPE_CHECKING: from reactpy_django.websocket.consumer import ReactpyAsyncWebsocketConsumer -__all__ = [ - "_Result", - "_Params", - "_Data", - "Query", - "Mutation", - "Connection", - "AsyncPostprocessor", - "SyncPostprocessor", - "QueryOptions", - "MutationOptions", - "ComponentParams", -] -_Result = TypeVar("_Result", bound=Union[Model, QuerySet[Any]]) -_Params = ParamSpec("_Params") -_Data = TypeVar("_Data") -_UserDataType = TypeVar("_UserDataType") - - -Connection = _Connection[Union["ReactpyAsyncWebsocketConsumer", HttpRequest]] +FuncParams = ParamSpec("FuncParams") +Inferred = TypeVar("Inferred") +ConnectionType = Connection[Union["ReactpyAsyncWebsocketConsumer", HttpRequest]] @dataclass -class Query(Generic[_Data]): +class Query(Generic[Inferred]): """Queries generated by the `use_query` hook.""" - data: _Data + data: Inferred loading: bool error: Exception | None refetch: Callable[[], None] @dataclass -class Mutation(Generic[_Params]): +class Mutation(Generic[FuncParams]): """Mutations generated by the `use_mutation` hook.""" - execute: Callable[_Params, None] + execute: Callable[FuncParams, None] loading: bool error: Exception | None reset: Callable[[], None] @@ -121,6 +102,6 @@ class ComponentParams: @dataclass -class UserData(Generic[_UserDataType]): - data: Query[_UserDataType | None] - set_data: Mutation[_UserDataType] +class UserData(Generic[Inferred]): + data: Query[Inferred] + set_data: Mutation[Inferred] diff --git a/tests/test_app/components.py b/tests/test_app/components.py index f7569307..56439a92 100644 --- a/tests/test_app/components.py +++ b/tests/test_app/components.py @@ -207,7 +207,7 @@ def relational_query(): "id": "relational-query", "data-success": bool(mtm) and bool(oto) and bool(mto) and bool(fk), }, - html.p(inspect.currentframe().f_code.co_name), + html.p(inspect.currentframe().f_code.co_name), # type: ignore html.div(f"Relational Parent Many To Many: {mtm}"), html.div(f"Relational Parent One To One: {oto}"), html.div(f"Relational Parent Many to One: {mto}"), @@ -268,7 +268,7 @@ def async_relational_query(): "id": "async-relational-query", "data-success": bool(mtm) and bool(oto) and bool(mto) and bool(fk), }, - html.p(inspect.currentframe().f_code.co_name), + html.p(inspect.currentframe().f_code.co_name), # type: ignore html.div(f"Relational Parent Many To Many: {mtm}"), html.div(f"Relational Parent One To One: {oto}"), html.div(f"Relational Parent Many to One: {mto}"), From b0bdbfc1797068febe0da6aa397645152368f52c Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 25 Sep 2023 19:35:43 -0700 Subject: [PATCH 17/63] use_user test --- tests/test_app/prerender/components.py | 16 ++++++++++++++++ tests/test_app/templates/prerender.html | 2 ++ tests/test_app/tests/test_components.py | 4 ++++ 3 files changed, 22 insertions(+) diff --git a/tests/test_app/prerender/components.py b/tests/test_app/prerender/components.py index 0ae4af0e..1dc013f3 100644 --- a/tests/test_app/prerender/components.py +++ b/tests/test_app/prerender/components.py @@ -38,3 +38,19 @@ def inner(value): return inner("prerender_component: Prerendered") return inner("prerender_component: Fully Rendered") + + +@component +def use_user(): + user = reactpy_django.hooks.use_user() + scope = reactpy_django.hooks.use_scope() + success = bool(user) + + if scope.get("type") == "http": + return html.div( + {"id": "use-user-http", "data-success": success}, f"use_user: {user} (HTTP)" + ) + + return html.div( + {"id": "use-user-ws", "data-success": success}, f"use_user: {user} (WebSocket)" + ) diff --git a/tests/test_app/templates/prerender.html b/tests/test_app/templates/prerender.html index 03befec5..ed571554 100644 --- a/tests/test_app/templates/prerender.html +++ b/tests/test_app/templates/prerender.html @@ -25,6 +25,8 @@

ReactPy Prerender Test Page

{% component "test_app.prerender.components.prerender_component" class="prerender-component" prerender="true" %}
+ {% component "test_app.prerender.components.use_user" prerender="true" %} +
diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index a5db4159..236edeab 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -368,10 +368,13 @@ def test_prerender(self): string = new_page.locator("#prerender_string") vdom = new_page.locator("#prerender_vdom") component = new_page.locator("#prerender_component") + use_user_http = new_page.locator("#use-user-http[data-success=True]") + use_user_ws = new_page.locator("#use-user-ws[data-success=true]") string.wait_for() vdom.wait_for() component.wait_for() + use_user_http.wait_for() # Check if the prerender occurred self.assertEqual( @@ -390,6 +393,7 @@ def test_prerender(self): self.assertEqual( component.all_inner_texts(), ["prerender_component: Fully Rendered"] ) + use_user_ws.wait_for() finally: new_page.close() From f6abbbe7e27ed1495aef3207ce2019c4ec929e39 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 26 Sep 2023 00:37:07 -0700 Subject: [PATCH 18/63] fix some bugs --- src/reactpy_django/hooks.py | 14 +++++------ .../0007_alter_userdatamodel_user.py | 24 +++++++++++++++++++ .../0008_alter_userdatamodel_user.py | 22 +++++++++++++++++ .../0009_alter_userdatamodel_data.py | 17 +++++++++++++ src/reactpy_django/models.py | 4 ++-- src/reactpy_django/types.py | 8 +++---- 6 files changed, 76 insertions(+), 13 deletions(-) create mode 100644 src/reactpy_django/migrations/0007_alter_userdatamodel_user.py create mode 100644 src/reactpy_django/migrations/0008_alter_userdatamodel_user.py create mode 100644 src/reactpy_django/migrations/0009_alter_userdatamodel_data.py diff --git a/src/reactpy_django/hooks.py b/src/reactpy_django/hooks.py index 3dcb2955..58547ca5 100644 --- a/src/reactpy_django/hooks.py +++ b/src/reactpy_django/hooks.py @@ -328,10 +328,12 @@ def use_user_data( async def _set_user_data(data): if not isinstance(data, dict): raise TypeError(f"Expected dict while setting user data, got {type(data)}") + if user.is_anonymous: + raise ValueError("AnonymousUser cannot have user data.") model, _ = await UserDataModel.objects.aget_or_create(user=user) model.data = pickle.dumps(data) - model.save() + await model.asave() data: Query[dict] = use_query( QueryOptions(postprocessor=None), @@ -363,13 +365,11 @@ def _use_mutation_args_2(mutation, refetch=None): async def _get_user_data(user: AbstractUser, defaults: None | dict) -> dict: from reactpy_django.models import UserDataModel - if user is None: - raise ValueError("No user is available.") + if not user or user.is_anonymous: + raise UserNotFoundError("No user is available, cannot fetch user data.") - model, _ = await UserDataModel.objects.aget_or_create( - user=user, data=pickle.dumps({}) - ) - data = pickle.loads(model.data) + model, _ = await UserDataModel.objects.aget_or_create(user=user) + data = pickle.loads(model.data) if model.data else {} if not isinstance(data, dict): raise TypeError(f"Expected dict while loading user data, got {type(data)}") diff --git a/src/reactpy_django/migrations/0007_alter_userdatamodel_user.py b/src/reactpy_django/migrations/0007_alter_userdatamodel_user.py new file mode 100644 index 00000000..7eaea2a3 --- /dev/null +++ b/src/reactpy_django/migrations/0007_alter_userdatamodel_user.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.5 on 2023-09-26 07:03 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("reactpy_django", "0006_userdatamodel"), + ] + + operations = [ + migrations.AlterField( + model_name="userdatamodel", + name="user", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + unique=True, + ), + ), + ] diff --git a/src/reactpy_django/migrations/0008_alter_userdatamodel_user.py b/src/reactpy_django/migrations/0008_alter_userdatamodel_user.py new file mode 100644 index 00000000..ee31c2fe --- /dev/null +++ b/src/reactpy_django/migrations/0008_alter_userdatamodel_user.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.5 on 2023-09-26 07:04 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("reactpy_django", "0007_alter_userdatamodel_user"), + ] + + operations = [ + migrations.AlterField( + model_name="userdatamodel", + name="user", + field=models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL + ), + ), + ] diff --git a/src/reactpy_django/migrations/0009_alter_userdatamodel_data.py b/src/reactpy_django/migrations/0009_alter_userdatamodel_data.py new file mode 100644 index 00000000..47b731b6 --- /dev/null +++ b/src/reactpy_django/migrations/0009_alter_userdatamodel_data.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.5 on 2023-09-26 07:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("reactpy_django", "0008_alter_userdatamodel_user"), + ] + + operations = [ + migrations.AlterField( + model_name="userdatamodel", + name="data", + field=models.BinaryField(blank=True, null=True), + ), + ] diff --git a/src/reactpy_django/models.py b/src/reactpy_django/models.py index 5a1f9cfd..20165c97 100644 --- a/src/reactpy_django/models.py +++ b/src/reactpy_django/models.py @@ -31,5 +31,5 @@ def load(cls): class UserDataModel(models.Model): """A model for storing `user_state` data.""" - user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) # type: ignore - data = models.BinaryField(blank=True) # type: ignore + user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) # type: ignore + data = models.BinaryField(null=True, blank=True) # type: ignore diff --git a/src/reactpy_django/types.py b/src/reactpy_django/types.py index 43a76988..0e223e98 100644 --- a/src/reactpy_django/types.py +++ b/src/reactpy_django/types.py @@ -7,6 +7,7 @@ Callable, Generic, MutableMapping, + NamedTuple, Protocol, Sequence, TypeVar, @@ -101,7 +102,6 @@ class ComponentParams: kwargs: MutableMapping[str, Any] -@dataclass -class UserData(Generic[Inferred]): - data: Query[Inferred] - set_data: Mutation[Inferred] +class UserData(NamedTuple): + data: Query[dict] + set_data: Mutation[dict] From 99a2a6d2d8c1f2697a32410b36468123ba55fc24 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 26 Sep 2023 00:42:25 -0700 Subject: [PATCH 19/63] fix broken type hint --- src/reactpy_django/hooks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/reactpy_django/hooks.py b/src/reactpy_django/hooks.py index 58547ca5..756638a5 100644 --- a/src/reactpy_django/hooks.py +++ b/src/reactpy_django/hooks.py @@ -320,7 +320,7 @@ def use_user() -> AbstractUser: def use_user_data( default_data: None | dict[str, Callable[[], Any] | Callable[[], Awaitable[Any]] | Any] = None, -) -> UserData[dict]: +) -> UserData: from reactpy_django.models import UserDataModel user = use_user() From adfc583414fe1d349ba4dd55b5c7ea8d11161745 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 26 Sep 2023 01:36:02 -0700 Subject: [PATCH 20/63] tests for user_passes_test --- src/reactpy_django/decorators.py | 7 +++++-- tests/test_app/components.py | 22 ++++++++++++++++++++++ tests/test_app/templates/base.html | 4 ++++ tests/test_app/tests/test_components.py | 18 ++++++++++++++++++ 4 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/reactpy_django/decorators.py b/src/reactpy_django/decorators.py index 710691f4..d249a9f3 100644 --- a/src/reactpy_django/decorators.py +++ b/src/reactpy_django/decorators.py @@ -1,13 +1,16 @@ from __future__ import annotations from functools import wraps -from typing import Any, Callable +from typing import TYPE_CHECKING, Any, Callable from warnings import warn from reactpy.core.types import ComponentType, VdomDict from reactpy_django.hooks import use_scope, use_user +if TYPE_CHECKING: + from django.contrib.auth.models import AbstractUser + def auth_required( component: Callable | None = None, @@ -47,7 +50,7 @@ def _wrapped_func(*args, **kwargs): def user_passes_test( - test_func: Callable[[Any], bool], + test_func: Callable[[AbstractUser], bool], fallback: Any | None = None, ) -> Callable: """Imitation of Django's `user_passes_test` decorator that works with components. diff --git a/tests/test_app/components.py b/tests/test_app/components.py index 56439a92..5e024aa2 100644 --- a/tests/test_app/components.py +++ b/tests/test_app/components.py @@ -164,6 +164,28 @@ def authorized_user(): return html.div({"id": "authorized-user"}, "authorized_user: Success") +@component +@reactpy_django.decorators.user_passes_test( + lambda user: user.is_anonymous, + fallback=html.div( + {"id": "authorized-user-test-fallback"}, "authorized_user_test: Fail" + ), +) +def authorized_user_test(): + return html.div({"id": "authorized-user-test"}, "authorized_user_test: Success") + + +@component +@reactpy_django.decorators.user_passes_test( + lambda user: user.is_active, + fallback=html.div( + {"id": "unauthorized-user-test-fallback"}, "unauthorized_user_test: Success" + ), +) +def unauthorized_user_test(): + return html.div({"id": "unauthorized-user-test"}, "unauthorized_user_test: Fail") + + def create_relational_parent() -> RelationalParent: child_1 = RelationalChild.objects.create(text="ManyToMany Child 1") child_2 = RelationalChild.objects.create(text="ManyToMany Child 2") diff --git a/tests/test_app/templates/base.html b/tests/test_app/templates/base.html index a10e2b33..c67e6074 100644 --- a/tests/test_app/templates/base.html +++ b/tests/test_app/templates/base.html @@ -45,6 +45,10 @@

ReactPy Test Page


{% component "test_app.components.authorized_user" %}
+ {% component "test_app.components.unauthorized_user_test" %} +
+ {% component "test_app.components.authorized_user_test" %} +
{% component "test_app.components.relational_query" %}
{% component "test_app.components.async_relational_query" %} diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index 236edeab..82d4ade9 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -151,6 +151,24 @@ def test_authorized_user(self): ) self.page.wait_for_selector("#authorized-user") + def test_unauthorized_user_test(self): + self.assertRaises( + TimeoutError, + self.page.wait_for_selector, + "#unauthorized-user-test", + timeout=1, + ) + self.page.wait_for_selector("#unauthorized-user-test-fallback") + + def test_authorized_user_test(self): + self.assertRaises( + TimeoutError, + self.page.wait_for_selector, + "#authorized-user-test-fallback", + timeout=1, + ) + self.page.wait_for_selector("#authorized-user-test") + def test_relational_query(self): self.page.locator("#relational-query[data-success=true]").wait_for() From 09dc565db84ee11b21dedd60da98d34a20ee0e4d Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 26 Sep 2023 01:36:26 -0700 Subject: [PATCH 21/63] Type hint for UserData as potentially None type --- src/reactpy_django/hooks.py | 2 +- src/reactpy_django/types.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/reactpy_django/hooks.py b/src/reactpy_django/hooks.py index 756638a5..91662f87 100644 --- a/src/reactpy_django/hooks.py +++ b/src/reactpy_django/hooks.py @@ -335,7 +335,7 @@ async def _set_user_data(data): model.data = pickle.dumps(data) await model.asave() - data: Query[dict] = use_query( + data: Query[dict | None] = use_query( QueryOptions(postprocessor=None), _get_user_data, user=user, diff --git a/src/reactpy_django/types.py b/src/reactpy_django/types.py index 0e223e98..6ed52596 100644 --- a/src/reactpy_django/types.py +++ b/src/reactpy_django/types.py @@ -103,5 +103,5 @@ class ComponentParams: class UserData(NamedTuple): - data: Query[dict] + data: Query[dict | None] set_data: Mutation[dict] From 29cccab16561028c7bfecce89e7f7e3b4a96b8b1 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 26 Sep 2023 02:18:54 -0700 Subject: [PATCH 22/63] REACTPY_AUTO_LOGIN setting --- CHANGELOG.md | 19 +++++++++--- src/reactpy_django/config.py | 5 +++ src/reactpy_django/websocket/consumer.py | 39 +++++++++++++++--------- 3 files changed, 43 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a505aaa..01ddd20c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,20 +39,29 @@ Using the following categories, list your changes in this order: - ReactPy components can now use SEO compatible rendering! - `settings.py:REACTPY_PRERENDER` can be set to `True` to enable this behavior by default - Or, you can enable it on individual components via the template tag: `{% component "..." prerender="True" %}` -- `reactpy_django.components.view_to_iframe` component has been added, which uses an `