Skip to content

Commit

Permalink
Use the async sessions api if it exists (#2092)
Browse files Browse the repository at this point in the history
  • Loading branch information
bigfootjon authored Jul 30, 2024
1 parent 8d90b07 commit e39fe13
Show file tree
Hide file tree
Showing 3 changed files with 95 additions and 11 deletions.
14 changes: 8 additions & 6 deletions channels/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
"""
Expand All @@ -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:
Expand Down Expand Up @@ -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 "
Expand Down
3 changes: 2 additions & 1 deletion docs/topics/sessions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
89 changes: 85 additions & 4 deletions tests/test_http.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)()
Expand All @@ -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()

0 comments on commit e39fe13

Please sign in to comment.