Update admin theme/layout and refresh README details.
Align the project baseline with the latest admin interface styling and layout structure while documenting setup and usage updates in README.
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
"""initial schema
|
||||
|
||||
Revision ID: 20260711_0001
|
||||
Revises:
|
||||
Create Date: 2026-07-11 14:00:00
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260711_0001"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("email", sa.String(length=255), nullable=False),
|
||||
sa.Column("password_hash", sa.String(length=255), nullable=False),
|
||||
sa.Column("role", sa.String(length=16), nullable=False),
|
||||
sa.Column("status", sa.String(length=16), nullable=False),
|
||||
sa.Column("failed_login_attempts", sa.Integer(), nullable=False),
|
||||
sa.Column("locked_until", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("email_verified_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("email"),
|
||||
)
|
||||
op.create_index("idx_users_email", "users", ["email"], unique=True)
|
||||
op.create_index("idx_users_status", "users", ["status"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"user_profiles",
|
||||
sa.Column("user_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("display_name", sa.String(length=120), nullable=False),
|
||||
sa.Column("avatar_url", sa.String(length=512), nullable=True),
|
||||
sa.Column("metadata_json", sa.Text(), nullable=True),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("user_id"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"refresh_tokens",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("user_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("token_hash", sa.String(length=64), nullable=False),
|
||||
sa.Column("family_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("token_hash"),
|
||||
)
|
||||
op.create_index("idx_refresh_tokens_user", "refresh_tokens", ["user_id"], unique=False)
|
||||
op.create_index("idx_refresh_tokens_family", "refresh_tokens", ["family_id"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"password_reset_tokens",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("user_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("token_hash", sa.String(length=64), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("token_hash"),
|
||||
)
|
||||
op.create_index("idx_password_reset_user", "password_reset_tokens", ["user_id"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"email_verification_tokens",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("user_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("token_hash", sa.String(length=64), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("token_hash"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"content_pages",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("slug", sa.String(length=120), nullable=False),
|
||||
sa.Column("title", sa.String(length=255), nullable=False),
|
||||
sa.Column("body", sa.Text(), nullable=False),
|
||||
sa.Column("status", sa.String(length=16), nullable=False),
|
||||
sa.Column("author_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("published_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(["author_id"], ["users.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("slug"),
|
||||
)
|
||||
op.create_index("idx_content_slug", "content_pages", ["slug"], unique=True)
|
||||
op.create_index("idx_content_status", "content_pages", ["status"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("idx_content_status", table_name="content_pages")
|
||||
op.drop_index("idx_content_slug", table_name="content_pages")
|
||||
op.drop_table("content_pages")
|
||||
op.drop_table("email_verification_tokens")
|
||||
op.drop_index("idx_password_reset_user", table_name="password_reset_tokens")
|
||||
op.drop_table("password_reset_tokens")
|
||||
op.drop_index("idx_refresh_tokens_family", table_name="refresh_tokens")
|
||||
op.drop_index("idx_refresh_tokens_user", table_name="refresh_tokens")
|
||||
op.drop_table("refresh_tokens")
|
||||
op.drop_table("user_profiles")
|
||||
op.drop_index("idx_users_status", table_name="users")
|
||||
op.drop_index("idx_users_email", table_name="users")
|
||||
op.drop_table("users")
|
||||
@@ -0,0 +1,33 @@
|
||||
"""seed admin and demo content
|
||||
|
||||
Revision ID: 20260711_0002
|
||||
Revises: 20260711_0001
|
||||
Create Date: 2026-07-11 14:05:00
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260711_0002"
|
||||
down_revision: Union[str, None] = "20260711_0001"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Demo users and CMS pages are seeded on API startup (app.main lifespan → run_seed)
|
||||
# after all schema migrations, including is_superuser (20260714_0003).
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
from sqlalchemy import delete
|
||||
|
||||
from app.core.database import session_scope
|
||||
from app.modules.content.models import ContentPage
|
||||
from app.modules.users.models import User
|
||||
|
||||
with session_scope() as db:
|
||||
db.execute(delete(ContentPage).where(ContentPage.slug.in_(["about", "privacy", "terms"])))
|
||||
db.execute(delete(User).where(User.email == "admin@compton.example"))
|
||||
@@ -0,0 +1,36 @@
|
||||
"""add is_superuser to users
|
||||
|
||||
Revision ID: 20260714_0003
|
||||
Revises: 20260711_0002
|
||||
Create Date: 2026-07-14 12:45:00
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260714_0003"
|
||||
down_revision: Union[str, None] = "20260711_0002"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column("is_superuser", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
)
|
||||
bind = op.get_bind()
|
||||
if bind.dialect.name != "sqlite":
|
||||
op.alter_column("users", "is_superuser", server_default=None)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"UPDATE users SET is_superuser = true "
|
||||
"WHERE email = 'admin@compton.example' AND role = 'admin'"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("users", "is_superuser")
|
||||
@@ -0,0 +1,55 @@
|
||||
"""db security constraints and token indexes
|
||||
|
||||
Revision ID: 20260714_0004
|
||||
Revises: 20260714_0003
|
||||
Create Date: 2026-07-14 13:30:00
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260714_0004"
|
||||
down_revision: Union[str, None] = "20260714_0003"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if bind.dialect.name != "sqlite":
|
||||
op.create_check_constraint(
|
||||
"ck_users_role_allowed",
|
||||
"users",
|
||||
"role IN ('user', 'admin')",
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_users_status_allowed",
|
||||
"users",
|
||||
"status IN ('pending', 'active', 'blocked')",
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_users_superuser_requires_admin",
|
||||
"users",
|
||||
"(is_superuser = false) OR (role = 'admin')",
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_content_pages_status_allowed",
|
||||
"content_pages",
|
||||
"status IN ('draft', 'published')",
|
||||
)
|
||||
op.create_index("idx_refresh_tokens_expires_at", "refresh_tokens", ["expires_at"], unique=False)
|
||||
op.create_index("idx_password_reset_expires_at", "password_reset_tokens", ["expires_at"], unique=False)
|
||||
op.create_index("idx_email_verify_expires_at", "email_verification_tokens", ["expires_at"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("idx_email_verify_expires_at", table_name="email_verification_tokens")
|
||||
op.drop_index("idx_password_reset_expires_at", table_name="password_reset_tokens")
|
||||
op.drop_index("idx_refresh_tokens_expires_at", table_name="refresh_tokens")
|
||||
bind = op.get_bind()
|
||||
if bind.dialect.name != "sqlite":
|
||||
op.drop_constraint("ck_content_pages_status_allowed", "content_pages", type_="check")
|
||||
op.drop_constraint("ck_users_superuser_requires_admin", "users", type_="check")
|
||||
op.drop_constraint("ck_users_status_allowed", "users", type_="check")
|
||||
op.drop_constraint("ck_users_role_allowed", "users", type_="check")
|
||||
@@ -0,0 +1,35 @@
|
||||
"""backfill superuser flag for seeded admin
|
||||
|
||||
Revision ID: 20260714_0005
|
||||
Revises: 20260714_0004
|
||||
Create Date: 2026-07-14 14:00:00
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260714_0005"
|
||||
down_revision: Union[str, None] = "20260714_0004"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
sa.text(
|
||||
"UPDATE users SET is_superuser = true "
|
||||
"WHERE email = 'admin@compton.example' AND role = 'admin'"
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"UPDATE users SET is_superuser = false "
|
||||
"WHERE email IN ('ops@compton.example', 'user@compton.example')"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
Reference in New Issue
Block a user