skrift 0.1.0a14__tar.gz → 0.1.0a16__tar.gz
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-0.1.0a14 → skrift-0.1.0a16}/PKG-INFO +14 -3
- {skrift-0.1.0a14 → skrift-0.1.0a16}/README.md +13 -2
- {skrift-0.1.0a14 → skrift-0.1.0a16}/pyproject.toml +2 -1
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/admin/controller.py +114 -26
- skrift-0.1.0a16/skrift/alembic/versions/20260202_add_content_scheduling.py +35 -0
- skrift-0.1.0a16/skrift/alembic/versions/20260202_add_page_ordering.py +32 -0
- skrift-0.1.0a16/skrift/alembic/versions/20260202_add_page_revisions.py +46 -0
- skrift-0.1.0a16/skrift/alembic/versions/20260202_add_seo_fields.py +42 -0
- skrift-0.1.0a16/skrift/claude_skill/SKILL.md +205 -0
- skrift-0.1.0a16/skrift/claude_skill/__init__.py +0 -0
- skrift-0.1.0a16/skrift/claude_skill/architecture.md +328 -0
- skrift-0.1.0a16/skrift/claude_skill/patterns.md +552 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/cli.py +47 -0
- skrift-0.1.0a16/skrift/controllers/sitemap.py +116 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/controllers/web.py +18 -1
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/db/models/__init__.py +2 -1
- skrift-0.1.0a16/skrift/db/models/page.py +51 -0
- skrift-0.1.0a16/skrift/db/models/page_revision.py +45 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/db/services/page_service.py +113 -6
- skrift-0.1.0a16/skrift/db/services/revision_service.py +146 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/db/services/setting_service.py +7 -0
- skrift-0.1.0a16/skrift/lib/__init__.py +15 -0
- skrift-0.1.0a16/skrift/lib/flash.py +103 -0
- skrift-0.1.0a16/skrift/lib/hooks.py +292 -0
- skrift-0.1.0a16/skrift/lib/seo.py +103 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/static/css/style.css +92 -2
- skrift-0.1.0a16/skrift/templates/admin/pages/edit.html +72 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/templates/admin/pages/list.html +8 -1
- skrift-0.1.0a16/skrift/templates/admin/pages/revisions.html +59 -0
- skrift-0.1.0a16/skrift/templates/base.html +106 -0
- skrift-0.1.0a14/skrift/db/models/page.py +0 -26
- skrift-0.1.0a14/skrift/lib/__init__.py +0 -4
- skrift-0.1.0a14/skrift/templates/admin/pages/edit.html +0 -32
- skrift-0.1.0a14/skrift/templates/base.html +0 -52
- {skrift-0.1.0a14 → skrift-0.1.0a16}/.gitignore +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/__init__.py +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/__main__.py +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/admin/__init__.py +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/admin/navigation.py +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/alembic/env.py +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/alembic/script.py.mako +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/alembic/versions/20260120_210154_09b0364dbb7b_initial_schema.py +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/alembic/versions/20260122_152744_0b7c927d2591_add_roles_and_permissions.py +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/alembic/versions/20260122_172836_cdf734a5b847_add_sa_orm_sentinel_column.py +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/alembic/versions/20260122_175637_a9c55348eae7_remove_page_type_column.py +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/alembic/versions/20260122_200000_add_settings_table.py +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/alembic/versions/20260129_add_oauth_accounts.py +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/alembic/versions/20260129_add_provider_metadata.py +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/alembic.ini +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/asgi.py +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/auth/__init__.py +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/auth/guards.py +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/auth/roles.py +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/auth/services.py +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/config.py +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/controllers/__init__.py +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/controllers/auth.py +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/db/__init__.py +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/db/base.py +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/db/models/oauth_account.py +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/db/models/role.py +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/db/models/setting.py +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/db/models/user.py +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/db/services/__init__.py +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/db/services/oauth_service.py +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/lib/exceptions.py +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/lib/markdown.py +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/lib/template.py +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/setup/__init__.py +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/setup/config_writer.py +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/setup/controller.py +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/setup/middleware.py +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/setup/providers.py +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/setup/state.py +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/templates/admin/admin.html +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/templates/admin/base.html +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/templates/admin/settings/site.html +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/templates/admin/users/list.html +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/templates/admin/users/roles.html +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/templates/auth/dummy_login.html +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/templates/auth/login.html +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/templates/error-404.html +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/templates/error-500.html +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/templates/error.html +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/templates/index.html +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/templates/page.html +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/templates/setup/admin.html +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/templates/setup/auth.html +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/templates/setup/base.html +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/templates/setup/complete.html +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/templates/setup/configuring.html +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/templates/setup/database.html +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/templates/setup/restart.html +0 -0
- {skrift-0.1.0a14 → skrift-0.1.0a16}/skrift/templates/setup/site.html +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: skrift
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.0a16
|
|
4
4
|
Summary: A lightweight async Python CMS for crafting modern websites
|
|
5
5
|
Requires-Python: >=3.13
|
|
6
6
|
Requires-Dist: advanced-alchemy>=0.26.0
|
|
@@ -35,6 +35,11 @@ A modern Litestar-powered content management framework with multi-provider OAuth
|
|
|
35
35
|
- **Dynamic Controllers**: Load controllers from `app.yaml` configuration
|
|
36
36
|
- **SQLAlchemy Integration**: Async database support with SQLite/PostgreSQL
|
|
37
37
|
- **Client-Side Sessions**: Encrypted cookie sessions for horizontal scalability
|
|
38
|
+
- **Hook/Filter System**: WordPress-like extensibility with async support
|
|
39
|
+
- **SEO Metadata**: Built-in meta descriptions, OpenGraph tags, and robots directives
|
|
40
|
+
- **Content Scheduling**: Schedule pages to publish at a future date
|
|
41
|
+
- **Page Revisions**: Automatic content history with restore capability
|
|
42
|
+
- **Sitemap & Robots.txt**: Auto-generated with filter extensibility
|
|
38
43
|
|
|
39
44
|
## Quick Start
|
|
40
45
|
|
|
@@ -121,11 +126,17 @@ skrift/
|
|
|
121
126
|
├── skrift/ # Main Python package
|
|
122
127
|
│ ├── asgi.py # Application factory
|
|
123
128
|
│ ├── config.py # Settings management
|
|
124
|
-
│ ├── controllers/ # Route handlers
|
|
129
|
+
│ ├── controllers/ # Route handlers (auth, web, sitemap)
|
|
125
130
|
│ ├── admin/ # Admin panel
|
|
126
131
|
│ ├── auth/ # RBAC and guards
|
|
127
132
|
│ ├── db/ # Models and services
|
|
128
|
-
│ ├──
|
|
133
|
+
│ │ ├── models/ # Page, User, Role, PageRevision
|
|
134
|
+
│ │ └── services/ # page_service, revision_service
|
|
135
|
+
│ ├── lib/ # Core utilities
|
|
136
|
+
│ │ ├── hooks.py # Hook/filter system
|
|
137
|
+
│ │ ├── seo.py # SEO metadata utilities
|
|
138
|
+
│ │ ├── flash.py # Enhanced flash messages
|
|
139
|
+
│ │ └── template.py # Template resolver
|
|
129
140
|
│ └── setup/ # Setup wizard
|
|
130
141
|
├── templates/ # Jinja2 templates
|
|
131
142
|
├── static/ # Static assets
|
|
@@ -12,6 +12,11 @@ A modern Litestar-powered content management framework with multi-provider OAuth
|
|
|
12
12
|
- **Dynamic Controllers**: Load controllers from `app.yaml` configuration
|
|
13
13
|
- **SQLAlchemy Integration**: Async database support with SQLite/PostgreSQL
|
|
14
14
|
- **Client-Side Sessions**: Encrypted cookie sessions for horizontal scalability
|
|
15
|
+
- **Hook/Filter System**: WordPress-like extensibility with async support
|
|
16
|
+
- **SEO Metadata**: Built-in meta descriptions, OpenGraph tags, and robots directives
|
|
17
|
+
- **Content Scheduling**: Schedule pages to publish at a future date
|
|
18
|
+
- **Page Revisions**: Automatic content history with restore capability
|
|
19
|
+
- **Sitemap & Robots.txt**: Auto-generated with filter extensibility
|
|
15
20
|
|
|
16
21
|
## Quick Start
|
|
17
22
|
|
|
@@ -98,11 +103,17 @@ skrift/
|
|
|
98
103
|
├── skrift/ # Main Python package
|
|
99
104
|
│ ├── asgi.py # Application factory
|
|
100
105
|
│ ├── config.py # Settings management
|
|
101
|
-
│ ├── controllers/ # Route handlers
|
|
106
|
+
│ ├── controllers/ # Route handlers (auth, web, sitemap)
|
|
102
107
|
│ ├── admin/ # Admin panel
|
|
103
108
|
│ ├── auth/ # RBAC and guards
|
|
104
109
|
│ ├── db/ # Models and services
|
|
105
|
-
│ ├──
|
|
110
|
+
│ │ ├── models/ # Page, User, Role, PageRevision
|
|
111
|
+
│ │ └── services/ # page_service, revision_service
|
|
112
|
+
│ ├── lib/ # Core utilities
|
|
113
|
+
│ │ ├── hooks.py # Hook/filter system
|
|
114
|
+
│ │ ├── seo.py # SEO metadata utilities
|
|
115
|
+
│ │ ├── flash.py # Enhanced flash messages
|
|
116
|
+
│ │ └── template.py # Template resolver
|
|
106
117
|
│ └── setup/ # Setup wizard
|
|
107
118
|
├── templates/ # Jinja2 templates
|
|
108
119
|
├── static/ # Static assets
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "skrift"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.0a16"
|
|
4
4
|
description = "A lightweight async Python CMS for crafting modern websites"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.13"
|
|
@@ -47,6 +47,7 @@ include = [
|
|
|
47
47
|
"skrift/static/**/*",
|
|
48
48
|
"skrift/alembic/**/*",
|
|
49
49
|
"skrift/alembic.ini",
|
|
50
|
+
"skrift/claude_skill/*.md",
|
|
50
51
|
]
|
|
51
52
|
exclude = [
|
|
52
53
|
"**/__pycache__",
|
|
@@ -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
|
-
|
|
78
|
+
flash_messages = get_flash_messages(request)
|
|
78
79
|
return TemplateResponse(
|
|
79
80
|
"admin/admin.html",
|
|
80
|
-
context={"
|
|
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
|
-
|
|
104
|
+
flash_messages = get_flash_messages(request)
|
|
104
105
|
return TemplateResponse(
|
|
105
106
|
"admin/users/list.html",
|
|
106
|
-
context={"
|
|
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
|
-
|
|
133
|
+
flash_messages = get_flash_messages(request)
|
|
133
134
|
return TemplateResponse(
|
|
134
135
|
"admin/users/roles.html",
|
|
135
136
|
context={
|
|
136
|
-
"
|
|
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
|
-
|
|
212
|
+
flash_messages = get_flash_messages(request)
|
|
212
213
|
return TemplateResponse(
|
|
213
214
|
"admin/pages/list.html",
|
|
214
|
-
context={"
|
|
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
|
-
|
|
227
|
+
flash_messages = get_flash_messages(request)
|
|
227
228
|
return TemplateResponse(
|
|
228
229
|
"admin/pages/edit.html",
|
|
229
|
-
context={"
|
|
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
|
|
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
|
|
283
|
+
flash_success(request, f"Page '{title}' created successfully!")
|
|
264
284
|
return Redirect(path="/admin/pages")
|
|
265
285
|
except Exception as e:
|
|
266
|
-
request
|
|
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
|
|
301
|
+
flash_error(request, "Page not found")
|
|
282
302
|
return Redirect(path="/admin/pages")
|
|
283
303
|
|
|
284
|
-
|
|
304
|
+
flash_messages = get_flash_messages(request)
|
|
285
305
|
return TemplateResponse(
|
|
286
306
|
"admin/pages/edit.html",
|
|
287
|
-
context={"
|
|
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
|
|
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
|
|
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
|
|
369
|
+
flash_success(request, f"Page '{title}' updated successfully!")
|
|
331
370
|
return Redirect(path="/admin/pages")
|
|
332
371
|
except Exception as e:
|
|
333
|
-
request
|
|
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
|
-
|
|
501
|
+
flash_messages = get_flash_messages(request)
|
|
414
502
|
return TemplateResponse(
|
|
415
503
|
"admin/settings/site.html",
|
|
416
|
-
context={"
|
|
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
|
|
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')
|