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.
Files changed (74) hide show
  1. skrift/__init__.py +1 -0
  2. skrift/__main__.py +12 -0
  3. skrift/admin/__init__.py +11 -0
  4. skrift/admin/controller.py +452 -0
  5. skrift/admin/navigation.py +105 -0
  6. skrift/alembic/env.py +92 -0
  7. skrift/alembic/script.py.mako +26 -0
  8. skrift/alembic/versions/20260120_210154_09b0364dbb7b_initial_schema.py +70 -0
  9. skrift/alembic/versions/20260122_152744_0b7c927d2591_add_roles_and_permissions.py +57 -0
  10. skrift/alembic/versions/20260122_172836_cdf734a5b847_add_sa_orm_sentinel_column.py +31 -0
  11. skrift/alembic/versions/20260122_175637_a9c55348eae7_remove_page_type_column.py +43 -0
  12. skrift/alembic/versions/20260122_200000_add_settings_table.py +38 -0
  13. skrift/alembic/versions/20260129_add_oauth_accounts.py +141 -0
  14. skrift/alembic/versions/20260129_add_provider_metadata.py +29 -0
  15. skrift/alembic.ini +77 -0
  16. skrift/asgi.py +670 -0
  17. skrift/auth/__init__.py +58 -0
  18. skrift/auth/guards.py +130 -0
  19. skrift/auth/roles.py +129 -0
  20. skrift/auth/services.py +184 -0
  21. skrift/cli.py +143 -0
  22. skrift/config.py +259 -0
  23. skrift/controllers/__init__.py +4 -0
  24. skrift/controllers/auth.py +595 -0
  25. skrift/controllers/web.py +67 -0
  26. skrift/db/__init__.py +3 -0
  27. skrift/db/base.py +7 -0
  28. skrift/db/models/__init__.py +7 -0
  29. skrift/db/models/oauth_account.py +50 -0
  30. skrift/db/models/page.py +26 -0
  31. skrift/db/models/role.py +56 -0
  32. skrift/db/models/setting.py +13 -0
  33. skrift/db/models/user.py +36 -0
  34. skrift/db/services/__init__.py +1 -0
  35. skrift/db/services/oauth_service.py +195 -0
  36. skrift/db/services/page_service.py +217 -0
  37. skrift/db/services/setting_service.py +206 -0
  38. skrift/lib/__init__.py +3 -0
  39. skrift/lib/exceptions.py +168 -0
  40. skrift/lib/template.py +108 -0
  41. skrift/setup/__init__.py +14 -0
  42. skrift/setup/config_writer.py +213 -0
  43. skrift/setup/controller.py +888 -0
  44. skrift/setup/middleware.py +89 -0
  45. skrift/setup/providers.py +214 -0
  46. skrift/setup/state.py +315 -0
  47. skrift/static/css/style.css +1003 -0
  48. skrift/templates/admin/admin.html +19 -0
  49. skrift/templates/admin/base.html +24 -0
  50. skrift/templates/admin/pages/edit.html +32 -0
  51. skrift/templates/admin/pages/list.html +62 -0
  52. skrift/templates/admin/settings/site.html +32 -0
  53. skrift/templates/admin/users/list.html +58 -0
  54. skrift/templates/admin/users/roles.html +42 -0
  55. skrift/templates/auth/dummy_login.html +102 -0
  56. skrift/templates/auth/login.html +139 -0
  57. skrift/templates/base.html +52 -0
  58. skrift/templates/error-404.html +19 -0
  59. skrift/templates/error-500.html +19 -0
  60. skrift/templates/error.html +19 -0
  61. skrift/templates/index.html +9 -0
  62. skrift/templates/page.html +26 -0
  63. skrift/templates/setup/admin.html +24 -0
  64. skrift/templates/setup/auth.html +110 -0
  65. skrift/templates/setup/base.html +407 -0
  66. skrift/templates/setup/complete.html +17 -0
  67. skrift/templates/setup/configuring.html +158 -0
  68. skrift/templates/setup/database.html +125 -0
  69. skrift/templates/setup/restart.html +28 -0
  70. skrift/templates/setup/site.html +39 -0
  71. skrift-0.1.0a12.dist-info/METADATA +235 -0
  72. skrift-0.1.0a12.dist-info/RECORD +74 -0
  73. skrift-0.1.0a12.dist-info/WHEEL +4 -0
  74. 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
@@ -0,0 +1,12 @@
1
+ """Entry point for the skrift package."""
2
+
3
+ from skrift.cli import cli
4
+
5
+
6
+ def main():
7
+ """Run the Skrift CLI."""
8
+ cli()
9
+
10
+
11
+ if __name__ == "__main__":
12
+ main()
@@ -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()