Categories rules for rule-based categorization

This patch extends the categories_rules with a rule for each field of a
transaction.

It also changes the ORM classes to behave as dataclasses again.
This commit is contained in:
Luís Murta 2022-12-09 19:38:12 +00:00
parent 63d6a2aab7
commit 471331ffc9
Signed by: satprog
GPG Key ID: 169EF1BBD7049F94
6 changed files with 139 additions and 23 deletions

View File

@ -0,0 +1,92 @@
"""Transaction based rules
Revision ID: 8b5d5fbc8211
Revises: e77395969585
Create Date: 2022-12-08 21:05:41.378466+00:00
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "8b5d5fbc8211"
down_revision = "e77395969585"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_constraint("pk_categories_rules", "categories_rules", schema="transactions")
op.execute(sa.schema.CreateSequence(sa.schema.Sequence("categories_rules_id_seq", schema="transactions")))
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"categories_rules",
sa.Column(
"id",
sa.BigInteger(),
autoincrement=True,
nullable=False,
server_default=sa.text(
"nextval('transactions.categories_rules_id_seq'::regclass)"
),
),
schema="transactions",
)
op.add_column(
"categories_rules",
sa.Column("date", sa.String(), nullable=True),
schema="transactions",
)
op.add_column(
"categories_rules",
sa.Column("description", sa.String(), nullable=True),
schema="transactions",
)
op.add_column(
"categories_rules",
sa.Column("bank", sa.String(), nullable=True),
schema="transactions",
)
op.add_column(
"categories_rules",
sa.Column("min_amount", sa.Float(), nullable=True),
schema="transactions",
)
op.add_column(
"categories_rules",
sa.Column("max_amount", sa.Float(), nullable=True),
schema="transactions",
)
op.drop_column("categories_rules", "rule", schema="transactions")
# ### end Alembic commands ###
op.create_primary_key(
"pk_categories_rules",
"categories_rules",
["id"],
schema="transactions",
)
def downgrade() -> None:
op.drop_constraint("pk_categories_rules", "categories_rules", schema="transactions")
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"categories_rules",
sa.Column("rule", sa.String(), autoincrement=False, nullable=False),
schema="transactions",
)
op.drop_column("categories_rules", "max_amount", schema="transactions")
op.drop_column("categories_rules", "min_amount", schema="transactions")
op.drop_column("categories_rules", "bank", schema="transactions")
op.drop_column("categories_rules", "description", schema="transactions")
op.drop_column("categories_rules", "date", schema="transactions")
op.drop_column("categories_rules", "id", schema="transactions")
# ### end Alembic commands ###
op.execute(sa.schema.DropSequence(sa.schema.Sequence("categories_rules_id_seq", schema="transactions")))
op.create_primary_key(
"pk_categories_rules",
"categories_rules",
["name", "rule"],
schema="transactions",
)

View File

@ -340,6 +340,15 @@ def category(parser: argparse.ArgumentParser, universal: argparse.ArgumentParser
schedule.add_argument("period", nargs=1, choices=[e.value for e in Period]) schedule.add_argument("period", nargs=1, choices=[e.value for e in Period])
schedule.add_argument("--frequency", nargs=1, default=[1], type=int) schedule.add_argument("--frequency", nargs=1, default=[1], type=int)
rule = commands.add_parser("rule", parents=[universal])
rule.set_defaults(op=Operation.CategoryRule)
rule.add_argument("category", nargs="+", type=str)
rule.add_argument("--date", nargs=1, type=str)
rule.add_argument("--description", nargs=1, type=str)
rule.add_argument("--bank", nargs=1, type=str)
rule.add_argument("--min", nargs=1, type=float)
rule.add_argument("--max", nargs=1, type=float)
group = commands.add_parser("group", parents=[universal]) group = commands.add_parser("group", parents=[universal])
category_group(group, universal) category_group(group, universal)

View File

@ -17,6 +17,7 @@ class Operation(Enum):
CategoryUpdate = auto() CategoryUpdate = auto()
CategoryRemove = auto() CategoryRemove = auto()
CategorySchedule = auto() CategorySchedule = auto()
CategoryRule = auto()
GroupAdd = auto() GroupAdd = auto()
GroupRemove = auto() GroupRemove = auto()

View File

@ -2,7 +2,7 @@ from pfbudget.input.input import Input
from pfbudget.input.nordigen import NordigenClient from pfbudget.input.nordigen import NordigenClient
from pfbudget.input.parsers import parse_data from pfbudget.input.parsers import parse_data
from pfbudget.db.client import DbClient from pfbudget.db.client import DbClient
from pfbudget.db.model import Category, CategoryGroup, CategorySchedule from pfbudget.db.model import Category, CategoryGroup, CategoryRule, CategorySchedule
from pfbudget.common.types import Operation from pfbudget.common.types import Operation
from pfbudget.core.categorizer import Categorizer from pfbudget.core.categorizer import Categorizer
from pfbudget.utils import convert from pfbudget.utils import convert
@ -72,15 +72,9 @@ class Manager:
"period" in self.args and "frequency" in self.args "period" in self.args and "frequency" in self.args
), "Schedule not well defined" ), "Schedule not well defined"
case Operation.CategoryRule:
with self.db.session() as session: with self.db.session() as session:
session.updateschedules( session.addrules(params)
[Category(name=category) for category in self.args["category"]],
CategorySchedule(
recurring=True,
period=self.args["period"][0],
period_multiplier=self.args["frequency"][0],
),
)
case Operation.GroupAdd: case Operation.GroupAdd:
with self.db.session() as session: with self.db.session() as session:

View File

@ -8,6 +8,7 @@ from pfbudget.db.model import (
Bank, Bank,
Category, Category,
CategoryGroup, CategoryGroup,
CategoryRule,
CategorySchedule, CategorySchedule,
Transaction, Transaction,
) )
@ -124,6 +125,9 @@ class DbClient:
) )
self.__session.execute(stmt) self.__session.execute(stmt)
def addrules(self, rules: list[CategoryRule]):
self.__session.add_all(rules)
def addcategorygroup(self, group: CategoryGroup): def addcategorygroup(self, group: CategoryGroup):
self.__session.add(group) self.__session.add(group)

View File

@ -9,7 +9,13 @@ from sqlalchemy import (
String, String,
Text, Text,
) )
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship from sqlalchemy.orm import (
DeclarativeBase,
Mapped,
mapped_column,
MappedAsDataclass,
relationship,
)
from decimal import Decimal from decimal import Decimal
from typing import Annotated, Optional from typing import Annotated, Optional
@ -17,7 +23,7 @@ import datetime as dt
import enum import enum
class Base(DeclarativeBase): class Base(MappedAsDataclass, DeclarativeBase):
__table_args__ = {"schema": "transactions"} __table_args__ = {"schema": "transactions"}
metadata = MetaData( metadata = MetaData(
naming_convention={ naming_convention={
@ -69,18 +75,21 @@ money = Annotated[Decimal, mapped_column(Numeric(16, 2), nullable=False)]
class Transaction(Base): class Transaction(Base):
__tablename__ = "originals" __tablename__ = "originals"
id: Mapped[idpk] = mapped_column(autoincrement=True) id: Mapped[idpk] = mapped_column(autoincrement=True, init=False)
date: Mapped[dt.date] date: Mapped[dt.date]
description: Mapped[Optional[str]] description: Mapped[Optional[str]]
bank: Mapped[bankfk] bank: Mapped[bankfk]
amount: Mapped[money] amount: Mapped[money]
category: Mapped[Optional[TransactionCategory]] = relationship( category: Mapped[Optional[TransactionCategory]] = relationship(
back_populates="original", lazy="joined" back_populates="original", lazy="joined", default=None
) )
note: Mapped[Optional[Note]] = relationship(back_populates="original") note: Mapped[Optional[Note]] = relationship(back_populates="original", default=None)
tags: Mapped[Optional[set[Tag]]] = relationship( tags: Mapped[Optional[set[Tag]]] = relationship(
back_populates="original", cascade="all, delete-orphan", passive_deletes=True back_populates="original",
cascade="all, delete-orphan",
passive_deletes=True,
default=None,
) )
def __repr__(self) -> str: def __repr__(self) -> str:
@ -102,12 +111,16 @@ class Category(Base):
__tablename__ = "categories_available" __tablename__ = "categories_available"
name: Mapped[str] = mapped_column(primary_key=True) name: Mapped[str] = mapped_column(primary_key=True)
group: Mapped[Optional[str]] = mapped_column(ForeignKey(CategoryGroup.name)) group: Mapped[Optional[str]] = mapped_column(
ForeignKey(CategoryGroup.name), default=None
)
rules: Mapped[Optional[set[CategoryRule]]] = relationship( rules: Mapped[Optional[set[CategoryRule]]] = relationship(
cascade="all, delete-orphan", passive_deletes=True cascade="all, delete-orphan", passive_deletes=True, default=None
)
schedule: Mapped[CategorySchedule] = relationship(
back_populates="category", default=None
) )
schedule: Mapped[CategorySchedule] = relationship(back_populates="category")
def __repr__(self) -> str: def __repr__(self) -> str:
return f"Category(name={self.name}, group={self.group}, #rules={len(self.rules)}, schedule={self.schedule})" return f"Category(name={self.name}, group={self.group}, #rules={len(self.rules)}, schedule={self.schedule})"
@ -122,7 +135,7 @@ catfk = Annotated[
class TransactionCategory(Base): class TransactionCategory(Base):
__tablename__ = "categorized" __tablename__ = "categorized"
id: Mapped[idfk] = mapped_column(primary_key=True) id: Mapped[idfk] = mapped_column(primary_key=True, init=False)
name: Mapped[str] = mapped_column(ForeignKey(Category.name)) name: Mapped[str] = mapped_column(ForeignKey(Category.name))
original: Mapped[Transaction] = relationship(back_populates="category") original: Mapped[Transaction] = relationship(back_populates="category")
@ -167,10 +180,13 @@ class Tag(Base):
class CategoryRule(Base): class CategoryRule(Base):
__tablename__ = "categories_rules" __tablename__ = "categories_rules"
name: Mapped[str] = mapped_column( id: Mapped[idpk] = mapped_column(autoincrement=True, init=False)
ForeignKey(Category.name, ondelete="CASCADE"), primary_key=True name: Mapped[catfk] = mapped_column()
) date: Mapped[Optional[str]] = mapped_column()
rule: Mapped[str] = mapped_column(primary_key=True) description: Mapped[Optional[str]] = mapped_column()
bank: Mapped[Optional[str]] = mapped_column()
min_amount: Mapped[Optional[float]] = mapped_column()
max_amount: Mapped[Optional[float]] = mapped_column()
class Selector(enum.Enum): class Selector(enum.Enum):