Compare commits

...

10 Commits

Author SHA1 Message Date
1d256d7def
Adds links between transactions
Sometimes transctions are directly related to one another w/o being of
an equal value, e.g. someone pays for the meal w/ the CC, and everyone
pays him/her.

Clear leftover __repr__ methods in the model classes, the database
decorator will create those automatically.
2022-12-19 22:48:49 +00:00
f20cf685ad
Update Foreign Key delete cascade for categories 2022-12-19 22:10:06 +00:00
e57859f601
Updates verbosity options
Now it's used by adding -v to the command called. No verbosity=quiet.
2022-12-19 22:02:11 +00:00
e27f2f08cf
Adds rule based tagging of transactions
Tags will work as additional categories to filter/organize by. It makes
sense they can also be rule based.
Since rules are common to both categories and tags, reorganize the
classes in the model. It doesn't affect the DB.
2022-12-19 21:59:41 +00:00
058d0cc05d
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.
2022-12-19 20:35:18 +00:00
6c33a94a5f
Change min_amount/max_amount columns for min/max
Simplifies the action creation.
2022-12-19 20:35:18 +00:00
a2b2f2c1d1
Adds modify rule operation 2022-12-18 00:48:45 +00:00
7986fe8e5b
[Fix] argparse group option not an array 2022-12-17 20:59:40 +00:00
17d8d5d813
[Fix] Changes rule's money type to Decimal
Also fixes comparison w/ max amount.
2022-12-17 20:48:16 +00:00
72a8995fe6
Adds regex rule and remove rule option
Categorization rules can now search using a regex pattern.
2022-12-10 18:54:16 +00:00
13 changed files with 697 additions and 116 deletions

View File

@ -0,0 +1,32 @@
"""Regex rule
Revision ID: 0ce89e987770
Revises: 7adf89ec8d14
Create Date: 2022-12-10 14:00:49.418494+00:00
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "0ce89e987770"
down_revision = "7adf89ec8d14"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"categories_rules",
sa.Column("regex", sa.String(), nullable=True),
schema="transactions",
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("categories_rules", "regex", schema="transactions")
# ### end Alembic commands ###

View File

@ -0,0 +1,49 @@
"""Rule min/max
Revision ID: 753c0bfb2062
Revises: e36e6321568e
Create Date: 2022-12-18 00:24:03.861461+00:00
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = "753c0bfb2062"
down_revision = "e36e6321568e"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column(
"categories_rules",
"min_amount",
new_column_name="min",
schema="transactions",
)
op.alter_column(
"categories_rules",
"max_amount",
new_column_name="max",
schema="transactions",
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column(
"categories_rules",
"min",
new_column_name="min_amount",
schema="transactions",
)
op.alter_column(
"categories_rules",
"max",
new_column_name="max_amount",
schema="transactions",
)
# ### end Alembic commands ###

View File

@ -0,0 +1,46 @@
"""Links
Revision ID: 8cc9870b0d74
Revises: a910e1b2214d
Create Date: 2022-12-19 22:10:25.136479+00:00
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "8cc9870b0d74"
down_revision = "a910e1b2214d"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"links",
sa.Column("original", sa.BigInteger(), nullable=False),
sa.Column("link", sa.BigInteger(), nullable=False),
sa.ForeignKeyConstraint(
["link"],
["transactions.originals.id"],
name=op.f("fk_links_link_originals"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["original"],
["transactions.originals.id"],
name=op.f("fk_links_original_originals"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("original", "link", name=op.f("pk_links")),
schema="transactions",
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("links", schema="transactions")
# ### end Alembic commands ###

View File

@ -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 ###

View File

@ -0,0 +1,56 @@
"""Rule inheritance
Revision ID: a910e1b2214d
Revises: 9028b0f3b985
Create Date: 2022-12-19 20:48:04.682812+00:00
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = "a910e1b2214d"
down_revision = "9028b0f3b985"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(
"fk_categorized_name_categories_available",
"categorized",
schema="transactions",
type_="foreignkey",
)
op.create_foreign_key(
op.f("fk_categorized_name_categories_available"),
"categorized",
"categories_available",
["name"],
["name"],
source_schema="transactions",
referent_schema="transactions",
ondelete="CASCADE",
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(
op.f("fk_categorized_name_categories_available"),
"categorized",
schema="transactions",
type_="foreignkey",
)
op.create_foreign_key(
"fk_categorized_name_categories_available",
"categorized",
"categories_available",
["name"],
["name"],
source_schema="transactions",
referent_schema="transactions",
)
# ### end Alembic commands ###

View File

@ -0,0 +1,58 @@
"""Rules min/max money
Revision ID: e36e6321568e
Revises: 0ce89e987770
Create Date: 2022-12-10 18:55:07.149010+00:00
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "e36e6321568e"
down_revision = "0ce89e987770"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column(
"categories_rules",
"min_amount",
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=16, scale=2),
existing_nullable=True,
schema="transactions",
)
op.alter_column(
"categories_rules",
"max_amount",
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=16, scale=2),
existing_nullable=True,
schema="transactions",
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column(
"categories_rules",
"max_amount",
existing_type=sa.Numeric(precision=16, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=True,
schema="transactions",
)
op.alter_column(
"categories_rules",
"min_amount",
existing_type=sa.Numeric(precision=16, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=True,
schema="transactions",
)
# ### end Alembic commands ###

View File

@ -6,24 +6,26 @@ if __name__ == "__main__":
args = vars(argparser.parse_args())
assert "op" in args, "No pfbudget.Operation selected"
op: pfbudget.Operation = args["op"]
op: pfbudget.Operation = args.pop("op")
assert "database" in args, "No database selected"
db = args["database"]
db = args.pop("database")
assert "verbose" in args, "No verbose level specified"
verbosity = args.pop("verbose")
params = None
match (op):
case pfbudget.Operation.CategoryAdd:
assert args.keys() >= {"category", "group"}, "argparser ill defined"
params = [
pfbudget.types.Category(cat, args["group"][0])
for cat in args["category"]
pfbudget.types.Category(cat, args["group"]) for cat in args["category"]
]
case pfbudget.Operation.CategoryUpdate:
assert args.keys() >= {"category", "group"}, "argparser ill defined"
params = [pfbudget.types.Category(cat) for cat in args["category"]]
params.append(args["group"][0])
params.append(args["group"])
case pfbudget.Operation.CategoryRemove:
assert "category" in args, "argparser ill defined"
@ -43,7 +45,7 @@ if __name__ == "__main__":
for cat in args["category"]
]
case pfbudget.Operation.CategoryRule:
case pfbudget.Operation.RuleAdd:
assert args.keys() >= {
"category",
"date",
@ -58,6 +60,7 @@ if __name__ == "__main__":
cat,
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,
@ -65,6 +68,79 @@ if __name__ == "__main__":
for cat in args["category"]
]
case pfbudget.Operation.RuleRemove | pfbudget.Operation.TagRuleRemove:
assert args.keys() >= {"id"}, "argparser ill defined"
params = args["id"]
case pfbudget.Operation.RuleModify:
assert args.keys() >= {
"id",
"category",
"date",
"description",
"bank",
"min",
"max",
"remove",
}, "argparser ill defined"
nargs_1 = ["category", "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.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"]]
@ -73,4 +149,10 @@ if __name__ == "__main__":
assert "group" in args, "argparser ill defined"
params = [pfbudget.types.CategoryGroup(group) for group in args["group"]]
pfbudget.Manager(db, args).action(op, params)
case pfbudget.Operation.Forge | pfbudget.Operation.Dismantle:
assert args.keys() >= {"original", "links"}, "argparser ill defined"
params = [
pfbudget.types.Link(args["original"][0], link) for link in args["links"]
]
pfbudget.Manager(db, verbosity, args).action(op, params)

View File

@ -1,12 +1,11 @@
from pathlib import Path
import argparse
import datetime as dt
import decimal
import re
from pfbudget.common.types import Operation
from pfbudget.core.categories import categorize_data
from pfbudget.db.model import Period
from pfbudget.input.json import JsonParser
from pfbudget.input.nordigen import NordigenInput
from pfbudget.db.sqlite import DatabaseClient
import pfbudget.reporting.graph
@ -39,12 +38,7 @@ def argparser() -> argparse.ArgumentParser:
help="select current database",
default=DEFAULT_DB,
)
universal.add_argument(
"-q", "--quiet", action="store_true", help="reduces the amount of verbose"
)
universal.add_argument(
"-v", "--verbose", action="store_true", help="increases the amount of verbose"
)
universal.add_argument("-v", "--verbose", action="count", default=0)
period = argparse.ArgumentParser(add_help=False).add_mutually_exclusive_group()
period.add_argument(
@ -254,6 +248,12 @@ 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)
# Link
link(subparsers.add_parser("link"))
return parser
@ -341,13 +341,7 @@ def category(parser: argparse.ArgumentParser, universal: argparse.ArgumentParser
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=dt.date.fromisoformat)
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)
category_rule(rule, universal)
group = commands.add_parser("group", parents=[universal])
category_group(group, universal)
@ -365,6 +359,82 @@ def category_group(parser: argparse.ArgumentParser, universal: argparse.Argument
remove.add_argument("group", nargs="+", type=str)
def category_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.RuleAdd)
add.add_argument("category", nargs="+", type=str)
rules(add)
remove = commands.add_parser("remove", parents=[universal])
remove.set_defaults(op=Operation.RuleRemove)
remove.add_argument("id", nargs="+", type=int)
modify = commands.add_parser("modify", parents=[universal])
modify.set_defaults(op=Operation.RuleModify)
modify.add_argument("id", nargs="+", type=int)
modify.add_argument("--category", nargs=1, 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 link(parser: argparse.ArgumentParser):
commands = parser.add_subparsers(required=True)
forge = commands.add_parser("forge")
forge.set_defaults(op=Operation.Forge)
forge.add_argument("original", nargs=1, type=int)
forge.add_argument("links", nargs="+", type=int)
dismantle = commands.add_parser("dismantle")
dismantle.set_defaults(op=Operation.Dismantle)
dismantle.add_argument("original", nargs=1, type=int)
dismantle.add_argument("links", nargs="+", type=int)
def run():
args = vars(argparser().parse_args())
assert "op" in args, "No operation selected"

View File

@ -17,9 +17,18 @@ class Operation(Enum):
CategoryUpdate = auto()
CategoryRemove = auto()
CategorySchedule = auto()
CategoryRule = auto()
RuleAdd = auto()
RuleRemove = auto()
RuleModify = auto()
GroupAdd = auto()
GroupRemove = auto()
TagAdd = auto()
TagRemove = auto()
TagRuleAdd = auto()
TagRuleRemove = auto()
TagRuleModify = auto()
Forge = auto()
Dismantle = auto()
class TransactionError(Exception):

View File

@ -2,8 +2,10 @@ from pfbudget.db.model import (
Category,
CategorySelector,
Selector,
Tag,
Transaction,
TransactionCategory,
TransactionTag,
)
from datetime import timedelta
@ -15,7 +17,12 @@ class Categorizer:
def __init__(self):
self.options["null_days"] = 4
def categorize(self, transactions: list[Transaction], categories: list[Category]):
def categorize(
self,
transactions: list[Transaction],
categories: list[Category],
tags: list[Tag],
):
"""Overarching categorization tool
Receives a list of transactions (by ref) and updates their category
@ -25,7 +32,8 @@ class Categorizer:
"""
self._nullify(transactions)
self._rules(transactions, categories)
self._rule_based_categories(transactions, categories)
self._rule_based_tags(transactions, tags)
def _nullify(self, transactions: list[Transaction]):
count = 0
@ -57,27 +65,57 @@ class Categorizer:
print(f"Nullified {count} transactions")
def _rules(self, transactions: list[Transaction], categories: list[Category]):
def _rule_based_categories(
self, transactions: list[Transaction], categories: list[Category]
):
d = {}
for category in [c for c in categories if c.rules]:
for rule in category.rules:
for transaction in [t for t in transactions if not t.category]:
if rule.date:
if rule.date < transaction.date:
continue
if rule.description:
if rule.description not in transaction.description:
continue
if rule.bank:
if rule.bank != transaction.bank:
continue
if rule.min_amount:
if rule.min_amount > transaction.amount:
continue
if rule.max_amount:
if rule.max_amount <= transaction.amount:
continue
# for transaction in [t for t in transactions if not t.category]:
for transaction in [
t
for t in transactions
if not t.category or t.category.name != "null"
]:
if not rule.matches(transaction):
continue
# passed all conditions, assign category
transaction.category = TransactionCategory(
category.name, CategorySelector(Selector.rules)
)
if rule in d:
d[rule] += 1
else:
d[rule] = 1
for k, v in d.items():
print(f"{v}: {k}")
def _rule_based_tags(self, transactions: list[Transaction], tags: list[Tag]):
d = {}
for tag in [t for t in tags if t.rules]:
for rule in tag.rules:
# for transaction in [t for t in transactions if not t.category]:
for transaction in [
t
for t in transactions
if tag.name not in [tag.tag for tag in t.tags]
]:
if not rule.matches(transaction):
continue
if not transaction.tags:
transaction.tags = {TransactionTag(tag.name)}
else:
transaction.tags.add(TransactionTag(tag.name))
if rule in d:
d[rule] += 1
else:
d[rule] = 1
for k, v in d.items():
print(f"{v}: {k}")

View File

@ -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
@ -11,13 +18,16 @@ from pfbudget.cli.runnable import download, parse
class Manager:
def __init__(self, db: str, args: dict):
def __init__(self, db: str, verbosity: int = 0, args: dict = {}):
self._args = args
print(args)
self._db = db
self._verbosity = verbosity
def action(self, op: Operation, params: list):
if self._verbosity > 0:
print(f"op={op}, params={params}")
match (op):
case Operation.Init:
pass
@ -27,11 +37,13 @@ class Manager:
case Operation.Download:
# TODO this is a monstrosity, remove when possible
download(self, self.args)
case Operation.Categorize:
with self.db.session() as session:
uncategorized = session.uncategorized()
categories = session.categories()
Categorizer().categorize(uncategorized, categories)
tags = session.tags()
Categorizer().categorize(uncategorized, categories, tags)
case Operation.Register:
# self._db = DbClient(args["database"])
@ -49,9 +61,9 @@ class Manager:
self.args["name"], self.args["country"]
)
case Operation.CategoryAdd:
case Operation.CategoryAdd | Operation.RuleAdd | Operation.TagAdd | Operation.TagRuleAdd:
with self.db.session() as session:
session.addcategories(params)
session.add(params)
case Operation.CategoryUpdate:
with self.db.session() as session:
@ -59,26 +71,49 @@ 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.CategoryRule:
case Operation.RuleRemove:
assert all(isinstance(param, int) for param in params)
with self.db.session() as session:
session.addrules(params)
session.remove_by_id(CategoryRule, params)
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:
for group in self.args["group"]:
session.addcategorygroup(CategoryGroup(name=group))
session.add(CategoryGroup(params))
case Operation.GroupRemove:
assert all(isinstance(param, CategoryGroup) for param in params)
with self.db.session() as session:
session.removecategorygroup(
[CategoryGroup(name=group) for group in self.args["group"]]
)
session.remove_by_name(CategoryGroup, params)
case Operation.Forge:
with self.db.session() as session:
session.add(params)
case Operation.Dismantle:
with self.db.session() as session:
original = params[0].original
links = [link.link for link in params]
session.remove_links(original, links)
# def init(self):
# client = DatabaseClient(self.__db)
@ -119,7 +154,7 @@ class Manager:
@property
def db(self) -> DbClient:
return DbClient(self._db, self.args["verbose"])
return DbClient(self._db, self._verbosity > 0)
@db.setter
def db(self, url: str):

View File

@ -1,4 +1,3 @@
from copy import deepcopy
from dataclasses import asdict
from sqlalchemy import create_engine, delete, select, update
from sqlalchemy.dialects.postgresql import insert
@ -10,6 +9,9 @@ from pfbudget.db.model import (
CategoryGroup,
CategoryRule,
CategorySchedule,
Link,
Tag,
TagRule,
Transaction,
)
@ -81,16 +83,11 @@ class DbClient:
def commit(self):
self.__session.commit()
def add(self, transactions: list[Transaction]):
self.__session.add_all(transactions)
def add(self, rows: list):
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,16 +110,15 @@ class DbClient:
)
self.__session.execute(stmt)
def addrules(self, rules: list[CategoryRule]):
self.__session.add_all(rules)
def remove_by_id(self, type: CategoryRule | TagRule, ids: list[int]):
stmt = delete(type).where(type.id.in_(ids))
self.__session.execute(stmt)
def addcategorygroup(self, group: CategoryGroup):
self.__session.add(group)
def updaterules(self, rules: list[dict]):
self.__session.execute(update(CategoryRule), rules)
def removecategorygroup(self, groups: list[CategoryGroup]):
stmt = delete(CategoryGroup).where(
CategoryGroup.name.in_([grp.name for grp in groups])
)
def remove_links(self, original, links: list):
stmt = delete(Link).where(Link.original == original, Link.link.in_(link for link in links))
self.__session.execute(stmt)
def uncategorized(self) -> list[Transaction]:
@ -133,5 +129,9 @@ class DbClient:
stmt = select(Category)
return self.__session.scalars(stmt).all()
def tags(self) -> list[Tag]:
stmt = select(Tag)
return self.__session.scalars(stmt).all()
def session(self) -> ClientSession:
return self.ClientSession(self.engine)

View File

@ -21,6 +21,7 @@ from decimal import Decimal
from typing import Annotated, Optional
import datetime as dt
import enum
import re
class Base(MappedAsDataclass, DeclarativeBase):
@ -62,36 +63,25 @@ class Bank(Base):
back_populates="bank", lazy="joined"
)
def __repr__(self) -> str:
return f"Bank(name={self.name}, BIC={self.BIC}, type={self.type}, nordigen={self.nordigen})"
bankfk = Annotated[str, mapped_column(Text, ForeignKey(Bank.name))]
idpk = Annotated[int, mapped_column(BigInteger, primary_key=True)]
money = Annotated[Decimal, mapped_column(Numeric(16, 2), nullable=False)]
idpk = Annotated[int, mapped_column(BigInteger, primary_key=True, autoincrement=True)]
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] = mapped_column(init=False)
date: Mapped[dt.date]
description: Mapped[Optional[str]]
bank: Mapped[bankfk]
amount: Mapped[money]
category: Mapped[Optional[TransactionCategory]] = relationship()
note: Mapped[Optional[Note]] = relationship(back_populates="original", default=None)
tags: Mapped[Optional[set[Tag]]] = relationship(
back_populates="original",
cascade="all, delete-orphan",
passive_deletes=True,
default=None,
)
def __repr__(self) -> str:
return f"Transaction(date={self.date}, description={self.description}, bank={self.bank}, amount={self.amount}, category={self.category})"
note: Mapped[Optional[Note]] = relationship(back_populates="original")
tags: Mapped[Optional[set[TransactionTag]]] = relationship()
idfk = Annotated[
@ -114,7 +104,7 @@ class Category(Base):
)
rules: Mapped[Optional[set[CategoryRule]]] = relationship(
cascade="all, delete-orphan", passive_deletes=True, default=None
cascade="all, delete-orphan", passive_deletes=True, default_factory=set
)
schedule: Mapped[CategorySchedule] = relationship(
back_populates="category", default=None
@ -134,9 +124,9 @@ class TransactionCategory(Base):
__tablename__ = "categorized"
id: Mapped[idfk] = mapped_column(primary_key=True, init=False)
name: Mapped[str] = mapped_column(ForeignKey(Category.name))
name: Mapped[catfk]
selector: Mapped[CategorySelector] = relationship()
selector: Mapped[CategorySelector] = relationship(cascade="all, delete-orphan")
def __repr__(self) -> str:
return f"Category({self.name})"
@ -145,7 +135,7 @@ class TransactionCategory(Base):
class Note(Base):
__tablename__ = "notes"
id: Mapped[idfk] = mapped_column(primary_key=True)
id: Mapped[idfk] = mapped_column(primary_key=True, init=False)
note: Mapped[str]
original: Mapped[Transaction] = relationship(back_populates="note")
@ -161,29 +151,22 @@ class Nordigen(Base):
bank: Mapped[Bank] = relationship(back_populates="nordigen")
def __repr__(self) -> str:
return f"(bank_id={self.bank_id}, requisition_id={self.requisition_id}, invert={self.invert})"
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)
original: Mapped[Transaction] = relationship(back_populates="tags")
class CategoryRule(Base):
__tablename__ = "categories_rules"
id: Mapped[idpk] = mapped_column(autoincrement=True, init=False)
name: Mapped[catfk] = mapped_column()
date: Mapped[Optional[dt.date]] = mapped_column()
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()
id: Mapped[idfk] = mapped_column(primary_key=True, init=False)
tag: Mapped[str] = mapped_column(ForeignKey(Tag.name), primary_key=True)
def __hash__(self):
return hash(self.id)
@ -236,5 +219,60 @@ class CategorySchedule(Base):
category: Mapped[Category] = relationship(back_populates="schedule")
def __repr__(self) -> str:
return f"{self.name} schedule=Schedule(period={self.period}, multiplier={self.period_multiplier}, amount={self.amount})"
class Link(Base):
__tablename__ = "links"
original: Mapped[idfk] = mapped_column(primary_key=True)
link: Mapped[idfk] = mapped_column(primary_key=True)
class Rule:
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]]
def matches(self, transaction: Transaction) -> bool:
if (
(self.date and self.date < transaction.date)
or (
self.description
and transaction.description
and self.description not in transaction.description
)
or (
self.regex
and transaction.description
and not re.compile(self.regex, re.IGNORECASE).search(
transaction.description
)
)
or (self.bank and self.bank != transaction.bank)
or (self.min and self.min > transaction.amount)
or (self.max and self.max < transaction.amount)
):
return False
return True
class CategoryRule(Base, Rule):
__tablename__ = "categories_rules"
id: Mapped[idpk] = mapped_column(init=False)
name: Mapped[catfk]
def __hash__(self):
return hash(self.id)
class TagRule(Base, Rule):
__tablename__ = "tag_rules"
id: Mapped[idpk] = mapped_column(init=False)
tag: Mapped[str] = mapped_column(ForeignKey(Tag.name, ondelete="CASCADE"))
def __hash__(self):
return hash(self.id)