skrift 0.1.0a14__py3-none-any.whl → 0.1.0a15__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- skrift/admin/controller.py +114 -26
- skrift/alembic/versions/20260202_add_content_scheduling.py +35 -0
- skrift/alembic/versions/20260202_add_page_ordering.py +32 -0
- skrift/alembic/versions/20260202_add_page_revisions.py +46 -0
- skrift/alembic/versions/20260202_add_seo_fields.py +42 -0
- skrift/controllers/sitemap.py +116 -0
- skrift/controllers/web.py +18 -1
- skrift/db/models/__init__.py +2 -1
- skrift/db/models/page.py +26 -1
- skrift/db/models/page_revision.py +45 -0
- skrift/db/services/page_service.py +113 -6
- skrift/db/services/revision_service.py +146 -0
- skrift/db/services/setting_service.py +7 -0
- skrift/lib/__init__.py +12 -1
- skrift/lib/flash.py +103 -0
- skrift/lib/hooks.py +292 -0
- skrift/lib/seo.py +103 -0
- skrift/static/css/style.css +92 -2
- skrift/templates/admin/pages/edit.html +44 -4
- skrift/templates/admin/pages/list.html +8 -1
- skrift/templates/admin/pages/revisions.html +59 -0
- skrift/templates/base.html +58 -4
- {skrift-0.1.0a14.dist-info → skrift-0.1.0a15.dist-info}/METADATA +14 -3
- {skrift-0.1.0a14.dist-info → skrift-0.1.0a15.dist-info}/RECORD +26 -15
- {skrift-0.1.0a14.dist-info → skrift-0.1.0a15.dist-info}/WHEEL +0 -0
- {skrift-0.1.0a14.dist-info → skrift-0.1.0a15.dist-info}/entry_points.txt +0 -0
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
"""Page service for CRUD operations on pages."""
|
|
2
2
|
|
|
3
|
-
from datetime import datetime
|
|
3
|
+
from datetime import datetime, UTC
|
|
4
|
+
from typing import Literal
|
|
4
5
|
from uuid import UUID
|
|
5
6
|
|
|
6
|
-
from sqlalchemy import select, and_
|
|
7
|
+
from sqlalchemy import select, and_, or_
|
|
7
8
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
8
9
|
|
|
9
10
|
from skrift.db.models import Page
|
|
11
|
+
from skrift.db.services import revision_service
|
|
12
|
+
from skrift.lib.hooks import hooks, BEFORE_PAGE_SAVE, AFTER_PAGE_SAVE, BEFORE_PAGE_DELETE, AFTER_PAGE_DELETE
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
OrderBy = Literal["order", "created", "published", "title"]
|
|
10
16
|
|
|
11
17
|
|
|
12
18
|
async def list_pages(
|
|
@@ -15,15 +21,17 @@ async def list_pages(
|
|
|
15
21
|
user_id: UUID | None = None,
|
|
16
22
|
limit: int | None = None,
|
|
17
23
|
offset: int = 0,
|
|
24
|
+
order_by: OrderBy = "order",
|
|
18
25
|
) -> list[Page]:
|
|
19
26
|
"""List pages with optional filtering.
|
|
20
27
|
|
|
21
28
|
Args:
|
|
22
29
|
db_session: Database session
|
|
23
|
-
published_only: Only return published pages
|
|
30
|
+
published_only: Only return published pages (respects scheduling)
|
|
24
31
|
user_id: Filter by user ID (author)
|
|
25
32
|
limit: Maximum number of results
|
|
26
33
|
offset: Number of results to skip
|
|
34
|
+
order_by: Sort order - "order" (default), "created", "published", "title"
|
|
27
35
|
|
|
28
36
|
Returns:
|
|
29
37
|
List of Page objects
|
|
@@ -33,15 +41,25 @@ async def list_pages(
|
|
|
33
41
|
# Build filters
|
|
34
42
|
filters = []
|
|
35
43
|
if published_only:
|
|
44
|
+
now = datetime.now(UTC)
|
|
36
45
|
filters.append(Page.is_published == True)
|
|
46
|
+
# Respect scheduling: either no publish_at set, or publish_at is in the past
|
|
47
|
+
filters.append(or_(Page.publish_at.is_(None), Page.publish_at <= now))
|
|
37
48
|
if user_id:
|
|
38
49
|
filters.append(Page.user_id == user_id)
|
|
39
50
|
|
|
40
51
|
if filters:
|
|
41
52
|
query = query.where(and_(*filters))
|
|
42
53
|
|
|
43
|
-
#
|
|
44
|
-
|
|
54
|
+
# Apply ordering
|
|
55
|
+
if order_by == "order":
|
|
56
|
+
query = query.order_by(Page.order.asc(), Page.created_at.desc())
|
|
57
|
+
elif order_by == "created":
|
|
58
|
+
query = query.order_by(Page.created_at.desc())
|
|
59
|
+
elif order_by == "published":
|
|
60
|
+
query = query.order_by(Page.published_at.desc().nullslast(), Page.created_at.desc())
|
|
61
|
+
elif order_by == "title":
|
|
62
|
+
query = query.order_by(Page.title.asc())
|
|
45
63
|
|
|
46
64
|
# Apply pagination
|
|
47
65
|
if offset:
|
|
@@ -63,7 +81,7 @@ async def get_page_by_slug(
|
|
|
63
81
|
Args:
|
|
64
82
|
db_session: Database session
|
|
65
83
|
slug: Page slug
|
|
66
|
-
published_only: Only return if published
|
|
84
|
+
published_only: Only return if published (respects scheduling)
|
|
67
85
|
|
|
68
86
|
Returns:
|
|
69
87
|
Page object or None if not found
|
|
@@ -71,7 +89,10 @@ async def get_page_by_slug(
|
|
|
71
89
|
query = select(Page).where(Page.slug == slug)
|
|
72
90
|
|
|
73
91
|
if published_only:
|
|
92
|
+
now = datetime.now(UTC)
|
|
74
93
|
query = query.where(Page.is_published == True)
|
|
94
|
+
# Respect scheduling: either no publish_at set, or publish_at is in the past
|
|
95
|
+
query = query.where(or_(Page.publish_at.is_(None), Page.publish_at <= now))
|
|
75
96
|
|
|
76
97
|
result = await db_session.execute(query)
|
|
77
98
|
return result.scalar_one_or_none()
|
|
@@ -102,6 +123,13 @@ async def create_page(
|
|
|
102
123
|
is_published: bool = False,
|
|
103
124
|
published_at: datetime | None = None,
|
|
104
125
|
user_id: UUID | None = None,
|
|
126
|
+
order: int = 0,
|
|
127
|
+
publish_at: datetime | None = None,
|
|
128
|
+
meta_description: str | None = None,
|
|
129
|
+
og_title: str | None = None,
|
|
130
|
+
og_description: str | None = None,
|
|
131
|
+
og_image: str | None = None,
|
|
132
|
+
meta_robots: str | None = None,
|
|
105
133
|
) -> Page:
|
|
106
134
|
"""Create a new page.
|
|
107
135
|
|
|
@@ -113,6 +141,13 @@ async def create_page(
|
|
|
113
141
|
is_published: Whether page is published
|
|
114
142
|
published_at: Publication timestamp
|
|
115
143
|
user_id: Author user ID (optional)
|
|
144
|
+
order: Display order (lower numbers first)
|
|
145
|
+
publish_at: Scheduled publish datetime
|
|
146
|
+
meta_description: SEO meta description
|
|
147
|
+
og_title: OpenGraph title override
|
|
148
|
+
og_description: OpenGraph description override
|
|
149
|
+
og_image: OpenGraph image URL
|
|
150
|
+
meta_robots: Meta robots directive
|
|
116
151
|
|
|
117
152
|
Returns:
|
|
118
153
|
Created Page object
|
|
@@ -124,13 +159,31 @@ async def create_page(
|
|
|
124
159
|
is_published=is_published,
|
|
125
160
|
published_at=published_at,
|
|
126
161
|
user_id=user_id,
|
|
162
|
+
order=order,
|
|
163
|
+
publish_at=publish_at,
|
|
164
|
+
meta_description=meta_description,
|
|
165
|
+
og_title=og_title,
|
|
166
|
+
og_description=og_description,
|
|
167
|
+
og_image=og_image,
|
|
168
|
+
meta_robots=meta_robots,
|
|
127
169
|
)
|
|
170
|
+
|
|
171
|
+
# Fire before_page_save action (is_new=True for creates)
|
|
172
|
+
await hooks.do_action(BEFORE_PAGE_SAVE, page, is_new=True)
|
|
173
|
+
|
|
128
174
|
db_session.add(page)
|
|
129
175
|
await db_session.commit()
|
|
130
176
|
await db_session.refresh(page)
|
|
177
|
+
|
|
178
|
+
# Fire after_page_save action
|
|
179
|
+
await hooks.do_action(AFTER_PAGE_SAVE, page, is_new=True)
|
|
180
|
+
|
|
131
181
|
return page
|
|
132
182
|
|
|
133
183
|
|
|
184
|
+
_UNSET = object() # Sentinel for distinguishing None from "not provided"
|
|
185
|
+
|
|
186
|
+
|
|
134
187
|
async def update_page(
|
|
135
188
|
db_session: AsyncSession,
|
|
136
189
|
page_id: UUID,
|
|
@@ -139,6 +192,15 @@ async def update_page(
|
|
|
139
192
|
content: str | None = None,
|
|
140
193
|
is_published: bool | None = None,
|
|
141
194
|
published_at: datetime | None = None,
|
|
195
|
+
order: int | None = None,
|
|
196
|
+
publish_at: datetime | None | object = _UNSET,
|
|
197
|
+
meta_description: str | None | object = _UNSET,
|
|
198
|
+
og_title: str | None | object = _UNSET,
|
|
199
|
+
og_description: str | None | object = _UNSET,
|
|
200
|
+
og_image: str | None | object = _UNSET,
|
|
201
|
+
meta_robots: str | None | object = _UNSET,
|
|
202
|
+
create_revision: bool = True,
|
|
203
|
+
user_id: UUID | None = None,
|
|
142
204
|
) -> Page | None:
|
|
143
205
|
"""Update an existing page.
|
|
144
206
|
|
|
@@ -150,6 +212,15 @@ async def update_page(
|
|
|
150
212
|
content: New content (optional)
|
|
151
213
|
is_published: New published status (optional)
|
|
152
214
|
published_at: New publication timestamp (optional)
|
|
215
|
+
order: New display order (optional)
|
|
216
|
+
publish_at: New scheduled publish datetime (optional, use None to clear)
|
|
217
|
+
meta_description: New SEO meta description (optional, use None to clear)
|
|
218
|
+
og_title: New OpenGraph title (optional, use None to clear)
|
|
219
|
+
og_description: New OpenGraph description (optional, use None to clear)
|
|
220
|
+
og_image: New OpenGraph image URL (optional, use None to clear)
|
|
221
|
+
meta_robots: New meta robots directive (optional, use None to clear)
|
|
222
|
+
create_revision: Whether to create a revision before updating (default True)
|
|
223
|
+
user_id: ID of user making the change (for revision tracking)
|
|
153
224
|
|
|
154
225
|
Returns:
|
|
155
226
|
Updated Page object or None if not found
|
|
@@ -158,6 +229,17 @@ async def update_page(
|
|
|
158
229
|
if not page:
|
|
159
230
|
return None
|
|
160
231
|
|
|
232
|
+
# Create revision before making changes (if title or content is changing)
|
|
233
|
+
if create_revision and (title is not None or content is not None):
|
|
234
|
+
# Only create revision if title or content actually differs
|
|
235
|
+
title_changed = title is not None and title != page.title
|
|
236
|
+
content_changed = content is not None and content != page.content
|
|
237
|
+
if title_changed or content_changed:
|
|
238
|
+
await revision_service.create_revision(db_session, page, user_id)
|
|
239
|
+
|
|
240
|
+
# Fire before_page_save action (is_new=False for updates)
|
|
241
|
+
await hooks.do_action(BEFORE_PAGE_SAVE, page, is_new=False)
|
|
242
|
+
|
|
161
243
|
if slug is not None:
|
|
162
244
|
page.slug = slug
|
|
163
245
|
if title is not None:
|
|
@@ -168,9 +250,27 @@ async def update_page(
|
|
|
168
250
|
page.is_published = is_published
|
|
169
251
|
if published_at is not None:
|
|
170
252
|
page.published_at = published_at
|
|
253
|
+
if order is not None:
|
|
254
|
+
page.order = order
|
|
255
|
+
if publish_at is not _UNSET:
|
|
256
|
+
page.publish_at = publish_at
|
|
257
|
+
if meta_description is not _UNSET:
|
|
258
|
+
page.meta_description = meta_description
|
|
259
|
+
if og_title is not _UNSET:
|
|
260
|
+
page.og_title = og_title
|
|
261
|
+
if og_description is not _UNSET:
|
|
262
|
+
page.og_description = og_description
|
|
263
|
+
if og_image is not _UNSET:
|
|
264
|
+
page.og_image = og_image
|
|
265
|
+
if meta_robots is not _UNSET:
|
|
266
|
+
page.meta_robots = meta_robots
|
|
171
267
|
|
|
172
268
|
await db_session.commit()
|
|
173
269
|
await db_session.refresh(page)
|
|
270
|
+
|
|
271
|
+
# Fire after_page_save action
|
|
272
|
+
await hooks.do_action(AFTER_PAGE_SAVE, page, is_new=False)
|
|
273
|
+
|
|
174
274
|
return page
|
|
175
275
|
|
|
176
276
|
|
|
@@ -191,8 +291,15 @@ async def delete_page(
|
|
|
191
291
|
if not page:
|
|
192
292
|
return False
|
|
193
293
|
|
|
294
|
+
# Fire before_page_delete action
|
|
295
|
+
await hooks.do_action(BEFORE_PAGE_DELETE, page)
|
|
296
|
+
|
|
194
297
|
await db_session.delete(page)
|
|
195
298
|
await db_session.commit()
|
|
299
|
+
|
|
300
|
+
# Fire after_page_delete action
|
|
301
|
+
await hooks.do_action(AFTER_PAGE_DELETE, page)
|
|
302
|
+
|
|
196
303
|
return True
|
|
197
304
|
|
|
198
305
|
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Revision service for page history management."""
|
|
2
|
+
|
|
3
|
+
from uuid import UUID
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import select, func
|
|
6
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
7
|
+
|
|
8
|
+
from skrift.db.models import Page, PageRevision
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def create_revision(
|
|
12
|
+
db_session: AsyncSession,
|
|
13
|
+
page: Page,
|
|
14
|
+
user_id: UUID | None = None,
|
|
15
|
+
) -> PageRevision:
|
|
16
|
+
"""Create a revision snapshot of the current page state.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
db_session: Database session
|
|
20
|
+
page: The page to snapshot
|
|
21
|
+
user_id: ID of user making the change (optional)
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
The created PageRevision object
|
|
25
|
+
"""
|
|
26
|
+
# Get the next revision number for this page
|
|
27
|
+
result = await db_session.execute(
|
|
28
|
+
select(func.coalesce(func.max(PageRevision.revision_number), 0))
|
|
29
|
+
.where(PageRevision.page_id == page.id)
|
|
30
|
+
)
|
|
31
|
+
max_revision = result.scalar()
|
|
32
|
+
next_revision = (max_revision or 0) + 1
|
|
33
|
+
|
|
34
|
+
revision = PageRevision(
|
|
35
|
+
page_id=page.id,
|
|
36
|
+
user_id=user_id,
|
|
37
|
+
revision_number=next_revision,
|
|
38
|
+
title=page.title,
|
|
39
|
+
content=page.content,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
db_session.add(revision)
|
|
43
|
+
await db_session.commit()
|
|
44
|
+
await db_session.refresh(revision)
|
|
45
|
+
|
|
46
|
+
return revision
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def list_revisions(
|
|
50
|
+
db_session: AsyncSession,
|
|
51
|
+
page_id: UUID,
|
|
52
|
+
limit: int | None = None,
|
|
53
|
+
) -> list[PageRevision]:
|
|
54
|
+
"""List revisions for a page, newest first.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
db_session: Database session
|
|
58
|
+
page_id: The page ID to get revisions for
|
|
59
|
+
limit: Maximum number of revisions to return (None for all)
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
List of PageRevision objects ordered by revision_number descending
|
|
63
|
+
"""
|
|
64
|
+
query = (
|
|
65
|
+
select(PageRevision)
|
|
66
|
+
.where(PageRevision.page_id == page_id)
|
|
67
|
+
.order_by(PageRevision.revision_number.desc())
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
if limit:
|
|
71
|
+
query = query.limit(limit)
|
|
72
|
+
|
|
73
|
+
result = await db_session.execute(query)
|
|
74
|
+
return list(result.scalars().all())
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def get_revision(
|
|
78
|
+
db_session: AsyncSession,
|
|
79
|
+
revision_id: UUID,
|
|
80
|
+
) -> PageRevision | None:
|
|
81
|
+
"""Get a specific revision by ID.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
db_session: Database session
|
|
85
|
+
revision_id: The revision ID
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
PageRevision object or None if not found
|
|
89
|
+
"""
|
|
90
|
+
result = await db_session.execute(
|
|
91
|
+
select(PageRevision).where(PageRevision.id == revision_id)
|
|
92
|
+
)
|
|
93
|
+
return result.scalar_one_or_none()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
async def restore_revision(
|
|
97
|
+
db_session: AsyncSession,
|
|
98
|
+
page: Page,
|
|
99
|
+
revision: PageRevision,
|
|
100
|
+
user_id: UUID | None = None,
|
|
101
|
+
) -> Page:
|
|
102
|
+
"""Restore a page to a previous revision state.
|
|
103
|
+
|
|
104
|
+
This creates a new revision first (to preserve current state),
|
|
105
|
+
then updates the page content to match the target revision.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
db_session: Database session
|
|
109
|
+
page: The page to restore
|
|
110
|
+
revision: The revision to restore to
|
|
111
|
+
user_id: ID of user performing the restore (optional)
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
The updated Page object
|
|
115
|
+
"""
|
|
116
|
+
# Create a revision of the current state before restoring
|
|
117
|
+
await create_revision(db_session, page, user_id)
|
|
118
|
+
|
|
119
|
+
# Update the page to match the revision
|
|
120
|
+
page.title = revision.title
|
|
121
|
+
page.content = revision.content
|
|
122
|
+
|
|
123
|
+
await db_session.commit()
|
|
124
|
+
await db_session.refresh(page)
|
|
125
|
+
|
|
126
|
+
return page
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
async def get_revision_count(
|
|
130
|
+
db_session: AsyncSession,
|
|
131
|
+
page_id: UUID,
|
|
132
|
+
) -> int:
|
|
133
|
+
"""Get the total number of revisions for a page.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
db_session: Database session
|
|
137
|
+
page_id: The page ID
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
Number of revisions
|
|
141
|
+
"""
|
|
142
|
+
result = await db_session.execute(
|
|
143
|
+
select(func.count(PageRevision.id))
|
|
144
|
+
.where(PageRevision.page_id == page_id)
|
|
145
|
+
)
|
|
146
|
+
return result.scalar() or 0
|
|
@@ -126,6 +126,7 @@ SITE_NAME_KEY = "site_name"
|
|
|
126
126
|
SITE_TAGLINE_KEY = "site_tagline"
|
|
127
127
|
SITE_COPYRIGHT_HOLDER_KEY = "site_copyright_holder"
|
|
128
128
|
SITE_COPYRIGHT_START_YEAR_KEY = "site_copyright_start_year"
|
|
129
|
+
SITE_BASE_URL_KEY = "site_base_url"
|
|
129
130
|
|
|
130
131
|
# Setup wizard key
|
|
131
132
|
SETUP_COMPLETED_AT_KEY = "setup_completed_at"
|
|
@@ -136,6 +137,7 @@ SITE_DEFAULTS = {
|
|
|
136
137
|
SITE_TAGLINE_KEY: "Welcome to my site",
|
|
137
138
|
SITE_COPYRIGHT_HOLDER_KEY: "",
|
|
138
139
|
SITE_COPYRIGHT_START_YEAR_KEY: "",
|
|
140
|
+
SITE_BASE_URL_KEY: "",
|
|
139
141
|
}
|
|
140
142
|
|
|
141
143
|
|
|
@@ -204,3 +206,8 @@ def get_cached_site_copyright_start_year() -> str | int | None:
|
|
|
204
206
|
if value and value.isdigit():
|
|
205
207
|
return int(value)
|
|
206
208
|
return None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def get_cached_site_base_url() -> str:
|
|
212
|
+
"""Get the cached site base URL for use in SEO/sitemap."""
|
|
213
|
+
return _site_settings_cache.get(SITE_BASE_URL_KEY, SITE_DEFAULTS[SITE_BASE_URL_KEY])
|
skrift/lib/__init__.py
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
|
+
from skrift.lib.hooks import hooks, action, filter, add_action, add_filter, do_action, apply_filters
|
|
1
2
|
from skrift.lib.markdown import render_markdown
|
|
2
3
|
from skrift.lib.template import Template
|
|
3
4
|
|
|
4
|
-
__all__ = [
|
|
5
|
+
__all__ = [
|
|
6
|
+
"Template",
|
|
7
|
+
"render_markdown",
|
|
8
|
+
"hooks",
|
|
9
|
+
"action",
|
|
10
|
+
"filter",
|
|
11
|
+
"add_action",
|
|
12
|
+
"add_filter",
|
|
13
|
+
"do_action",
|
|
14
|
+
"apply_filters",
|
|
15
|
+
]
|
skrift/lib/flash.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Enhanced flash message system with types and multiple messages support."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from litestar import Request
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FlashType(str, Enum):
|
|
12
|
+
"""Types of flash messages with corresponding CSS classes."""
|
|
13
|
+
|
|
14
|
+
SUCCESS = "success"
|
|
15
|
+
ERROR = "error"
|
|
16
|
+
WARNING = "warning"
|
|
17
|
+
INFO = "info"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class FlashMessage:
|
|
22
|
+
"""A flash message with type and dismissibility."""
|
|
23
|
+
|
|
24
|
+
message: str
|
|
25
|
+
type: FlashType = FlashType.INFO
|
|
26
|
+
dismissible: bool = True
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def add_flash(
|
|
30
|
+
request: "Request",
|
|
31
|
+
message: str,
|
|
32
|
+
flash_type: FlashType = FlashType.INFO,
|
|
33
|
+
dismissible: bool = True,
|
|
34
|
+
) -> None:
|
|
35
|
+
"""Add a flash message to the session queue.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
request: The Litestar request object
|
|
39
|
+
message: The message text to display
|
|
40
|
+
flash_type: Type of message (success, error, warning, info)
|
|
41
|
+
dismissible: Whether the message can be dismissed by the user
|
|
42
|
+
"""
|
|
43
|
+
if "flash_messages" not in request.session:
|
|
44
|
+
request.session["flash_messages"] = []
|
|
45
|
+
|
|
46
|
+
request.session["flash_messages"].append({
|
|
47
|
+
"message": message,
|
|
48
|
+
"type": flash_type.value,
|
|
49
|
+
"dismissible": dismissible,
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_flash_messages(request: "Request") -> list[FlashMessage]:
|
|
54
|
+
"""Get and clear all flash messages from the session.
|
|
55
|
+
|
|
56
|
+
Also handles backwards compatibility with old single-string flash.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
request: The Litestar request object
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
List of FlashMessage objects
|
|
63
|
+
"""
|
|
64
|
+
messages = request.session.pop("flash_messages", [])
|
|
65
|
+
|
|
66
|
+
# Backwards compatibility: convert old single-string flash
|
|
67
|
+
old_flash = request.session.pop("flash", None)
|
|
68
|
+
if old_flash:
|
|
69
|
+
messages.insert(0, {
|
|
70
|
+
"message": old_flash,
|
|
71
|
+
"type": FlashType.INFO.value,
|
|
72
|
+
"dismissible": True,
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
return [
|
|
76
|
+
FlashMessage(
|
|
77
|
+
message=m["message"],
|
|
78
|
+
type=FlashType(m["type"]),
|
|
79
|
+
dismissible=m.get("dismissible", True),
|
|
80
|
+
)
|
|
81
|
+
for m in messages
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# Convenience functions for common flash types
|
|
86
|
+
def flash_success(request: "Request", message: str, dismissible: bool = True) -> None:
|
|
87
|
+
"""Add a success flash message."""
|
|
88
|
+
add_flash(request, message, FlashType.SUCCESS, dismissible)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def flash_error(request: "Request", message: str, dismissible: bool = True) -> None:
|
|
92
|
+
"""Add an error flash message."""
|
|
93
|
+
add_flash(request, message, FlashType.ERROR, dismissible)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def flash_warning(request: "Request", message: str, dismissible: bool = True) -> None:
|
|
97
|
+
"""Add a warning flash message."""
|
|
98
|
+
add_flash(request, message, FlashType.WARNING, dismissible)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def flash_info(request: "Request", message: str, dismissible: bool = True) -> None:
|
|
102
|
+
"""Add an info flash message."""
|
|
103
|
+
add_flash(request, message, FlashType.INFO, dismissible)
|