Adds group configuration and bugfixes

Now possible to configure each group in the categories.yaml. Only color
added at this moment. The order in which the groups are listed is used
by the graph module and will later be used by the report.

Fixes search for vacations. The negative match is now done by groups,
and the key changes from negative_regex to not_in_groups.
Only updates transactions when any travel is assigned. Was previously
updating all uncategorized transactions that reached the travel search.

Regex match now correctly done lower letters to lower letters.

Transaction __repr__ method added and updated along with __str__, to
provide a more clearer output when manually categorizing.
This commit is contained in:
Luís Murta 2021-08-10 23:35:47 +01:00
parent ac4e14ca35
commit 865874f637
Signed by: satprog
GPG Key ID: DDF2EFC6179009DC
4 changed files with 68 additions and 22 deletions

View File

@ -1,3 +1,15 @@
Groups:
income:
color: limegreen
fixed:
color: tab:blue
required:
color: tab:orange
health:
color: tab:red
discretionary:
color: tab:brown
Income1: Income1:
group: income group: income
regex: regex:
@ -69,9 +81,9 @@ Travel:
- ryanair - ryanair
- easyjet - easyjet
- airbnb - airbnb
negative_regex: not_in_groups:
- Commute - income
- Utilities - fixed
date_fmt: "%Y-%m-%d" date_fmt: "%Y-%m-%d"
vacations: vacations:
- ["2019-12-23", "2020-01-02"] - ["2019-12-23", "2020-01-02"]

View File

@ -19,7 +19,7 @@ Options = namedtuple(
"regex", "regex",
"banks", "banks",
"regular", "regular",
"negative_regex", "not_in_groups",
"date_fmt", "date_fmt",
"vacations", "vacations",
"timedelta", "timedelta",
@ -29,7 +29,9 @@ Options = namedtuple(
cfg = yaml.safe_load(open("categories.yaml")) cfg = yaml.safe_load(open("categories.yaml"))
try: try:
categories = {k: Options(**v) if v else Options() for k, v in cfg.items()} categories = {
k: Options(**v) if v and k != "Groups" else Options() for k, v in cfg.items()
}
except TypeError: except TypeError:
logging.exception("Invalid option in categories.yaml") logging.exception("Invalid option in categories.yaml")
categories = {} categories = {}
@ -42,6 +44,12 @@ groups = {
} }
categories.setdefault("Null", Options()) categories.setdefault("Null", Options())
order = {k: i for i, k in enumerate(cfg["Groups"])}
groups = {
group: groups[group]
for group in sorted(groups, key=lambda x: order.get(x, len(groups)))
}
def categorize_data(db: DBManager): def categorize_data(db: DBManager):
@ -71,9 +79,11 @@ def categorize_data(db: DBManager):
) )
for transaction in transactions: for transaction in transactions:
while True: while True:
category = input(f"{transaction} category: ") category = input(f"{repr(transaction)} category: ")
if category == "quit" or category == "exit": if category == "quit" or category == "exit":
return return
if not category:
break
if category not in categories: if category not in categories:
print( print(
f"Category {category} doesn't exist. Please use one of {categories.keys()}" f"Category {category} doesn't exist. Please use one of {categories.keys()}"
@ -95,20 +105,28 @@ def vacations(db: DBManager) -> None:
logging.warning(f"{e} continuing...") logging.warning(f"{e} continuing...")
continue continue
not_vacations = categories["Travel"].negative_regex # default is [] not_in_groups = categories["Travel"].not_in_groups # default is []
update = False
if transactions := db.get_daterange_uncategorized_transactions(start, end): if transactions := db.get_daterange_uncategorized_transactions(start, end):
for transaction in transactions: for transaction in transactions:
if not_vacations: if not_in_groups:
for category in not_vacations: if not any(
if not matches( matches(
transaction, categories.get(category, Options()) transaction,
): categories.get(category, Options()),
transaction.category = "Travel" )
for group in not_in_groups
for category in groups[group]
):
transaction.category = "Travel"
update = True
else: else:
transaction.category = "Travel" transaction.category = "Travel"
update = True
db.update_categories(transactions) if update:
db.update_categories(transactions)
except KeyError as e: except KeyError as e:
print(e) print(e)
@ -151,7 +169,7 @@ def matches(transaction: Transaction, category: Options):
return False return False
try: try:
return any( return any(
re.compile(pattern).search(transaction.description.lower()) re.compile(pattern.lower()).search(transaction.description.lower())
for pattern in category.regex for pattern in category.regex
) )
except re.error as e: except re.error as e:

View File

@ -12,6 +12,9 @@ if TYPE_CHECKING:
from pfbudget.database import DBManager from pfbudget.database import DBManager
groups = pfbudget.categories.cfg["Groups"]
def monthly( def monthly(
db: DBManager, args: dict, start: dt.date = dt.date.min, end: dt.date = dt.date.max db: DBManager, args: dict, start: dt.date = dt.date.min, end: dt.date = dt.date.max
): ):
@ -45,6 +48,7 @@ def monthly(
plt.plot( plt.plot(
list(rrule(MONTHLY, dtstart=start.replace(day=1), until=end.replace(day=1))), list(rrule(MONTHLY, dtstart=start.replace(day=1), until=end.replace(day=1))),
[groups["income"] for _, groups in monthly_transactions], [groups["income"] for _, groups in monthly_transactions],
color=groups["income"]["color"],
) )
plt.stackplot( plt.stackplot(
list(rrule(MONTHLY, dtstart=start.replace(day=1), until=end.replace(day=1))), list(rrule(MONTHLY, dtstart=start.replace(day=1), until=end.replace(day=1))),
@ -58,6 +62,11 @@ def monthly(
for group in pfbudget.categories.groups for group in pfbudget.categories.groups
if group != "income" and group != "investment" if group != "income" and group != "investment"
], ],
colors=[
groups.get(group, {"color": "gray"})["color"]
for group in pfbudget.categories.groups
if group != "income" and group != "investment"
],
) )
plt.legend(loc="upper left") plt.legend(loc="upper left")
if args["save"]: if args["save"]:
@ -95,7 +104,7 @@ def discrete(
] ]
) )
plt.figure(figsize=(30, 10)) plt.figure(tight_layout=True)
plt.plot( plt.plot(
list(rrule(MONTHLY, dtstart=start.replace(day=1), until=end.replace(day=1))), list(rrule(MONTHLY, dtstart=start.replace(day=1), until=end.replace(day=1))),
[ [
@ -106,6 +115,7 @@ def discrete(
) )
for _, categories in monthly_transactions for _, categories in monthly_transactions
], ],
color=groups["income"]["color"],
) )
plt.stackplot( plt.stackplot(
list(rrule(MONTHLY, dtstart=start.replace(day=1), until=end.replace(day=1))), list(rrule(MONTHLY, dtstart=start.replace(day=1), until=end.replace(day=1))),
@ -114,16 +124,18 @@ def discrete(
for category in pfbudget.categories.categories for category in pfbudget.categories.categories
if category not in pfbudget.categories.groups["income"] if category not in pfbudget.categories.groups["income"]
and category not in pfbudget.categories.groups["investment"] and category not in pfbudget.categories.groups["investment"]
and category != "Null"
], ],
labels=[ labels=[
category category
for category in pfbudget.categories.categories for category in pfbudget.categories.categories
if category not in pfbudget.categories.groups["income"] if category not in pfbudget.categories.groups["income"]
and category not in pfbudget.categories.groups["investment"] and category not in pfbudget.categories.groups["investment"]
and category != "Null"
], ],
) )
plt.grid()
plt.legend(loc="upper left") plt.legend(loc="upper left")
plt.tight_layout()
if args["save"]: if args["save"]:
plt.savefig("graph.png") plt.savefig("graph.png")
else: else:

View File

@ -84,12 +84,16 @@ class Transaction:
def __ge__(self, other): def __ge__(self, other):
return self.date >= other.date return self.date >= other.date
def desc(self): def __repr__(self):
return "{} {} {}€ ({})".format( return "{} {} ({}) {}€ at {}".format(
self.date.strftime("%d/%m/%y"), self.description, self.value, self.bank self.date.strftime("%d/%m/%y"),
self.description,
self.category,
self.value,
self.bank,
) )
def __repr__(self): def __str__(self):
return "{} {} {}€ ({})".format( return "{} {} {}at {}".format(
self.date.strftime("%d/%m/%y"), self.category, self.value, self.bank self.date.strftime("%d/%m/%y"), self.category, self.value, self.bank
) )