From 058d0cc05d5d626d2ff30f30390b2fd31e232074 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Murta?= Date: Sun, 18 Dec 2022 23:38:45 +0000 Subject: [PATCH] Adds Tags rules Creates an available tags table to cross reference to. Clarifies some logic in the DbClient, since all adds/removes are the same. --- alembic/versions/9028b0f3b985_tag_rules.py | 68 ++++++++++++++++++++++ pfbudget/__main__.py | 50 +++++++++++++++- pfbudget/cli/runnable.py | 62 +++++++++++++++----- pfbudget/common/types.py | 5 ++ pfbudget/core/manager.py | 36 ++++++++---- pfbudget/db/client.py | 36 +++++------- pfbudget/db/model.py | 35 +++++++++-- 7 files changed, 240 insertions(+), 52 deletions(-) create mode 100644 alembic/versions/9028b0f3b985_tag_rules.py diff --git a/alembic/versions/9028b0f3b985_tag_rules.py b/alembic/versions/9028b0f3b985_tag_rules.py new file mode 100644 index 0000000..ae8cf2c --- /dev/null +++ b/alembic/versions/9028b0f3b985_tag_rules.py @@ -0,0 +1,68 @@ +"""Tag rules + +Revision ID: 9028b0f3b985 +Revises: 753c0bfb2062 +Create Date: 2022-12-18 22:53:13.334046+00:00 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "9028b0f3b985" +down_revision = "753c0bfb2062" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "tags_available", + sa.Column("name", sa.String(), nullable=False), + sa.PrimaryKeyConstraint("name", name=op.f("pk_tags_available")), + schema="transactions", + ) + op.create_table( + "tag_rules", + sa.Column("id", sa.BigInteger(), nullable=False), + sa.Column("tag", sa.String(), nullable=False), + sa.Column("date", sa.Date(), nullable=True), + sa.Column("description", sa.String(), nullable=True), + sa.Column("regex", sa.String(), nullable=True), + sa.Column("bank", sa.String(), nullable=True), + sa.Column("min", sa.Numeric(precision=16, scale=2), nullable=True), + sa.Column("max", sa.Numeric(precision=16, scale=2), nullable=True), + sa.ForeignKeyConstraint( + ["tag"], + ["transactions.tags_available.name"], + name=op.f("fk_tag_rules_tag_tags_available"), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_tag_rules")), + schema="transactions", + ) + op.create_foreign_key( + op.f("fk_tags_tag_tags_available"), + "tags", + "tags_available", + ["tag"], + ["name"], + source_schema="transactions", + referent_schema="transactions", + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint( + op.f("fk_tags_tag_tags_available"), + "tags", + schema="transactions", + type_="foreignkey", + ) + op.drop_table("tag_rules", schema="transactions") + op.drop_table("tags_available", schema="transactions") + # ### end Alembic commands ### diff --git a/pfbudget/__main__.py b/pfbudget/__main__.py index 1348ca9..e5c0303 100644 --- a/pfbudget/__main__.py +++ b/pfbudget/__main__.py @@ -65,7 +65,7 @@ if __name__ == "__main__": for cat in args["category"] ] - case pfbudget.Operation.RuleRemove: + case pfbudget.Operation.RuleRemove | pfbudget.Operation.TagRuleRemove: assert args.keys() >= {"id"}, "argparser ill defined" params = args["id"] @@ -90,6 +90,54 @@ if __name__ == "__main__": params.append(param) + case pfbudget.Operation.TagAdd: + assert args.keys() >= {"tag"}, "argparser ill defined" + params = [pfbudget.types.Tag(tag) for tag in args["tag"]] + + case pfbudget.Operation.TagRuleAdd: + assert args.keys() >= { + "tag", + "date", + "description", + "bank", + "min", + "max", + }, "argparser ill defined" + + params = [ + pfbudget.types.TagRule( + tag, + args["date"][0] if args["date"] else None, + args["description"][0] if args["description"] else None, + args["regex"][0] if args["regex"] else None, + args["bank"][0] if args["bank"] else None, + args["min"][0] if args["min"] else None, + args["max"][0] if args["max"] else None, + ) + for tag in args["tag"] + ] + + case pfbudget.Operation.TagRuleModify: + assert args.keys() >= { + "id", + "tag", + "date", + "description", + "bank", + "min", + "max", + "remove", + }, "argparser ill defined" + + nargs_1 = ["tag", "date", "description", "regex", "bank", "min", "max"] + params = [] + for id in args["id"]: + param = {"id": id} + param |= {k: v[0] for k, v in args.items() if k in nargs_1 and args[k]} + param |= {k: None for k in args["remove"] if k in nargs_1} + + params.append(param) + case pfbudget.Operation.GroupAdd: assert "group" in args, "argparser ill defined" params = [pfbudget.types.CategoryGroup(group) for group in args["group"]] diff --git a/pfbudget/cli/runnable.py b/pfbudget/cli/runnable.py index 3a44494..cad96cc 100644 --- a/pfbudget/cli/runnable.py +++ b/pfbudget/cli/runnable.py @@ -255,6 +255,9 @@ def argparser() -> argparse.ArgumentParser: category_parser = subparsers.add_parser("category", parents=[universal]) category(category_parser, universal) + # Tag + tags(subparsers.add_parser("tag", parents=[universal]), universal) + return parser @@ -366,12 +369,7 @@ def category_rule(parser: argparse.ArgumentParser, universal: argparse.ArgumentP add = commands.add_parser("add", parents=[universal]) add.set_defaults(op=Operation.RuleAdd) add.add_argument("category", nargs="+", type=str) - add.add_argument("--date", nargs=1, type=dt.date.fromisoformat) - add.add_argument("--description", nargs=1, type=str) - add.add_argument("--regex", nargs=1, type=str) - add.add_argument("--bank", nargs=1, type=str) - add.add_argument("--min", nargs=1, type=decimal.Decimal) - add.add_argument("--max", nargs=1, type=decimal.Decimal) + rules(add) remove = commands.add_parser("remove", parents=[universal]) remove.set_defaults(op=Operation.RuleRemove) @@ -381,13 +379,51 @@ def category_rule(parser: argparse.ArgumentParser, universal: argparse.ArgumentP modify.set_defaults(op=Operation.RuleModify) modify.add_argument("id", nargs="+", type=int) modify.add_argument("--category", nargs=1, type=str) - modify.add_argument("--date", nargs=1, type=dt.date.fromisoformat) - modify.add_argument("--description", nargs=1, type=str) - modify.add_argument("--regex", nargs=1, type=str) - modify.add_argument("--bank", nargs=1, type=str) - modify.add_argument("--min", nargs=1, type=decimal.Decimal) - modify.add_argument("--max", nargs=1, type=decimal.Decimal) - modify.add_argument("--remove", nargs="*", default=[], type=str) + rules(modify) + + +def tags(parser: argparse.ArgumentParser, universal: argparse.ArgumentParser): + + commands = parser.add_subparsers(required=True) + + add = commands.add_parser("add", parents=[universal]) + add.set_defaults(op=Operation.TagAdd) + add.add_argument("tag", nargs="+", type=str) + + remove = commands.add_parser("remove", parents=[universal]) + remove.set_defaults(op=Operation.TagRemove) + remove.add_argument("tag", nargs="+", type=str) + + rule = commands.add_parser("rule", parents=[universal]) + tag_rule(rule, universal) + + +def tag_rule(parser: argparse.ArgumentParser, universal: argparse.ArgumentParser): + commands = parser.add_subparsers(required=True) + + add = commands.add_parser("add", parents=[universal]) + add.set_defaults(op=Operation.TagRuleAdd) + add.add_argument("tag", nargs="+", type=str) + rules(add) + + remove = commands.add_parser("remove", parents=[universal]) + remove.set_defaults(op=Operation.TagRuleRemove) + remove.add_argument("id", nargs="+", type=int) + + modify = commands.add_parser("modify", parents=[universal]) + modify.set_defaults(op=Operation.TagRuleModify) + modify.add_argument("id", nargs="+", type=int) + modify.add_argument("--tag", nargs=1, type=str) + rules(modify) + + +def rules(parser: argparse.ArgumentParser): + parser.add_argument("--date", nargs=1, type=dt.date.fromisoformat) + parser.add_argument("--description", nargs=1, type=str) + parser.add_argument("--regex", nargs=1, type=str) + parser.add_argument("--bank", nargs=1, type=str) + parser.add_argument("--min", nargs=1, type=decimal.Decimal) + parser.add_argument("--max", nargs=1, type=decimal.Decimal) def run(): diff --git a/pfbudget/common/types.py b/pfbudget/common/types.py index c8f39d4..8a6c0e4 100644 --- a/pfbudget/common/types.py +++ b/pfbudget/common/types.py @@ -22,6 +22,11 @@ class Operation(Enum): RuleModify = auto() GroupAdd = auto() GroupRemove = auto() + TagAdd = auto() + TagRemove = auto() + TagRuleAdd = auto() + TagRuleRemove = auto() + TagRuleModify = auto() class TransactionError(Exception): diff --git a/pfbudget/core/manager.py b/pfbudget/core/manager.py index 2597ea1..bf6d4bf 100644 --- a/pfbudget/core/manager.py +++ b/pfbudget/core/manager.py @@ -2,7 +2,14 @@ from pfbudget.input.input import Input from pfbudget.input.nordigen import NordigenClient from pfbudget.input.parsers import parse_data from pfbudget.db.client import DbClient -from pfbudget.db.model import Category, CategoryGroup, CategoryRule, CategorySchedule +from pfbudget.db.model import ( + Category, + CategoryGroup, + CategoryRule, + CategorySchedule, + Tag, + TagRule, +) from pfbudget.common.types import Operation from pfbudget.core.categorizer import Categorizer from pfbudget.utils import convert @@ -49,9 +56,9 @@ class Manager: self.args["name"], self.args["country"] ) - case Operation.CategoryAdd: + case Operation.CategoryAdd | Operation.TagAdd: with self.db.session() as session: - session.addcategories(params) + session.add(params) case Operation.CategoryUpdate: with self.db.session() as session: @@ -59,34 +66,43 @@ class Manager: case Operation.CategoryRemove: with self.db.session() as session: - session.removecategories(params) + session.remove_by_name(Category, params) case Operation.CategorySchedule: with self.db.session() as session: session.updateschedules(params) - case Operation.RuleAdd: + case Operation.RuleAdd | Operation.TagRuleAdd: with self.db.session() as session: - session.addrules(params) + session.add(params) case Operation.RuleRemove: assert all(isinstance(param, int) for param in params) with self.db.session() as session: - session.removerules(params) + session.remove_by_id(CategoryRule, params) - case Operation.RuleModify: + case Operation.TagRemove: + with self.db.session() as session: + session.remove_by_name(Tag, params) + + case Operation.TagRuleRemove: + assert all(isinstance(param, int) for param in params) + with self.db.session() as session: + session.remove_by_id(TagRule, params) + + case Operation.RuleModify | Operation.TagRuleModify: assert all(isinstance(param, dict) for param in params) with self.db.session() as session: session.updaterules(params) case Operation.GroupAdd: with self.db.session() as session: - session.addgroups(CategoryGroup(params)) + session.add(CategoryGroup(params)) case Operation.GroupRemove: assert all(isinstance(param, CategoryGroup) for param in params) with self.db.session() as session: - session.removegroups(params) + session.remove_by_name(CategoryGroup, params) # def init(self): # client = DatabaseClient(self.__db) diff --git a/pfbudget/db/client.py b/pfbudget/db/client.py index 175fa67..68803e3 100644 --- a/pfbudget/db/client.py +++ b/pfbudget/db/client.py @@ -10,6 +10,8 @@ from pfbudget.db.model import ( CategoryGroup, CategoryRule, CategorySchedule, + Tag, + TagRule, Transaction, ) @@ -81,16 +83,16 @@ class DbClient: def commit(self): self.__session.commit() - def add(self, transactions: list[Transaction]): - self.__session.add_all(transactions) + def add( + self, + rows: list[ + Category | CategoryGroup | CategoryRule | Tag | TagRule | Transaction + ], + ): + self.__session.add_all(rows) - def addcategories(self, category: list[Category]): - self.__session.add_all(category) - - def removecategories(self, categories: list[Category]): - stmt = delete(Category).where( - Category.name.in_([cat.name for cat in categories]) - ) + def remove_by_name(self, type: Category | Tag | Transaction, rows: list): + stmt = delete(type).where(type.name.in_([row.name for row in rows])) self.__session.execute(stmt) def updategroup(self, categories: list[Category], group: CategoryGroup): @@ -113,25 +115,13 @@ class DbClient: ) self.__session.execute(stmt) - def addrules(self, rules: list[CategoryRule]): - self.__session.add_all(rules) - - def removerules(self, ids: list[int]): - stmt = delete(CategoryRule).where(CategoryRule.id.in_(ids)) + def remove_by_id(self, type: CategoryRule | TagRule, ids: list[int]): + stmt = delete(type).where(type.id.in_(ids)) self.__session.execute(stmt) def updaterules(self, rules: list[dict]): self.__session.execute(update(CategoryRule), rules) - def addgroups(self, groups: list[CategoryGroup]): - self.__session.add_all(groups) - - def removegroups(self, groups: list[CategoryGroup]): - stmt = delete(CategoryGroup).where( - CategoryGroup.name.in_([grp.name for grp in groups]) - ) - self.__session.execute(stmt) - def uncategorized(self) -> list[Transaction]: stmt = select(Transaction).where(~Transaction.category.has()) return self.__session.scalars(stmt).all() diff --git a/pfbudget/db/model.py b/pfbudget/db/model.py index b506783..0c31380 100644 --- a/pfbudget/db/model.py +++ b/pfbudget/db/model.py @@ -68,14 +68,16 @@ class Bank(Base): bankfk = Annotated[str, mapped_column(Text, ForeignKey(Bank.name))] -idpk = Annotated[int, mapped_column(BigInteger, primary_key=True)] +idpk = Annotated[ + int, mapped_column(BigInteger, primary_key=True, autoincrement=True, init=False) +] money = Annotated[Decimal, mapped_column(Numeric(16, 2))] class Transaction(Base): __tablename__ = "originals" - id: Mapped[idpk] = mapped_column(autoincrement=True, init=False) + id: Mapped[idpk] date: Mapped[dt.date] description: Mapped[Optional[str]] bank: Mapped[bankfk] @@ -83,7 +85,7 @@ class Transaction(Base): category: Mapped[Optional[TransactionCategory]] = relationship() note: Mapped[Optional[Note]] = relationship(back_populates="original", default=None) - tags: Mapped[Optional[set[Tag]]] = relationship( + tags: Mapped[Optional[set[TransactionTag]]] = relationship( back_populates="original", cascade="all, delete-orphan", passive_deletes=True, @@ -166,10 +168,20 @@ class Nordigen(Base): class Tag(Base): + __tablename__ = "tags_available" + + name: Mapped[str] = mapped_column(primary_key=True) + + rules: Mapped[Optional[set[TagRule]]] = relationship( + cascade="all, delete-orphan", passive_deletes=True, default_factory=set + ) + + +class TransactionTag(Base): __tablename__ = "tags" id: Mapped[idfk] = mapped_column(primary_key=True) - tag: Mapped[str] = mapped_column(primary_key=True) + tag: Mapped[str] = mapped_column(ForeignKey(Tag.name), primary_key=True) original: Mapped[Transaction] = relationship(back_populates="tags") @@ -177,7 +189,7 @@ class Tag(Base): class CategoryRule(Base): __tablename__ = "categories_rules" - id: Mapped[idpk] = mapped_column(autoincrement=True, init=False) + id: Mapped[idpk] name: Mapped[catfk] date: Mapped[Optional[dt.date]] description: Mapped[Optional[str]] @@ -239,3 +251,16 @@ class CategorySchedule(Base): def __repr__(self) -> str: return f"{self.name} schedule=Schedule(period={self.period}, multiplier={self.period_multiplier}, amount={self.amount})" + + +class TagRule(Base): + __tablename__ = "tag_rules" + + id: Mapped[idpk] + tag: Mapped[str] = mapped_column(ForeignKey(Tag.name, ondelete="CASCADE")) + date: Mapped[Optional[dt.date]] + description: Mapped[Optional[str]] + regex: Mapped[Optional[str]] + bank: Mapped[Optional[str]] + min: Mapped[Optional[money]] + max: Mapped[Optional[money]]