Compare commits
No commits in common. "ed2dda63e949198e20c7aa672c9cc7954da4194e" and "23eb2c80bdfb61cb05ca35f46ad64821bcb84338" have entirely different histories.
ed2dda63e9
...
23eb2c80bd
@ -1,32 +0,0 @@
|
||||
"""Start/End date rule
|
||||
|
||||
Revision ID: 952de57a3c43
|
||||
Revises: 18572111d9ff
|
||||
Create Date: 2023-02-06 21:57:57.545327+00:00
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "952de57a3c43"
|
||||
down_revision = "18572111d9ff"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column(
|
||||
"rules", sa.Column("start", sa.Date(), nullable=True), schema="transactions"
|
||||
)
|
||||
op.alter_column("rules", column_name="date", new_column_name="end", schema="transactions")
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.alter_column("rules", column_name="end", new_column_name="date", schema="transactions")
|
||||
op.drop_column("rules", "start", schema="transactions")
|
||||
# ### end Alembic commands ###
|
||||
@ -1,3 +1,6 @@
|
||||
from decimal import Decimal
|
||||
from typing import Sequence
|
||||
|
||||
from pfbudget.cli.argparser import argparser
|
||||
from pfbudget.cli.interactive import Interactive
|
||||
from pfbudget.common.types import Operation
|
||||
@ -143,13 +146,12 @@ if __name__ == "__main__":
|
||||
]
|
||||
|
||||
case Operation.RuleAdd:
|
||||
keys = {"category", "start", "end", "description", "regex", "bank", "min", "max"}
|
||||
keys = {"category", "date", "description", "bank", "min", "max"}
|
||||
assert args.keys() >= keys, f"missing {args.keys() - keys}"
|
||||
|
||||
params = [
|
||||
type.CategoryRule(
|
||||
args["start"][0] if args["start"] else None,
|
||||
args["end"][0] if args["end"] else None,
|
||||
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,
|
||||
@ -195,13 +197,12 @@ if __name__ == "__main__":
|
||||
params = [type.Tag(tag) for tag in args["tag"]]
|
||||
|
||||
case Operation.TagRuleAdd:
|
||||
keys = {"tag", "start", "end", "description", "regex", "bank", "min", "max"}
|
||||
keys = {"tag", "date", "description", "bank", "min", "max"}
|
||||
assert args.keys() >= keys, f"missing {args.keys() - keys}"
|
||||
|
||||
params = [
|
||||
type.TagRule(
|
||||
args["start"][0] if args["start"] else None,
|
||||
args["end"][0] if args["end"] else None,
|
||||
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,
|
||||
@ -237,7 +238,7 @@ if __name__ == "__main__":
|
||||
keys = {"original", "links"}
|
||||
assert args.keys() >= keys, f"missing {args.keys() - keys}"
|
||||
|
||||
params = [args["original"][0], args["links"]]
|
||||
params = [type.Link(args["original"][0], link) for link in args["links"]]
|
||||
|
||||
case (
|
||||
Operation.Export
|
||||
@ -253,9 +254,9 @@ if __name__ == "__main__":
|
||||
| Operation.ExportCategoryGroups
|
||||
| Operation.ImportCategoryGroups
|
||||
):
|
||||
keys = {"file", "format"}
|
||||
keys = {"file"}
|
||||
assert args.keys() >= keys, f"missing {args.keys() - keys}"
|
||||
|
||||
params = [args["file"][0], args["format"][0]]
|
||||
params = args["file"]
|
||||
|
||||
Manager(db, verbosity).action(op, params)
|
||||
|
||||
@ -63,7 +63,7 @@ def argparser() -> argparse.ArgumentParser:
|
||||
# Exports transactions to .csv file
|
||||
export = subparsers.add_parser("export")
|
||||
export.set_defaults(op=Operation.Export)
|
||||
file_options(export)
|
||||
export_args(export)
|
||||
|
||||
pimport = subparsers.add_parser("import")
|
||||
pimport.set_defaults(op=Operation.Import)
|
||||
@ -215,11 +215,11 @@ def bank(parser: argparse.ArgumentParser):
|
||||
|
||||
export = commands.add_parser("export")
|
||||
export.set_defaults(op=Operation.ExportBanks)
|
||||
file_options(export)
|
||||
export_args(export)
|
||||
|
||||
pimport = commands.add_parser("import")
|
||||
pimport.set_defaults(op=Operation.ImportBanks)
|
||||
file_options(pimport)
|
||||
export_args(pimport)
|
||||
|
||||
|
||||
def nordigen(parser: argparse.ArgumentParser):
|
||||
@ -276,11 +276,11 @@ def category(parser: argparse.ArgumentParser):
|
||||
|
||||
export = commands.add_parser("export")
|
||||
export.set_defaults(op=Operation.ExportCategories)
|
||||
file_options(export)
|
||||
export_args(export)
|
||||
|
||||
pimport = commands.add_parser("import")
|
||||
pimport.set_defaults(op=Operation.ImportCategories)
|
||||
file_options(pimport)
|
||||
export_args(pimport)
|
||||
|
||||
|
||||
def category_group(parser: argparse.ArgumentParser):
|
||||
@ -296,11 +296,11 @@ def category_group(parser: argparse.ArgumentParser):
|
||||
|
||||
export = commands.add_parser("export")
|
||||
export.set_defaults(op=Operation.ExportCategoryGroups)
|
||||
file_options(export)
|
||||
export_args(export)
|
||||
|
||||
pimport = commands.add_parser("import")
|
||||
pimport.set_defaults(op=Operation.ImportCategoryGroups)
|
||||
file_options(pimport)
|
||||
export_args(pimport)
|
||||
|
||||
|
||||
def category_rule(parser: argparse.ArgumentParser):
|
||||
@ -324,11 +324,11 @@ def category_rule(parser: argparse.ArgumentParser):
|
||||
|
||||
export = commands.add_parser("export")
|
||||
export.set_defaults(op=Operation.ExportCategoryRules)
|
||||
file_options(export)
|
||||
export_args(export)
|
||||
|
||||
pimport = commands.add_parser("import")
|
||||
pimport.set_defaults(op=Operation.ImportCategoryRules)
|
||||
file_options(pimport)
|
||||
export_args(pimport)
|
||||
|
||||
|
||||
def tags(parser: argparse.ArgumentParser):
|
||||
@ -366,16 +366,15 @@ def tag_rule(parser: argparse.ArgumentParser):
|
||||
|
||||
export = commands.add_parser("export")
|
||||
export.set_defaults(op=Operation.ExportTagRules)
|
||||
file_options(export)
|
||||
export_args(export)
|
||||
|
||||
pimport = commands.add_parser("import")
|
||||
pimport.set_defaults(op=Operation.ImportTagRules)
|
||||
file_options(pimport)
|
||||
export_args(pimport)
|
||||
|
||||
|
||||
def rules(parser: argparse.ArgumentParser):
|
||||
parser.add_argument("--start", nargs=1, type=dt.date.fromisoformat)
|
||||
parser.add_argument("--end", nargs=1, type=dt.date.fromisoformat)
|
||||
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)
|
||||
@ -397,6 +396,5 @@ def link(parser: argparse.ArgumentParser):
|
||||
dismantle.add_argument("links", nargs="+", type=int)
|
||||
|
||||
|
||||
def file_options(parser: argparse.ArgumentParser):
|
||||
def export_args(parser: argparse.ArgumentParser):
|
||||
parser.add_argument("file", nargs=1, type=str)
|
||||
parser.add_argument("format", nargs=1, default="pickle")
|
||||
|
||||
@ -6,7 +6,6 @@ from enum import Enum, auto
|
||||
|
||||
class Operation(Enum):
|
||||
Init = auto()
|
||||
Transactions = auto()
|
||||
Parse = auto()
|
||||
Download = auto()
|
||||
Categorize = auto()
|
||||
|
||||
@ -9,7 +9,7 @@ class Categorizer:
|
||||
options = {}
|
||||
|
||||
def __init__(self):
|
||||
self.options["null_days"] = 3
|
||||
self.options["null_days"] = 4
|
||||
|
||||
def rules(
|
||||
self,
|
||||
@ -28,20 +28,14 @@ class Categorizer:
|
||||
tags (Sequence[Tag]): currently available tags
|
||||
"""
|
||||
|
||||
try:
|
||||
null = next(cat for cat in categories if cat.name == "null")
|
||||
print("Nullifying")
|
||||
self._nullify(transactions, null)
|
||||
|
||||
categories = [cat for cat in categories if cat.name != "null"]
|
||||
except StopIteration:
|
||||
print("Null category not defined")
|
||||
self._nullify(transactions)
|
||||
|
||||
self._rule_based_categories(transactions, categories)
|
||||
self._rule_based_tags(transactions, tags)
|
||||
|
||||
@Timer(name="nullify")
|
||||
def _nullify(self, transactions: Sequence[t.BankTransaction], null: t.Category):
|
||||
def _nullify(self, transactions: Sequence[t.BankTransaction]):
|
||||
print(f"Nullifying {len(transactions)} transactions")
|
||||
count = 0
|
||||
matching = []
|
||||
for transaction in transactions:
|
||||
@ -52,13 +46,11 @@ class Categorizer:
|
||||
transaction.date - timedelta(days=self.options["null_days"])
|
||||
<= cancel.date
|
||||
<= transaction.date + timedelta(days=self.options["null_days"])
|
||||
and transaction not in matching
|
||||
and cancel not in matching
|
||||
and cancel != transaction
|
||||
and cancel.bank != transaction.bank
|
||||
and cancel.amount == -transaction.amount
|
||||
and transaction not in matching
|
||||
and cancel not in matching
|
||||
and all(r.matches(transaction) for r in null.rules)
|
||||
and all(r.matches(cancel) for r in null.rules)
|
||||
)
|
||||
):
|
||||
transaction.category = t.TransactionCategory(
|
||||
@ -73,7 +65,7 @@ class Categorizer:
|
||||
count += 2
|
||||
break
|
||||
|
||||
print(f"Nullified {count} of {len(transactions)} transactions")
|
||||
print(f"Nullified {count} transactions")
|
||||
|
||||
@Timer(name="categoryrules")
|
||||
def _rule_based_categories(
|
||||
@ -95,14 +87,12 @@ class Categorizer:
|
||||
continue
|
||||
|
||||
# passed all conditions, assign category
|
||||
if transaction.category:
|
||||
if transaction.category.name == category.name:
|
||||
continue
|
||||
|
||||
if (
|
||||
transaction.category
|
||||
and transaction.category.name == category.name
|
||||
):
|
||||
if (
|
||||
input(
|
||||
f"Overwrite {transaction} with {category.name}? (y/n)"
|
||||
)
|
||||
input(f"Overwrite {transaction} with {category}? (y/n)")
|
||||
== "y"
|
||||
):
|
||||
transaction.category.name = category.name
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import csv
|
||||
from pathlib import Path
|
||||
import pickle
|
||||
import webbrowser
|
||||
@ -18,7 +17,6 @@ from pfbudget.db.model import (
|
||||
MoneyTransaction,
|
||||
Nordigen,
|
||||
Rule,
|
||||
Selector_T,
|
||||
SplitTransaction,
|
||||
Tag,
|
||||
TagRule,
|
||||
@ -34,23 +32,14 @@ class Manager:
|
||||
self._db = db
|
||||
self._verbosity = verbosity
|
||||
|
||||
def action(self, op: Operation, params=None):
|
||||
def action(self, op: Operation, params: list):
|
||||
if self._verbosity > 0:
|
||||
print(f"op={op}, params={params}")
|
||||
|
||||
if params is None:
|
||||
params = []
|
||||
|
||||
match (op):
|
||||
case Operation.Init:
|
||||
pass
|
||||
|
||||
case Operation.Transactions:
|
||||
with self.db.session() as session:
|
||||
transactions = session.get(Transaction)
|
||||
ret = [t.format for t in transactions]
|
||||
return ret
|
||||
|
||||
case Operation.Parse:
|
||||
# Adapter for the parse_data method. Can be refactored.
|
||||
args = {"bank": params[1], "creditcard": params[2], "category": None}
|
||||
@ -179,32 +168,8 @@ class Manager:
|
||||
session.remove_by_name(CategoryGroup, params)
|
||||
|
||||
case Operation.Forge:
|
||||
if not (
|
||||
isinstance(params[0], int)
|
||||
and all(isinstance(p, int) for p in params[1])
|
||||
):
|
||||
raise TypeError("f{params} are not transaction ids")
|
||||
|
||||
with self.db.session() as session:
|
||||
original = session.get(Transaction, Transaction.id, params[0])[0]
|
||||
links = session.get(Transaction, Transaction.id, params[1])
|
||||
|
||||
if not original.category:
|
||||
original.category = self.askcategory(original)
|
||||
|
||||
for link in links:
|
||||
if (
|
||||
not link.category
|
||||
or link.category.name != original.category.name
|
||||
):
|
||||
print(
|
||||
f"{link} category will change to"
|
||||
f" {original.category.name}"
|
||||
)
|
||||
link.category = original.category
|
||||
|
||||
tobelinked = [Link(original.id, link.id) for link in links]
|
||||
session.add(tobelinked)
|
||||
session.add(params)
|
||||
|
||||
case Operation.Dismantle:
|
||||
assert all(isinstance(param, Link) for param in params)
|
||||
@ -237,8 +202,7 @@ class Manager:
|
||||
if originals[0].date != t.date:
|
||||
t.date = originals[0].date
|
||||
print(
|
||||
f"{t.date} is different from original date"
|
||||
f" {originals[0].date}, using original"
|
||||
f"{t.date} is different from original date {originals[0].date}, using original"
|
||||
)
|
||||
|
||||
splitted = SplitTransaction(
|
||||
@ -251,11 +215,11 @@ class Manager:
|
||||
|
||||
case Operation.Export:
|
||||
with self.db.session() as session:
|
||||
self.dump(params[0], params[1], sorted(session.get(Transaction)))
|
||||
self.dump(params[0], sorted(session.get(Transaction)))
|
||||
|
||||
case Operation.Import:
|
||||
transactions = []
|
||||
for row in self.load(params[0], params[1]):
|
||||
for row in self.load(params[0]):
|
||||
match row["type"]:
|
||||
case "bank":
|
||||
transaction = BankTransaction(
|
||||
@ -288,11 +252,11 @@ class Manager:
|
||||
|
||||
case Operation.ExportBanks:
|
||||
with self.db.session() as session:
|
||||
self.dump(params[0], params[1], session.get(Bank))
|
||||
self.dump(params[0], session.get(Bank))
|
||||
|
||||
case Operation.ImportBanks:
|
||||
banks = []
|
||||
for row in self.load(params[0], params[1]):
|
||||
for row in self.load(params[0]):
|
||||
bank = Bank(row["name"], row["BIC"], row["type"])
|
||||
if row["nordigen"]:
|
||||
bank.nordigen = Nordigen(**row["nordigen"])
|
||||
@ -304,10 +268,10 @@ class Manager:
|
||||
|
||||
case Operation.ExportCategoryRules:
|
||||
with self.db.session() as session:
|
||||
self.dump(params[0], params[1], session.get(CategoryRule))
|
||||
self.dump(params[0], session.get(CategoryRule))
|
||||
|
||||
case Operation.ImportCategoryRules:
|
||||
rules = [CategoryRule(**row) for row in self.load(params[0], params[1])]
|
||||
rules = [CategoryRule(**row) for row in self.load(params[0])]
|
||||
|
||||
if self.certify(rules):
|
||||
with self.db.session() as session:
|
||||
@ -315,10 +279,10 @@ class Manager:
|
||||
|
||||
case Operation.ExportTagRules:
|
||||
with self.db.session() as session:
|
||||
self.dump(params[0], params[1], session.get(TagRule))
|
||||
self.dump(params[0], session.get(TagRule))
|
||||
|
||||
case Operation.ImportTagRules:
|
||||
rules = [TagRule(**row) for row in self.load(params[0], params[1])]
|
||||
rules = [TagRule(**row) for row in self.load(params[0])]
|
||||
|
||||
if self.certify(rules):
|
||||
with self.db.session() as session:
|
||||
@ -326,12 +290,12 @@ class Manager:
|
||||
|
||||
case Operation.ExportCategories:
|
||||
with self.db.session() as session:
|
||||
self.dump(params[0], params[1], session.get(Category))
|
||||
self.dump(params[0], session.get(Category))
|
||||
|
||||
case Operation.ImportCategories:
|
||||
# rules = [Category(**row) for row in self.load(params[0])]
|
||||
categories = []
|
||||
for row in self.load(params[0], params[1]):
|
||||
for row in self.load(params[0]):
|
||||
category = Category(row["name"], row["group"])
|
||||
if len(row["rules"]) > 0:
|
||||
# Only category rules could have been created with a rule
|
||||
@ -350,12 +314,10 @@ class Manager:
|
||||
|
||||
case Operation.ExportCategoryGroups:
|
||||
with self.db.session() as session:
|
||||
self.dump(params[0], params[1], session.get(CategoryGroup))
|
||||
self.dump(params[0], session.get(CategoryGroup))
|
||||
|
||||
case Operation.ImportCategoryGroups:
|
||||
groups = [
|
||||
CategoryGroup(**row) for row in self.load(params[0], params[1])
|
||||
]
|
||||
groups = [CategoryGroup(**row) for row in self.load(params[0])]
|
||||
|
||||
if self.certify(groups):
|
||||
with self.db.session() as session:
|
||||
@ -364,38 +326,15 @@ class Manager:
|
||||
def parse(self, filename: Path, args: dict):
|
||||
return parse_data(filename, args)
|
||||
|
||||
def askcategory(self, transaction: Transaction):
|
||||
selector = CategorySelector(Selector_T.manual)
|
||||
|
||||
with self.db.session() as session:
|
||||
categories = session.get(Category)
|
||||
|
||||
while True:
|
||||
category = input(f"{transaction}: ")
|
||||
if category in [c.name for c in categories]:
|
||||
return TransactionCategory(category, selector)
|
||||
@staticmethod
|
||||
def dump(fn, sequence):
|
||||
with open(fn, "wb") as f:
|
||||
pickle.dump([e.format for e in sequence], f)
|
||||
|
||||
@staticmethod
|
||||
def dump(fn, format, sequence):
|
||||
if format == "pickle":
|
||||
with open(fn, "wb") as f:
|
||||
pickle.dump([e.format for e in sequence], f)
|
||||
elif format == "csv":
|
||||
with open(fn, "w", newline="") as f:
|
||||
csv.writer(f).writerows([e.format.values() for e in sequence])
|
||||
else:
|
||||
print("format not well specified")
|
||||
|
||||
@staticmethod
|
||||
def load(fn, format):
|
||||
if format == "pickle":
|
||||
with open(fn, "rb") as f:
|
||||
return pickle.load(f)
|
||||
elif format == "csv":
|
||||
raise Exception("CSV import not supported")
|
||||
else:
|
||||
print("format not well specified")
|
||||
return []
|
||||
def load(fn):
|
||||
with open(fn, "rb") as f:
|
||||
return pickle.load(f)
|
||||
|
||||
@staticmethod
|
||||
def certify(imports: list) -> bool:
|
||||
|
||||
@ -51,10 +51,7 @@ class DbClient:
|
||||
def get(self, type: Type[T], column=None, values=None) -> Sequence[T]:
|
||||
if column is not None:
|
||||
if values:
|
||||
if isinstance(values, Sequence):
|
||||
stmt = select(type).where(column.in_(values))
|
||||
else:
|
||||
stmt = select(type).where(column == values)
|
||||
stmt = select(type).where(column.in_(values))
|
||||
else:
|
||||
stmt = select(type).where(column)
|
||||
else:
|
||||
|
||||
@ -335,8 +335,7 @@ class Rule(Base, Export):
|
||||
__tablename__ = "rules"
|
||||
|
||||
id: Mapped[idpk] = mapped_column(init=False)
|
||||
start: Mapped[Optional[dt.date]]
|
||||
end: Mapped[Optional[dt.date]]
|
||||
date: Mapped[Optional[dt.date]]
|
||||
description: Mapped[Optional[str]]
|
||||
regex: Mapped[Optional[str]]
|
||||
bank: Mapped[Optional[str]]
|
||||
@ -350,34 +349,32 @@ class Rule(Base, Export):
|
||||
"polymorphic_on": "type",
|
||||
}
|
||||
|
||||
def matches(self, t: BankTransaction) -> bool:
|
||||
valid = None
|
||||
if self.regex:
|
||||
valid = re.compile(self.regex, re.IGNORECASE)
|
||||
|
||||
ops = (
|
||||
Rule.exists(self.start, lambda r: r < t.date),
|
||||
Rule.exists(self.end, lambda r: r > t.date),
|
||||
Rule.exists(self.description, lambda r: r == t.description),
|
||||
Rule.exists(
|
||||
valid,
|
||||
lambda r: r.search(t.description) if t.description else False,
|
||||
),
|
||||
Rule.exists(self.bank, lambda r: r == t.bank),
|
||||
Rule.exists(self.min, lambda r: r < t.amount),
|
||||
Rule.exists(self.max, lambda r: r > t.amount),
|
||||
)
|
||||
|
||||
if all(ops):
|
||||
return True
|
||||
|
||||
return False
|
||||
def matches(self, transaction: BankTransaction) -> 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
|
||||
|
||||
@property
|
||||
def format(self) -> dict[str, Any]:
|
||||
return dict(
|
||||
start=self.start,
|
||||
end=self.end,
|
||||
date=self.date,
|
||||
description=self.description,
|
||||
regex=self.regex,
|
||||
bank=self.bank,
|
||||
@ -386,10 +383,6 @@ class Rule(Base, Export):
|
||||
type=self.type,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def exists(r, op) -> bool:
|
||||
return op(r) if r is not None else True
|
||||
|
||||
|
||||
class CategoryRule(Rule):
|
||||
__table_args__ = {"schema": "category"}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user