skrift 0.1.0a2__py3-none-any.whl → 0.1.0a4__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/alembic/env.py CHANGED
@@ -12,6 +12,7 @@ from skrift.config import get_settings
12
12
  from skrift.db.base import Base
13
13
 
14
14
  # Import all models to ensure they're registered with Base.metadata
15
+ from skrift.db.models.oauth_account import OAuthAccount # noqa: F401
15
16
  from skrift.db.models.user import User # noqa: F401
16
17
  from skrift.db.models.page import Page # noqa: F401
17
18
  from skrift.db.models.role import Role, RolePermission # noqa: F401
@@ -31,7 +32,7 @@ def get_url() -> str:
31
32
  """Get database URL from settings or alembic.ini."""
32
33
  try:
33
34
  settings = get_settings()
34
- return settings.database_url
35
+ return settings.db.url
35
36
  except Exception:
36
37
  # Fall back to alembic.ini config if settings can't be loaded
37
38
  return config.get_main_option("sqlalchemy.url", "")
@@ -0,0 +1,134 @@
1
+ """add oauth_accounts table
2
+
3
+ Revision ID: 1a2b3c4d5e6f
4
+ Revises: 8f3a5c2d1e0b
5
+ Create Date: 2026-01-29 10:00:00.000000
6
+
7
+ This migration:
8
+ 1. Creates the oauth_accounts table to store multiple OAuth identities per user
9
+ 2. Migrates existing oauth data from users table to oauth_accounts
10
+ 3. Makes users.email nullable (for providers like Twitter that don't provide email)
11
+ 4. Removes oauth_provider and oauth_id columns from users table
12
+ """
13
+ from typing import Sequence, Union
14
+
15
+ from alembic import op
16
+ import sqlalchemy as sa
17
+ from advanced_alchemy.types import GUID, DateTimeUTC
18
+
19
+
20
+ # revision identifiers, used by Alembic.
21
+ revision: str = '1a2b3c4d5e6f'
22
+ down_revision: Union[str, None] = '8f3a5c2d1e0b'
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
+ # Step 1: Create oauth_accounts table
29
+ op.create_table(
30
+ 'oauth_accounts',
31
+ sa.Column('id', GUID(length=16), nullable=False),
32
+ sa.Column('created_at', DateTimeUTC(timezone=True), nullable=False),
33
+ sa.Column('updated_at', DateTimeUTC(timezone=True), nullable=False),
34
+ sa.Column('sa_orm_sentinel', sa.Integer(), nullable=True),
35
+ sa.Column('provider', sa.String(length=50), nullable=False),
36
+ sa.Column('provider_account_id', sa.String(length=255), nullable=False),
37
+ sa.Column('provider_email', sa.String(length=255), nullable=True),
38
+ sa.Column('user_id', GUID(length=16), nullable=False),
39
+ sa.PrimaryKeyConstraint('id', name=op.f('pk_oauth_accounts')),
40
+ sa.ForeignKeyConstraint(
41
+ ['user_id'], ['users.id'],
42
+ name=op.f('fk_oauth_accounts_user_id_users'),
43
+ ondelete='CASCADE'
44
+ ),
45
+ sa.UniqueConstraint(
46
+ 'provider', 'provider_account_id',
47
+ name='uq_oauth_provider_account'
48
+ ),
49
+ )
50
+ op.create_index(
51
+ op.f('ix_oauth_accounts_user_id'),
52
+ 'oauth_accounts',
53
+ ['user_id'],
54
+ unique=False
55
+ )
56
+ op.create_index(
57
+ op.f('ix_oauth_accounts_provider_account'),
58
+ 'oauth_accounts',
59
+ ['provider', 'provider_account_id'],
60
+ unique=True
61
+ )
62
+
63
+ # Step 2: Migrate existing data from users to oauth_accounts
64
+ # Generate binary UUIDs (16 bytes) for new records and copy oauth data
65
+ conn = op.get_bind()
66
+ conn.execute(sa.text("""
67
+ INSERT INTO oauth_accounts (id, created_at, updated_at, provider, provider_account_id, provider_email, user_id)
68
+ SELECT
69
+ randomblob(16),
70
+ created_at,
71
+ updated_at,
72
+ oauth_provider,
73
+ oauth_id,
74
+ email,
75
+ id
76
+ FROM users
77
+ WHERE oauth_provider IS NOT NULL AND oauth_id IS NOT NULL
78
+ """))
79
+
80
+ # Step 3: Make email nullable on users table
81
+ # SQLite doesn't support ALTER COLUMN, so we need to recreate the table
82
+ # For SQLite, we'll use batch_alter_table
83
+ with op.batch_alter_table('users', schema=None) as batch_op:
84
+ # Drop the unique constraint on oauth_id
85
+ batch_op.drop_constraint('uq_users_oauth_id', type_='unique')
86
+ # Drop the oauth columns
87
+ batch_op.drop_column('oauth_provider')
88
+ batch_op.drop_column('oauth_id')
89
+ # Make email nullable - this requires recreating the column in SQLite
90
+ batch_op.alter_column('email',
91
+ existing_type=sa.String(length=255),
92
+ nullable=True)
93
+
94
+
95
+ def downgrade() -> None:
96
+ # Step 1: Add back oauth columns to users table
97
+ with op.batch_alter_table('users', schema=None) as batch_op:
98
+ batch_op.add_column(sa.Column('oauth_provider', sa.String(length=50), nullable=True))
99
+ batch_op.add_column(sa.Column('oauth_id', sa.String(length=255), nullable=True))
100
+ batch_op.alter_column('email',
101
+ existing_type=sa.String(length=255),
102
+ nullable=False)
103
+
104
+ # Step 2: Migrate data back from oauth_accounts to users
105
+ # Only migrate the first oauth account per user
106
+ conn = op.get_bind()
107
+ conn.execute(sa.text("""
108
+ UPDATE users
109
+ SET oauth_provider = (
110
+ SELECT provider FROM oauth_accounts
111
+ WHERE oauth_accounts.user_id = users.id
112
+ LIMIT 1
113
+ ),
114
+ oauth_id = (
115
+ SELECT provider_account_id FROM oauth_accounts
116
+ WHERE oauth_accounts.user_id = users.id
117
+ LIMIT 1
118
+ )
119
+ """))
120
+
121
+ # Step 3: Make oauth columns non-nullable and add unique constraint
122
+ with op.batch_alter_table('users', schema=None) as batch_op:
123
+ batch_op.alter_column('oauth_provider',
124
+ existing_type=sa.String(length=50),
125
+ nullable=False)
126
+ batch_op.alter_column('oauth_id',
127
+ existing_type=sa.String(length=255),
128
+ nullable=False)
129
+ batch_op.create_unique_constraint('uq_users_oauth_id', ['oauth_id'])
130
+
131
+ # Step 4: Drop oauth_accounts table
132
+ op.drop_index(op.f('ix_oauth_accounts_provider_account'), table_name='oauth_accounts')
133
+ op.drop_index(op.f('ix_oauth_accounts_user_id'), table_name='oauth_accounts')
134
+ op.drop_table('oauth_accounts')
skrift/alembic.ini CHANGED
@@ -1,8 +1,8 @@
1
1
  # Alembic Configuration File
2
2
 
3
3
  [alembic]
4
- # Path to migration scripts
5
- script_location = alembic
4
+ # Path to migration scripts (relative to this file)
5
+ script_location = %(here)s/alembic
6
6
 
7
7
  # Template used to generate migration files
8
8
  file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d%%(second).2d_%%(rev)s_%%(slug)s
skrift/cli.py CHANGED
@@ -19,24 +19,33 @@ def db() -> None:
19
19
  """
20
20
  from alembic.config import main as alembic_main
21
21
 
22
- # Ensure we're running from the project root where alembic.ini is located
23
- # If alembic.ini is not in cwd, check common locations
24
- alembic_ini = Path.cwd() / "alembic.ini"
25
-
22
+ import os
23
+
24
+ # Always run from the project root (where app.yaml and .env are)
25
+ # This ensures database paths like ./app.db resolve correctly
26
+ project_root = Path.cwd()
27
+ if not (project_root / "app.yaml").exists():
28
+ # If not in project root, try parent directory
29
+ project_root = Path(__file__).parent.parent
30
+ os.chdir(project_root)
31
+
32
+ # Find alembic.ini - check project root first, then skrift package directory
33
+ alembic_ini = project_root / "alembic.ini"
26
34
  if not alembic_ini.exists():
27
- # Try to find alembic.ini relative to this module
28
- module_dir = Path(__file__).parent.parent
29
- alembic_ini = module_dir / "alembic.ini"
30
-
31
- if alembic_ini.exists():
32
- # Change to the directory containing alembic.ini
33
- import os
34
- os.chdir(module_dir)
35
- else:
35
+ skrift_dir = Path(__file__).parent
36
+ alembic_ini = skrift_dir / "alembic.ini"
37
+
38
+ if not alembic_ini.exists():
36
39
  print("Error: Could not find alembic.ini", file=sys.stderr)
37
40
  print("Make sure you're running from the project root directory.", file=sys.stderr)
38
41
  sys.exit(1)
39
42
 
43
+ # Build argv with config path at the beginning (before any subcommand)
44
+ # Original argv: ['skrift-db', 'upgrade', 'head']
45
+ # New argv: ['skrift-db', '-c', '/path/to/alembic.ini', 'upgrade', 'head']
46
+ new_argv = [sys.argv[0], "-c", str(alembic_ini)] + sys.argv[1:]
47
+ sys.argv = new_argv
48
+
40
49
  # Pass through all CLI arguments to Alembic
41
50
  sys.exit(alembic_main(sys.argv[1:]))
42
51
 
skrift/config.py CHANGED
@@ -9,8 +9,8 @@ from pydantic import BaseModel
9
9
  from pydantic_settings import BaseSettings, SettingsConfigDict
10
10
 
11
11
  # Load .env file early so env vars are available for YAML interpolation
12
- # Use explicit path to handle subprocess spawning (uvicorn workers)
13
- _env_file = Path(__file__).parent.parent / ".env"
12
+ # Load from current working directory (where app.yaml lives)
13
+ _env_file = Path.cwd() / ".env"
14
14
  load_dotenv(_env_file)
15
15
 
16
16
  # Pattern to match $VAR_NAME environment variable references
@@ -18,8 +18,10 @@ from litestar.params import Parameter
18
18
  from litestar.response import Redirect, Template as TemplateResponse
19
19
  from sqlalchemy import select
20
20
  from sqlalchemy.ext.asyncio import AsyncSession
21
+ from sqlalchemy.orm import selectinload
21
22
 
22
23
  from skrift.config import get_settings
24
+ from skrift.db.models.oauth_account import OAuthAccount
23
25
  from skrift.db.models.user import User
24
26
  from skrift.setup.providers import DUMMY_PROVIDER_KEY, OAUTH_PROVIDERS, get_provider_info
25
27
 
@@ -315,30 +317,67 @@ class AuthController(Controller):
315
317
  if not oauth_id:
316
318
  raise HTTPException(status_code=400, detail="Could not determine user ID")
317
319
 
318
- # Find or create user
320
+ email = user_data["email"]
321
+
322
+ # Step 1: Check if OAuth account already exists
319
323
  result = await db_session.execute(
320
- select(User).where(User.oauth_id == oauth_id, User.oauth_provider == provider)
324
+ select(OAuthAccount)
325
+ .options(selectinload(OAuthAccount.user))
326
+ .where(OAuthAccount.provider == provider, OAuthAccount.provider_account_id == oauth_id)
321
327
  )
322
- user = result.scalar_one_or_none()
328
+ oauth_account = result.scalar_one_or_none()
323
329
 
324
- if user:
325
- # Update existing user
330
+ if oauth_account:
331
+ # Existing OAuth account - update user profile
332
+ user = oauth_account.user
326
333
  user.name = user_data["name"]
327
334
  if user_data["picture_url"]:
328
335
  user.picture_url = user_data["picture_url"]
329
336
  user.last_login_at = datetime.now(UTC)
337
+ # Update provider email if changed
338
+ if email:
339
+ oauth_account.provider_email = email
330
340
  else:
331
- # Create new user (admin role is only assigned through setup flow)
332
- user = User(
333
- oauth_provider=provider,
334
- oauth_id=oauth_id,
335
- email=user_data["email"],
336
- name=user_data["name"],
337
- picture_url=user_data["picture_url"],
338
- last_login_at=datetime.now(UTC),
339
- )
340
- db_session.add(user)
341
- await db_session.flush()
341
+ # Step 2: Check if a user with this email already exists
342
+ user = None
343
+ if email:
344
+ result = await db_session.execute(
345
+ select(User).where(User.email == email)
346
+ )
347
+ user = result.scalar_one_or_none()
348
+
349
+ if user:
350
+ # Link new OAuth account to existing user
351
+ oauth_account = OAuthAccount(
352
+ provider=provider,
353
+ provider_account_id=oauth_id,
354
+ provider_email=email,
355
+ user_id=user.id,
356
+ )
357
+ db_session.add(oauth_account)
358
+ # Update user profile
359
+ user.name = user_data["name"]
360
+ if user_data["picture_url"]:
361
+ user.picture_url = user_data["picture_url"]
362
+ user.last_login_at = datetime.now(UTC)
363
+ else:
364
+ # Step 3: Create new user + OAuth account
365
+ user = User(
366
+ email=email,
367
+ name=user_data["name"],
368
+ picture_url=user_data["picture_url"],
369
+ last_login_at=datetime.now(UTC),
370
+ )
371
+ db_session.add(user)
372
+ await db_session.flush()
373
+
374
+ oauth_account = OAuthAccount(
375
+ provider=provider,
376
+ provider_account_id=oauth_id,
377
+ provider_email=email,
378
+ user_id=user.id,
379
+ )
380
+ db_session.add(oauth_account)
342
381
 
343
382
  await db_session.commit()
344
383
 
@@ -405,31 +444,60 @@ class AuthController(Controller):
405
444
  # Generate deterministic oauth_id from email
406
445
  oauth_id = f"dummy_{hashlib.sha256(email.encode()).hexdigest()[:16]}"
407
446
 
408
- # Find or create user
447
+ # Step 1: Check if OAuth account already exists
409
448
  result = await db_session.execute(
410
- select(User).where(
411
- User.oauth_id == oauth_id,
412
- User.oauth_provider == DUMMY_PROVIDER_KEY,
449
+ select(OAuthAccount)
450
+ .options(selectinload(OAuthAccount.user))
451
+ .where(
452
+ OAuthAccount.provider == DUMMY_PROVIDER_KEY,
453
+ OAuthAccount.provider_account_id == oauth_id,
413
454
  )
414
455
  )
415
- user = result.scalar_one_or_none()
456
+ oauth_account = result.scalar_one_or_none()
416
457
 
417
- if user:
418
- # Update existing user
458
+ if oauth_account:
459
+ # Existing OAuth account - update user profile
460
+ user = oauth_account.user
419
461
  user.name = name
420
462
  user.email = email
421
463
  user.last_login_at = datetime.now(UTC)
464
+ oauth_account.provider_email = email
422
465
  else:
423
- # Create new user
424
- user = User(
425
- oauth_provider=DUMMY_PROVIDER_KEY,
426
- oauth_id=oauth_id,
427
- email=email,
428
- name=name,
429
- last_login_at=datetime.now(UTC),
466
+ # Step 2: Check if a user with this email already exists
467
+ result = await db_session.execute(
468
+ select(User).where(User.email == email)
430
469
  )
431
- db_session.add(user)
432
- await db_session.flush()
470
+ user = result.scalar_one_or_none()
471
+
472
+ if user:
473
+ # Link new OAuth account to existing user
474
+ oauth_account = OAuthAccount(
475
+ provider=DUMMY_PROVIDER_KEY,
476
+ provider_account_id=oauth_id,
477
+ provider_email=email,
478
+ user_id=user.id,
479
+ )
480
+ db_session.add(oauth_account)
481
+ # Update user profile
482
+ user.name = name
483
+ user.last_login_at = datetime.now(UTC)
484
+ else:
485
+ # Step 3: Create new user + OAuth account
486
+ user = User(
487
+ email=email,
488
+ name=name,
489
+ last_login_at=datetime.now(UTC),
490
+ )
491
+ db_session.add(user)
492
+ await db_session.flush()
493
+
494
+ oauth_account = OAuthAccount(
495
+ provider=DUMMY_PROVIDER_KEY,
496
+ provider_account_id=oauth_id,
497
+ provider_email=email,
498
+ user_id=user.id,
499
+ )
500
+ db_session.add(oauth_account)
433
501
 
434
502
  await db_session.commit()
435
503
 
@@ -1,6 +1,7 @@
1
+ from skrift.db.models.oauth_account import OAuthAccount
1
2
  from skrift.db.models.page import Page
2
3
  from skrift.db.models.role import Role, RolePermission, user_roles
3
4
  from skrift.db.models.setting import Setting
4
5
  from skrift.db.models.user import User
5
6
 
6
- __all__ = ["Page", "Role", "RolePermission", "Setting", "User", "user_roles"]
7
+ __all__ = ["OAuthAccount", "Page", "Role", "RolePermission", "Setting", "User", "user_roles"]
@@ -0,0 +1,37 @@
1
+ """OAuth account model for storing multiple OAuth identities per user."""
2
+
3
+ from typing import TYPE_CHECKING
4
+ from uuid import UUID
5
+
6
+ from sqlalchemy import ForeignKey, 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
+
22
+ __tablename__ = "oauth_accounts"
23
+
24
+ provider: Mapped[str] = mapped_column(String(50), nullable=False)
25
+ provider_account_id: Mapped[str] = mapped_column(String(255), nullable=False)
26
+ provider_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
27
+ user_id: Mapped[UUID] = mapped_column(
28
+ ForeignKey("users.id", ondelete="CASCADE"), nullable=False
29
+ )
30
+
31
+ user: Mapped["User"] = relationship("User", back_populates="oauth_accounts")
32
+
33
+ __table_args__ = (
34
+ UniqueConstraint(
35
+ "provider", "provider_account_id", name="uq_oauth_provider_account"
36
+ ),
37
+ )
skrift/db/models/user.py CHANGED
@@ -7,6 +7,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
7
7
  from skrift.db.base import Base
8
8
 
9
9
  if TYPE_CHECKING:
10
+ from skrift.db.models.oauth_account import OAuthAccount
10
11
  from skrift.db.models.page import Page
11
12
  from skrift.db.models.role import Role
12
13
 
@@ -16,12 +17,8 @@ class User(Base):
16
17
 
17
18
  __tablename__ = "users"
18
19
 
19
- # OAuth identifiers
20
- oauth_provider: Mapped[str] = mapped_column(String(50), nullable=False)
21
- oauth_id: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
22
-
23
20
  # Profile data from OAuth provider
24
- email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
21
+ email: Mapped[str | None] = mapped_column(String(255), nullable=True, unique=True)
25
22
  name: Mapped[str | None] = mapped_column(String(255), nullable=True)
26
23
  picture_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
27
24
 
@@ -30,6 +27,9 @@ class User(Base):
30
27
  last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
31
28
 
32
29
  # Relationships
30
+ oauth_accounts: Mapped[list["OAuthAccount"]] = relationship(
31
+ "OAuthAccount", back_populates="user", cascade="all, delete-orphan"
32
+ )
33
33
  pages: Mapped[list["Page"]] = relationship("Page", back_populates="user")
34
34
  roles: Mapped[list["Role"]] = relationship(
35
35
  "Role", secondary="user_roles", back_populates="users", lazy="selectin"
@@ -1,12 +1,13 @@
1
1
  """Setup wizard controller for first-time Skrift configuration."""
2
2
 
3
+ import asyncio
3
4
  import base64
4
5
  import hashlib
6
+ import json
5
7
  import secrets
6
- import subprocess
8
+ from collections.abc import AsyncGenerator
7
9
  from contextlib import asynccontextmanager
8
10
  from datetime import UTC, datetime
9
- from pathlib import Path
10
11
  from urllib.parse import urlencode
11
12
 
12
13
  import httpx
@@ -16,9 +17,12 @@ from litestar import Controller, Request, get, post
16
17
  from litestar.exceptions import HTTPException
17
18
  from litestar.params import Parameter
18
19
  from litestar.response import Redirect, Template as TemplateResponse
19
- from sqlalchemy import func, select
20
+ from litestar.response.sse import ServerSentEvent
21
+ from sqlalchemy import select
20
22
  from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
23
+ from sqlalchemy.orm import selectinload
21
24
 
25
+ from skrift.db.models.oauth_account import OAuthAccount
22
26
  from skrift.db.models.role import Role, user_roles
23
27
  from skrift.db.models.user import User
24
28
  from skrift.db.services import setting_service
@@ -32,7 +36,15 @@ from skrift.setup.config_writer import (
32
36
  update_database_config,
33
37
  )
34
38
  from skrift.setup.providers import get_all_providers, get_provider_info
35
- from skrift.setup.state import can_connect_to_database, app_yaml_exists, get_database_url_from_yaml
39
+ from skrift.setup.state import (
40
+ can_connect_to_database,
41
+ get_database_url_from_yaml,
42
+ get_first_incomplete_step,
43
+ is_auth_configured,
44
+ is_site_configured,
45
+ run_migrations_if_needed,
46
+ reset_migrations_flag,
47
+ )
36
48
 
37
49
 
38
50
  @asynccontextmanager
@@ -75,27 +87,32 @@ class SetupController(Controller):
75
87
 
76
88
  @get("/")
77
89
  async def index(self, request: Request) -> Redirect:
78
- """Redirect to appropriate setup step."""
79
- # Check wizard progress from session
80
- wizard_step = request.session.get("setup_wizard_step", "database")
81
-
82
- # If we don't have app.yaml or db isn't configured, start at database
83
- if not app_yaml_exists():
84
- return Redirect(path="/setup/database")
90
+ """Redirect to the first incomplete setup step.
85
91
 
92
+ Uses smart detection to skip already-configured steps, allowing users
93
+ to resume setup without re-entering existing configuration.
94
+ """
95
+ # Check if database is configured and connectable
86
96
  can_connect, _ = await can_connect_to_database()
87
- if not can_connect:
88
- return Redirect(path="/setup/database")
97
+ if can_connect:
98
+ # Database is configured - go through configuring page to run migrations
99
+ return Redirect(path="/setup/configuring")
89
100
 
90
- # Otherwise go to the saved step
91
- return Redirect(path=f"/setup/{wizard_step}")
101
+ # Database not configured - go to database step
102
+ request.session["setup_wizard_step"] = "database"
103
+ return Redirect(path="/setup/database")
92
104
 
93
105
  @get("/database")
94
- async def database_step(self, request: Request) -> TemplateResponse:
106
+ async def database_step(self, request: Request) -> TemplateResponse | Redirect:
95
107
  """Step 1: Database configuration."""
96
108
  flash = request.session.pop("flash", None)
97
109
  error = request.session.pop("setup_error", None)
98
110
 
111
+ # If database is already configured and no errors, go to configuring page
112
+ can_connect, _ = await can_connect_to_database()
113
+ if can_connect and not error:
114
+ return Redirect(path="/setup/configuring")
115
+
99
116
  # Load current config if exists
100
117
  config = load_config()
101
118
  db_config = config.get("db", {})
@@ -167,41 +184,9 @@ class SetupController(Controller):
167
184
  request.session["setup_error"] = f"Connection failed: {error}"
168
185
  return Redirect(path="/setup/database")
169
186
 
170
- # Run migrations
171
- try:
172
- result = subprocess.run(
173
- ["skrift-db", "upgrade", "head"],
174
- capture_output=True,
175
- text=True,
176
- cwd=Path.cwd(),
177
- timeout=60,
178
- )
179
- if result.returncode != 0:
180
- request.session["setup_error"] = f"Migration failed: {result.stderr}"
181
- return Redirect(path="/setup/database")
182
- except subprocess.TimeoutExpired:
183
- request.session["setup_error"] = "Migration timed out"
184
- return Redirect(path="/setup/database")
185
- except FileNotFoundError:
186
- # skrift-db might not be installed yet, try alembic directly
187
- try:
188
- result = subprocess.run(
189
- ["alembic", "upgrade", "head"],
190
- capture_output=True,
191
- text=True,
192
- cwd=Path.cwd(),
193
- timeout=60,
194
- )
195
- if result.returncode != 0:
196
- request.session["setup_error"] = f"Migration failed: {result.stderr}"
197
- return Redirect(path="/setup/database")
198
- except Exception as e:
199
- request.session["setup_error"] = f"Could not run migrations: {e}"
200
- return Redirect(path="/setup/database")
201
-
202
- request.session["setup_wizard_step"] = "auth"
203
- request.session["flash"] = "Database configured successfully!"
204
- return Redirect(path="/setup/auth")
187
+ # Connection successful - redirect to configuring page to run migrations
188
+ request.session["setup_wizard_step"] = "configuring"
189
+ return Redirect(path="/setup/configuring")
205
190
 
206
191
  except Exception as e:
207
192
  request.session["setup_error"] = str(e)
@@ -213,12 +198,116 @@ class SetupController(Controller):
213
198
  request.session["setup_wizard_step"] = "auth"
214
199
  return Redirect(path="/setup/auth")
215
200
 
201
+ @get("/configuring")
202
+ async def configuring_step(self, request: Request) -> TemplateResponse | Redirect:
203
+ """Database configuration in progress page.
204
+
205
+ Shows a loading spinner while migrations run via SSE.
206
+ """
207
+ flash = request.session.pop("flash", None)
208
+ error = request.session.pop("setup_error", None)
209
+
210
+ # Verify we can connect to the database first
211
+ can_connect, connection_error = await can_connect_to_database()
212
+ if not can_connect:
213
+ request.session["setup_error"] = f"Cannot connect to database: {connection_error}"
214
+ return Redirect(path="/setup/database")
215
+
216
+ # Reset migrations flag so they run fresh via SSE
217
+ reset_migrations_flag()
218
+
219
+ return TemplateResponse(
220
+ "setup/configuring.html",
221
+ context={
222
+ "flash": flash,
223
+ "error": error,
224
+ "step": 1,
225
+ "total_steps": 4,
226
+ },
227
+ )
228
+
229
+ @get("/configuring/status")
230
+ async def configuring_status(self, request: Request) -> ServerSentEvent:
231
+ """SSE endpoint for database configuration status.
232
+
233
+ Streams migration progress and completion status.
234
+ """
235
+ async def generate_status() -> AsyncGenerator[str, None]:
236
+ # Send initial status
237
+ yield json.dumps({
238
+ "status": "running",
239
+ "message": "Testing database connection...",
240
+ "detail": "",
241
+ })
242
+
243
+ await asyncio.sleep(0.5)
244
+
245
+ # Test connection
246
+ can_connect, connection_error = await can_connect_to_database()
247
+ if not can_connect:
248
+ yield json.dumps({
249
+ "status": "error",
250
+ "message": f"Database connection failed: {connection_error}",
251
+ })
252
+ return
253
+
254
+ yield json.dumps({
255
+ "status": "running",
256
+ "message": "Running database migrations...",
257
+ "detail": "This may take a moment",
258
+ })
259
+
260
+ await asyncio.sleep(0.3)
261
+
262
+ # Run migrations
263
+ success, error = run_migrations_if_needed()
264
+
265
+ if not success:
266
+ yield json.dumps({
267
+ "status": "error",
268
+ "message": f"Migration failed: {error}",
269
+ })
270
+ return
271
+
272
+ yield json.dumps({
273
+ "status": "running",
274
+ "message": "Verifying database schema...",
275
+ "detail": "",
276
+ })
277
+
278
+ await asyncio.sleep(0.3)
279
+
280
+ # Determine next step
281
+ if is_auth_configured():
282
+ if await is_site_configured():
283
+ next_step = "admin"
284
+ else:
285
+ next_step = "site"
286
+ else:
287
+ next_step = "auth"
288
+
289
+ # All done - include next step
290
+ yield json.dumps({
291
+ "status": "complete",
292
+ "message": "Database configured successfully!",
293
+ "next_step": next_step,
294
+ })
295
+
296
+ return ServerSentEvent(generate_status())
297
+
216
298
  @get("/auth")
217
- async def auth_step(self, request: Request) -> TemplateResponse:
299
+ async def auth_step(self, request: Request) -> TemplateResponse | Redirect:
218
300
  """Step 2: Authentication providers."""
219
301
  flash = request.session.pop("flash", None)
220
302
  error = request.session.pop("setup_error", None)
221
303
 
304
+ # If auth is already configured and no errors, skip to next step
305
+ if is_auth_configured() and not error:
306
+ next_step = await get_first_incomplete_step()
307
+ if next_step.value != "auth":
308
+ request.session["setup_wizard_step"] = next_step.value
309
+ return Redirect(path=f"/setup/{next_step.value}")
310
+
222
311
  # Get current redirect URL from request
223
312
  scheme = request.headers.get("x-forwarded-proto", request.url.scheme)
224
313
  host = request.headers.get("host", request.url.netloc)
@@ -291,20 +380,29 @@ class SetupController(Controller):
291
380
  use_env_vars=use_env_vars,
292
381
  )
293
382
 
294
- request.session["setup_wizard_step"] = "site"
383
+ # Determine next step using smart detection
384
+ next_step = await get_first_incomplete_step()
385
+ request.session["setup_wizard_step"] = next_step.value
295
386
  request.session["flash"] = "Authentication configured successfully!"
296
- return Redirect(path="/setup/site")
387
+ return Redirect(path=f"/setup/{next_step.value}")
297
388
 
298
389
  except Exception as e:
299
390
  request.session["setup_error"] = str(e)
300
391
  return Redirect(path="/setup/auth")
301
392
 
302
393
  @get("/site")
303
- async def site_step(self, request: Request) -> TemplateResponse:
394
+ async def site_step(self, request: Request) -> TemplateResponse | Redirect:
304
395
  """Step 3: Site settings."""
305
396
  flash = request.session.pop("flash", None)
306
397
  error = request.session.pop("setup_error", None)
307
398
 
399
+ # If site is already configured and no errors, skip to next step
400
+ if await is_site_configured() and not error:
401
+ next_step = await get_first_incomplete_step()
402
+ if next_step.value != "site":
403
+ request.session["setup_wizard_step"] = next_step.value
404
+ return Redirect(path=f"/setup/{next_step.value}")
405
+
308
406
  return TemplateResponse(
309
407
  "setup/site.html",
310
408
  context={
@@ -356,9 +454,11 @@ class SetupController(Controller):
356
454
  # Reload cache
357
455
  await setting_service.load_site_settings_cache(db_session)
358
456
 
359
- request.session["setup_wizard_step"] = "admin"
457
+ # Determine next step using smart detection - should be admin at this point
458
+ next_step = await get_first_incomplete_step()
459
+ request.session["setup_wizard_step"] = next_step.value
360
460
  request.session["flash"] = "Site settings saved!"
361
- return Redirect(path="/setup/admin")
461
+ return Redirect(path=f"/setup/{next_step.value}")
362
462
 
363
463
  except Exception as e:
364
464
  request.session["setup_error"] = str(e)
@@ -621,31 +721,68 @@ class SetupAuthController(Controller):
621
721
  if not oauth_id:
622
722
  raise HTTPException(status_code=400, detail="Could not determine user ID")
623
723
 
724
+ email = user_data["email"]
725
+
624
726
  # Create user and mark setup complete
625
727
  async with get_setup_db_session() as db_session:
626
- # Check if user exists
728
+ # Step 1: Check if OAuth account already exists
627
729
  result = await db_session.execute(
628
- select(User).where(User.oauth_id == oauth_id, User.oauth_provider == provider)
730
+ select(OAuthAccount)
731
+ .options(selectinload(OAuthAccount.user))
732
+ .where(OAuthAccount.provider == provider, OAuthAccount.provider_account_id == oauth_id)
629
733
  )
630
- user = result.scalar_one_or_none()
734
+ oauth_account = result.scalar_one_or_none()
631
735
 
632
- if user:
736
+ if oauth_account:
737
+ # Existing OAuth account - update user profile
738
+ user = oauth_account.user
633
739
  user.name = user_data["name"]
634
740
  if user_data["picture_url"]:
635
741
  user.picture_url = user_data["picture_url"]
636
742
  user.last_login_at = datetime.now(UTC)
743
+ if email:
744
+ oauth_account.provider_email = email
637
745
  else:
638
- # Create new user
639
- user = User(
640
- oauth_provider=provider,
641
- oauth_id=oauth_id,
642
- email=user_data["email"],
643
- name=user_data["name"],
644
- picture_url=user_data["picture_url"],
645
- last_login_at=datetime.now(UTC),
646
- )
647
- db_session.add(user)
648
- await db_session.flush()
746
+ # Step 2: Check if a user with this email already exists
747
+ user = None
748
+ if email:
749
+ result = await db_session.execute(
750
+ select(User).where(User.email == email)
751
+ )
752
+ user = result.scalar_one_or_none()
753
+
754
+ if user:
755
+ # Link new OAuth account to existing user
756
+ oauth_account = OAuthAccount(
757
+ provider=provider,
758
+ provider_account_id=oauth_id,
759
+ provider_email=email,
760
+ user_id=user.id,
761
+ )
762
+ db_session.add(oauth_account)
763
+ # Update user profile
764
+ user.name = user_data["name"]
765
+ if user_data["picture_url"]:
766
+ user.picture_url = user_data["picture_url"]
767
+ user.last_login_at = datetime.now(UTC)
768
+ else:
769
+ # Step 3: Create new user + OAuth account
770
+ user = User(
771
+ email=email,
772
+ name=user_data["name"],
773
+ picture_url=user_data["picture_url"],
774
+ last_login_at=datetime.now(UTC),
775
+ )
776
+ db_session.add(user)
777
+ await db_session.flush()
778
+
779
+ oauth_account = OAuthAccount(
780
+ provider=provider,
781
+ provider_account_id=oauth_id,
782
+ provider_email=email,
783
+ user_id=user.id,
784
+ )
785
+ db_session.add(oauth_account)
649
786
 
650
787
  # Ensure roles are synced (they may not exist if DB was created after server start)
651
788
  from skrift.auth import sync_roles_to_database
skrift/setup/state.py CHANGED
@@ -3,17 +3,36 @@
3
3
  This module implements a two-tier detection strategy:
4
4
  1. Pre-database check: Can we connect to a database?
5
5
  2. Post-database check: Is setup complete (check for setup_completed_at setting)?
6
+
7
+ Smart step detection: If config is already present, skip to the first incomplete step.
6
8
  """
7
9
 
8
10
  import os
11
+ import subprocess
9
12
  from enum import Enum
10
13
  from pathlib import Path
11
-
14
+ import yaml
12
15
  from sqlalchemy import text
13
16
  from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
14
17
 
18
+ # Track if migrations have been run this session to avoid running multiple times
19
+ _migrations_run = False
20
+
21
+
22
+ def reset_migrations_flag() -> None:
23
+ """Reset the migrations flag to allow re-running migrations.
24
+
25
+ Call this when starting the configuring page to ensure migrations run fresh.
26
+ """
27
+ global _migrations_run
28
+ _migrations_run = False
29
+
15
30
  from skrift.config import get_config_path
16
- from skrift.db.services.setting_service import SETUP_COMPLETED_AT_KEY, get_setting
31
+ from skrift.db.services.setting_service import (
32
+ SETUP_COMPLETED_AT_KEY,
33
+ SITE_NAME_KEY,
34
+ get_setting,
35
+ )
17
36
 
18
37
 
19
38
  class SetupStep(Enum):
@@ -103,6 +122,167 @@ async def is_setup_complete(db_session: AsyncSession) -> bool:
103
122
  return False
104
123
 
105
124
 
125
+ def is_auth_configured() -> bool:
126
+ """Check if at least one OAuth provider is fully configured in app.yaml.
127
+
128
+ A provider is considered configured if it has both client_id and client_secret.
129
+
130
+ Returns:
131
+ True if at least one provider is configured, False otherwise.
132
+ """
133
+ config_path = get_config_path()
134
+ if not config_path.exists():
135
+ return False
136
+
137
+ try:
138
+ with open(config_path, "r") as f:
139
+ config = yaml.safe_load(f)
140
+
141
+ if not config:
142
+ return False
143
+
144
+ auth = config.get("auth", {})
145
+ providers = auth.get("providers", {})
146
+
147
+ for _, provider_config in providers.items():
148
+ if not isinstance(provider_config, dict):
149
+ continue
150
+ # Check if provider has both client_id and client_secret (even as env var refs)
151
+ client_id = provider_config.get("client_id", "")
152
+ client_secret = provider_config.get("client_secret", "")
153
+ if client_id and client_secret:
154
+ return True
155
+
156
+ return False
157
+ except Exception:
158
+ return False
159
+
160
+
161
+ def run_migrations_if_needed() -> tuple[bool, str | None]:
162
+ """Run database migrations if they haven't been run this session.
163
+
164
+ This ensures the database schema is up to date before checking for
165
+ settings or other database-dependent configuration.
166
+
167
+ Returns:
168
+ Tuple of (success, error_message)
169
+ """
170
+ global _migrations_run
171
+ if _migrations_run:
172
+ return True, None
173
+
174
+ try:
175
+ # Try skrift-db first
176
+ result = subprocess.run(
177
+ ["skrift-db", "upgrade", "head"],
178
+ capture_output=True,
179
+ text=True,
180
+ cwd=Path.cwd(),
181
+ timeout=60,
182
+ )
183
+ if result.returncode == 0:
184
+ _migrations_run = True
185
+ return True, None
186
+ # If skrift-db fails, try alembic directly
187
+ except (subprocess.TimeoutExpired, FileNotFoundError):
188
+ pass
189
+
190
+ try:
191
+ result = subprocess.run(
192
+ ["alembic", "upgrade", "head"],
193
+ capture_output=True,
194
+ text=True,
195
+ cwd=Path.cwd(),
196
+ timeout=60,
197
+ )
198
+ if result.returncode == 0:
199
+ _migrations_run = True
200
+ return True, None
201
+ return False, result.stderr
202
+ except subprocess.TimeoutExpired:
203
+ return False, "Migration timed out"
204
+ except FileNotFoundError:
205
+ return False, "Neither skrift-db nor alembic found"
206
+ except Exception as e:
207
+ return False, str(e)
208
+
209
+
210
+ async def is_site_configured() -> bool:
211
+ """Check if site settings have been configured in the database.
212
+
213
+ The site step is considered complete if site_name has been set.
214
+ Returns False if the settings table doesn't exist yet (pre-migration).
215
+
216
+ Returns:
217
+ True if site is configured, False otherwise.
218
+ """
219
+ db_url = get_database_url_from_yaml()
220
+ if not db_url:
221
+ return False
222
+
223
+ engine = None
224
+ try:
225
+ engine = create_async_engine(db_url)
226
+ from sqlalchemy.ext.asyncio import async_sessionmaker
227
+
228
+ async_session = async_sessionmaker(engine, expire_on_commit=False)
229
+ async with async_session() as session:
230
+ try:
231
+ site_name = await get_setting(session, SITE_NAME_KEY)
232
+ return site_name is not None
233
+ except Exception:
234
+ # Table might not exist yet (before migration)
235
+ return False
236
+ except Exception:
237
+ return False
238
+ finally:
239
+ if engine:
240
+ await engine.dispose()
241
+
242
+
243
+ async def get_first_incomplete_step() -> SetupStep:
244
+ """Determine the first incomplete step in the setup wizard.
245
+
246
+ This function checks configuration completeness for each step and returns
247
+ the first step that needs to be completed. Use this to skip already-configured
248
+ steps when the user is forced back into the setup wizard.
249
+
250
+ If database is configured and connectable, runs migrations to ensure
251
+ all tables exist before checking database-dependent configuration.
252
+
253
+ Returns:
254
+ The first setup step that needs user input.
255
+ """
256
+ # Step 1: Database - check if we can connect
257
+ if not app_yaml_exists():
258
+ return SetupStep.DATABASE
259
+
260
+ db_url = get_database_url_from_yaml()
261
+ if not db_url:
262
+ return SetupStep.DATABASE
263
+
264
+ can_connect, _ = await can_connect_to_database()
265
+ if not can_connect:
266
+ return SetupStep.DATABASE
267
+
268
+ # Database is configured and connectable - run migrations to ensure tables exist
269
+ migration_success, _ = run_migrations_if_needed()
270
+ if not migration_success:
271
+ # If migrations fail, go back to database step to show the error
272
+ return SetupStep.DATABASE
273
+
274
+ # Step 2: Auth - check if at least one provider is configured
275
+ if not is_auth_configured():
276
+ return SetupStep.AUTH
277
+
278
+ # Step 3: Site - check if site settings exist in DB
279
+ if not await is_site_configured():
280
+ return SetupStep.SITE
281
+
282
+ # Step 4: Admin - always go here if setup not complete
283
+ return SetupStep.ADMIN
284
+
285
+
106
286
  async def get_setup_step(db_session: AsyncSession | None = None) -> SetupStep:
107
287
  """Determine which setup step the user should be on.
108
288
 
@@ -0,0 +1,158 @@
1
+ {% extends "setup/base.html" %}
2
+
3
+ {% block title %}Configuring Database - Skrift{% endblock %}
4
+
5
+ {% block head %}
6
+ <style>
7
+ .configuring-content {
8
+ text-align: center;
9
+ padding: 2rem 0;
10
+ }
11
+
12
+ .spinner {
13
+ width: 48px;
14
+ height: 48px;
15
+ border: 4px solid var(--color-border);
16
+ border-top-color: var(--color-primary);
17
+ border-radius: 50%;
18
+ animation: spin 1s linear infinite;
19
+ margin: 0 auto 1.5rem;
20
+ }
21
+
22
+ .spinner.complete {
23
+ border-color: var(--color-success);
24
+ border-top-color: var(--color-success);
25
+ animation: none;
26
+ }
27
+
28
+ .spinner.error {
29
+ border-color: var(--color-error);
30
+ border-top-color: var(--color-error);
31
+ animation: none;
32
+ }
33
+
34
+ @keyframes spin {
35
+ to { transform: rotate(360deg); }
36
+ }
37
+
38
+ .status-icon {
39
+ width: 48px;
40
+ height: 48px;
41
+ margin: 0 auto 1.5rem;
42
+ font-size: 48px;
43
+ line-height: 1;
44
+ display: none;
45
+ }
46
+
47
+ .status-icon.complete {
48
+ display: block;
49
+ color: var(--color-success);
50
+ }
51
+
52
+ .status-icon.error {
53
+ display: block;
54
+ color: var(--color-error);
55
+ }
56
+
57
+ .status-message {
58
+ font-size: 1.125rem;
59
+ margin-bottom: 0.5rem;
60
+ }
61
+
62
+ .status-detail {
63
+ color: var(--color-text-muted);
64
+ font-size: 0.875rem;
65
+ min-height: 1.25rem;
66
+ }
67
+
68
+ .form-actions button:disabled {
69
+ opacity: 0.5;
70
+ cursor: not-allowed;
71
+ }
72
+ </style>
73
+ {% endblock %}
74
+
75
+ {% block content %}
76
+ <h2>Configuring Database</h2>
77
+ <p class="description">Setting up your database and running migrations.</p>
78
+
79
+ <div class="configuring-content">
80
+ <div class="spinner" id="spinner"></div>
81
+ <div class="status-icon" id="status-icon"></div>
82
+ <div class="status-message" id="status-message">Initializing...</div>
83
+ <div class="status-detail" id="status-detail"></div>
84
+ </div>
85
+
86
+ <div class="form-actions">
87
+ <a href="/setup/database" role="button" class="btn-secondary">Back</a>
88
+ <button type="button" id="next-button" disabled>Continue</button>
89
+ </div>
90
+ {% endblock %}
91
+
92
+ {% block scripts %}
93
+ <script>
94
+ document.addEventListener('DOMContentLoaded', function() {
95
+ const spinner = document.getElementById('spinner');
96
+ const statusIcon = document.getElementById('status-icon');
97
+ const statusMessage = document.getElementById('status-message');
98
+ const statusDetail = document.getElementById('status-detail');
99
+ const nextButton = document.getElementById('next-button');
100
+
101
+ let nextStep = 'auth'; // Default fallback
102
+
103
+ function setComplete(step) {
104
+ spinner.style.display = 'none';
105
+ statusIcon.textContent = '\u2713';
106
+ statusIcon.classList.add('complete');
107
+ statusMessage.textContent = 'Database configured successfully!';
108
+ statusDetail.textContent = '';
109
+ nextButton.disabled = false;
110
+ if (step) {
111
+ nextStep = step;
112
+ }
113
+ nextButton.onclick = function() {
114
+ window.location.href = '/setup/' + nextStep;
115
+ };
116
+ }
117
+
118
+ function setError(message) {
119
+ spinner.style.display = 'none';
120
+ statusIcon.textContent = '\u2717';
121
+ statusIcon.classList.add('error');
122
+ statusMessage.textContent = 'Configuration failed';
123
+ statusDetail.textContent = message || 'An error occurred during setup.';
124
+ }
125
+
126
+ function setStatus(message, detail) {
127
+ statusMessage.textContent = message;
128
+ statusDetail.textContent = detail || '';
129
+ }
130
+
131
+ // Connect to SSE endpoint
132
+ const eventSource = new EventSource('/setup/configuring/status');
133
+
134
+ eventSource.onmessage = function(event) {
135
+ const data = JSON.parse(event.data);
136
+
137
+ if (data.status === 'running') {
138
+ setStatus(data.message, data.detail);
139
+ } else if (data.status === 'complete') {
140
+ setComplete(data.next_step);
141
+ eventSource.close();
142
+ } else if (data.status === 'error') {
143
+ setError(data.message);
144
+ eventSource.close();
145
+ }
146
+ };
147
+
148
+ eventSource.onerror = function() {
149
+ // Check if we were successful before the connection closed
150
+ if (!nextButton.disabled) {
151
+ return; // Already complete, ignore the error
152
+ }
153
+ setError('Connection lost. Please refresh the page.');
154
+ eventSource.close();
155
+ };
156
+ });
157
+ </script>
158
+ {% endblock %}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: skrift
3
- Version: 0.1.0a2
3
+ Version: 0.1.0a4
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
@@ -1,33 +1,35 @@
1
1
  skrift/__init__.py,sha256=eXE5PFVkJpH5XsV_ZlrTIeFPUPrmcHYAj4GpRS3R5PY,29
2
2
  skrift/__main__.py,sha256=Fs17xxkqTjZpJn9MMC6pzkW4sOzNhV5RGLsA2ONPFww,271
3
- skrift/alembic.ini,sha256=aq1sNDUgKvdON-N28lN9fncRcYtg430HE2DsuEMIDtk,1782
3
+ skrift/alembic.ini,sha256=mYguI6CbMCTyfHctsGiTyf9Z5gv21FdeI3qtfgOHO3A,1815
4
4
  skrift/asgi.py,sha256=U-9p_JlWlwjrhKJzAlBHpQ3DvTB2uIcvZe3-5Gs87Ec,19248
5
- skrift/cli.py,sha256=rrm0cSc2LhQ1FA5-ucDN1sDTwFwKnDk1DCYf746SYyo,1522
6
- skrift/config.py,sha256=7Jqqm9jXff7rIRY83C4jchDVk_1UkpGdKATX3JgX974,7423
5
+ skrift/cli.py,sha256=DB_MssHeBAvpm7DVgd3V-BQr5BjBqSO0O_0vm15PZJ8,1941
6
+ skrift/config.py,sha256=_fXfdRdy03yJWUhijthC4cNgOXLJ0jhB__C-VwVYaxc,7398
7
7
  skrift/admin/__init__.py,sha256=x81Cj_ilVmv6slaMl16HHyT_AgrnLxKEWkS0RPa4V9s,289
8
8
  skrift/admin/controller.py,sha256=5ZDypvKHXLNDESsKNsdsH2E3Si5OqlpzttFl7Ot8aF0,15651
9
9
  skrift/admin/navigation.py,sha256=VwttFoIUIJy5rONKIkJd5w4CNkUpeK22_OfLGHecN34,3382
10
- skrift/alembic/env.py,sha256=vYy8dBm7zW8F5izsQdVgQyJ6yGmRbqUfGoWgefD49vQ,2609
10
+ skrift/alembic/env.py,sha256=GaQx7D-3f0zVTV4YJNhN0GOfqHL99N4VBfMzjZJz0Bc,2673
11
11
  skrift/alembic/script.py.mako,sha256=MEqL-2qATlST9TAOeYgscMn1uy6HUS9NFvDgl93dMj8,635
12
12
  skrift/alembic/versions/20260120_210154_09b0364dbb7b_initial_schema.py,sha256=X6w1vbVKFurhEcblTLGO4Nd_IKkMVtb8TsptV4gpAJ4,2750
13
13
  skrift/alembic/versions/20260122_152744_0b7c927d2591_add_roles_and_permissions.py,sha256=yCGjnMTpfSMHVYQCsPTD6wdDI8pJSUtGRESO6bz0KfU,2672
14
14
  skrift/alembic/versions/20260122_172836_cdf734a5b847_add_sa_orm_sentinel_column.py,sha256=96TCvvQbYk3RGpZdEQ0x4bIDXxBLc3Gv7VTItTq9T54,850
15
15
  skrift/alembic/versions/20260122_175637_a9c55348eae7_remove_page_type_column.py,sha256=XJ55LhwzgN07MAREk6w4Bx2Vu3KvMe_bDkIjgE6EfqM,1223
16
16
  skrift/alembic/versions/20260122_200000_add_settings_table.py,sha256=aG4pxp18fwonGAWt7EoT0XaiIEmu7zltSrj8e2a8QZU,1310
17
+ skrift/alembic/versions/20260129_add_oauth_accounts.py,sha256=SJYQl0cfyJnUKLzsbGqu97Kcm7waPhxmpyARJXB3Z1Q,5240
17
18
  skrift/auth/__init__.py,sha256=uHMqty3dgDSYlReVT96WhygzH6qNAWSVDWoxumzxmsA,1155
18
19
  skrift/auth/guards.py,sha256=QePajHsGnJ4R_hlhzblr5IoAgZcY5jzeZ64bJwDL9hM,4451
19
20
  skrift/auth/roles.py,sha256=jpqK4qaavqAhJRxhptm2x5mUb6KkwEALaml8sEH4Sug,2267
20
21
  skrift/auth/services.py,sha256=h6GTXdN5UMRYglnaFz4asMoutVkSSAyL3_Vt56N26pA,5441
21
22
  skrift/controllers/__init__.py,sha256=bVr0tsSGz7jBi002Lqd1AA1FQd7ZA_IagsqTKpiHiK0,147
22
- skrift/controllers/auth.py,sha256=3rECj_bfpISngcoUfMI3TcqxsmnTlpjMHfVNpMjDG4I,16050
23
+ skrift/controllers/auth.py,sha256=2XQSljDnqdk1NZZTfYLBAqfZb4Ref2p3Uy2maUkKJgY,18881
23
24
  skrift/controllers/web.py,sha256=vmoBS1u5G9gCBu65S49yqZn_WBKlmsqlcvX5tYXTKnE,2348
24
25
  skrift/db/__init__.py,sha256=uSghyDFT2K4SFiEqUzdjCGzWpS-Oy6Sd1FUappau-v0,52
25
26
  skrift/db/base.py,sha256=QJplFj9235kZdScASEpvyNHln6YW2hqbHwJEYZ3OSsc,173
26
- skrift/db/models/__init__.py,sha256=8ivPDjVMDZPvl06ZWBjp4YmSfC4qhbq76PDJ8MWqzv4,269
27
+ skrift/db/models/__init__.py,sha256=wFF9YWe7rhIWIzRuMtAWVDcZdJsNcKnjfhKbLZw9JVs,341
28
+ skrift/db/models/oauth_account.py,sha256=kLb-1Do8a7tluW3f-uhA_BpgkZ9h10kg_8v0jInDrwY,1216
27
29
  skrift/db/models/page.py,sha256=CI5W2sWq0zbKHxfwH5TpmGdJ4zlTicckzpdZtaqjJVE,1019
28
30
  skrift/db/models/role.py,sha256=VkwkF3XWemmFtkUpQRk6RTIBfcukrU-0PUPCeo8x834,1768
29
31
  skrift/db/models/setting.py,sha256=Am4HTyq2LFR408R6BZb--slys1xM8YLYq9HTq_gybD0,392
30
- skrift/db/models/user.py,sha256=zqZ-sYcYa0StFsSGsPcovy7EpswFo_PtrObBJ8TyHiA,1310
32
+ skrift/db/models/user.py,sha256=kkR3h0CphUyvOGJOpiZH6Qp28LqW2OriwVTfybs7vuY,1338
31
33
  skrift/db/services/__init__.py,sha256=qAC24IPOYg6AampUWonmLeajljQRMwUw0lgXksQG6Nk,69
32
34
  skrift/db/services/page_service.py,sha256=cXJ-urV7LjUDoVsT1P52f6wcOsur3T-pvqKkGG4xlvI,5246
33
35
  skrift/db/services/setting_service.py,sha256=eqFxukn8QFrDHQbaBILOLjP1nr34JPIPFT_NnhmeaTY,5613
@@ -36,10 +38,10 @@ skrift/lib/exceptions.py,sha256=p8ceLIQCc7agCwW6-mhBDAuMAMxZDcf9TDLC6PfztU4,5803
36
38
  skrift/lib/template.py,sha256=4_urkRfvth75yNeQ5TyGTHvkvs3vVef7TcwZx0k285k,4226
37
39
  skrift/setup/__init__.py,sha256=3VjFPMES5y0M5cQ9R4C1xazqiEPEDqTPjX9-3rBMXnA,478
38
40
  skrift/setup/config_writer.py,sha256=YFH3FVjXN7Rum2fzGVPAQRkjdc9b0bHECDqMKYiEkhg,6347
39
- skrift/setup/controller.py,sha256=HZs6Q2lNdvNh-JakmznMVL-1sj_6wD9axr3ESL1iIaA,29202
41
+ skrift/setup/controller.py,sha256=v0Ey8T7ptJ5A3vOqQ1TUAXH1bQwA0288J5uyUWMihsw,34250
40
42
  skrift/setup/middleware.py,sha256=Nai8ZG2vHldngmAhq7kWzAwKRNcP5tHKhJHa5dCh404,2941
41
43
  skrift/setup/providers.py,sha256=0BFKB6168NcmtXxFF6ofHgEDMQD2FbXkexsqrARVtDI,7967
42
- skrift/setup/state.py,sha256=cQT0LIBFHRHpTzbgsBUO5a4iqAlUSAyUeH3m1k6C95w,3739
44
+ skrift/setup/state.py,sha256=RMe9LtIjzDoOm9u-Nk5-KAnr_JBiQIjWDpTP9E30ezc,9304
43
45
  skrift/static/css/style.css,sha256=sJ7-y8nrUdB5EB5_CyjWo1CTnqmYq0UgMZYMjxw0988,20824
44
46
  skrift/templates/base.html,sha256=4bg4s4VdES0dSvhJYLgrfrN26ynqeq1-3jyKPkWWVWk,2065
45
47
  skrift/templates/error-404.html,sha256=sJrDaF3Or3Nyki8mxo3wBxLLzgy4wkB9p9wdS8pRA6k,409
@@ -60,10 +62,11 @@ skrift/templates/setup/admin.html,sha256=BSIztZT2iqxVSW23Tfg7ZM51SdGrKL422CR05DP
60
62
  skrift/templates/setup/auth.html,sha256=0DVL0kU6DlJ2pWe4zwc3DsIVfBAdqpMJxPYvS4zm4OM,4953
61
63
  skrift/templates/setup/base.html,sha256=LTXqbnHMvx1wDsxFvo4BSieBPD9pcLMj6NM4ZGzErFM,10372
62
64
  skrift/templates/setup/complete.html,sha256=oyT-rYPl0uuyOjPXgNeLr8YoptW9QjHTlScZSViDvTk,630
65
+ skrift/templates/setup/configuring.html,sha256=2KHW9h2BrJgL_kO5IizbAYs4pnFLyRf76IQvEj_cNRM,4607
63
66
  skrift/templates/setup/database.html,sha256=gU4-315-QraHa2Eq4Fh3b55QpOM2CkJzh27_Yz13frA,5495
64
67
  skrift/templates/setup/restart.html,sha256=GHg31F_e2uLFhWUzJoalk0Y0oYLqsFWyZXWKX3mblbY,1355
65
68
  skrift/templates/setup/site.html,sha256=PSOH-q1-ZBl47iSW9-Ad6lEfJn_fzdGD3Pk4vb3xgK4,1680
66
- skrift-0.1.0a2.dist-info/METADATA,sha256=YhazEDBHS7K8MohN6aRwVbC5VlkbmM3X9vjghL79XR0,6435
67
- skrift-0.1.0a2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
68
- skrift-0.1.0a2.dist-info/entry_points.txt,sha256=4AIrmbeWKOdZnvTsKT3US6N3X9rrgk9jEDsYOPEZ1AE,74
69
- skrift-0.1.0a2.dist-info/RECORD,,
69
+ skrift-0.1.0a4.dist-info/METADATA,sha256=4fREc0NuDDDf5aPruF_rmqwPOBzMDsBF12N9lB4CFPw,6435
70
+ skrift-0.1.0a4.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
71
+ skrift-0.1.0a4.dist-info/entry_points.txt,sha256=4AIrmbeWKOdZnvTsKT3US6N3X9rrgk9jEDsYOPEZ1AE,74
72
+ skrift-0.1.0a4.dist-info/RECORD,,