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
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""OAuth account model for storing multiple OAuth identities per user."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
|
|
6
|
+
from sqlalchemy import ForeignKey, JSON, String, UniqueConstraint
|
|
7
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
8
|
+
|
|
9
|
+
from skrift.db.base import Base
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from skrift.db.models.user import User
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class OAuthAccount(Base):
|
|
16
|
+
"""OAuth account model linking OAuth provider identities to users.
|
|
17
|
+
|
|
18
|
+
This allows a single user to have multiple OAuth provider accounts
|
|
19
|
+
linked to their profile, enabling login via different providers.
|
|
20
|
+
|
|
21
|
+
The provider_metadata column stores the full raw OAuth provider response,
|
|
22
|
+
which varies by provider:
|
|
23
|
+
|
|
24
|
+
- Discord: id, username, global_name, discriminator, avatar, email, verified, locale
|
|
25
|
+
- GitHub: id, login, name, email, avatar_url, bio, company, location, public_repos
|
|
26
|
+
- Google: id, email, name, picture, verified_email, locale, hd
|
|
27
|
+
- Twitter: id, username, name
|
|
28
|
+
- Microsoft: id, displayName, mail, userPrincipalName
|
|
29
|
+
- Facebook: id, name, email, picture.data.url
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
__tablename__ = "oauth_accounts"
|
|
33
|
+
|
|
34
|
+
provider: Mapped[str] = mapped_column(String(50), nullable=False)
|
|
35
|
+
provider_account_id: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
36
|
+
provider_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
37
|
+
provider_metadata: Mapped[dict[str, Any] | None] = mapped_column(
|
|
38
|
+
JSON, nullable=True, default=None
|
|
39
|
+
)
|
|
40
|
+
user_id: Mapped[UUID] = mapped_column(
|
|
41
|
+
ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
user: Mapped["User"] = relationship("User", back_populates="oauth_accounts")
|
|
45
|
+
|
|
46
|
+
__table_args__ = (
|
|
47
|
+
UniqueConstraint(
|
|
48
|
+
"provider", "provider_account_id", name="uq_oauth_provider_account"
|
|
49
|
+
),
|
|
50
|
+
)
|
skrift/db/models/page.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from uuid import UUID
|
|
3
|
+
|
|
4
|
+
from sqlalchemy import String, Text, Boolean, DateTime, ForeignKey
|
|
5
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
6
|
+
|
|
7
|
+
from skrift.db.base import Base
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Page(Base):
|
|
11
|
+
"""Page model for content management."""
|
|
12
|
+
|
|
13
|
+
__tablename__ = "pages"
|
|
14
|
+
|
|
15
|
+
# Author relationship (optional - pages may not have an author)
|
|
16
|
+
user_id: Mapped[UUID | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
|
|
17
|
+
user: Mapped["User"] = relationship("User", back_populates="pages")
|
|
18
|
+
|
|
19
|
+
# Content fields
|
|
20
|
+
slug: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, index=True)
|
|
21
|
+
title: Mapped[str] = mapped_column(String(500), nullable=False)
|
|
22
|
+
content: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
|
23
|
+
|
|
24
|
+
# Publication fields
|
|
25
|
+
is_published: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
|
26
|
+
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
skrift/db/models/role.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Role and permission database models."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
|
|
6
|
+
from sqlalchemy import Column, ForeignKey, String, Table, UniqueConstraint
|
|
7
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
8
|
+
|
|
9
|
+
from skrift.db.base import Base
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from skrift.db.models.user import User
|
|
13
|
+
|
|
14
|
+
# Association table for many-to-many relationship between users and roles
|
|
15
|
+
user_roles = Table(
|
|
16
|
+
"user_roles",
|
|
17
|
+
Base.metadata,
|
|
18
|
+
Column("user_id", ForeignKey("users.id", ondelete="CASCADE"), primary_key=True),
|
|
19
|
+
Column("role_id", ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True),
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Role(Base):
|
|
24
|
+
"""Role model for user authorization."""
|
|
25
|
+
|
|
26
|
+
__tablename__ = "roles"
|
|
27
|
+
|
|
28
|
+
name: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
|
|
29
|
+
display_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
|
30
|
+
description: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
|
31
|
+
|
|
32
|
+
# Relationships
|
|
33
|
+
users: Mapped[list["User"]] = relationship(
|
|
34
|
+
"User", secondary=user_roles, back_populates="roles"
|
|
35
|
+
)
|
|
36
|
+
permissions: Mapped[list["RolePermission"]] = relationship(
|
|
37
|
+
"RolePermission", back_populates="role", cascade="all, delete-orphan"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class RolePermission(Base):
|
|
42
|
+
"""Permission associated with a role."""
|
|
43
|
+
|
|
44
|
+
__tablename__ = "role_permissions"
|
|
45
|
+
|
|
46
|
+
role_id: Mapped[UUID] = mapped_column(
|
|
47
|
+
ForeignKey("roles.id", ondelete="CASCADE"), nullable=False
|
|
48
|
+
)
|
|
49
|
+
permission: Mapped[str] = mapped_column(String(100), nullable=False)
|
|
50
|
+
|
|
51
|
+
# Relationships
|
|
52
|
+
role: Mapped["Role"] = relationship("Role", back_populates="permissions")
|
|
53
|
+
|
|
54
|
+
__table_args__ = (
|
|
55
|
+
UniqueConstraint("role_id", "permission", name="uq_role_permission"),
|
|
56
|
+
)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from sqlalchemy import String, Text
|
|
2
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
3
|
+
|
|
4
|
+
from skrift.db.base import Base
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Setting(Base):
|
|
8
|
+
"""Key-value setting storage for site configuration."""
|
|
9
|
+
|
|
10
|
+
__tablename__ = "settings"
|
|
11
|
+
|
|
12
|
+
key: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, index=True)
|
|
13
|
+
value: Mapped[str | None] = mapped_column(Text, nullable=True)
|
skrift/db/models/user.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import TYPE_CHECKING
|
|
3
|
+
|
|
4
|
+
from sqlalchemy import String, Boolean, DateTime
|
|
5
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
6
|
+
|
|
7
|
+
from skrift.db.base import Base
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from skrift.db.models.oauth_account import OAuthAccount
|
|
11
|
+
from skrift.db.models.page import Page
|
|
12
|
+
from skrift.db.models.role import Role
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class User(Base):
|
|
16
|
+
"""User model for OAuth authentication."""
|
|
17
|
+
|
|
18
|
+
__tablename__ = "users"
|
|
19
|
+
|
|
20
|
+
# Profile data from OAuth provider
|
|
21
|
+
email: Mapped[str | None] = mapped_column(String(255), nullable=True, unique=True)
|
|
22
|
+
name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
23
|
+
picture_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
|
24
|
+
|
|
25
|
+
# Application fields
|
|
26
|
+
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
|
27
|
+
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
28
|
+
|
|
29
|
+
# Relationships
|
|
30
|
+
oauth_accounts: Mapped[list["OAuthAccount"]] = relationship(
|
|
31
|
+
"OAuthAccount", back_populates="user", cascade="all, delete-orphan"
|
|
32
|
+
)
|
|
33
|
+
pages: Mapped[list["Page"]] = relationship("Page", back_populates="user")
|
|
34
|
+
roles: Mapped[list["Role"]] = relationship(
|
|
35
|
+
"Role", secondary="user_roles", back_populates="users", lazy="selectin"
|
|
36
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Database service layer for business logic and CRUD operations."""
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""OAuth service for accessing OAuth account data and provider metadata."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
|
|
6
|
+
from sqlalchemy import select
|
|
7
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
8
|
+
|
|
9
|
+
from skrift.db.models.oauth_account import OAuthAccount
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def get_oauth_account_by_user_and_provider(
|
|
13
|
+
db_session: AsyncSession,
|
|
14
|
+
user_id: UUID,
|
|
15
|
+
provider: str,
|
|
16
|
+
) -> OAuthAccount | None:
|
|
17
|
+
"""Get a specific OAuth account for a user and provider.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
db_session: Database session
|
|
21
|
+
user_id: User UUID
|
|
22
|
+
provider: OAuth provider name (e.g., 'discord', 'github')
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
OAuthAccount or None if not found
|
|
26
|
+
"""
|
|
27
|
+
result = await db_session.execute(
|
|
28
|
+
select(OAuthAccount).where(
|
|
29
|
+
OAuthAccount.user_id == user_id,
|
|
30
|
+
OAuthAccount.provider == provider,
|
|
31
|
+
)
|
|
32
|
+
)
|
|
33
|
+
return result.scalar_one_or_none()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def get_oauth_accounts_by_user(
|
|
37
|
+
db_session: AsyncSession,
|
|
38
|
+
user_id: UUID,
|
|
39
|
+
) -> list[OAuthAccount]:
|
|
40
|
+
"""Get all OAuth accounts linked to a user.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
db_session: Database session
|
|
44
|
+
user_id: User UUID
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
List of OAuthAccount objects
|
|
48
|
+
"""
|
|
49
|
+
result = await db_session.execute(
|
|
50
|
+
select(OAuthAccount).where(OAuthAccount.user_id == user_id)
|
|
51
|
+
)
|
|
52
|
+
return list(result.scalars().all())
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def get_provider_metadata(
|
|
56
|
+
db_session: AsyncSession,
|
|
57
|
+
user_id: UUID,
|
|
58
|
+
provider: str,
|
|
59
|
+
) -> dict[str, Any] | None:
|
|
60
|
+
"""Get the raw provider metadata for a user's OAuth account.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
db_session: Database session
|
|
64
|
+
user_id: User UUID
|
|
65
|
+
provider: OAuth provider name
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Provider metadata dict or None if not found
|
|
69
|
+
"""
|
|
70
|
+
oauth_account = await get_oauth_account_by_user_and_provider(
|
|
71
|
+
db_session, user_id, provider
|
|
72
|
+
)
|
|
73
|
+
if oauth_account:
|
|
74
|
+
return oauth_account.provider_metadata
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def extract_metadata_field(
|
|
79
|
+
metadata: dict[str, Any] | None,
|
|
80
|
+
*keys: str,
|
|
81
|
+
default: Any = None,
|
|
82
|
+
) -> Any:
|
|
83
|
+
"""Safely extract a nested field from metadata.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
metadata: Provider metadata dict
|
|
87
|
+
*keys: Sequence of keys for nested access (e.g., 'picture', 'data', 'url')
|
|
88
|
+
default: Default value if field not found
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Field value or default
|
|
92
|
+
"""
|
|
93
|
+
if metadata is None:
|
|
94
|
+
return default
|
|
95
|
+
|
|
96
|
+
current = metadata
|
|
97
|
+
for key in keys:
|
|
98
|
+
if isinstance(current, dict) and key in current:
|
|
99
|
+
current = current[key]
|
|
100
|
+
else:
|
|
101
|
+
return default
|
|
102
|
+
return current
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
async def get_provider_username(
|
|
106
|
+
db_session: AsyncSession,
|
|
107
|
+
user_id: UUID,
|
|
108
|
+
provider: str,
|
|
109
|
+
) -> str | None:
|
|
110
|
+
"""Get the username from a provider's metadata.
|
|
111
|
+
|
|
112
|
+
Provider-specific username fields:
|
|
113
|
+
- Discord: username
|
|
114
|
+
- GitHub: login
|
|
115
|
+
- Twitter: username
|
|
116
|
+
- Google: email (no username concept)
|
|
117
|
+
- Microsoft: userPrincipalName
|
|
118
|
+
- Facebook: name (no username concept)
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
db_session: Database session
|
|
122
|
+
user_id: User UUID
|
|
123
|
+
provider: OAuth provider name
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
Username string or None
|
|
127
|
+
"""
|
|
128
|
+
metadata = await get_provider_metadata(db_session, user_id, provider)
|
|
129
|
+
if metadata is None:
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
# Provider-specific username extraction
|
|
133
|
+
username_fields = {
|
|
134
|
+
"discord": "username",
|
|
135
|
+
"github": "login",
|
|
136
|
+
"twitter": "username",
|
|
137
|
+
"microsoft": "userPrincipalName",
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
field = username_fields.get(provider)
|
|
141
|
+
if field:
|
|
142
|
+
return extract_metadata_field(metadata, field)
|
|
143
|
+
|
|
144
|
+
# Fallback for providers without usernames
|
|
145
|
+
return extract_metadata_field(metadata, "email") or extract_metadata_field(
|
|
146
|
+
metadata, "name"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
async def get_provider_avatar_url(
|
|
151
|
+
db_session: AsyncSession,
|
|
152
|
+
user_id: UUID,
|
|
153
|
+
provider: str,
|
|
154
|
+
) -> str | None:
|
|
155
|
+
"""Get the avatar URL from a provider's metadata.
|
|
156
|
+
|
|
157
|
+
Provider-specific avatar URL construction:
|
|
158
|
+
- Discord: Constructed from id + avatar hash
|
|
159
|
+
- GitHub: avatar_url
|
|
160
|
+
- Google: picture
|
|
161
|
+
- Microsoft: No direct URL (requires Graph API call)
|
|
162
|
+
- Facebook: picture.data.url
|
|
163
|
+
- Twitter: No avatar in basic userinfo
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
db_session: Database session
|
|
167
|
+
user_id: User UUID
|
|
168
|
+
provider: OAuth provider name
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
Avatar URL string or None
|
|
172
|
+
"""
|
|
173
|
+
metadata = await get_provider_metadata(db_session, user_id, provider)
|
|
174
|
+
if metadata is None:
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
if provider == "discord":
|
|
178
|
+
# Discord avatar URL must be constructed
|
|
179
|
+
user_id_discord = extract_metadata_field(metadata, "id")
|
|
180
|
+
avatar_hash = extract_metadata_field(metadata, "avatar")
|
|
181
|
+
if user_id_discord and avatar_hash:
|
|
182
|
+
return f"https://cdn.discordapp.com/avatars/{user_id_discord}/{avatar_hash}.png"
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
if provider == "github":
|
|
186
|
+
return extract_metadata_field(metadata, "avatar_url")
|
|
187
|
+
|
|
188
|
+
if provider == "google":
|
|
189
|
+
return extract_metadata_field(metadata, "picture")
|
|
190
|
+
|
|
191
|
+
if provider == "facebook":
|
|
192
|
+
return extract_metadata_field(metadata, "picture", "data", "url")
|
|
193
|
+
|
|
194
|
+
# Microsoft and Twitter don't provide direct avatar URLs
|
|
195
|
+
return None
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Page service for CRUD operations on pages."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
|
|
6
|
+
from sqlalchemy import select, and_
|
|
7
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
8
|
+
|
|
9
|
+
from skrift.db.models import Page
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def list_pages(
|
|
13
|
+
db_session: AsyncSession,
|
|
14
|
+
published_only: bool = False,
|
|
15
|
+
user_id: UUID | None = None,
|
|
16
|
+
limit: int | None = None,
|
|
17
|
+
offset: int = 0,
|
|
18
|
+
) -> list[Page]:
|
|
19
|
+
"""List pages with optional filtering.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
db_session: Database session
|
|
23
|
+
published_only: Only return published pages
|
|
24
|
+
user_id: Filter by user ID (author)
|
|
25
|
+
limit: Maximum number of results
|
|
26
|
+
offset: Number of results to skip
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
List of Page objects
|
|
30
|
+
"""
|
|
31
|
+
query = select(Page)
|
|
32
|
+
|
|
33
|
+
# Build filters
|
|
34
|
+
filters = []
|
|
35
|
+
if published_only:
|
|
36
|
+
filters.append(Page.is_published == True)
|
|
37
|
+
if user_id:
|
|
38
|
+
filters.append(Page.user_id == user_id)
|
|
39
|
+
|
|
40
|
+
if filters:
|
|
41
|
+
query = query.where(and_(*filters))
|
|
42
|
+
|
|
43
|
+
# Order by published date (newest first), then created date
|
|
44
|
+
query = query.order_by(Page.published_at.desc().nullslast(), Page.created_at.desc())
|
|
45
|
+
|
|
46
|
+
# Apply pagination
|
|
47
|
+
if offset:
|
|
48
|
+
query = query.offset(offset)
|
|
49
|
+
if limit:
|
|
50
|
+
query = query.limit(limit)
|
|
51
|
+
|
|
52
|
+
result = await db_session.execute(query)
|
|
53
|
+
return list(result.scalars().all())
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def get_page_by_slug(
|
|
57
|
+
db_session: AsyncSession,
|
|
58
|
+
slug: str,
|
|
59
|
+
published_only: bool = False,
|
|
60
|
+
) -> Page | None:
|
|
61
|
+
"""Get a single page by slug.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
db_session: Database session
|
|
65
|
+
slug: Page slug
|
|
66
|
+
published_only: Only return if published
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Page object or None if not found
|
|
70
|
+
"""
|
|
71
|
+
query = select(Page).where(Page.slug == slug)
|
|
72
|
+
|
|
73
|
+
if published_only:
|
|
74
|
+
query = query.where(Page.is_published == True)
|
|
75
|
+
|
|
76
|
+
result = await db_session.execute(query)
|
|
77
|
+
return result.scalar_one_or_none()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def get_page_by_id(
|
|
81
|
+
db_session: AsyncSession,
|
|
82
|
+
page_id: UUID,
|
|
83
|
+
) -> Page | None:
|
|
84
|
+
"""Get a single page by ID.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
db_session: Database session
|
|
88
|
+
page_id: Page UUID
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Page object or None if not found
|
|
92
|
+
"""
|
|
93
|
+
result = await db_session.execute(select(Page).where(Page.id == page_id))
|
|
94
|
+
return result.scalar_one_or_none()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def create_page(
|
|
98
|
+
db_session: AsyncSession,
|
|
99
|
+
slug: str,
|
|
100
|
+
title: str,
|
|
101
|
+
content: str = "",
|
|
102
|
+
is_published: bool = False,
|
|
103
|
+
published_at: datetime | None = None,
|
|
104
|
+
user_id: UUID | None = None,
|
|
105
|
+
) -> Page:
|
|
106
|
+
"""Create a new page.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
db_session: Database session
|
|
110
|
+
slug: Unique page slug
|
|
111
|
+
title: Page title
|
|
112
|
+
content: Page content (HTML or markdown)
|
|
113
|
+
is_published: Whether page is published
|
|
114
|
+
published_at: Publication timestamp
|
|
115
|
+
user_id: Author user ID (optional)
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
Created Page object
|
|
119
|
+
"""
|
|
120
|
+
page = Page(
|
|
121
|
+
slug=slug,
|
|
122
|
+
title=title,
|
|
123
|
+
content=content,
|
|
124
|
+
is_published=is_published,
|
|
125
|
+
published_at=published_at,
|
|
126
|
+
user_id=user_id,
|
|
127
|
+
)
|
|
128
|
+
db_session.add(page)
|
|
129
|
+
await db_session.commit()
|
|
130
|
+
await db_session.refresh(page)
|
|
131
|
+
return page
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
async def update_page(
|
|
135
|
+
db_session: AsyncSession,
|
|
136
|
+
page_id: UUID,
|
|
137
|
+
slug: str | None = None,
|
|
138
|
+
title: str | None = None,
|
|
139
|
+
content: str | None = None,
|
|
140
|
+
is_published: bool | None = None,
|
|
141
|
+
published_at: datetime | None = None,
|
|
142
|
+
) -> Page | None:
|
|
143
|
+
"""Update an existing page.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
db_session: Database session
|
|
147
|
+
page_id: Page UUID to update
|
|
148
|
+
slug: New slug (optional)
|
|
149
|
+
title: New title (optional)
|
|
150
|
+
content: New content (optional)
|
|
151
|
+
is_published: New published status (optional)
|
|
152
|
+
published_at: New publication timestamp (optional)
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
Updated Page object or None if not found
|
|
156
|
+
"""
|
|
157
|
+
page = await get_page_by_id(db_session, page_id)
|
|
158
|
+
if not page:
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
if slug is not None:
|
|
162
|
+
page.slug = slug
|
|
163
|
+
if title is not None:
|
|
164
|
+
page.title = title
|
|
165
|
+
if content is not None:
|
|
166
|
+
page.content = content
|
|
167
|
+
if is_published is not None:
|
|
168
|
+
page.is_published = is_published
|
|
169
|
+
if published_at is not None:
|
|
170
|
+
page.published_at = published_at
|
|
171
|
+
|
|
172
|
+
await db_session.commit()
|
|
173
|
+
await db_session.refresh(page)
|
|
174
|
+
return page
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
async def delete_page(
|
|
178
|
+
db_session: AsyncSession,
|
|
179
|
+
page_id: UUID,
|
|
180
|
+
) -> bool:
|
|
181
|
+
"""Delete a page.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
db_session: Database session
|
|
185
|
+
page_id: Page UUID to delete
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
True if deleted, False if not found
|
|
189
|
+
"""
|
|
190
|
+
page = await get_page_by_id(db_session, page_id)
|
|
191
|
+
if not page:
|
|
192
|
+
return False
|
|
193
|
+
|
|
194
|
+
await db_session.delete(page)
|
|
195
|
+
await db_session.commit()
|
|
196
|
+
return True
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
async def check_page_ownership(
|
|
200
|
+
db_session: AsyncSession,
|
|
201
|
+
page_id: UUID,
|
|
202
|
+
user_id: UUID,
|
|
203
|
+
) -> bool:
|
|
204
|
+
"""Check if a user owns a page.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
db_session: Database session
|
|
208
|
+
page_id: Page UUID to check
|
|
209
|
+
user_id: User UUID to check ownership
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
True if user owns the page, False otherwise
|
|
213
|
+
"""
|
|
214
|
+
page = await get_page_by_id(db_session, page_id)
|
|
215
|
+
if not page:
|
|
216
|
+
return False
|
|
217
|
+
return page.user_id == user_id
|