Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Use the async session.asave method if it exists #2092

Merged
merged 1 commit into from
Jul 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)()
bigfootjon marked this conversation as resolved.
Show resolved Hide resolved
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 "
bigfootjon marked this conversation as resolved.
Show resolved Hide resolved
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()
Loading