Skip to content

Commit aec55e3

Browse files
committed
New session model with id and JSON data
1 parent 9873a11 commit aec55e3

File tree

7 files changed

+134
-134
lines changed

7 files changed

+134
-134
lines changed

plain-admin/tests/test_admin.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,7 @@ def test_admin_login_required(db):
1818
user.is_admin = True
1919
user.save()
2020

21-
# Now admin
22-
assert client.get("/admin/").status_code == 200
21+
# Now admin (currently redirects to the first view)
22+
resp = client.get("/admin/")
23+
assert resp.status_code == 302
24+
assert resp.url == "/admin/p/session/"
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,28 @@
11
from plain.admin.toolbar import ToolbarPanel, register_toolbar_panel
2+
from plain.admin.views import (
3+
AdminModelDetailView,
4+
AdminModelListView,
5+
AdminViewset,
6+
register_viewset,
7+
)
8+
9+
from .models import Session
210

311

412
@register_toolbar_panel
513
class SessionToolbarPanel(ToolbarPanel):
614
name = "Session"
715
template_name = "toolbar/session.html"
16+
17+
18+
@register_viewset
19+
class SessionAdmin(AdminViewset):
20+
class ListView(AdminModelListView):
21+
model = Session
22+
fields = ["session_key", "expires_at", "created_at"]
23+
search_fields = ["session_key"]
24+
nav_section = "Sessions"
25+
queryset_order = ["-created_at"]
26+
27+
class DetailView(AdminModelDetailView):
28+
model = Session

plain-sessions/plain/sessions/core.py

Lines changed: 28 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,12 @@
1-
import logging
21
import string
32
from datetime import timedelta
43

5-
from plain import signing
6-
from plain.exceptions import SuspiciousOperation
7-
from plain.models import DatabaseError, IntegrityError, transaction
4+
from plain.models import transaction
85
from plain.runtime import settings
96
from plain.utils import timezone
107
from plain.utils.crypto import get_random_string
118

129

13-
class CreateError(Exception):
14-
"""
15-
Used internally as a consistent exception type to catch from save (see the
16-
docstring for SessionBase.save() for details).
17-
"""
18-
19-
pass
20-
21-
22-
class UpdateError(Exception):
23-
"""
24-
Occurs if Plain tries to update a session that was deleted.
25-
"""
26-
27-
pass
28-
29-
3010
class SessionStore:
3111
"""
3212
The actual session object that gets attached to a request,
@@ -59,10 +39,6 @@ def __delitem__(self, key):
5939
del self._session[key]
6040
self.modified = True
6141

62-
@property
63-
def key_salt(self):
64-
return "plain.sessions." + self.__class__.__qualname__
65-
6642
def get(self, key, default=None):
6743
return self._session.get(key, default)
6844

@@ -79,26 +55,6 @@ def setdefault(self, key, value):
7955
self._session[key] = value
8056
return value
8157

82-
def _encode(self, session_dict):
83-
"Return the given session dictionary serialized and encoded as a string."
84-
return signing.dumps(
85-
session_dict,
86-
salt=self.key_salt,
87-
compress=True,
88-
)
89-
90-
def _decode(self, session_data):
91-
try:
92-
return signing.loads(session_data, salt=self.key_salt)
93-
except signing.BadSignature:
94-
logger = logging.getLogger("plain.security.SuspiciousSession")
95-
logger.warning("Session data corrupted")
96-
except Exception:
97-
# ValueError, unpickling exceptions. If any of these happen, just
98-
# return an empty dictionary (an empty session).
99-
pass
100-
return {}
101-
10258
def update(self, dict_):
10359
self._session.update(dict_)
10460
self.modified = True
@@ -190,56 +146,39 @@ def _load(self):
190146
session = self._model.objects.get(
191147
session_key=self.session_key, expires_at__gt=timezone.now()
192148
)
193-
except (self._model.DoesNotExist, SuspiciousOperation) as e:
194-
if isinstance(e, SuspiciousOperation):
195-
logger = logging.getLogger(f"plain.security.{e.__class__.__name__}")
196-
logger.warning(str(e))
149+
except self._model.DoesNotExist:
197150
self.session_key = None
198151
session = None
199152

200-
return self._decode(session.session_data) if session else {}
153+
return session.session_data if session else {}
201154

202155
def create(self):
203-
while True:
204-
self.session_key = self._get_new_session_key()
205-
try:
206-
# Save immediately to ensure we have a unique entry in the
207-
# database.
208-
self.save(must_create=True)
209-
except CreateError:
210-
# Key wasn't unique. Try again.
211-
continue
212-
self.modified = True
213-
return
156+
self.session_key = self._get_new_session_key()
157+
data = self._get_session(no_load=True)
158+
with transaction.atomic():
159+
self._model.objects.create(
160+
session_key=self.session_key,
161+
session_data=data,
162+
expires_at=timezone.now()
163+
+ timedelta(seconds=settings.SESSION_COOKIE_AGE),
164+
)
165+
self.modified = True
214166

215-
def save(self, must_create=False):
167+
def save(self):
216168
"""
217-
Save the current session data to the database. If 'must_create' is
218-
True, raise a database error if the saving operation doesn't create a
219-
new entry (as opposed to possibly updating an existing entry).
169+
Save the current session data to the database using update_or_create.
220170
"""
221-
if self.session_key is None:
222-
return self.create()
223-
data = self._get_session(no_load=must_create)
224-
225-
obj = self._model(
226-
session_key=self._get_or_create_session_key(),
227-
session_data=self._encode(data),
228-
expires_at=timezone.now() + timedelta(seconds=settings.SESSION_COOKIE_AGE),
229-
)
171+
data = self._get_session(no_load=False)
172+
173+
with transaction.atomic():
174+
_, created = self._model.objects.update_or_create(
175+
session_key=self._get_or_create_session_key(),
176+
defaults={
177+
"session_data": data,
178+
"expires_at": timezone.now()
179+
+ timedelta(seconds=settings.SESSION_COOKIE_AGE),
180+
},
181+
)
230182

231-
try:
232-
with transaction.atomic():
233-
obj.save(
234-
clean_and_validate=False,
235-
force_insert=must_create,
236-
force_update=not must_create,
237-
)
238-
except IntegrityError:
239-
if must_create:
240-
raise CreateError
241-
raise
242-
except DatabaseError:
243-
if not must_create:
244-
raise UpdateError
245-
raise
183+
if created:
184+
self.modified = True

plain-sessions/plain/sessions/exceptions.py

Lines changed: 0 additions & 7 deletions
This file was deleted.

plain-sessions/plain/sessions/middleware.py

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import time
22

33
from plain.runtime import settings
4-
from plain.sessions.exceptions import SessionInterrupted
54
from plain.utils.cache import patch_vary_headers
65
from plain.utils.http import http_date
76

8-
from .core import SessionStore, UpdateError
7+
from .core import SessionStore
98

109

1110
class SessionMiddleware:
@@ -51,14 +50,7 @@ def __call__(self, request):
5150
# Save the session data and refresh the client cookie.
5251
# Skip session save for 5xx responses.
5352
if response.status_code < 500:
54-
try:
55-
request.session.save()
56-
except UpdateError:
57-
raise SessionInterrupted(
58-
"The request's session was deleted before the "
59-
"request completed. The user may have logged "
60-
"out in a concurrent request, for example."
61-
)
53+
request.session.save()
6254
response.set_cookie(
6355
settings.SESSION_COOKIE_NAME,
6456
request.session.session_key,
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# Generated by Plain 0.52.2 on 2025-07-06 02:00
2+
3+
from plain import models, signing
4+
from plain.models import migrations
5+
6+
7+
def copy_sessions(apps, schema_editor):
8+
"""
9+
Copy data from the old Session2 model to the new Session model.
10+
"""
11+
Session2 = apps.get_model("plainsessions", "Session2")
12+
Session = apps.get_model("plainsessions", "Session")
13+
14+
salt = "plain.sessions.SessionStore"
15+
16+
to_create = []
17+
18+
for session in Session2.objects.all():
19+
session_data = signing.loads(session.session_data, salt=salt)
20+
to_create.append(
21+
Session(
22+
session_key=session.session_key,
23+
session_data=session_data,
24+
expires_at=session.expires_at,
25+
)
26+
)
27+
28+
Session.objects.bulk_create(to_create)
29+
30+
31+
class Migration(migrations.Migration):
32+
dependencies = [
33+
("plainsessions", "0004_alter_session_managers_and_more"),
34+
]
35+
36+
operations = [
37+
migrations.RenameModel(
38+
old_name="Session",
39+
new_name="Session2",
40+
),
41+
migrations.RenameIndex(
42+
model_name="session2",
43+
new_name="plainsessio_expires_f66c08_idx",
44+
old_name="plainsessio_expires_d87cb5_idx",
45+
),
46+
migrations.CreateModel(
47+
name="Session",
48+
fields=[
49+
("id", models.BigAutoField(auto_created=True, primary_key=True)),
50+
("session_key", models.CharField(max_length=40)),
51+
("session_data", models.JSONField(default=dict, required=False)),
52+
("created_at", models.DateTimeField(auto_now_add=True)),
53+
("expires_at", models.DateTimeField(allow_null=True)),
54+
],
55+
),
56+
migrations.AddIndex(
57+
model_name="session",
58+
index=models.Index(
59+
fields=["expires_at"], name="plainsessio_expires_d87cb5_idx"
60+
),
61+
),
62+
migrations.AddConstraint(
63+
model_name="session",
64+
constraint=models.UniqueConstraint(
65+
fields=("session_key",), name="unique_session_key"
66+
),
67+
),
68+
migrations.RunPython(copy_sessions),
69+
migrations.DeleteModel(
70+
name="Session2",
71+
),
72+
]

plain-sessions/plain/sessions/models.py

Lines changed: 7 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,37 +3,18 @@
33

44
@models.register_model
55
class Session(models.Model):
6-
"""
7-
Plain provides full support for anonymous sessions. The session
8-
framework lets you store and retrieve arbitrary data on a
9-
per-site-visitor basis. It stores data on the server side and
10-
abstracts the sending and receiving of cookies. Cookies contain a
11-
session ID -- not the data itself.
12-
13-
The Plain sessions framework is entirely cookie-based. It does
14-
not fall back to putting session IDs in URLs. This is an intentional
15-
design decision. Not only does that behavior make URLs ugly, it makes
16-
your site vulnerable to session-ID theft via the "Referer" header.
17-
18-
For complete documentation on using Sessions in your code, consult
19-
the sessions documentation that is shipped with Plain (also available
20-
on the Plain web site).
21-
"""
22-
23-
session_key = models.CharField(max_length=40, primary_key=True)
24-
session_data = models.TextField()
25-
expires_at = models.DateTimeField()
6+
session_key = models.CharField(max_length=40)
7+
session_data = models.JSONField(default=dict, required=False)
8+
created_at = models.DateTimeField(auto_now_add=True)
9+
expires_at = models.DateTimeField(allow_null=True)
2610

2711
class Meta:
2812
indexes = [
2913
models.Index(fields=["expires_at"]),
3014
]
15+
constraints = [
16+
models.UniqueConstraint(fields=["session_key"], name="unique_session_key")
17+
]
3118

3219
def __str__(self):
3320
return self.session_key
34-
35-
def decoded_data(self):
36-
from .core import SessionStore
37-
38-
# A little weird to init an empty one just to use the decode
39-
return SessionStore()._decode(self.session_data)

0 commit comments

Comments
 (0)