skrift 0.1.0a12__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/__init__.py +1 -0
- skrift/__main__.py +12 -0
- skrift/admin/__init__.py +11 -0
- skrift/admin/controller.py +452 -0
- skrift/admin/navigation.py +105 -0
- skrift/alembic/env.py +92 -0
- skrift/alembic/script.py.mako +26 -0
- skrift/alembic/versions/20260120_210154_09b0364dbb7b_initial_schema.py +70 -0
- skrift/alembic/versions/20260122_152744_0b7c927d2591_add_roles_and_permissions.py +57 -0
- skrift/alembic/versions/20260122_172836_cdf734a5b847_add_sa_orm_sentinel_column.py +31 -0
- skrift/alembic/versions/20260122_175637_a9c55348eae7_remove_page_type_column.py +43 -0
- skrift/alembic/versions/20260122_200000_add_settings_table.py +38 -0
- skrift/alembic/versions/20260129_add_oauth_accounts.py +141 -0
- skrift/alembic/versions/20260129_add_provider_metadata.py +29 -0
- skrift/alembic.ini +77 -0
- skrift/asgi.py +670 -0
- skrift/auth/__init__.py +58 -0
- skrift/auth/guards.py +130 -0
- skrift/auth/roles.py +129 -0
- skrift/auth/services.py +184 -0
- skrift/cli.py +143 -0
- skrift/config.py +259 -0
- skrift/controllers/__init__.py +4 -0
- skrift/controllers/auth.py +595 -0
- skrift/controllers/web.py +67 -0
- skrift/db/__init__.py +3 -0
- skrift/db/base.py +7 -0
- skrift/db/models/__init__.py +7 -0
- skrift/db/models/oauth_account.py +50 -0
- skrift/db/models/page.py +26 -0
- skrift/db/models/role.py +56 -0
- skrift/db/models/setting.py +13 -0
- skrift/db/models/user.py +36 -0
- skrift/db/services/__init__.py +1 -0
- skrift/db/services/oauth_service.py +195 -0
- skrift/db/services/page_service.py +217 -0
- skrift/db/services/setting_service.py +206 -0
- skrift/lib/__init__.py +3 -0
- skrift/lib/exceptions.py +168 -0
- skrift/lib/template.py +108 -0
- skrift/setup/__init__.py +14 -0
- skrift/setup/config_writer.py +213 -0
- skrift/setup/controller.py +888 -0
- skrift/setup/middleware.py +89 -0
- skrift/setup/providers.py +214 -0
- skrift/setup/state.py +315 -0
- skrift/static/css/style.css +1003 -0
- skrift/templates/admin/admin.html +19 -0
- skrift/templates/admin/base.html +24 -0
- skrift/templates/admin/pages/edit.html +32 -0
- skrift/templates/admin/pages/list.html +62 -0
- skrift/templates/admin/settings/site.html +32 -0
- skrift/templates/admin/users/list.html +58 -0
- skrift/templates/admin/users/roles.html +42 -0
- skrift/templates/auth/dummy_login.html +102 -0
- skrift/templates/auth/login.html +139 -0
- skrift/templates/base.html +52 -0
- skrift/templates/error-404.html +19 -0
- skrift/templates/error-500.html +19 -0
- skrift/templates/error.html +19 -0
- skrift/templates/index.html +9 -0
- skrift/templates/page.html +26 -0
- skrift/templates/setup/admin.html +24 -0
- skrift/templates/setup/auth.html +110 -0
- skrift/templates/setup/base.html +407 -0
- skrift/templates/setup/complete.html +17 -0
- skrift/templates/setup/configuring.html +158 -0
- skrift/templates/setup/database.html +125 -0
- skrift/templates/setup/restart.html +28 -0
- skrift/templates/setup/site.html +39 -0
- skrift-0.1.0a12.dist-info/METADATA +235 -0
- skrift-0.1.0a12.dist-info/RECORD +74 -0
- skrift-0.1.0a12.dist-info/WHEEL +4 -0
- skrift-0.1.0a12.dist-info/entry_points.txt +2 -0
skrift/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Skrift application package
|
skrift/__main__.py
ADDED
skrift/admin/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Admin module for administrative functionality."""
|
|
2
|
+
|
|
3
|
+
from skrift.admin.controller import AdminController
|
|
4
|
+
from skrift.admin.navigation import AdminNavItem, build_admin_nav, ADMIN_NAV_TAG
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"AdminController",
|
|
8
|
+
"AdminNavItem",
|
|
9
|
+
"build_admin_nav",
|
|
10
|
+
"ADMIN_NAV_TAG",
|
|
11
|
+
]
|
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
"""Admin controller for administrative functionality."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, UTC
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
from uuid import UUID
|
|
8
|
+
|
|
9
|
+
from litestar import Controller, Request, get, post
|
|
10
|
+
from litestar.exceptions import NotAuthorizedException
|
|
11
|
+
from litestar.response import Template as TemplateResponse, Redirect
|
|
12
|
+
from litestar.params import Body
|
|
13
|
+
from litestar.enums import RequestEncodingType
|
|
14
|
+
from sqlalchemy import select
|
|
15
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
16
|
+
from sqlalchemy.orm import selectinload
|
|
17
|
+
|
|
18
|
+
from skrift.auth.guards import auth_guard, Permission
|
|
19
|
+
from skrift.auth.services import (
|
|
20
|
+
get_user_permissions,
|
|
21
|
+
assign_role_to_user,
|
|
22
|
+
remove_role_from_user,
|
|
23
|
+
invalidate_user_permissions_cache,
|
|
24
|
+
)
|
|
25
|
+
from skrift.auth.roles import ROLE_DEFINITIONS
|
|
26
|
+
from skrift.admin.navigation import build_admin_nav, ADMIN_NAV_TAG
|
|
27
|
+
from skrift.db.models.user import User
|
|
28
|
+
from skrift.db.models import Page
|
|
29
|
+
from skrift.db.services import page_service
|
|
30
|
+
from skrift.db.services import setting_service
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AdminController(Controller):
|
|
34
|
+
"""Controller for admin functionality."""
|
|
35
|
+
|
|
36
|
+
path = "/admin"
|
|
37
|
+
guards = [auth_guard]
|
|
38
|
+
|
|
39
|
+
async def _get_admin_context(
|
|
40
|
+
self, request: Request, db_session: AsyncSession
|
|
41
|
+
) -> dict:
|
|
42
|
+
"""Get common admin context including nav and user."""
|
|
43
|
+
user_id = request.session.get("user_id")
|
|
44
|
+
if not user_id:
|
|
45
|
+
raise NotAuthorizedException("Authentication required")
|
|
46
|
+
|
|
47
|
+
result = await db_session.execute(
|
|
48
|
+
select(User).where(User.id == UUID(user_id))
|
|
49
|
+
)
|
|
50
|
+
user = result.scalar_one_or_none()
|
|
51
|
+
if not user:
|
|
52
|
+
raise NotAuthorizedException("Invalid user session")
|
|
53
|
+
|
|
54
|
+
permissions = await get_user_permissions(db_session, user_id)
|
|
55
|
+
nav_items = await build_admin_nav(
|
|
56
|
+
request.app, permissions, request.url.path
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
"user": user,
|
|
61
|
+
"permissions": permissions,
|
|
62
|
+
"admin_nav": nav_items,
|
|
63
|
+
"current_path": request.url.path,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
@get("/")
|
|
67
|
+
async def admin_index(
|
|
68
|
+
self, request: Request, db_session: AsyncSession
|
|
69
|
+
) -> TemplateResponse:
|
|
70
|
+
"""Admin landing page. Returns 403 if user has no accessible admin pages."""
|
|
71
|
+
ctx = await self._get_admin_context(request, db_session)
|
|
72
|
+
|
|
73
|
+
# Check if user has any admin pages accessible
|
|
74
|
+
if not ctx["admin_nav"]:
|
|
75
|
+
raise NotAuthorizedException("No admin pages accessible")
|
|
76
|
+
|
|
77
|
+
flash = request.session.pop("flash", None)
|
|
78
|
+
return TemplateResponse(
|
|
79
|
+
"admin/admin.html",
|
|
80
|
+
context={"flash": flash, **ctx},
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
@get(
|
|
84
|
+
"/users",
|
|
85
|
+
tags=[ADMIN_NAV_TAG],
|
|
86
|
+
guards=[auth_guard, Permission("manage-users")],
|
|
87
|
+
opt={"label": "Users", "icon": "users", "order": 10},
|
|
88
|
+
)
|
|
89
|
+
async def list_users(
|
|
90
|
+
self, request: Request, db_session: AsyncSession
|
|
91
|
+
) -> TemplateResponse:
|
|
92
|
+
"""List all users with their roles."""
|
|
93
|
+
ctx = await self._get_admin_context(request, db_session)
|
|
94
|
+
|
|
95
|
+
# Get all users with their roles
|
|
96
|
+
result = await db_session.execute(
|
|
97
|
+
select(User)
|
|
98
|
+
.options(selectinload(User.roles))
|
|
99
|
+
.order_by(User.created_at.desc())
|
|
100
|
+
)
|
|
101
|
+
users = list(result.scalars().all())
|
|
102
|
+
|
|
103
|
+
flash = request.session.pop("flash", None)
|
|
104
|
+
return TemplateResponse(
|
|
105
|
+
"admin/users/list.html",
|
|
106
|
+
context={"flash": flash, "users": users, **ctx},
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
@get(
|
|
110
|
+
"/users/{user_id:uuid}/roles",
|
|
111
|
+
guards=[auth_guard, Permission("manage-users")],
|
|
112
|
+
)
|
|
113
|
+
async def edit_user_roles(
|
|
114
|
+
self, request: Request, db_session: AsyncSession, user_id: UUID
|
|
115
|
+
) -> TemplateResponse:
|
|
116
|
+
"""Edit user roles form."""
|
|
117
|
+
ctx = await self._get_admin_context(request, db_session)
|
|
118
|
+
|
|
119
|
+
# Get the target user
|
|
120
|
+
result = await db_session.execute(
|
|
121
|
+
select(User)
|
|
122
|
+
.where(User.id == user_id)
|
|
123
|
+
.options(selectinload(User.roles))
|
|
124
|
+
)
|
|
125
|
+
target_user = result.scalar_one_or_none()
|
|
126
|
+
if not target_user:
|
|
127
|
+
raise NotAuthorizedException("User not found")
|
|
128
|
+
|
|
129
|
+
# Get user's current role names
|
|
130
|
+
current_roles = {role.name for role in target_user.roles}
|
|
131
|
+
|
|
132
|
+
flash = request.session.pop("flash", None)
|
|
133
|
+
return TemplateResponse(
|
|
134
|
+
"admin/users/roles.html",
|
|
135
|
+
context={
|
|
136
|
+
"flash": flash,
|
|
137
|
+
"target_user": target_user,
|
|
138
|
+
"current_roles": current_roles,
|
|
139
|
+
"available_roles": ROLE_DEFINITIONS,
|
|
140
|
+
**ctx,
|
|
141
|
+
},
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
@post(
|
|
145
|
+
"/users/{user_id:uuid}/roles",
|
|
146
|
+
guards=[auth_guard, Permission("manage-users")],
|
|
147
|
+
)
|
|
148
|
+
async def save_user_roles(
|
|
149
|
+
self,
|
|
150
|
+
request: Request,
|
|
151
|
+
db_session: AsyncSession,
|
|
152
|
+
user_id: UUID,
|
|
153
|
+
data: Annotated[dict, Body(media_type=RequestEncodingType.URL_ENCODED)],
|
|
154
|
+
) -> Redirect:
|
|
155
|
+
"""Save user roles."""
|
|
156
|
+
# Get selected roles from form
|
|
157
|
+
selected_roles = set()
|
|
158
|
+
for key in data:
|
|
159
|
+
if key.startswith("role_"):
|
|
160
|
+
role_name = key[5:] # Remove "role_" prefix
|
|
161
|
+
if data[key] == "on":
|
|
162
|
+
selected_roles.add(role_name)
|
|
163
|
+
|
|
164
|
+
# Get target user's current roles
|
|
165
|
+
result = await db_session.execute(
|
|
166
|
+
select(User)
|
|
167
|
+
.where(User.id == user_id)
|
|
168
|
+
.options(selectinload(User.roles))
|
|
169
|
+
)
|
|
170
|
+
target_user = result.scalar_one_or_none()
|
|
171
|
+
if not target_user:
|
|
172
|
+
request.session["flash"] = "User not found"
|
|
173
|
+
return Redirect(path="/admin/users")
|
|
174
|
+
|
|
175
|
+
current_roles = {role.name for role in target_user.roles}
|
|
176
|
+
|
|
177
|
+
# Add new roles
|
|
178
|
+
for role_name in selected_roles - current_roles:
|
|
179
|
+
await assign_role_to_user(db_session, user_id, role_name)
|
|
180
|
+
|
|
181
|
+
# Remove unchecked roles
|
|
182
|
+
for role_name in current_roles - selected_roles:
|
|
183
|
+
await remove_role_from_user(db_session, user_id, role_name)
|
|
184
|
+
|
|
185
|
+
# Invalidate cache for this user
|
|
186
|
+
invalidate_user_permissions_cache(user_id)
|
|
187
|
+
|
|
188
|
+
request.session["flash"] = f"Roles updated for {target_user.name or target_user.email}"
|
|
189
|
+
return Redirect(path="/admin/users")
|
|
190
|
+
|
|
191
|
+
@get(
|
|
192
|
+
"/pages",
|
|
193
|
+
tags=[ADMIN_NAV_TAG],
|
|
194
|
+
guards=[auth_guard, Permission("manage-pages")],
|
|
195
|
+
opt={"label": "Pages", "icon": "file-text", "order": 20},
|
|
196
|
+
)
|
|
197
|
+
async def list_pages(
|
|
198
|
+
self, request: Request, db_session: AsyncSession
|
|
199
|
+
) -> TemplateResponse:
|
|
200
|
+
"""List all pages with management actions."""
|
|
201
|
+
ctx = await self._get_admin_context(request, db_session)
|
|
202
|
+
|
|
203
|
+
# Get all pages with their authors
|
|
204
|
+
result = await db_session.execute(
|
|
205
|
+
select(Page)
|
|
206
|
+
.options(selectinload(Page.user))
|
|
207
|
+
.order_by(Page.created_at.desc())
|
|
208
|
+
)
|
|
209
|
+
pages = list(result.scalars().all())
|
|
210
|
+
|
|
211
|
+
flash = request.session.pop("flash", None)
|
|
212
|
+
return TemplateResponse(
|
|
213
|
+
"admin/pages/list.html",
|
|
214
|
+
context={"flash": flash, "pages": pages, **ctx},
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
@get(
|
|
218
|
+
"/pages/new",
|
|
219
|
+
guards=[auth_guard, Permission("manage-pages")],
|
|
220
|
+
)
|
|
221
|
+
async def new_page(
|
|
222
|
+
self, request: Request, db_session: AsyncSession
|
|
223
|
+
) -> TemplateResponse:
|
|
224
|
+
"""Show new page form."""
|
|
225
|
+
ctx = await self._get_admin_context(request, db_session)
|
|
226
|
+
flash = request.session.pop("flash", None)
|
|
227
|
+
return TemplateResponse(
|
|
228
|
+
"admin/pages/edit.html",
|
|
229
|
+
context={"flash": flash, "page": None, **ctx},
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
@post(
|
|
233
|
+
"/pages/new",
|
|
234
|
+
guards=[auth_guard, Permission("manage-pages")],
|
|
235
|
+
)
|
|
236
|
+
async def create_page(
|
|
237
|
+
self,
|
|
238
|
+
request: Request,
|
|
239
|
+
db_session: AsyncSession,
|
|
240
|
+
data: Annotated[dict, Body(media_type=RequestEncodingType.URL_ENCODED)],
|
|
241
|
+
) -> Redirect:
|
|
242
|
+
"""Create a new page."""
|
|
243
|
+
title = data.get("title", "").strip()
|
|
244
|
+
slug = data.get("slug", "").strip()
|
|
245
|
+
content = data.get("content", "").strip()
|
|
246
|
+
is_published = data.get("is_published") == "on"
|
|
247
|
+
|
|
248
|
+
if not title or not slug:
|
|
249
|
+
request.session["flash"] = "Title and slug are required"
|
|
250
|
+
return Redirect(path="/admin/pages/new")
|
|
251
|
+
|
|
252
|
+
published_at = datetime.now(UTC) if is_published else None
|
|
253
|
+
|
|
254
|
+
try:
|
|
255
|
+
await page_service.create_page(
|
|
256
|
+
db_session,
|
|
257
|
+
slug=slug,
|
|
258
|
+
title=title,
|
|
259
|
+
content=content,
|
|
260
|
+
is_published=is_published,
|
|
261
|
+
published_at=published_at,
|
|
262
|
+
)
|
|
263
|
+
request.session["flash"] = f"Page '{title}' created successfully!"
|
|
264
|
+
return Redirect(path="/admin/pages")
|
|
265
|
+
except Exception as e:
|
|
266
|
+
request.session["flash"] = f"Error creating page: {str(e)}"
|
|
267
|
+
return Redirect(path="/admin/pages/new")
|
|
268
|
+
|
|
269
|
+
@get(
|
|
270
|
+
"/pages/{page_id:uuid}/edit",
|
|
271
|
+
guards=[auth_guard, Permission("manage-pages")],
|
|
272
|
+
)
|
|
273
|
+
async def edit_page(
|
|
274
|
+
self, request: Request, db_session: AsyncSession, page_id: UUID
|
|
275
|
+
) -> TemplateResponse:
|
|
276
|
+
"""Show edit page form."""
|
|
277
|
+
ctx = await self._get_admin_context(request, db_session)
|
|
278
|
+
|
|
279
|
+
page = await page_service.get_page_by_id(db_session, page_id)
|
|
280
|
+
if not page:
|
|
281
|
+
request.session["flash"] = "Page not found"
|
|
282
|
+
return Redirect(path="/admin/pages")
|
|
283
|
+
|
|
284
|
+
flash = request.session.pop("flash", None)
|
|
285
|
+
return TemplateResponse(
|
|
286
|
+
"admin/pages/edit.html",
|
|
287
|
+
context={"flash": flash, "page": page, **ctx},
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
@post(
|
|
291
|
+
"/pages/{page_id:uuid}/edit",
|
|
292
|
+
guards=[auth_guard, Permission("manage-pages")],
|
|
293
|
+
)
|
|
294
|
+
async def update_page(
|
|
295
|
+
self,
|
|
296
|
+
request: Request,
|
|
297
|
+
db_session: AsyncSession,
|
|
298
|
+
page_id: UUID,
|
|
299
|
+
data: Annotated[dict, Body(media_type=RequestEncodingType.URL_ENCODED)],
|
|
300
|
+
) -> Redirect:
|
|
301
|
+
"""Update an existing page."""
|
|
302
|
+
title = data.get("title", "").strip()
|
|
303
|
+
slug = data.get("slug", "").strip()
|
|
304
|
+
content = data.get("content", "").strip()
|
|
305
|
+
is_published = data.get("is_published") == "on"
|
|
306
|
+
|
|
307
|
+
if not title or not slug:
|
|
308
|
+
request.session["flash"] = "Title and slug are required"
|
|
309
|
+
return Redirect(path=f"/admin/pages/{page_id}/edit")
|
|
310
|
+
|
|
311
|
+
page = await page_service.get_page_by_id(db_session, page_id)
|
|
312
|
+
if not page:
|
|
313
|
+
request.session["flash"] = "Page not found"
|
|
314
|
+
return Redirect(path="/admin/pages")
|
|
315
|
+
|
|
316
|
+
published_at = page.published_at
|
|
317
|
+
if is_published and not page.is_published:
|
|
318
|
+
published_at = datetime.now(UTC)
|
|
319
|
+
|
|
320
|
+
try:
|
|
321
|
+
await page_service.update_page(
|
|
322
|
+
db_session,
|
|
323
|
+
page_id=page_id,
|
|
324
|
+
slug=slug,
|
|
325
|
+
title=title,
|
|
326
|
+
content=content,
|
|
327
|
+
is_published=is_published,
|
|
328
|
+
published_at=published_at,
|
|
329
|
+
)
|
|
330
|
+
request.session["flash"] = f"Page '{title}' updated successfully!"
|
|
331
|
+
return Redirect(path="/admin/pages")
|
|
332
|
+
except Exception as e:
|
|
333
|
+
request.session["flash"] = f"Error updating page: {str(e)}"
|
|
334
|
+
return Redirect(path=f"/admin/pages/{page_id}/edit")
|
|
335
|
+
|
|
336
|
+
@post(
|
|
337
|
+
"/pages/{page_id:uuid}/publish",
|
|
338
|
+
guards=[auth_guard, Permission("manage-pages")],
|
|
339
|
+
)
|
|
340
|
+
async def publish_page(
|
|
341
|
+
self, request: Request, db_session: AsyncSession, page_id: UUID
|
|
342
|
+
) -> Redirect:
|
|
343
|
+
"""Publish a page."""
|
|
344
|
+
page = await page_service.get_page_by_id(db_session, page_id)
|
|
345
|
+
if not page:
|
|
346
|
+
request.session["flash"] = "Page not found"
|
|
347
|
+
return Redirect(path="/admin/pages")
|
|
348
|
+
|
|
349
|
+
await page_service.update_page(
|
|
350
|
+
db_session,
|
|
351
|
+
page_id=page_id,
|
|
352
|
+
is_published=True,
|
|
353
|
+
published_at=datetime.now(UTC),
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
request.session["flash"] = f"'{page.title}' has been published"
|
|
357
|
+
return Redirect(path="/admin/pages")
|
|
358
|
+
|
|
359
|
+
@post(
|
|
360
|
+
"/pages/{page_id:uuid}/unpublish",
|
|
361
|
+
guards=[auth_guard, Permission("manage-pages")],
|
|
362
|
+
)
|
|
363
|
+
async def unpublish_page(
|
|
364
|
+
self, request: Request, db_session: AsyncSession, page_id: UUID
|
|
365
|
+
) -> Redirect:
|
|
366
|
+
"""Unpublish a page."""
|
|
367
|
+
page = await page_service.get_page_by_id(db_session, page_id)
|
|
368
|
+
if not page:
|
|
369
|
+
request.session["flash"] = "Page not found"
|
|
370
|
+
return Redirect(path="/admin/pages")
|
|
371
|
+
|
|
372
|
+
await page_service.update_page(
|
|
373
|
+
db_session,
|
|
374
|
+
page_id=page_id,
|
|
375
|
+
is_published=False,
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
request.session["flash"] = f"'{page.title}' has been unpublished"
|
|
379
|
+
return Redirect(path="/admin/pages")
|
|
380
|
+
|
|
381
|
+
@post(
|
|
382
|
+
"/pages/{page_id:uuid}/delete",
|
|
383
|
+
guards=[auth_guard, Permission("manage-pages")],
|
|
384
|
+
)
|
|
385
|
+
async def delete_page(
|
|
386
|
+
self, request: Request, db_session: AsyncSession, page_id: UUID
|
|
387
|
+
) -> Redirect:
|
|
388
|
+
"""Delete a page."""
|
|
389
|
+
page = await page_service.get_page_by_id(db_session, page_id)
|
|
390
|
+
if not page:
|
|
391
|
+
request.session["flash"] = "Page not found"
|
|
392
|
+
return Redirect(path="/admin/pages")
|
|
393
|
+
|
|
394
|
+
page_title = page.title
|
|
395
|
+
await page_service.delete_page(db_session, page_id)
|
|
396
|
+
|
|
397
|
+
request.session["flash"] = f"'{page_title}' has been deleted"
|
|
398
|
+
return Redirect(path="/admin/pages")
|
|
399
|
+
|
|
400
|
+
@get(
|
|
401
|
+
"/settings",
|
|
402
|
+
tags=[ADMIN_NAV_TAG],
|
|
403
|
+
guards=[auth_guard, Permission("modify-site")],
|
|
404
|
+
opt={"label": "Settings", "icon": "settings", "order": 100},
|
|
405
|
+
)
|
|
406
|
+
async def site_settings(
|
|
407
|
+
self, request: Request, db_session: AsyncSession
|
|
408
|
+
) -> TemplateResponse:
|
|
409
|
+
"""Site settings page."""
|
|
410
|
+
ctx = await self._get_admin_context(request, db_session)
|
|
411
|
+
site_settings = await setting_service.get_site_settings(db_session)
|
|
412
|
+
|
|
413
|
+
flash = request.session.pop("flash", None)
|
|
414
|
+
return TemplateResponse(
|
|
415
|
+
"admin/settings/site.html",
|
|
416
|
+
context={"flash": flash, "settings": site_settings, **ctx},
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
@post(
|
|
420
|
+
"/settings",
|
|
421
|
+
guards=[auth_guard, Permission("modify-site")],
|
|
422
|
+
)
|
|
423
|
+
async def save_site_settings(
|
|
424
|
+
self,
|
|
425
|
+
request: Request,
|
|
426
|
+
db_session: AsyncSession,
|
|
427
|
+
data: Annotated[dict, Body(media_type=RequestEncodingType.URL_ENCODED)],
|
|
428
|
+
) -> Redirect:
|
|
429
|
+
"""Save site settings."""
|
|
430
|
+
site_name = data.get("site_name", "").strip()
|
|
431
|
+
site_tagline = data.get("site_tagline", "").strip()
|
|
432
|
+
site_copyright_holder = data.get("site_copyright_holder", "").strip()
|
|
433
|
+
site_copyright_start_year = data.get("site_copyright_start_year", "").strip()
|
|
434
|
+
|
|
435
|
+
await setting_service.set_setting(
|
|
436
|
+
db_session, setting_service.SITE_NAME_KEY, site_name
|
|
437
|
+
)
|
|
438
|
+
await setting_service.set_setting(
|
|
439
|
+
db_session, setting_service.SITE_TAGLINE_KEY, site_tagline
|
|
440
|
+
)
|
|
441
|
+
await setting_service.set_setting(
|
|
442
|
+
db_session, setting_service.SITE_COPYRIGHT_HOLDER_KEY, site_copyright_holder
|
|
443
|
+
)
|
|
444
|
+
await setting_service.set_setting(
|
|
445
|
+
db_session, setting_service.SITE_COPYRIGHT_START_YEAR_KEY, site_copyright_start_year
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
# Refresh the site settings cache
|
|
449
|
+
await setting_service.load_site_settings_cache(db_session)
|
|
450
|
+
|
|
451
|
+
request.session["flash"] = "Site settings saved successfully"
|
|
452
|
+
return Redirect(path="/admin/settings")
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Navigation service for building admin nav from route introspection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from litestar.routes import HTTPRoute
|
|
9
|
+
|
|
10
|
+
from skrift.auth.guards import AuthRequirement, OrRequirement, AndRequirement
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from litestar import Litestar
|
|
14
|
+
from skrift.auth.services import UserPermissions
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
ADMIN_NAV_TAG = "admin-nav"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class AdminNavItem:
|
|
22
|
+
"""Represents a navigation item in the admin sidebar."""
|
|
23
|
+
|
|
24
|
+
path: str
|
|
25
|
+
label: str
|
|
26
|
+
icon: str = "circle"
|
|
27
|
+
order: int = 100
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def check_requirement(
|
|
31
|
+
requirement: AuthRequirement, permissions: "UserPermissions"
|
|
32
|
+
) -> bool:
|
|
33
|
+
"""Recursively check if a requirement is satisfied."""
|
|
34
|
+
if isinstance(requirement, OrRequirement):
|
|
35
|
+
left_ok = await check_requirement(requirement.left, permissions)
|
|
36
|
+
right_ok = await check_requirement(requirement.right, permissions)
|
|
37
|
+
return left_ok or right_ok
|
|
38
|
+
elif isinstance(requirement, AndRequirement):
|
|
39
|
+
left_ok = await check_requirement(requirement.left, permissions)
|
|
40
|
+
right_ok = await check_requirement(requirement.right, permissions)
|
|
41
|
+
return left_ok and right_ok
|
|
42
|
+
else:
|
|
43
|
+
return await requirement.check(permissions)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def build_admin_nav(
|
|
47
|
+
app: "Litestar",
|
|
48
|
+
user_permissions: "UserPermissions",
|
|
49
|
+
current_path: str | None = None,
|
|
50
|
+
) -> list[AdminNavItem]:
|
|
51
|
+
"""Build admin navigation by introspecting routes.
|
|
52
|
+
|
|
53
|
+
Iterates through app routes to find handlers tagged with ADMIN_NAV_TAG,
|
|
54
|
+
extracts their permission guards, checks them against user permissions,
|
|
55
|
+
and returns accessible nav items sorted by order.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
app: The Litestar application instance
|
|
59
|
+
user_permissions: The current user's permissions
|
|
60
|
+
current_path: The current request path (for highlighting active nav)
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
List of AdminNavItem for routes the user can access
|
|
64
|
+
"""
|
|
65
|
+
nav_items: list[AdminNavItem] = []
|
|
66
|
+
|
|
67
|
+
for route in app.routes:
|
|
68
|
+
if not isinstance(route, HTTPRoute):
|
|
69
|
+
continue
|
|
70
|
+
|
|
71
|
+
for handler in route.route_handlers:
|
|
72
|
+
# Check if handler has the admin-nav tag
|
|
73
|
+
if not hasattr(handler, "tags") or ADMIN_NAV_TAG not in (handler.tags or []):
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
# Check if handler has opt metadata with label
|
|
77
|
+
opt = getattr(handler, "opt", {}) or {}
|
|
78
|
+
if "label" not in opt:
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
# Extract auth requirement guards
|
|
82
|
+
guards = handler.guards or []
|
|
83
|
+
auth_requirements = [g for g in guards if isinstance(g, AuthRequirement)]
|
|
84
|
+
|
|
85
|
+
# Check all requirements
|
|
86
|
+
can_access = True
|
|
87
|
+
for requirement in auth_requirements:
|
|
88
|
+
if not await check_requirement(requirement, user_permissions):
|
|
89
|
+
can_access = False
|
|
90
|
+
break
|
|
91
|
+
|
|
92
|
+
if can_access:
|
|
93
|
+
nav_items.append(
|
|
94
|
+
AdminNavItem(
|
|
95
|
+
path=route.path,
|
|
96
|
+
label=opt["label"],
|
|
97
|
+
icon=opt.get("icon", "circle"),
|
|
98
|
+
order=opt.get("order", 100),
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# Sort by order, then by label
|
|
103
|
+
nav_items.sort(key=lambda x: (x.order, x.label))
|
|
104
|
+
|
|
105
|
+
return nav_items
|
skrift/alembic/env.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Alembic environment configuration for async SQLAlchemy migrations."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from logging.config import fileConfig
|
|
5
|
+
|
|
6
|
+
from alembic import context
|
|
7
|
+
from sqlalchemy import pool
|
|
8
|
+
from sqlalchemy.engine import Connection
|
|
9
|
+
from sqlalchemy.ext.asyncio import async_engine_from_config
|
|
10
|
+
|
|
11
|
+
from skrift.config import get_settings
|
|
12
|
+
from skrift.db.base import Base
|
|
13
|
+
|
|
14
|
+
# Import all models to ensure they're registered with Base.metadata
|
|
15
|
+
from skrift.db.models.oauth_account import OAuthAccount # noqa: F401
|
|
16
|
+
from skrift.db.models.user import User # noqa: F401
|
|
17
|
+
from skrift.db.models.page import Page # noqa: F401
|
|
18
|
+
from skrift.db.models.role import Role, RolePermission # noqa: F401
|
|
19
|
+
|
|
20
|
+
# Alembic Config object
|
|
21
|
+
config = context.config
|
|
22
|
+
|
|
23
|
+
# Set up logging from alembic.ini
|
|
24
|
+
if config.config_file_name is not None:
|
|
25
|
+
fileConfig(config.config_file_name)
|
|
26
|
+
|
|
27
|
+
# Target metadata for 'autogenerate' support
|
|
28
|
+
target_metadata = Base.metadata
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_url() -> str:
|
|
32
|
+
"""Get database URL from settings or alembic.ini."""
|
|
33
|
+
try:
|
|
34
|
+
settings = get_settings()
|
|
35
|
+
return settings.db.url
|
|
36
|
+
except Exception:
|
|
37
|
+
# Fall back to alembic.ini config if settings can't be loaded
|
|
38
|
+
return config.get_main_option("sqlalchemy.url", "")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def run_migrations_offline() -> None:
|
|
42
|
+
"""Run migrations in 'offline' mode.
|
|
43
|
+
|
|
44
|
+
This configures the context with just a URL and not an Engine.
|
|
45
|
+
Calls to context.execute() emit the SQL to the script output.
|
|
46
|
+
"""
|
|
47
|
+
url = get_url()
|
|
48
|
+
context.configure(
|
|
49
|
+
url=url,
|
|
50
|
+
target_metadata=target_metadata,
|
|
51
|
+
literal_binds=True,
|
|
52
|
+
dialect_opts={"paramstyle": "named"},
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
with context.begin_transaction():
|
|
56
|
+
context.run_migrations()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def do_run_migrations(connection: Connection) -> None:
|
|
60
|
+
"""Run migrations within a connection context."""
|
|
61
|
+
context.configure(connection=connection, target_metadata=target_metadata)
|
|
62
|
+
|
|
63
|
+
with context.begin_transaction():
|
|
64
|
+
context.run_migrations()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def run_async_migrations() -> None:
|
|
68
|
+
"""Run migrations in 'online' mode with async engine."""
|
|
69
|
+
configuration = config.get_section(config.config_ini_section, {})
|
|
70
|
+
configuration["sqlalchemy.url"] = get_url()
|
|
71
|
+
|
|
72
|
+
connectable = async_engine_from_config(
|
|
73
|
+
configuration,
|
|
74
|
+
prefix="sqlalchemy.",
|
|
75
|
+
poolclass=pool.NullPool,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
async with connectable.connect() as connection:
|
|
79
|
+
await connection.run_sync(do_run_migrations)
|
|
80
|
+
|
|
81
|
+
await connectable.dispose()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def run_migrations_online() -> None:
|
|
85
|
+
"""Run migrations in 'online' mode."""
|
|
86
|
+
asyncio.run(run_async_migrations())
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
if context.is_offline_mode():
|
|
90
|
+
run_migrations_offline()
|
|
91
|
+
else:
|
|
92
|
+
run_migrations_online()
|