From df56f74ee10a0bd58fead3db0973c55c163b6a0e Mon Sep 17 00:00:00 2001 From: Jon Janzen Date: Sun, 14 Apr 2024 18:59:49 -0700 Subject: [PATCH] Use the async sessions api if it exists --- channels/sessions.py | 14 ++++--- docs/topics/sessions.rst | 3 +- tests/test_http.py | 89 ++++++++++++++++++++++++++++++++++++++-- 3 files changed, 95 insertions(+), 11 deletions(-) diff --git a/channels/sessions.py b/channels/sessions.py index 0a4cadd2..f1d51d47 100644 --- a/channels/sessions.py +++ b/channels/sessions.py @@ -2,6 +2,7 @@ import time from importlib import import_module +import django from django.conf import settings from django.contrib.sessions.backends.base import UpdateError from django.core.exceptions import SuspiciousOperation @@ -163,9 +164,7 @@ def __init__(self, scope, send): async def resolve_session(self): session_key = self.scope["cookies"].get(self.cookie_name) - self.scope["session"]._wrapped = await database_sync_to_async( - self.session_store - )(session_key) + self.scope["session"]._wrapped = self.session_store(session_key) async def send(self, message): """ @@ -183,7 +182,7 @@ async def send(self, message): and message.get("status", 200) != 500 and (modified or settings.SESSION_SAVE_EVERY_REQUEST) ): - await database_sync_to_async(self.save_session)() + await self.save_session() # If this is a message type that can transport cookies back to the # client, then do so. if message["type"] in self.cookie_response_message_types: @@ -221,12 +220,15 @@ async def send(self, message): # Pass up the send return await self.real_send(message) - def save_session(self): + async def save_session(self): """ Saves the current session. """ try: - self.scope["session"].save() + if django.VERSION >= (5, 1): + await self.scope["session"].asave() + else: + await database_sync_to_async(self.scope["session"].save)() except UpdateError: raise SuspiciousOperation( "The request's session was deleted before the " diff --git a/docs/topics/sessions.rst b/docs/topics/sessions.rst index 29abb9cd..871194c1 100644 --- a/docs/topics/sessions.rst +++ b/docs/topics/sessions.rst @@ -73,7 +73,8 @@ whenever the session is modified. If you are in a WebSocket consumer, however, the session is populated **but will never be saved automatically** - you must call -``scope["session"].save()`` yourself whenever you want to persist a session +``scope["session"].save()`` (or the asynchronous version, +``scope["session"].asave()``) yourself whenever you want to persist a session to your session store. If you don't save, the session will still work correctly inside the consumer (as it's stored as an instance variable), but other connections or HTTP views won't be able to see the changes. diff --git a/tests/test_http.py b/tests/test_http.py index aa1fbe50..bb55ba0c 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -1,6 +1,9 @@ import re +from importlib import import_module +import django import pytest +from django.conf import settings from channels.consumer import AsyncConsumer from channels.db import database_sync_to_async @@ -93,15 +96,12 @@ async def test_session_samesite_invalid(samesite_invalid): @pytest.mark.django_db(transaction=True) @pytest.mark.asyncio -async def test_muliple_sessions(): +async def test_multiple_sessions(): """ Create two application instances and test then out of order to verify that separate scopes are used. """ - async def inner(scope, receive, send): - send(scope["path"]) - class SimpleHttpApp(AsyncConsumer): async def http_request(self, event): await database_sync_to_async(self.scope["session"].save)() @@ -123,3 +123,84 @@ async def http_request(self, event): first_response = await first_communicator.get_response() assert first_response["body"] == b"/first/" + + +@pytest.mark.django_db(transaction=True) +@pytest.mark.asyncio +async def test_session_saves(): + """ + Saves information to a session and validates that it actually saves to the backend + """ + + class SimpleHttpApp(AsyncConsumer): + @database_sync_to_async + def set_fav_color(self): + self.scope["session"]["fav_color"] = "blue" + + async def http_request(self, event): + if django.VERSION >= (5, 1): + await self.scope["session"].aset("fav_color", "blue") + else: + await self.set_fav_color() + await self.send( + {"type": "http.response.start", "status": 200, "headers": []} + ) + await self.send( + { + "type": "http.response.body", + "body": self.scope["session"].session_key.encode(), + } + ) + + app = SessionMiddlewareStack(SimpleHttpApp.as_asgi()) + + communicator = HttpCommunicator(app, "GET", "/first/") + + response = await communicator.get_response() + session_key = response["body"].decode() + + SessionStore = import_module(settings.SESSION_ENGINE).SessionStore + session = SessionStore(session_key=session_key) + if django.VERSION >= (5, 1): + session_fav_color = await session.aget("fav_color") + else: + session_fav_color = await database_sync_to_async(session.get)("fav_color") + + assert session_fav_color == "blue" + + +@pytest.mark.django_db(transaction=True) +@pytest.mark.asyncio +async def test_session_save_update_error(): + """ + Intentionally deletes the session to ensure that SuspiciousOperation is raised + """ + + async def inner(scope, receive, send): + send(scope["path"]) + + class SimpleHttpApp(AsyncConsumer): + @database_sync_to_async + def set_fav_color(self): + self.scope["session"]["fav_color"] = "blue" + + async def http_request(self, event): + # Create a session as normal: + await database_sync_to_async(self.scope["session"].save)() + + # Then simulate it's deletion from somewhere else: + # (e.g. logging out from another request) + SessionStore = import_module(settings.SESSION_ENGINE).SessionStore + session = SessionStore(session_key=self.scope["session"].session_key) + await database_sync_to_async(session.flush)() + + await self.send( + {"type": "http.response.start", "status": 200, "headers": []} + ) + + app = SessionMiddlewareStack(SimpleHttpApp.as_asgi()) + + communicator = HttpCommunicator(app, "GET", "/first/") + + with pytest.raises(django.core.exceptions.SuspiciousOperation): + await communicator.get_response()