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.
@@ -26,8 +26,9 @@ from skrift.auth.roles import ROLE_DEFINITIONS
26
26
  from skrift.admin.navigation import build_admin_nav, ADMIN_NAV_TAG
27
27
  from skrift.db.models.user import User
28
28
  from skrift.db.models import Page
29
- from skrift.db.services import page_service
29
+ from skrift.db.services import page_service, revision_service
30
30
  from skrift.db.services import setting_service
31
+ from skrift.lib.flash import flash_success, flash_error, flash_info, get_flash_messages
31
32
 
32
33
 
33
34
  class AdminController(Controller):
@@ -74,10 +75,10 @@ class AdminController(Controller):
74
75
  if not ctx["admin_nav"]:
75
76
  raise NotAuthorizedException("No admin pages accessible")
76
77
 
77
- flash = request.session.pop("flash", None)
78
+ flash_messages = get_flash_messages(request)
78
79
  return TemplateResponse(
79
80
  "admin/admin.html",
80
- context={"flash": flash, **ctx},
81
+ context={"flash_messages": flash_messages, **ctx},
81
82
  )
82
83
 
83
84
  @get(
@@ -100,10 +101,10 @@ class AdminController(Controller):
100
101
  )
101
102
  users = list(result.scalars().all())
102
103
 
103
- flash = request.session.pop("flash", None)
104
+ flash_messages = get_flash_messages(request)
104
105
  return TemplateResponse(
105
106
  "admin/users/list.html",
106
- context={"flash": flash, "users": users, **ctx},
107
+ context={"flash_messages": flash_messages, "users": users, **ctx},
107
108
  )
108
109
 
109
110
  @get(
@@ -129,11 +130,11 @@ class AdminController(Controller):
129
130
  # Get user's current role names
130
131
  current_roles = {role.name for role in target_user.roles}
131
132
 
132
- flash = request.session.pop("flash", None)
133
+ flash_messages = get_flash_messages(request)
133
134
  return TemplateResponse(
134
135
  "admin/users/roles.html",
135
136
  context={
136
- "flash": flash,
137
+ "flash_messages": flash_messages,
137
138
  "target_user": target_user,
138
139
  "current_roles": current_roles,
139
140
  "available_roles": ROLE_DEFINITIONS,
@@ -200,18 +201,18 @@ class AdminController(Controller):
200
201
  """List all pages with management actions."""
201
202
  ctx = await self._get_admin_context(request, db_session)
202
203
 
203
- # Get all pages with their authors
204
+ # Get all pages with their authors, ordered by display order then created
204
205
  result = await db_session.execute(
205
206
  select(Page)
206
207
  .options(selectinload(Page.user))
207
- .order_by(Page.created_at.desc())
208
+ .order_by(Page.order.asc(), Page.created_at.desc())
208
209
  )
209
210
  pages = list(result.scalars().all())
210
211
 
211
- flash = request.session.pop("flash", None)
212
+ flash_messages = get_flash_messages(request)
212
213
  return TemplateResponse(
213
214
  "admin/pages/list.html",
214
- context={"flash": flash, "pages": pages, **ctx},
215
+ context={"flash_messages": flash_messages, "pages": pages, **ctx},
215
216
  )
216
217
 
217
218
  @get(
@@ -223,10 +224,10 @@ class AdminController(Controller):
223
224
  ) -> TemplateResponse:
224
225
  """Show new page form."""
225
226
  ctx = await self._get_admin_context(request, db_session)
226
- flash = request.session.pop("flash", None)
227
+ flash_messages = get_flash_messages(request)
227
228
  return TemplateResponse(
228
229
  "admin/pages/edit.html",
229
- context={"flash": flash, "page": None, **ctx},
230
+ context={"flash_messages": flash_messages, "page": None, **ctx},
230
231
  )
231
232
 
232
233
  @post(
@@ -245,8 +246,20 @@ class AdminController(Controller):
245
246
  content = data.get("content", "").strip()
246
247
  is_published = data.get("is_published") == "on"
247
248
 
249
+ # New fields
250
+ order = int(data.get("order", 0) or 0)
251
+ publish_at_str = data.get("publish_at", "").strip()
252
+ publish_at = datetime.fromisoformat(publish_at_str) if publish_at_str else None
253
+
254
+ # SEO fields
255
+ meta_description = data.get("meta_description", "").strip() or None
256
+ og_title = data.get("og_title", "").strip() or None
257
+ og_description = data.get("og_description", "").strip() or None
258
+ og_image = data.get("og_image", "").strip() or None
259
+ meta_robots = data.get("meta_robots", "").strip() or None
260
+
248
261
  if not title or not slug:
249
- request.session["flash"] = "Title and slug are required"
262
+ flash_error(request, "Title and slug are required")
250
263
  return Redirect(path="/admin/pages/new")
251
264
 
252
265
  published_at = datetime.now(UTC) if is_published else None
@@ -259,11 +272,18 @@ class AdminController(Controller):
259
272
  content=content,
260
273
  is_published=is_published,
261
274
  published_at=published_at,
275
+ order=order,
276
+ publish_at=publish_at,
277
+ meta_description=meta_description,
278
+ og_title=og_title,
279
+ og_description=og_description,
280
+ og_image=og_image,
281
+ meta_robots=meta_robots,
262
282
  )
263
- request.session["flash"] = f"Page '{title}' created successfully!"
283
+ flash_success(request, f"Page '{title}' created successfully!")
264
284
  return Redirect(path="/admin/pages")
265
285
  except Exception as e:
266
- request.session["flash"] = f"Error creating page: {str(e)}"
286
+ flash_error(request, f"Error creating page: {str(e)}")
267
287
  return Redirect(path="/admin/pages/new")
268
288
 
269
289
  @get(
@@ -278,13 +298,13 @@ class AdminController(Controller):
278
298
 
279
299
  page = await page_service.get_page_by_id(db_session, page_id)
280
300
  if not page:
281
- request.session["flash"] = "Page not found"
301
+ flash_error(request, "Page not found")
282
302
  return Redirect(path="/admin/pages")
283
303
 
284
- flash = request.session.pop("flash", None)
304
+ flash_messages = get_flash_messages(request)
285
305
  return TemplateResponse(
286
306
  "admin/pages/edit.html",
287
- context={"flash": flash, "page": page, **ctx},
307
+ context={"flash_messages": flash_messages, "page": page, **ctx},
288
308
  )
289
309
 
290
310
  @post(
@@ -304,13 +324,25 @@ class AdminController(Controller):
304
324
  content = data.get("content", "").strip()
305
325
  is_published = data.get("is_published") == "on"
306
326
 
327
+ # New fields
328
+ order = int(data.get("order", 0) or 0)
329
+ publish_at_str = data.get("publish_at", "").strip()
330
+ publish_at = datetime.fromisoformat(publish_at_str) if publish_at_str else None
331
+
332
+ # SEO fields
333
+ meta_description = data.get("meta_description", "").strip() or None
334
+ og_title = data.get("og_title", "").strip() or None
335
+ og_description = data.get("og_description", "").strip() or None
336
+ og_image = data.get("og_image", "").strip() or None
337
+ meta_robots = data.get("meta_robots", "").strip() or None
338
+
307
339
  if not title or not slug:
308
- request.session["flash"] = "Title and slug are required"
340
+ flash_error(request, "Title and slug are required")
309
341
  return Redirect(path=f"/admin/pages/{page_id}/edit")
310
342
 
311
343
  page = await page_service.get_page_by_id(db_session, page_id)
312
344
  if not page:
313
- request.session["flash"] = "Page not found"
345
+ flash_error(request, "Page not found")
314
346
  return Redirect(path="/admin/pages")
315
347
 
316
348
  published_at = page.published_at
@@ -326,11 +358,18 @@ class AdminController(Controller):
326
358
  content=content,
327
359
  is_published=is_published,
328
360
  published_at=published_at,
361
+ order=order,
362
+ publish_at=publish_at,
363
+ meta_description=meta_description,
364
+ og_title=og_title,
365
+ og_description=og_description,
366
+ og_image=og_image,
367
+ meta_robots=meta_robots,
329
368
  )
330
- request.session["flash"] = f"Page '{title}' updated successfully!"
369
+ flash_success(request, f"Page '{title}' updated successfully!")
331
370
  return Redirect(path="/admin/pages")
332
371
  except Exception as e:
333
- request.session["flash"] = f"Error updating page: {str(e)}"
372
+ flash_error(request, f"Error updating page: {str(e)}")
334
373
  return Redirect(path=f"/admin/pages/{page_id}/edit")
335
374
 
336
375
  @post(
@@ -397,6 +436,55 @@ class AdminController(Controller):
397
436
  request.session["flash"] = f"'{page_title}' has been deleted"
398
437
  return Redirect(path="/admin/pages")
399
438
 
439
+ @get(
440
+ "/pages/{page_id:uuid}/revisions",
441
+ guards=[auth_guard, Permission("manage-pages")],
442
+ )
443
+ async def list_revisions(
444
+ self, request: Request, db_session: AsyncSession, page_id: UUID
445
+ ) -> TemplateResponse:
446
+ """List revisions for a page."""
447
+ ctx = await self._get_admin_context(request, db_session)
448
+
449
+ page = await page_service.get_page_by_id(db_session, page_id)
450
+ if not page:
451
+ flash_error(request, "Page not found")
452
+ return Redirect(path="/admin/pages")
453
+
454
+ revisions = await revision_service.list_revisions(db_session, page_id)
455
+
456
+ flash_messages = get_flash_messages(request)
457
+ return TemplateResponse(
458
+ "admin/pages/revisions.html",
459
+ context={"flash_messages": flash_messages, "page": page, "revisions": revisions, **ctx},
460
+ )
461
+
462
+ @post(
463
+ "/pages/{page_id:uuid}/revisions/{revision_id:uuid}/restore",
464
+ guards=[auth_guard, Permission("manage-pages")],
465
+ )
466
+ async def restore_revision(
467
+ self, request: Request, db_session: AsyncSession, page_id: UUID, revision_id: UUID
468
+ ) -> Redirect:
469
+ """Restore a page to a previous revision."""
470
+ page = await page_service.get_page_by_id(db_session, page_id)
471
+ if not page:
472
+ flash_error(request, "Page not found")
473
+ return Redirect(path="/admin/pages")
474
+
475
+ revision = await revision_service.get_revision(db_session, revision_id)
476
+ if not revision or revision.page_id != page_id:
477
+ flash_error(request, "Revision not found")
478
+ return Redirect(path=f"/admin/pages/{page_id}/revisions")
479
+
480
+ user_id = request.session.get("user_id")
481
+ await revision_service.restore_revision(
482
+ db_session, page, revision, UUID(user_id) if user_id else None
483
+ )
484
+
485
+ flash_success(request, f"Page restored to revision #{revision.revision_number}")
486
+ return Redirect(path=f"/admin/pages/{page_id}/edit")
487
+
400
488
  @get(
401
489
  "/settings",
402
490
  tags=[ADMIN_NAV_TAG],
@@ -410,10 +498,10 @@ class AdminController(Controller):
410
498
  ctx = await self._get_admin_context(request, db_session)
411
499
  site_settings = await setting_service.get_site_settings(db_session)
412
500
 
413
- flash = request.session.pop("flash", None)
501
+ flash_messages = get_flash_messages(request)
414
502
  return TemplateResponse(
415
503
  "admin/settings/site.html",
416
- context={"flash": flash, "settings": site_settings, **ctx},
504
+ context={"flash_messages": flash_messages, "settings": site_settings, **ctx},
417
505
  )
418
506
 
419
507
  @post(
@@ -448,5 +536,5 @@ class AdminController(Controller):
448
536
  # Refresh the site settings cache
449
537
  await setting_service.load_site_settings_cache(db_session)
450
538
 
451
- request.session["flash"] = "Site settings saved successfully"
539
+ flash_success(request, "Site settings saved successfully")
452
540
  return Redirect(path="/admin/settings")
@@ -0,0 +1,35 @@
1
+ """add content scheduling
2
+
3
+ Revision ID: 5e6f7g8h9i0j
4
+ Revises: 4d5e6f7g8h9i
5
+ Create Date: 2026-02-02 10:02:00.000000
6
+
7
+ This migration adds the publish_at column for scheduled publishing.
8
+ When set, pages with is_published=True will only be visible after this datetime.
9
+ """
10
+ from typing import Sequence, Union
11
+
12
+ from alembic import op
13
+ import sqlalchemy as sa
14
+ import advanced_alchemy.types
15
+
16
+
17
+ # revision identifiers, used by Alembic.
18
+ revision: str = '5e6f7g8h9i0j'
19
+ down_revision: Union[str, None] = '4d5e6f7g8h9i'
20
+ branch_labels: Union[str, Sequence[str], None] = None
21
+ depends_on: Union[str, Sequence[str], None] = None
22
+
23
+
24
+ def upgrade() -> None:
25
+ with op.batch_alter_table('pages') as batch_op:
26
+ batch_op.add_column(
27
+ sa.Column('publish_at', advanced_alchemy.types.datetime.DateTimeUTC(timezone=True), nullable=True)
28
+ )
29
+ batch_op.create_index('ix_pages_publish_at', ['publish_at'])
30
+
31
+
32
+ def downgrade() -> None:
33
+ with op.batch_alter_table('pages') as batch_op:
34
+ batch_op.drop_index('ix_pages_publish_at')
35
+ batch_op.drop_column('publish_at')
@@ -0,0 +1,32 @@
1
+ """add page ordering
2
+
3
+ Revision ID: 4d5e6f7g8h9i
4
+ Revises: 3c4d5e6f7g8h
5
+ Create Date: 2026-02-02 10:01:00.000000
6
+
7
+ This migration adds an order column to the pages table for custom ordering.
8
+ Lower numbers appear first, default is 0.
9
+ """
10
+ from typing import Sequence, Union
11
+
12
+ from alembic import op
13
+ import sqlalchemy as sa
14
+
15
+
16
+ # revision identifiers, used by Alembic.
17
+ revision: str = '4d5e6f7g8h9i'
18
+ down_revision: Union[str, None] = '3c4d5e6f7g8h'
19
+ branch_labels: Union[str, Sequence[str], None] = None
20
+ depends_on: Union[str, Sequence[str], None] = None
21
+
22
+
23
+ def upgrade() -> None:
24
+ with op.batch_alter_table('pages') as batch_op:
25
+ batch_op.add_column(sa.Column('order', sa.Integer(), nullable=False, server_default='0'))
26
+ batch_op.create_index('ix_pages_order', ['order'])
27
+
28
+
29
+ def downgrade() -> None:
30
+ with op.batch_alter_table('pages') as batch_op:
31
+ batch_op.drop_index('ix_pages_order')
32
+ batch_op.drop_column('order')
@@ -0,0 +1,46 @@
1
+ """add page revisions table
2
+
3
+ Revision ID: 6f7g8h9i0j1k
4
+ Revises: 5e6f7g8h9i0j
5
+ Create Date: 2026-02-02 10:03:00.000000
6
+
7
+ This migration creates the page_revisions table for tracking content history.
8
+ Revisions are created when page content is modified, allowing restoration.
9
+ """
10
+ from typing import Sequence, Union
11
+
12
+ from alembic import op
13
+ import sqlalchemy as sa
14
+ import advanced_alchemy.types
15
+
16
+
17
+ # revision identifiers, used by Alembic.
18
+ revision: str = '6f7g8h9i0j1k'
19
+ down_revision: Union[str, None] = '5e6f7g8h9i0j'
20
+ branch_labels: Union[str, Sequence[str], None] = None
21
+ depends_on: Union[str, Sequence[str], None] = None
22
+
23
+
24
+ def upgrade() -> None:
25
+ op.create_table('page_revisions',
26
+ sa.Column('id', advanced_alchemy.types.guid.GUID(length=16), nullable=False),
27
+ sa.Column('page_id', advanced_alchemy.types.guid.GUID(length=16), nullable=False),
28
+ sa.Column('user_id', advanced_alchemy.types.guid.GUID(length=16), nullable=True),
29
+ sa.Column('revision_number', sa.Integer(), nullable=False),
30
+ sa.Column('title', sa.String(length=500), nullable=False),
31
+ sa.Column('content', sa.Text(), nullable=False),
32
+ sa.Column('created_at', advanced_alchemy.types.datetime.DateTimeUTC(timezone=True), nullable=False),
33
+ sa.Column('sa_orm_sentinel', sa.Integer(), nullable=True),
34
+ sa.Column('updated_at', advanced_alchemy.types.datetime.DateTimeUTC(timezone=True), nullable=False),
35
+ sa.ForeignKeyConstraint(['page_id'], ['pages.id'], name=op.f('fk_page_revisions_page_id_pages'), ondelete='CASCADE'),
36
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('fk_page_revisions_user_id_users'), ondelete='SET NULL'),
37
+ sa.PrimaryKeyConstraint('id', name=op.f('pk_page_revisions'))
38
+ )
39
+ op.create_index(op.f('ix_page_revisions_page_id'), 'page_revisions', ['page_id'], unique=False)
40
+ op.create_index(op.f('ix_page_revisions_user_id'), 'page_revisions', ['user_id'], unique=False)
41
+
42
+
43
+ def downgrade() -> None:
44
+ op.drop_index(op.f('ix_page_revisions_user_id'), table_name='page_revisions')
45
+ op.drop_index(op.f('ix_page_revisions_page_id'), table_name='page_revisions')
46
+ op.drop_table('page_revisions')
@@ -0,0 +1,42 @@
1
+ """add SEO fields to pages
2
+
3
+ Revision ID: 3c4d5e6f7g8h
4
+ Revises: 2b3c4d5e6f7g
5
+ Create Date: 2026-02-02 10:00:00.000000
6
+
7
+ This migration adds SEO metadata fields to the pages table:
8
+ - meta_description: Meta description for search engines
9
+ - og_title: OpenGraph title override
10
+ - og_description: OpenGraph description override
11
+ - og_image: OpenGraph image URL
12
+ - meta_robots: Meta robots directive (noindex, nofollow, etc.)
13
+ """
14
+ from typing import Sequence, Union
15
+
16
+ from alembic import op
17
+ import sqlalchemy as sa
18
+
19
+
20
+ # revision identifiers, used by Alembic.
21
+ revision: str = '3c4d5e6f7g8h'
22
+ down_revision: Union[str, None] = '2b3c4d5e6f7g'
23
+ branch_labels: Union[str, Sequence[str], None] = None
24
+ depends_on: Union[str, Sequence[str], None] = None
25
+
26
+
27
+ def upgrade() -> None:
28
+ with op.batch_alter_table('pages') as batch_op:
29
+ batch_op.add_column(sa.Column('meta_description', sa.Text(), nullable=True))
30
+ batch_op.add_column(sa.Column('og_title', sa.String(length=500), nullable=True))
31
+ batch_op.add_column(sa.Column('og_description', sa.Text(), nullable=True))
32
+ batch_op.add_column(sa.Column('og_image', sa.String(length=1024), nullable=True))
33
+ batch_op.add_column(sa.Column('meta_robots', sa.String(length=100), nullable=True))
34
+
35
+
36
+ def downgrade() -> None:
37
+ with op.batch_alter_table('pages') as batch_op:
38
+ batch_op.drop_column('meta_robots')
39
+ batch_op.drop_column('og_image')
40
+ batch_op.drop_column('og_description')
41
+ batch_op.drop_column('og_title')
42
+ batch_op.drop_column('meta_description')
@@ -0,0 +1,116 @@
1
+ """Sitemap and robots.txt controller for SEO."""
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+ from xml.etree.ElementTree import Element, SubElement, tostring
6
+
7
+ from litestar import Controller, Request, get
8
+ from litestar.response import Response
9
+ from sqlalchemy.ext.asyncio import AsyncSession
10
+
11
+ from skrift.db.services import page_service
12
+ from skrift.db.services.setting_service import get_cached_site_base_url
13
+ from skrift.lib.hooks import hooks, SITEMAP_PAGE, SITEMAP_URLS, ROBOTS_TXT
14
+
15
+
16
+ @dataclass
17
+ class SitemapEntry:
18
+ """A single entry in the sitemap."""
19
+
20
+ loc: str
21
+ lastmod: datetime | None = None
22
+ changefreq: str | None = None
23
+ priority: float | None = None
24
+
25
+
26
+ class SitemapController(Controller):
27
+ """Controller for sitemap.xml and robots.txt."""
28
+
29
+ path = "/"
30
+
31
+ @get("/sitemap.xml")
32
+ async def sitemap(
33
+ self, request: Request, db_session: AsyncSession
34
+ ) -> Response:
35
+ """Generate sitemap.xml with published pages."""
36
+ base_url = get_cached_site_base_url() or str(request.base_url).rstrip("/")
37
+
38
+ # Get all published pages (respects scheduling)
39
+ pages = await page_service.list_pages(db_session, published_only=True)
40
+
41
+ entries: list[SitemapEntry] = []
42
+
43
+ for page in pages:
44
+ slug = page.slug.strip("/")
45
+ loc = f"{base_url}/{slug}" if slug else base_url
46
+
47
+ entry = SitemapEntry(
48
+ loc=loc,
49
+ lastmod=page.updated_at or page.created_at,
50
+ changefreq="weekly",
51
+ priority=0.8 if slug else 1.0, # Home page gets higher priority
52
+ )
53
+
54
+ # Apply sitemap_page filter (can return None to exclude)
55
+ entry = await hooks.apply_filters(SITEMAP_PAGE, entry, page)
56
+ if entry is not None:
57
+ entries.append(entry)
58
+
59
+ # Apply sitemap_urls filter to allow adding custom entries
60
+ entries = await hooks.apply_filters(SITEMAP_URLS, entries)
61
+
62
+ # Build XML
63
+ xml = self._build_sitemap_xml(entries)
64
+
65
+ return Response(
66
+ content=xml,
67
+ media_type="application/xml",
68
+ headers={"Content-Type": "application/xml; charset=utf-8"},
69
+ )
70
+
71
+ def _build_sitemap_xml(self, entries: list[SitemapEntry]) -> bytes:
72
+ """Build sitemap XML from entries."""
73
+ urlset = Element("urlset")
74
+ urlset.set("xmlns", "http://www.sitemaps.org/schemas/sitemap/0.9")
75
+
76
+ for entry in entries:
77
+ url = SubElement(urlset, "url")
78
+ loc = SubElement(url, "loc")
79
+ loc.text = entry.loc
80
+
81
+ if entry.lastmod:
82
+ lastmod = SubElement(url, "lastmod")
83
+ lastmod.text = entry.lastmod.strftime("%Y-%m-%d")
84
+
85
+ if entry.changefreq:
86
+ changefreq = SubElement(url, "changefreq")
87
+ changefreq.text = entry.changefreq
88
+
89
+ if entry.priority is not None:
90
+ priority = SubElement(url, "priority")
91
+ priority.text = str(entry.priority)
92
+
93
+ return b'<?xml version="1.0" encoding="UTF-8"?>\n' + tostring(urlset, encoding="utf-8")
94
+
95
+ @get("/robots.txt")
96
+ async def robots(
97
+ self, request: Request, db_session: AsyncSession
98
+ ) -> Response:
99
+ """Generate robots.txt with sitemap reference."""
100
+ base_url = get_cached_site_base_url() or str(request.base_url).rstrip("/")
101
+ sitemap_url = f"{base_url}/sitemap.xml"
102
+
103
+ content = f"""User-agent: *
104
+ Allow: /
105
+
106
+ Sitemap: {sitemap_url}
107
+ """
108
+
109
+ # Apply robots_txt filter for customization
110
+ content = await hooks.apply_filters(ROBOTS_TXT, content)
111
+
112
+ return Response(
113
+ content=content,
114
+ media_type="text/plain",
115
+ headers={"Content-Type": "text/plain; charset=utf-8"},
116
+ )
skrift/controllers/web.py CHANGED
@@ -9,6 +9,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
9
9
 
10
10
  from skrift.db.models.user import User
11
11
  from skrift.db.services import page_service
12
+ from skrift.db.services.setting_service import get_cached_site_name, get_cached_site_base_url
13
+ from skrift.lib.seo import get_page_seo_meta, get_page_og_meta
12
14
  from skrift.lib.template import Template
13
15
 
14
16
  TEMPLATE_DIR = Path(__file__).parent.parent.parent / "templates"
@@ -63,5 +65,20 @@ class WebController(Controller):
63
65
  if not page:
64
66
  raise NotFoundException(f"Page '{path}' not found")
65
67
 
66
- template = Template("page", *slugs, context={"path": path, "slugs": slugs, "page": page})
68
+ # Get SEO metadata
69
+ site_name = get_cached_site_name()
70
+ base_url = get_cached_site_base_url() or str(request.base_url).rstrip("/")
71
+ seo_meta = await get_page_seo_meta(page, site_name, base_url)
72
+ og_meta = await get_page_og_meta(page, site_name, base_url)
73
+
74
+ template = Template(
75
+ "page", *slugs,
76
+ context={
77
+ "path": path,
78
+ "slugs": slugs,
79
+ "page": page,
80
+ "seo_meta": seo_meta,
81
+ "og_meta": og_meta,
82
+ }
83
+ )
67
84
  return template.render(TEMPLATE_DIR, flash=flash, **user_ctx)
@@ -1,7 +1,8 @@
1
1
  from skrift.db.models.oauth_account import OAuthAccount
2
2
  from skrift.db.models.page import Page
3
+ from skrift.db.models.page_revision import PageRevision
3
4
  from skrift.db.models.role import Role, RolePermission, user_roles
4
5
  from skrift.db.models.setting import Setting
5
6
  from skrift.db.models.user import User
6
7
 
7
- __all__ = ["OAuthAccount", "Page", "Role", "RolePermission", "Setting", "User", "user_roles"]
8
+ __all__ = ["OAuthAccount", "Page", "PageRevision", "Role", "RolePermission", "Setting", "User", "user_roles"]
skrift/db/models/page.py CHANGED
@@ -1,11 +1,15 @@
1
1
  from datetime import datetime
2
+ from typing import TYPE_CHECKING
2
3
  from uuid import UUID
3
4
 
4
- from sqlalchemy import String, Text, Boolean, DateTime, ForeignKey
5
+ from sqlalchemy import String, Text, Boolean, DateTime, ForeignKey, Integer
5
6
  from sqlalchemy.orm import Mapped, mapped_column, relationship
6
7
 
7
8
  from skrift.db.base import Base
8
9
 
10
+ if TYPE_CHECKING:
11
+ from skrift.db.models.page_revision import PageRevision
12
+
9
13
 
10
14
  class Page(Base):
11
15
  """Page model for content management."""
@@ -24,3 +28,24 @@ class Page(Base):
24
28
  # Publication fields
25
29
  is_published: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
26
30
  published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
31
+
32
+ # Scheduling field
33
+ publish_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
34
+
35
+ # Ordering field
36
+ order: Mapped[int] = mapped_column(Integer, default=0, nullable=False, index=True)
37
+
38
+ # SEO fields
39
+ meta_description: Mapped[str | None] = mapped_column(Text, nullable=True)
40
+ og_title: Mapped[str | None] = mapped_column(String(500), nullable=True)
41
+ og_description: Mapped[str | None] = mapped_column(Text, nullable=True)
42
+ og_image: Mapped[str | None] = mapped_column(String(1024), nullable=True)
43
+ meta_robots: Mapped[str | None] = mapped_column(String(100), nullable=True)
44
+
45
+ # Revisions relationship (defined after PageRevision exists)
46
+ revisions: Mapped[list["PageRevision"]] = relationship(
47
+ "PageRevision",
48
+ back_populates="page",
49
+ cascade="all, delete-orphan",
50
+ order_by="desc(PageRevision.revision_number)",
51
+ )
@@ -0,0 +1,45 @@
1
+ """Page revision model for content versioning."""
2
+
3
+ from datetime import datetime, UTC
4
+ from uuid import UUID
5
+
6
+ from sqlalchemy import String, Text, DateTime, ForeignKey, Integer
7
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
8
+
9
+ from skrift.db.base import Base
10
+
11
+
12
+ class PageRevision(Base):
13
+ """Stores historical versions of page content."""
14
+
15
+ __tablename__ = "page_revisions"
16
+
17
+ # Relationship to page (cascade delete when page is deleted)
18
+ page_id: Mapped[UUID] = mapped_column(
19
+ ForeignKey("pages.id", ondelete="CASCADE"),
20
+ nullable=False,
21
+ index=True,
22
+ )
23
+ page: Mapped["Page"] = relationship("Page", back_populates="revisions")
24
+
25
+ # Who made the change (nullable for system changes)
26
+ user_id: Mapped[UUID | None] = mapped_column(
27
+ ForeignKey("users.id", ondelete="SET NULL"),
28
+ nullable=True,
29
+ index=True,
30
+ )
31
+ user: Mapped["User"] = relationship("User")
32
+
33
+ # Revision tracking
34
+ revision_number: Mapped[int] = mapped_column(Integer, nullable=False)
35
+
36
+ # Snapshot of page content at this revision
37
+ title: Mapped[str] = mapped_column(String(500), nullable=False)
38
+ content: Mapped[str] = mapped_column(Text, nullable=False, default="")
39
+
40
+ # Metadata
41
+ created_at: Mapped[datetime] = mapped_column(
42
+ DateTime(timezone=True),
43
+ nullable=False,
44
+ default=lambda: datetime.now(UTC),
45
+ )