-
Notifications
You must be signed in to change notification settings - Fork 0
feat: manage categories -- edit built-ins, add your own, backfill #4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 1 commit
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
da5ccf3
feat: manage categories -- edit built-ins, add your own, backfill
richardhapb 63404d7
fix: only fork builtin keywords when they actually change
richardhapb d9ca6dc
fix: restore NOT NULL on transferences.category when downgrading
richardhapb c385f46
chore: drop unrelated textarea CSS tweak from this branch
richardhapb File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
111 changes: 111 additions & 0 deletions
111
alembic/versions/c4d7e1b9a250_add_user_category_overrides.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,111 @@ | ||
| """add per-user category overrides and pattern ownership | ||
|
|
||
| Builtin categories are shared rows, so a user cannot edit them in place. This | ||
| migration adds: | ||
|
|
||
| * ``category_patterns.user_id`` -- NULL for the shared catalog rows, set when | ||
| the keyword belongs to a user (their own category, or their fork of a | ||
| builtin one). | ||
| * ``category_overrides`` -- a user's rename of a builtin category and the flag | ||
| marking that they took over its keyword set. | ||
|
|
||
| Revision ID: c4d7e1b9a250 | ||
| Revises: b3f1c2a4d5e6 | ||
| Create Date: 2026-07-25 | ||
|
|
||
| """ | ||
|
|
||
| from collections.abc import Sequence | ||
|
|
||
| from alembic import op | ||
| import sqlalchemy as sa | ||
|
|
||
|
|
||
| # revision identifiers, used by Alembic. | ||
| revision: str = "c4d7e1b9a250" | ||
| down_revision: str | Sequence[str] | None = "b3f1c2a4d5e6" | ||
| branch_labels: str | Sequence[str] | None = None | ||
| depends_on: str | Sequence[str] | None = None | ||
|
|
||
| LEGACY_PATTERN_INDEX = "ix_category_patterns_category_id_pattern" | ||
| PATTERN_INDEX = "ix_category_patterns_category_id_user_id_pattern" | ||
|
|
||
|
|
||
| def _index_names(table_name: str) -> set[str]: | ||
| inspector = sa.inspect(op.get_bind()) | ||
| return {index["name"] for index in inspector.get_indexes(table_name)} | ||
|
|
||
|
|
||
| def upgrade() -> None: | ||
| """Add pattern ownership and the per-user override table.""" | ||
| op.add_column("category_patterns", sa.Column("user_id", sa.Integer(), nullable=True)) | ||
| op.create_foreign_key( | ||
| "fk_category_patterns_user_id_users", | ||
| "category_patterns", | ||
| "users", | ||
| ["user_id"], | ||
| ["id"], | ||
| ) | ||
| op.create_index("ix_category_patterns_user_id", "category_patterns", ["user_id"], unique=False) | ||
|
|
||
| # Keywords of a user-owned category belong to that user. | ||
| op.execute( | ||
| sa.text( | ||
| """ | ||
| UPDATE category_patterns | ||
| SET user_id = ( | ||
| SELECT categories.user_id | ||
| FROM categories | ||
| WHERE categories.id = category_patterns.category_id | ||
| ) | ||
| """ | ||
| ) | ||
| ) | ||
|
|
||
| # A forked keyword set repeats builtin keywords for the same category, so | ||
| # uniqueness has to include the owner. | ||
| if LEGACY_PATTERN_INDEX in _index_names("category_patterns"): | ||
| op.drop_index(LEGACY_PATTERN_INDEX, table_name="category_patterns") | ||
| op.create_index( | ||
| PATTERN_INDEX, | ||
| "category_patterns", | ||
| ["category_id", "user_id", "pattern"], | ||
| unique=True, | ||
| ) | ||
|
|
||
| op.create_table( | ||
| "category_overrides", | ||
| sa.Column("id", sa.Integer(), nullable=False), | ||
| sa.Column("user_id", sa.Integer(), nullable=False), | ||
| sa.Column("category_id", sa.Integer(), nullable=False), | ||
| sa.Column("name", sa.String(), nullable=True), | ||
| sa.Column("patterns_overridden", sa.Boolean(), nullable=False, server_default=sa.false()), | ||
| sa.ForeignKeyConstraint(["user_id"], ["users.id"]), | ||
| sa.ForeignKeyConstraint(["category_id"], ["categories.id"]), | ||
| sa.PrimaryKeyConstraint("id"), | ||
| sa.UniqueConstraint("user_id", "category_id", name="uq_category_overrides_user_category"), | ||
| ) | ||
| op.create_index("ix_category_overrides_user_id", "category_overrides", ["user_id"], unique=False) | ||
| op.create_index("ix_category_overrides_category_id", "category_overrides", ["category_id"], unique=False) | ||
|
|
||
|
|
||
| def downgrade() -> None: | ||
| """Drop overrides and pattern ownership, keeping only the shared catalog.""" | ||
| op.drop_index("ix_category_overrides_category_id", table_name="category_overrides") | ||
| op.drop_index("ix_category_overrides_user_id", table_name="category_overrides") | ||
| op.drop_table("category_overrides") | ||
|
|
||
| # Forked keywords have no home in the old schema. | ||
| op.execute(sa.text("DELETE FROM category_patterns WHERE user_id IS NOT NULL")) | ||
|
|
||
| if PATTERN_INDEX in _index_names("category_patterns"): | ||
| op.drop_index(PATTERN_INDEX, table_name="category_patterns") | ||
| op.drop_index("ix_category_patterns_user_id", table_name="category_patterns") | ||
| op.drop_constraint("fk_category_patterns_user_id_users", "category_patterns", type_="foreignkey") | ||
| op.drop_column("category_patterns", "user_id") | ||
| op.create_index( | ||
| LEGACY_PATTERN_INDEX, | ||
| "category_patterns", | ||
| ["category_id", "pattern"], | ||
| unique=True, | ||
| ) |
130 changes: 130 additions & 0 deletions
130
alembic/versions/d5b8f1c07e42_add_transference_category_id.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,130 @@ | ||
| """point transferences at the categories catalog | ||
|
|
||
| Transferences stored their category as a builtin enum slug, so a user's own | ||
| category could never apply to them. This mirrors what expenses already do: | ||
| a ``category_id`` FK, backfilled from the legacy enum column. | ||
|
|
||
| Revision ID: d5b8f1c07e42 | ||
| Revises: c4d7e1b9a250 | ||
| Create Date: 2026-07-25 | ||
|
|
||
| """ | ||
|
|
||
| from collections.abc import Sequence | ||
|
|
||
| from alembic import op | ||
| import sqlalchemy as sa | ||
| from sqlalchemy.dialects import postgresql | ||
|
|
||
|
|
||
| # revision identifiers, used by Alembic. | ||
| revision: str = "d5b8f1c07e42" | ||
| down_revision: str | Sequence[str] | None = "c4d7e1b9a250" | ||
| branch_labels: str | Sequence[str] | None = None | ||
| depends_on: str | Sequence[str] | None = None | ||
|
|
||
| GENERAL_CATEGORY_SLUG = "general" | ||
|
|
||
|
|
||
| def _legacy_category_type() -> sa.types.TypeEngine: | ||
| """The pre-existing ``expensecategory`` enum, which is never dropped.""" | ||
| if op.get_bind().dialect.name == "postgresql": | ||
| return postgresql.ENUM(name="expensecategory", create_type=False) | ||
| return sa.String() | ||
|
|
||
|
|
||
| def _column_names(table_name: str) -> set[str]: | ||
| inspector = sa.inspect(op.get_bind()) | ||
| return {column["name"] for column in inspector.get_columns(table_name)} | ||
|
|
||
|
|
||
| def _global_categories_by_slug() -> dict[str, int]: | ||
| connection = op.get_bind() | ||
| metadata = sa.MetaData() | ||
| categories = sa.Table("categories", metadata, autoload_with=connection) | ||
| return { | ||
| row.slug: row.id | ||
| for row in connection.execute( | ||
| sa.select(categories.c.id, categories.c.slug).where(categories.c.user_id.is_(None)) | ||
| ) | ||
| } | ||
|
|
||
|
|
||
| def _normalize_legacy_category(value: object | None) -> str: | ||
| if value is None: | ||
| return GENERAL_CATEGORY_SLUG | ||
| normalized = str(value).split(".")[-1].strip().lower() | ||
| return normalized or GENERAL_CATEGORY_SLUG | ||
|
|
||
|
|
||
| def _backfill_transference_categories() -> None: | ||
| connection = op.get_bind() | ||
| metadata = sa.MetaData() | ||
| transferences = sa.Table("transferences", metadata, autoload_with=connection) | ||
|
|
||
| categories_by_slug = _global_categories_by_slug() | ||
| default_category_id = categories_by_slug[GENERAL_CATEGORY_SLUG] | ||
| has_legacy_column = "category" in _column_names("transferences") | ||
|
|
||
| if has_legacy_column: | ||
| rows = connection.execute( | ||
| sa.select(transferences.c.id, transferences.c.category, transferences.c.category_id) | ||
| ).all() | ||
| else: | ||
| rows = connection.execute(sa.select(transferences.c.id, transferences.c.category_id)).all() | ||
|
|
||
| for row in rows: | ||
| if row.category_id is not None: | ||
| continue | ||
| slug = _normalize_legacy_category(getattr(row, "category", None)) | ||
| category_id = categories_by_slug.get(slug, default_category_id) | ||
| connection.execute( | ||
| transferences.update().where(transferences.c.id == row.id).values(category_id=category_id) | ||
| ) | ||
|
|
||
|
|
||
| def upgrade() -> None: | ||
| """Add transferences.category_id, backfilled from the legacy enum.""" | ||
| if "category_id" not in _column_names("transferences"): | ||
| op.add_column("transferences", sa.Column("category_id", sa.Integer(), nullable=True)) | ||
|
|
||
| _backfill_transference_categories() | ||
|
|
||
| with op.batch_alter_table("transferences") as batch_op: | ||
| batch_op.create_foreign_key( | ||
| "fk_transferences_category_id_categories", "categories", ["category_id"], ["id"] | ||
| ) | ||
| batch_op.create_index("ix_transferences_category_id", ["category_id"], unique=False) | ||
| batch_op.alter_column("category_id", existing_type=sa.Integer(), nullable=False) | ||
| if "category" in _column_names("transferences"): | ||
| batch_op.drop_column("category") | ||
|
|
||
|
|
||
| def downgrade() -> None: | ||
| """Restore the legacy enum column from the linked category slug.""" | ||
| if "category" not in _column_names("transferences"): | ||
| op.add_column("transferences", sa.Column("category", _legacy_category_type(), nullable=True)) | ||
|
|
||
| connection = op.get_bind() | ||
| metadata = sa.MetaData() | ||
| transferences = sa.Table("transferences", metadata, autoload_with=connection) | ||
| categories = sa.Table("categories", metadata, autoload_with=connection) | ||
|
|
||
| slugs_by_id = {row.id: row.slug for row in connection.execute(sa.select(categories.c.id, categories.c.slug))} | ||
| global_slugs = { | ||
| row.slug | ||
| for row in connection.execute(sa.select(categories.c.slug).where(categories.c.user_id.is_(None))) | ||
| } | ||
| for row in connection.execute(sa.select(transferences.c.id, transferences.c.category_id)): | ||
| # Custom categories have no enum member, so they fall back to general. | ||
| slug = slugs_by_id.get(row.category_id, GENERAL_CATEGORY_SLUG) | ||
| if slug not in global_slugs: | ||
| slug = GENERAL_CATEGORY_SLUG | ||
| connection.execute( | ||
| transferences.update().where(transferences.c.id == row.id).values(category=slug.upper()) | ||
| ) | ||
|
|
||
| with op.batch_alter_table("transferences") as batch_op: | ||
| batch_op.drop_index("ix_transferences_category_id") | ||
| batch_op.drop_constraint("fk_transferences_category_id_categories", type_="foreignkey") | ||
| batch_op.drop_column("category_id") | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.