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,52 @@
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.base import Base
|
||||
|
||||
# Import models so Alembic can discover metadata.
|
||||
from app.modules.auth.models import EmailVerificationToken, PasswordResetToken, RefreshToken # noqa: F401
|
||||
from app.modules.content.models import ContentPage # noqa: F401
|
||||
from app.modules.users.models import User, UserProfile # noqa: F401
|
||||
|
||||
config = context.config
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
config.set_main_option("sqlalchemy.url", settings.database_url)
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
transaction_per_migration=True,
|
||||
)
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,25 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -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