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:
vlad
2026-07-14 17:12:28 +03:00
commit 86cc3fa541
278 changed files with 19416 additions and 0 deletions
@@ -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