feat(l4d2-web): script overlay schema — add overlay.script + last_build_status, drop globals tables
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
78ead0b41d
commit
43dc9b0ccf
4 changed files with 186 additions and 63 deletions
79
l4d2web/alembic/versions/0005_script_overlays.py
Normal file
79
l4d2web/alembic/versions/0005_script_overlays.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
"""script overlays
|
||||
|
||||
Revision ID: 0005_script_overlays
|
||||
Revises: 0004_drop_legacy_external_overlay_type
|
||||
Create Date: 2026-05-08
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "0005_script_overlays"
|
||||
down_revision: Union[str, Sequence[str], None] = "0004_drop_legacy_external_overlay_type"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. Wipe legacy global-type overlay rows and any references to them.
|
||||
op.execute(
|
||||
"DELETE FROM jobs "
|
||||
"WHERE overlay_id IN (SELECT id FROM overlays "
|
||||
"WHERE type IN ('l4d2center_maps', 'cedapug_maps'))"
|
||||
)
|
||||
op.execute(
|
||||
"DELETE FROM blueprint_overlays "
|
||||
"WHERE overlay_id IN (SELECT id FROM overlays "
|
||||
"WHERE type IN ('l4d2center_maps', 'cedapug_maps'))"
|
||||
)
|
||||
op.execute(
|
||||
"DELETE FROM overlay_workshop_items "
|
||||
"WHERE overlay_id IN (SELECT id FROM overlays "
|
||||
"WHERE type IN ('l4d2center_maps', 'cedapug_maps'))"
|
||||
)
|
||||
op.execute(
|
||||
"DELETE FROM overlays WHERE type IN ('l4d2center_maps', 'cedapug_maps')"
|
||||
)
|
||||
|
||||
# 2. Drop globals tables in FK order: item_files -> items -> sources.
|
||||
op.drop_index(
|
||||
"ix_global_overlay_item_files_item",
|
||||
table_name="global_overlay_item_files",
|
||||
)
|
||||
op.drop_table("global_overlay_item_files")
|
||||
|
||||
op.drop_index(
|
||||
"ix_global_overlay_items_source", table_name="global_overlay_items"
|
||||
)
|
||||
op.drop_table("global_overlay_items")
|
||||
|
||||
op.drop_index(
|
||||
"ix_global_overlay_sources_type", table_name="global_overlay_sources"
|
||||
)
|
||||
op.drop_table("global_overlay_sources")
|
||||
|
||||
# 3. Add new columns on overlays.
|
||||
with op.batch_alter_table("overlays") as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"script",
|
||||
sa.Text(),
|
||||
nullable=False,
|
||||
server_default="",
|
||||
)
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"last_build_status",
|
||||
sa.String(length=16),
|
||||
nullable=False,
|
||||
server_default="",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# data is gone; intentional one-way migration
|
||||
pass
|
||||
|
|
@ -59,69 +59,8 @@ class Overlay(Base):
|
|||
path: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||
type: Mapped[str] = mapped_column(String(16), nullable=False, default="workshop")
|
||||
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
||||
|
||||
|
||||
class GlobalOverlaySource(Base):
|
||||
__tablename__ = "global_overlay_sources"
|
||||
__table_args__ = (Index("ix_global_overlay_sources_type", "source_type"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
overlay_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("overlays.id", ondelete="CASCADE"), unique=True, nullable=False
|
||||
)
|
||||
source_key: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||
source_type: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
source_url: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
last_manifest_hash: Mapped[str] = mapped_column(String(64), default="", nullable=False)
|
||||
last_refreshed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
last_error: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
||||
|
||||
|
||||
class GlobalOverlayItem(Base):
|
||||
__tablename__ = "global_overlay_items"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("source_id", "item_key", name="uq_global_overlay_item_source_key"),
|
||||
Index("ix_global_overlay_items_source", "source_id"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
source_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("global_overlay_sources.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
item_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
display_name: Mapped[str] = mapped_column(String(255), default="", nullable=False)
|
||||
download_url: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
expected_vpk_name: Mapped[str] = mapped_column(String(255), default="", nullable=False)
|
||||
expected_size: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
expected_md5: Mapped[str] = mapped_column(String(32), default="", nullable=False)
|
||||
etag: Mapped[str] = mapped_column(String(255), default="", nullable=False)
|
||||
last_modified: Mapped[str] = mapped_column(String(255), default="", nullable=False)
|
||||
content_length: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
last_downloaded_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
last_error: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
||||
|
||||
|
||||
class GlobalOverlayItemFile(Base):
|
||||
__tablename__ = "global_overlay_item_files"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("item_id", "vpk_name", name="uq_global_overlay_item_file_name"),
|
||||
Index("ix_global_overlay_item_files_item", "item_id"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
item_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("global_overlay_items.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
vpk_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
cache_path: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
size: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
md5: Mapped[str] = mapped_column(String(32), default="", nullable=False)
|
||||
script: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||
last_build_status: Mapped[str] = mapped_column(String(16), default="", nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
||||
|
||||
|
|
|
|||
96
l4d2web/tests/test_alembic_migrations.py
Normal file
96
l4d2web/tests/test_alembic_migrations.py
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
"""Tests for the alembic migration history.
|
||||
|
||||
The 0005 migration adds `script` and `last_build_status` columns to `overlays`,
|
||||
drops the global_overlay_* tables, and wipes legacy l4d2center_maps/cedapug_maps
|
||||
overlay rows. This module pins those behaviors.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
|
||||
|
||||
_ALEMBIC_DIR = Path(__file__).resolve().parents[1] / "alembic"
|
||||
|
||||
|
||||
def _alembic_config(db_url: str) -> Config:
|
||||
cfg = Config()
|
||||
cfg.set_main_option("script_location", str(_ALEMBIC_DIR))
|
||||
cfg.set_main_option("sqlalchemy.url", db_url)
|
||||
return cfg
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_url(tmp_path, monkeypatch):
|
||||
path = tmp_path / "alembic.db"
|
||||
url = f"sqlite:///{path}"
|
||||
monkeypatch.setenv("DATABASE_URL", url)
|
||||
yield url
|
||||
|
||||
|
||||
def test_upgrade_0005_adds_script_columns(db_url) -> None:
|
||||
cfg = _alembic_config(db_url)
|
||||
|
||||
command.upgrade(cfg, "0004_drop_legacy_external_overlay_type")
|
||||
|
||||
engine = create_engine(db_url)
|
||||
with engine.begin() as conn:
|
||||
# Seed legacy global-type overlay rows that the migration must wipe.
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO overlays (name, path, type, created_at, updated_at) "
|
||||
"VALUES ('legacy-l4d2center', '1', 'l4d2center_maps', "
|
||||
"'2026-01-01', '2026-01-01')"
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO overlays (name, path, type, created_at, updated_at) "
|
||||
"VALUES ('legacy-cedapug', '2', 'cedapug_maps', "
|
||||
"'2026-01-01', '2026-01-01')"
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO overlays (name, path, type, created_at, updated_at) "
|
||||
"VALUES ('keep-workshop', '3', 'workshop', "
|
||||
"'2026-01-01', '2026-01-01')"
|
||||
)
|
||||
)
|
||||
|
||||
command.upgrade(cfg, "0005_script_overlays")
|
||||
|
||||
inspector = inspect(engine)
|
||||
|
||||
overlay_cols = {c["name"]: c for c in inspector.get_columns("overlays")}
|
||||
assert "script" in overlay_cols
|
||||
assert "last_build_status" in overlay_cols
|
||||
assert overlay_cols["script"]["nullable"] is False
|
||||
assert overlay_cols["last_build_status"]["nullable"] is False
|
||||
|
||||
table_names = set(inspector.get_table_names())
|
||||
assert "global_overlay_sources" not in table_names
|
||||
assert "global_overlay_items" not in table_names
|
||||
assert "global_overlay_item_files" not in table_names
|
||||
|
||||
with engine.connect() as conn:
|
||||
rows = conn.execute(
|
||||
text("SELECT name, type FROM overlays ORDER BY name")
|
||||
).all()
|
||||
assert rows == [("keep-workshop", "workshop")]
|
||||
|
||||
defaults = conn.execute(
|
||||
text(
|
||||
"SELECT script, last_build_status FROM overlays "
|
||||
"WHERE name = 'keep-workshop'"
|
||||
)
|
||||
).one()
|
||||
assert defaults == ("", "")
|
||||
|
||||
|
||||
def test_downgrade_0005_skipped() -> None:
|
||||
"""Per the project convention (see 0004) destructive migrations are
|
||||
intentionally one-way; do not test or maintain a downgrade."""
|
||||
pytest.skip("0005 is one-way: globals data is gone after upgrade")
|
||||
|
|
@ -38,6 +38,15 @@ def test_overlay_has_type_and_user_id(db) -> None:
|
|||
assert row.user_id is None
|
||||
|
||||
|
||||
def test_overlay_has_script_columns(db) -> None:
|
||||
with session_scope() as s:
|
||||
s.add(Overlay(name="defaulted", path="1"))
|
||||
s.flush()
|
||||
row = s.query(Overlay).filter_by(name="defaulted").one()
|
||||
assert row.script == ""
|
||||
assert row.last_build_status == ""
|
||||
|
||||
|
||||
def test_two_system_overlays_with_same_name_are_rejected(db) -> None:
|
||||
with session_scope() as s:
|
||||
s.add(Overlay(name="shared", path="shared", type="l4d2center_maps", user_id=None))
|
||||
|
|
|
|||
Loading…
Reference in a new issue