pdf-quiz-generator/backend/alembic/versions/j0a1b2c3d4e5_article_trash.py
Daniel aafea65a52 feat: delete an article, and a trash for the ones that were published
There was no way to delete an article from anywhere in the interface. The API
had one; the only route to it was curl.

Now there is a control at the foot of the editor, and it does one of two things
depending on the article's history — and says which before it is pressed:

* A draft that was **never published** is deleted outright. There is nothing to
  restore, and a trash full of abandoned stubs is a second list to maintain.
* Anything that has been published, even once, is **marked** and appears in the
  trash on Editorial, restorable exactly as it was. Somewhere there is a
  learner's note against one of its sections, a question linked to it, and a
  link somebody sent a colleague; a DELETE typed in the afternoon should not
  settle any of that.

`first_published_at` is what decides, stamped on the first publish and never
cleared — unpublishing does not make an article unseen, so it does not make
deleting it safe either. Backfilled from `reviewed_at` for everything currently
published, because an article with a null stamp reads to the rule as a
never-published draft.

A binned article is out of the listing, the editorial queue, every slug and id
lookup, and — immediately — the search index, so it cannot still answer a
learner's question from the trash.

Also on Editorial, because a hundred rows is a queue you work through and not a
page you scroll past on the way to the next queue: each bucket keeps its own
box, its own scrollbar and its own filter.

And the editor finally has a way out that is not Save: Back and Discard, with
an inline confirmation when there are unsaved changes. The way out was the
browser's back button, which throws the sitting away without saying so.

Migration j0a1b2c3d4e5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 19:31:28 +02:00

64 lines
2.4 KiB
Python

"""An article that was ever published goes to the trash instead of vanishing
Three columns on `articles`:
* `first_published_at` — stamped the first time it is published and never
cleared. It is what decides whether deleting is reversible.
* `deleted_at` / `deleted_by` — the trash itself.
The rule it exists for: a draft nobody ever saw is deleted outright, because
there is nothing to restore and a trash full of abandoned stubs is a second
list to maintain. Anything the world has seen — anything with a
`first_published_at` — is only ever marked, because somewhere there is a
learner's note against one of its sections, a question linked to it, and a link
somebody sent to a colleague.
Backfilled from `reviewed_at` where an article is published now: the exact
first-publication moment is not recorded anywhere, and any published article
must have a non-null stamp or the rule reads it as a never-published draft and
deletes it for good.
Revision ID: j0a1b2c3d4e5
Revises: i9f0a1b2c3d4
"""
import sqlalchemy as sa
from alembic import op
revision = "j0a1b2c3d4e5"
down_revision = "i9f0a1b2c3d4"
branch_labels = None
depends_on = None
COLUMNS = {
"first_published_at": sa.Column("first_published_at", sa.DateTime(), nullable=True),
"deleted_at": sa.Column("deleted_at", sa.DateTime(), nullable=True),
"deleted_by": sa.Column("deleted_by", sa.Integer(), nullable=True),
}
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
# `Base.metadata.create_all()` still runs at startup, so a fresh deploy may
# already have these. Idempotent by inspection rather than by try/except.
existing = {column["name"] for column in inspector.get_columns("articles")}
for name, column in COLUMNS.items():
if name not in existing:
op.add_column("articles", column)
indexes = {index["name"] for index in inspector.get_indexes("articles")}
if "ix_articles_deleted_at" not in indexes:
op.create_index("ix_articles_deleted_at", "articles", ["deleted_at"])
op.execute("""
UPDATE articles
SET first_published_at = COALESCE(reviewed_at, updated_at, created_at)
WHERE status = 'published' AND first_published_at IS NULL
""")
def downgrade() -> None:
op.drop_index("ix_articles_deleted_at", table_name="articles")
for name in COLUMNS:
op.drop_column("articles", name)