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:
parent
63d6a2aab7
commit
471331ffc9
92
alembic/versions/8b5d5fbc8211_transaction_based_rules.py
Normal file
92
alembic/versions/8b5d5fbc8211_transaction_based_rules.py
Normal 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",
|
||||||
|
)
|
||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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()
|
||||||
|
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user