Compare commits

..

14 Commits

Author SHA1 Message Date
bdd7cac4be
Download operation moves to Loader interface
to load extracted transactions to database.
2023-04-30 00:48:03 +01:00
13c783ca0e
[Refactor] Clean out old DB client class
Swap almost all remaining calls to the old postgresql only DB class with
the new DB client.

Warning! Some operations are currently not implement, such as setting
category schedules and dismantling links.

`update` and `delete` methods added to DB `Client`.
2023-04-30 00:38:15 +01:00
da44ba5306
[DB] Change Bank PK 2023-04-29 20:28:39 +01:00
94322ae542
Generalize insert in new Database interface
Move over all inserts on manager.py to new interface.
2023-04-29 20:20:20 +01:00
78ff6faa12
DatabaseSession wrapper for orm.Session
For DB connections that want to keep a session alive, there's a new
`DatabaseSession` class that holds a SQLAlchemy session inside and
offers methods similar to the `Database` class.

The `Database` moves to use the `DatabaseSession` to remove duplicated
code.
2023-04-29 20:20:20 +01:00
9f39836083
[Refactor] Export ops move to Client interface
Introduce new `Manager` parameter database to use new DB client API.
Fix TransactionCategory format.
Fix Client kwargs type.
2023-04-29 20:20:20 +01:00
ad62317e56
[Fix] Fix sessionless database client
The database `Client` wasn't working correcly when no session was
passed, as the inserted transactions would still be bound to the newly
created session inside.
Creates a copy of the input transactions to insert on the DB.
2023-04-29 20:20:20 +01:00
e7abae0d17
[Refactor] Database client interface changed
`add` method replaced with `insert`.
`insert` and `select` implemented for new database base class.
Database unit test added.

Due to SQLite implementation of the primary key autoinc, the type of the
IDs on the database for SQLite changed to Integer.
https://www.sqlite.org/autoinc.html
2023-04-29 20:20:20 +01:00
9c7c06c181
[DB] Schema unified on pfbudget 2023-04-29 20:20:16 +01:00
d6779b40ea
Alembic added to poetry 2023-04-23 16:37:11 +01:00
761720b712
[Fix] Add typing information to tests
And fix caught errors.
2023-04-23 00:51:22 +01:00
541295ef05
Adds Loader, DatabaseLoader and (Db)Client
Adds unit test to test interfaces.
2023-04-23 00:21:44 +01:00
14131f50f9
[Refactor] Rename the PostgreSQL file 2023-04-23 00:00:45 +01:00
c4d2d5a378
Install the psycopg binary along with the package 2023-04-22 23:55:07 +01:00
25 changed files with 1240 additions and 289 deletions

View File

@ -55,7 +55,7 @@ version_path_separator = os # Use os.pathsep. Default configuration used for ne
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = postgresql://pf-budget:muster-neutron-omega@database.lan/pf-budget
sqlalchemy.url = postgresql://pf-budget:muster-neutron-omega@database.home.arpa/pf-budget
[post_write_hooks]

View File

@ -27,7 +27,7 @@ target_metadata = Base.metadata
def include_name(name, type_, parent_names):
if type_ == "schema":
return name in ["bank", "category", "tag", "transactions"]
return name == "pfbudget"
else:
return True

View File

@ -0,0 +1,95 @@
"""Change bank constraints
Revision ID: ce68ee15e5d2
Revises: d7f0401c1fd3
Create Date: 2023-04-29 15:53:24.753628+00:00
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = "ce68ee15e5d2"
down_revision = "d7f0401c1fd3"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_constraint(
"fk_transactions_bank_banks",
"transactions",
type_="foreignkey",
schema="pfbudget",
)
op.drop_constraint(
"fk_banks_nordigen_name_banks",
"banks_nordigen",
type_="foreignkey",
schema="pfbudget",
)
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint("uq_banks_name", "banks", schema="pfbudget", type_="unique")
# ### end Alembic commands ###
op.execute("ALTER TABLE pfbudget.banks DROP CONSTRAINT pk_banks")
op.create_primary_key("pk_banks", "banks", ["name"], schema="pfbudget")
op.create_foreign_key(
"fk_transactions_bank_banks",
"transactions",
"banks",
["bank"],
["name"],
source_schema="pfbudget",
referent_schema="pfbudget",
)
op.create_foreign_key(
"fk_banks_nordigen_name_banks",
"banks_nordigen",
"banks",
["name"],
["name"],
source_schema="pfbudget",
referent_schema="pfbudget",
)
def downgrade() -> None:
op.drop_constraint(
"fk_transactions_bank_banks",
"transactions",
type_="foreignkey",
schema="pfbudget",
)
op.drop_constraint(
"fk_banks_nordigen_name_banks",
"banks_nordigen",
type_="foreignkey",
schema="pfbudget",
)
op.execute("ALTER TABLE pfbudget.banks DROP CONSTRAINT pk_banks")
op.create_primary_key("pk_banks", "banks", ["BIC", "type"], schema="pfbudget")
# ### commands auto generated by Alembic - please adjust! ###
op.create_unique_constraint("uq_banks_name", "banks", ["name"], schema="pfbudget")
# ### end Alembic commands ###
op.create_foreign_key(
"fk_transactions_bank_banks",
"transactions",
"banks",
["bank"],
["name"],
source_schema="pfbudget",
referent_schema="pfbudget",
)
op.create_foreign_key(
"fk_banks_nordigen_name_banks",
"banks_nordigen",
"banks",
["name"],
["name"],
source_schema="pfbudget",
referent_schema="pfbudget",
)

View File

@ -0,0 +1,594 @@
"""unified schemas
Revision ID: d7f0401c1fd3
Revises: 952de57a3c43
Create Date: 2023-04-27 16:30:08.514985+00:00
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = "d7f0401c1fd3"
down_revision = "952de57a3c43"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"banks",
sa.Column("name", sa.String(), nullable=False),
sa.Column("BIC", sa.String(length=8), nullable=False),
sa.Column(
"type",
sa.Enum(
"checking",
"savings",
"investment",
"mealcard",
"VISA",
"MASTERCARD",
name="accounttype",
schema="pfbudget",
inherit_schema=True,
),
nullable=False,
),
sa.PrimaryKeyConstraint("BIC", "type", name=op.f("pk_banks")),
sa.UniqueConstraint("name", name=op.f("uq_banks_name")),
schema="pfbudget",
)
op.create_table(
"category_groups",
sa.Column("name", sa.String(), nullable=False),
sa.PrimaryKeyConstraint("name", name=op.f("pk_category_groups")),
schema="pfbudget",
)
op.create_table(
"rules",
sa.Column(
"id", sa.BigInteger().with_variant(sa.Integer(), "sqlite"), nullable=False
),
sa.Column("start", sa.Date(), nullable=True),
sa.Column("end", 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.Column("type", sa.String(), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_rules")),
schema="pfbudget",
)
op.create_table(
"tags",
sa.Column("name", sa.String(), nullable=False),
sa.PrimaryKeyConstraint("name", name=op.f("pk_tags")),
schema="pfbudget",
)
op.create_table(
"banks_nordigen",
sa.Column("name", sa.Text(), nullable=False),
sa.Column("bank_id", sa.String(), nullable=True),
sa.Column("requisition_id", sa.String(), nullable=True),
sa.Column("invert", sa.Boolean(), nullable=True),
sa.ForeignKeyConstraint(
["name"], ["pfbudget.banks.name"], name=op.f("fk_banks_nordigen_name_banks")
),
sa.PrimaryKeyConstraint("name", name=op.f("pk_banks_nordigen")),
schema="pfbudget",
)
op.create_table(
"categories",
sa.Column("name", sa.String(), nullable=False),
sa.Column("group", sa.String(), nullable=True),
sa.ForeignKeyConstraint(
["group"],
["pfbudget.category_groups.name"],
name=op.f("fk_categories_group_category_groups"),
),
sa.PrimaryKeyConstraint("name", name=op.f("pk_categories")),
schema="pfbudget",
)
op.create_table(
"tag_rules",
sa.Column("id", sa.BigInteger(), nullable=False),
sa.Column("tag", sa.String(), nullable=False),
sa.ForeignKeyConstraint(
["id"],
["pfbudget.rules.id"],
name=op.f("fk_tag_rules_id_rules"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["tag"],
["pfbudget.tags.name"],
name=op.f("fk_tag_rules_tag_tags"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_tag_rules")),
schema="pfbudget",
)
op.create_table(
"transactions",
sa.Column(
"id", sa.BigInteger().with_variant(sa.Integer(), "sqlite"), nullable=False
),
sa.Column("date", sa.Date(), nullable=False),
sa.Column("description", sa.String(), nullable=True),
sa.Column("amount", sa.Numeric(precision=16, scale=2), nullable=False),
sa.Column("split", sa.Boolean(), nullable=False),
sa.Column("type", sa.String(), nullable=False),
sa.Column("bank", sa.Text(), nullable=True),
sa.Column("original", sa.BigInteger(), nullable=True),
sa.ForeignKeyConstraint(
["bank"], ["pfbudget.banks.name"], name=op.f("fk_transactions_bank_banks")
),
sa.ForeignKeyConstraint(
["original"],
["pfbudget.transactions.id"],
name=op.f("fk_transactions_original_transactions"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_transactions")),
schema="pfbudget",
)
op.create_table(
"category_rules",
sa.Column("id", sa.BigInteger(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.ForeignKeyConstraint(
["id"],
["pfbudget.rules.id"],
name=op.f("fk_category_rules_id_rules"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["name"],
["pfbudget.categories.name"],
name=op.f("fk_category_rules_name_categories"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_category_rules")),
schema="pfbudget",
)
op.create_table(
"category_schedules",
sa.Column("name", sa.String(), nullable=False),
sa.Column(
"period",
sa.Enum(
"daily",
"weekly",
"monthly",
"yearly",
name="period",
schema="pfbudget",
inherit_schema=True,
),
nullable=True,
),
sa.Column("period_multiplier", sa.Integer(), nullable=True),
sa.Column("amount", sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(
["name"],
["pfbudget.categories.name"],
name=op.f("fk_category_schedules_name_categories"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("name", name=op.f("pk_category_schedules")),
schema="pfbudget",
)
op.create_table(
"links",
sa.Column("original", sa.BigInteger(), nullable=False),
sa.Column("link", sa.BigInteger(), nullable=False),
sa.ForeignKeyConstraint(
["link"],
["pfbudget.transactions.id"],
name=op.f("fk_links_link_transactions"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["original"],
["pfbudget.transactions.id"],
name=op.f("fk_links_original_transactions"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("original", "link", name=op.f("pk_links")),
schema="pfbudget",
)
op.create_table(
"notes",
sa.Column("id", sa.BigInteger(), nullable=False),
sa.Column("note", sa.String(), nullable=False),
sa.ForeignKeyConstraint(
["id"],
["pfbudget.transactions.id"],
name=op.f("fk_notes_id_transactions"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_notes")),
schema="pfbudget",
)
op.create_table(
"transactions_categorized",
sa.Column("id", sa.BigInteger(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.ForeignKeyConstraint(
["id"],
["pfbudget.transactions.id"],
name=op.f("fk_transactions_categorized_id_transactions"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["name"],
["pfbudget.categories.name"],
name=op.f("fk_transactions_categorized_name_categories"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_transactions_categorized")),
schema="pfbudget",
)
op.create_table(
"transactions_tagged",
sa.Column("id", sa.BigInteger(), nullable=False),
sa.Column("tag", sa.String(), nullable=False),
sa.ForeignKeyConstraint(
["id"],
["pfbudget.transactions.id"],
name=op.f("fk_transactions_tagged_id_transactions"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["tag"],
["pfbudget.tags.name"],
name=op.f("fk_transactions_tagged_tag_tags"),
),
sa.PrimaryKeyConstraint("id", "tag", name=op.f("pk_transactions_tagged")),
schema="pfbudget",
)
op.create_table(
"category_selectors",
sa.Column("id", sa.BigInteger(), nullable=False),
sa.Column(
"selector",
sa.Enum(
"unknown",
"nullifier",
"vacations",
"rules",
"algorithm",
"manual",
name="selector_t",
schema="pfbudget",
inherit_schema=True,
),
nullable=False,
),
sa.ForeignKeyConstraint(
["id"],
["pfbudget.transactions_categorized.id"],
name=op.f("fk_category_selectors_id_transactions_categorized"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_category_selectors")),
schema="pfbudget",
)
op.drop_table("notes", schema="transactions")
op.drop_table("rules", schema="tag")
op.drop_table("tagged", schema="transactions")
op.drop_table("available", schema="tag")
op.drop_table("nordigen", schema="bank")
op.drop_table("links", schema="transactions")
op.drop_table("selector", schema="transactions")
op.drop_table("categorized", schema="transactions")
op.drop_table("transactions", schema="transactions")
op.drop_table("banks", schema="bank")
op.drop_table("rules", schema="category")
op.drop_table("schedules", schema="category")
op.drop_table("rules", schema="transactions")
op.drop_table("available", schema="category")
op.drop_table("groups", schema="category")
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"transactions",
sa.Column(
"id",
sa.BIGINT(),
server_default=sa.text(
"nextval('transactions.originals_id_seq'::regclass)"
),
autoincrement=True,
nullable=False,
),
sa.Column("date", sa.DATE(), autoincrement=False, nullable=False),
sa.Column("description", sa.VARCHAR(), autoincrement=False, nullable=True),
sa.Column("bank", sa.TEXT(), autoincrement=False, nullable=True),
sa.Column(
"amount",
sa.NUMERIC(precision=16, scale=2),
autoincrement=False,
nullable=False,
),
sa.Column("type", sa.VARCHAR(), autoincrement=False, nullable=False),
sa.Column("split", sa.BOOLEAN(), autoincrement=False, nullable=False),
sa.Column("original", sa.BIGINT(), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(
["bank"], ["bank.banks.name"], name="fk_originals_bank_banks"
),
sa.ForeignKeyConstraint(
["original"],
["transactions.transactions.id"],
name="fk_originals_original_originals",
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name="pk_originals"),
schema="transactions",
postgresql_ignore_search_path=False,
)
op.create_table(
"nordigen",
sa.Column("name", sa.TEXT(), autoincrement=False, nullable=False),
sa.Column("bank_id", sa.VARCHAR(), autoincrement=False, nullable=True),
sa.Column("requisition_id", sa.VARCHAR(), autoincrement=False, nullable=True),
sa.Column("invert", sa.BOOLEAN(), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(
["name"], ["bank.banks.name"], name="fk_nordigen_name_banks"
),
sa.PrimaryKeyConstraint("name", name="pk_nordigen"),
schema="bank",
)
op.create_table(
"tagged",
sa.Column("id", sa.BIGINT(), autoincrement=False, nullable=False),
sa.Column("tag", sa.VARCHAR(), autoincrement=False, nullable=False),
sa.ForeignKeyConstraint(
["id"],
["transactions.transactions.id"],
name="fk_tags_id_originals",
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["tag"], ["tag.available.name"], name="fk_tags_tag_available"
),
sa.PrimaryKeyConstraint("id", "tag", name="pk_tags"),
schema="transactions",
)
op.create_table(
"available",
sa.Column("name", sa.VARCHAR(), autoincrement=False, nullable=False),
sa.Column("group", sa.VARCHAR(), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(
["group"], ["category.groups.name"], name="fk_available_group_groups"
),
sa.PrimaryKeyConstraint("name", name="pk_available"),
schema="category",
postgresql_ignore_search_path=False,
)
op.create_table(
"rules",
sa.Column(
"id",
sa.BIGINT(),
server_default=sa.text("nextval('transactions.rules_id_seq'::regclass)"),
autoincrement=True,
nullable=False,
),
sa.Column("end", sa.DATE(), autoincrement=False, nullable=True),
sa.Column("description", sa.VARCHAR(), autoincrement=False, nullable=True),
sa.Column("regex", sa.VARCHAR(), autoincrement=False, nullable=True),
sa.Column("bank", sa.VARCHAR(), autoincrement=False, nullable=True),
sa.Column(
"min", sa.NUMERIC(precision=16, scale=2), autoincrement=False, nullable=True
),
sa.Column(
"max", sa.NUMERIC(precision=16, scale=2), autoincrement=False, nullable=True
),
sa.Column("type", sa.VARCHAR(), autoincrement=False, nullable=False),
sa.Column("start", sa.DATE(), autoincrement=False, nullable=True),
sa.PrimaryKeyConstraint("id", name="pk_rules"),
schema="transactions",
postgresql_ignore_search_path=False,
)
op.create_table(
"groups",
sa.Column("name", sa.VARCHAR(), autoincrement=False, nullable=False),
sa.PrimaryKeyConstraint("name", name="pk_groups"),
schema="category",
postgresql_ignore_search_path=False,
)
op.create_table(
"rules",
sa.Column("id", sa.BIGINT(), autoincrement=False, nullable=False),
sa.Column("tag", sa.VARCHAR(), autoincrement=False, nullable=False),
sa.ForeignKeyConstraint(
["id"],
["transactions.rules.id"],
name="fk_rules_id_rules",
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["tag"],
["tag.available.name"],
name="fk_rules_tag_available",
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name="pk_rules"),
schema="tag",
)
op.create_table(
"schedules",
sa.Column("name", sa.VARCHAR(), autoincrement=False, nullable=False),
sa.Column(
"period",
postgresql.ENUM(
"daily", "weekly", "monthly", "yearly", name="period", schema="category"
),
autoincrement=False,
nullable=True,
),
sa.Column(
"period_multiplier", sa.INTEGER(), autoincrement=False, nullable=True
),
sa.Column("amount", sa.INTEGER(), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(
["name"],
["category.available.name"],
name="fk_schedules_name_available",
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("name", name="pk_schedules"),
schema="category",
)
op.create_table(
"rules",
sa.Column("id", sa.BIGINT(), autoincrement=False, nullable=False),
sa.Column("name", sa.VARCHAR(), autoincrement=False, nullable=False),
sa.ForeignKeyConstraint(
["id"],
["transactions.rules.id"],
name="fk_rules_id_rules",
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["name"],
["category.available.name"],
name="fk_rules_name_available",
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name="pk_rules"),
schema="category",
)
op.create_table(
"links",
sa.Column("original", sa.BIGINT(), autoincrement=False, nullable=False),
sa.Column("link", sa.BIGINT(), autoincrement=False, nullable=False),
sa.ForeignKeyConstraint(
["link"],
["transactions.transactions.id"],
name="fk_links_link_originals",
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["original"],
["transactions.transactions.id"],
name="fk_links_original_originals",
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("original", "link", name="pk_links"),
schema="transactions",
)
op.create_table(
"selector",
sa.Column("id", sa.BIGINT(), autoincrement=False, nullable=False),
sa.Column(
"selector",
postgresql.ENUM(
"unknown",
"nullifier",
"vacations",
"rules",
"algorithm",
"manual",
name="selector_t",
schema="transactions",
),
autoincrement=False,
nullable=False,
),
sa.ForeignKeyConstraint(
["id"],
["transactions.categorized.id"],
name="fk_selector_id_categorized",
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name="pk_selector"),
schema="transactions",
)
op.create_table(
"banks",
sa.Column("name", sa.VARCHAR(), autoincrement=False, nullable=False),
sa.Column("BIC", sa.VARCHAR(length=8), autoincrement=False, nullable=False),
sa.Column(
"type",
postgresql.ENUM(
"checking",
"savings",
"investment",
"mealcard",
"VISA",
"MASTERCARD",
name="accounttype",
schema="bank",
),
autoincrement=False,
nullable=False,
),
sa.PrimaryKeyConstraint("BIC", "type", name="pk_banks"),
sa.UniqueConstraint("name", name="uq_banks_name"),
schema="bank",
postgresql_ignore_search_path=False,
)
op.create_table(
"available",
sa.Column("name", sa.VARCHAR(), autoincrement=False, nullable=False),
sa.PrimaryKeyConstraint("name", name="pk_available"),
schema="tag",
)
op.create_table(
"notes",
sa.Column("id", sa.BIGINT(), autoincrement=False, nullable=False),
sa.Column("note", sa.VARCHAR(), autoincrement=False, nullable=False),
sa.ForeignKeyConstraint(
["id"],
["transactions.transactions.id"],
name="fk_notes_id_originals",
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name="pk_notes"),
schema="transactions",
)
op.create_table(
"categorized",
sa.Column("id", sa.BIGINT(), autoincrement=False, nullable=False),
sa.Column("name", sa.VARCHAR(), autoincrement=False, nullable=False),
sa.ForeignKeyConstraint(
["id"],
["transactions.transactions.id"],
name="fk_categorized_id_originals",
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["name"],
["category.available.name"],
name="fk_categorized_name_available",
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name="pk_categorized"),
schema="transactions",
)
op.drop_table("category_selectors", schema="pfbudget")
op.drop_table("transactions_tagged", schema="pfbudget")
op.drop_table("transactions_categorized", schema="pfbudget")
op.drop_table("notes", schema="pfbudget")
op.drop_table("links", schema="pfbudget")
op.drop_table("category_schedules", schema="pfbudget")
op.drop_table("category_rules", schema="pfbudget")
op.drop_table("transactions", schema="pfbudget")
op.drop_table("tag_rules", schema="pfbudget")
op.drop_table("categories", schema="pfbudget")
op.drop_table("banks_nordigen", schema="pfbudget")
op.drop_table("tags", schema="pfbudget")
op.drop_table("rules", schema="pfbudget")
op.drop_table("category_groups", schema="pfbudget")
op.drop_table("banks", schema="pfbudget")
# ### end Alembic commands ###

View File

@ -53,7 +53,7 @@ if __name__ == "__main__":
if not args["all"]:
params.append(args["banks"])
else:
params.append([])
params.append(None)
case Operation.BankAdd:
keys = {"bank", "bic", "type"}
@ -130,12 +130,12 @@ if __name__ == "__main__":
keys = {"category", "group"}
assert args.keys() >= keys, f"missing {args.keys() - keys}"
params = [type.Category(cat) for cat in args["category"]]
params.append(args["group"])
params = [{"name": cat, "group": args["group"]} for cat in args["category"]]
case Operation.CategoryRemove:
assert "category" in args, "argparser ill defined"
params = [type.Category(cat) for cat in args["category"]]
params = args["category"]
case Operation.CategorySchedule:
keys = {"category", "period", "frequency"}
@ -246,7 +246,7 @@ if __name__ == "__main__":
case Operation.GroupRemove:
assert "group" in args, "argparser ill defined"
params = [type.CategoryGroup(group) for group in args["group"]]
params = args["group"]
case Operation.Forge | Operation.Dismantle:
keys = {"original", "links"}

View File

@ -67,7 +67,7 @@ def argparser() -> argparse.ArgumentParser:
pimport = subparsers.add_parser("import")
pimport.set_defaults(op=Operation.Import)
pimport.add_argument("file", nargs=1, type=str)
file_options(pimport)
# Parse from .csv
parse = subparsers.add_parser("parse")

View File

@ -57,7 +57,7 @@ class Interactive:
case "split":
new = self.split(next)
session.add(new)
session.insert(new)
case other:
if not other:
@ -84,7 +84,7 @@ class Interactive:
)
for tag in tags:
if tag not in [t.name for t in self.tags]:
session.add([Tag(tag)])
session.insert([Tag(tag)])
self.tags = session.get(Tag)
next.tags.add(TransactionTag(tag))

View File

@ -1,10 +1,12 @@
import csv
import json
from pathlib import Path
import pickle
from typing import Optional
import webbrowser
from pfbudget.common.types import Operation
from pfbudget.db.client import DbClient
from pfbudget.db.client import Client
from pfbudget.db.model import (
Bank,
BankTransaction,
@ -27,6 +29,7 @@ from pfbudget.db.model import (
from pfbudget.extract.nordigen import NordigenClient, NordigenCredentialsManager
from pfbudget.extract.parsers import parse_data
from pfbudget.extract.psd2 import PSD2Extractor
from pfbudget.load.database import DatabaseLoader
from pfbudget.transform.categorizer import Categorizer
from pfbudget.transform.nullifier import Nullifier
from pfbudget.transform.tagger import Tagger
@ -35,6 +38,7 @@ from pfbudget.transform.tagger import Tagger
class Manager:
def __init__(self, db: str, verbosity: int = 0):
self._db = db
self._database: Optional[Client] = None
self._verbosity = verbosity
def action(self, op: Operation, params=None):
@ -49,10 +53,7 @@ class Manager:
pass
case Operation.Transactions:
with self.db.session() as session:
transactions = session.get(Transaction)
ret = [t.format for t in transactions]
return ret
return [t.format for t in self.database.select(Transaction)]
case Operation.Parse:
# Adapter for the parse_data method. Can be refactored.
@ -71,37 +72,36 @@ class Manager:
len(transactions) > 0
and input(f"{transactions[:5]}\nCommit? (y/n)") == "y"
):
with self.db.session() as session:
session.add(sorted(transactions))
self.database.insert(sorted(transactions))
case Operation.Download:
client = Manager.nordigen_client()
with self.db.session() as session:
if len(params[3]) == 0:
banks = session.get(Bank, Bank.nordigen)
else:
banks = session.get(Bank, Bank.name, params[3])
session.expunge_all()
if params[3]:
values = params[3]
banks = self.database.select(Bank, lambda: Bank.name.in_(values))
else:
banks = self.database.select(Bank, Bank.nordigen)
extractor = PSD2Extractor(Manager.nordigen_client())
extractor = PSD2Extractor(client)
transactions = []
for bank in banks:
transactions.extend(extractor.extract(bank, params[0], params[1]))
# dry-run
if not params[2]:
with self.db.session() as session:
session.add(sorted(transactions))
else:
if params[2]:
print(sorted(transactions))
return
loader = DatabaseLoader(self.database)
loader.load(sorted(transactions))
case Operation.Categorize:
with self.db.session() as session:
uncategorized = session.get(
BankTransaction, ~BankTransaction.category.has()
with self.database.session as session:
uncategorized = session.select(
BankTransaction, lambda: ~BankTransaction.category.has()
)
categories = session.get(Category)
tags = session.get(Tag)
categories = session.select(Category)
tags = session.select(Tag)
rules = [cat.rules for cat in categories if cat.name == "null"]
Nullifier(rules).transform_inplace(uncategorized)
@ -113,20 +113,16 @@ class Manager:
Tagger(rules).transform_inplace(uncategorized)
case Operation.BankMod:
with self.db.session() as session:
session.update(Bank, params)
self.database.update(Bank, params)
case Operation.PSD2Mod:
with self.db.session() as session:
session.update(Nordigen, params)
self.database.update(Nordigen, params)
case Operation.BankDel:
with self.db.session() as session:
session.remove_by_name(Bank, params)
self.database.delete(Bank, Bank.name, params)
case Operation.PSD2Del:
with self.db.session() as session:
session.remove_by_name(Nordigen, params)
self.database.delete(Nordigen, Nordigen.name, params)
case Operation.Token:
Manager.nordigen_client().generate_token()
@ -143,53 +139,37 @@ class Manager:
case (
Operation.BankAdd
| Operation.CategoryAdd
| Operation.GroupAdd
| Operation.PSD2Add
| Operation.RuleAdd
| Operation.TagAdd
| Operation.TagRuleAdd
):
with self.db.session() as session:
session.add(params)
self.database.insert(params)
case Operation.CategoryUpdate:
with self.db.session() as session:
session.updategroup(*params)
self.database.update(Category, params)
case Operation.CategoryRemove:
with self.db.session() as session:
session.remove_by_name(Category, params)
self.database.delete(Category, Category.name, params)
case Operation.CategorySchedule:
with self.db.session() as session:
session.updateschedules(params)
raise NotImplementedError
case Operation.RuleRemove:
assert all(isinstance(param, int) for param in params)
with self.db.session() as session:
session.remove_by_id(CategoryRule, params)
self.database.delete(CategoryRule, CategoryRule.id, params)
case Operation.TagRemove:
with self.db.session() as session:
session.remove_by_name(Tag, params)
self.database.delete(Tag, Tag.name, 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)
self.database.delete(TagRule, TagRule.id, params)
case Operation.RuleModify | Operation.TagRuleModify:
assert all(isinstance(param, dict) for param in params)
with self.db.session() as session:
session.update(Rule, params)
case Operation.GroupAdd:
with self.db.session() as session:
session.add(params)
self.database.update(Rule, params)
case Operation.GroupRemove:
assert all(isinstance(param, CategoryGroup) for param in params)
with self.db.session() as session:
session.remove_by_name(CategoryGroup, params)
self.database.delete(CategoryGroup, CategoryGroup.name, params)
case Operation.Forge:
if not (
@ -198,9 +178,14 @@ class Manager:
):
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])
with self.database.session as session:
id = params[0]
original = session.select(
Transaction, lambda: Transaction.id == id
)[0]
ids = params[1]
links = session.select(Transaction, lambda: Transaction.id.in_(ids))
if not original.category:
original.category = self.askcategory(original)
@ -217,15 +202,10 @@ class Manager:
link.category = original.category
tobelinked = [Link(original.id, link.id) for link in links]
session.add(tobelinked)
session.insert(tobelinked)
case Operation.Dismantle:
assert all(isinstance(param, Link) for param in params)
with self.db.session() as session:
original = params[0].original
links = [link.link for link in params]
session.remove_links(original, links)
raise NotImplementedError
case Operation.Split:
if len(params) < 1 and not all(
@ -240,8 +220,10 @@ class Manager:
f"{original.amount}€ != {sum(v for v, _ in params[1:])}"
)
with self.db.session() as session:
originals = session.get(Transaction, Transaction.id, [original.id])
with self.database.session as session:
originals = session.select(
Transaction, lambda: Transaction.id == original.id
)
assert len(originals) == 1, ">1 transactions matched {original.id}!"
originals[0].split = True
@ -260,11 +242,13 @@ class Manager:
splitted.category = t.category
transactions.append(splitted)
session.add(transactions)
session.insert(transactions)
case Operation.Export:
with self.db.session() as session:
self.dump(params[0], params[1], sorted(session.get(Transaction)))
with self.database.session as session:
self.dump(
params[0], params[1], self.database.select(Transaction, session)
)
case Operation.Import:
transactions = []
@ -297,12 +281,11 @@ class Manager:
transactions.append(transaction)
if self.certify(transactions):
with self.db.session() as session:
session.add(transactions)
self.database.insert(transactions)
case Operation.ExportBanks:
with self.db.session() as session:
self.dump(params[0], params[1], session.get(Bank))
with self.database.session as session:
self.dump(params[0], params[1], self.database.select(Bank, session))
case Operation.ImportBanks:
banks = []
@ -313,34 +296,39 @@ class Manager:
banks.append(bank)
if self.certify(banks):
with self.db.session() as session:
session.add(banks)
self.database.insert(banks)
case Operation.ExportCategoryRules:
with self.db.session() as session:
self.dump(params[0], params[1], session.get(CategoryRule))
with self.database.session as session:
self.dump(
params[0],
params[1],
self.database.select(CategoryRule, session),
)
case Operation.ImportCategoryRules:
rules = [CategoryRule(**row) for row in self.load(params[0], params[1])]
if self.certify(rules):
with self.db.session() as session:
session.add(rules)
self.database.insert(rules)
case Operation.ExportTagRules:
with self.db.session() as session:
self.dump(params[0], params[1], session.get(TagRule))
with self.database.session as session:
self.dump(
params[0], params[1], self.database.select(TagRule, session)
)
case Operation.ImportTagRules:
rules = [TagRule(**row) for row in self.load(params[0], params[1])]
if self.certify(rules):
with self.db.session() as session:
session.add(rules)
self.database.insert(rules)
case Operation.ExportCategories:
with self.db.session() as session:
self.dump(params[0], params[1], session.get(Category))
with self.database.session as session:
self.dump(
params[0], params[1], self.database.select(Category, session)
)
case Operation.ImportCategories:
# rules = [Category(**row) for row in self.load(params[0])]
@ -359,12 +347,15 @@ class Manager:
categories.append(category)
if self.certify(categories):
with self.db.session() as session:
session.add(categories)
self.database.insert(categories)
case Operation.ExportCategoryGroups:
with self.db.session() as session:
self.dump(params[0], params[1], session.get(CategoryGroup))
with self.database.session as session:
self.dump(
params[0],
params[1],
self.database.select(CategoryGroup, session),
)
case Operation.ImportCategoryGroups:
groups = [
@ -372,8 +363,7 @@ class Manager:
]
if self.certify(groups):
with self.db.session() as session:
session.add(groups)
self.database.insert(groups)
def parse(self, filename: Path, args: dict):
return parse_data(filename, args)
@ -381,13 +371,12 @@ class Manager:
def askcategory(self, transaction: Transaction):
selector = CategorySelector(Selector_T.manual)
with self.db.session() as session:
categories = session.get(Category)
categories = self.database.select(Category)
while True:
category = input(f"{transaction}: ")
if category in [c.name for c in categories]:
return TransactionCategory(category, selector)
while True:
category = input(f"{transaction}: ")
if category in [c.name for c in categories]:
return TransactionCategory(category, selector)
@staticmethod
def dump(fn, format, sequence):
@ -397,6 +386,9 @@ class Manager:
elif format == "csv":
with open(fn, "w", newline="") as f:
csv.writer(f).writerows([e.format.values() for e in sequence])
elif format == "json":
with open(fn, "w", newline="") as f:
json.dump([e.format for e in sequence], f, indent=4, default=str)
else:
print("format not well specified")
@ -418,12 +410,10 @@ class Manager:
return False
@property
def db(self) -> DbClient:
return DbClient(self._db, self._verbosity > 2)
@db.setter
def db(self, url: str):
self._db = url
def database(self) -> Client:
if not self._database:
self._database = Client(self._db, echo=self._verbosity > 2)
return self._database
@staticmethod
def nordigen_client() -> NordigenClient:

View File

@ -1,123 +1,69 @@
from dataclasses import asdict
from sqlalchemy import create_engine, delete, select, update
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.orm import Session
from sqlalchemy.sql.expression import false
from typing import Sequence, Type, TypeVar
from collections.abc import Sequence
from copy import deepcopy
from sqlalchemy import Engine, create_engine, delete, select, update
from sqlalchemy.orm import Session, sessionmaker
from typing import Any, Mapping, Optional, Type, TypeVar
from pfbudget.db.model import (
Category,
CategoryGroup,
CategorySchedule,
Link,
Transaction,
)
# from pfbudget.db.exceptions import InsertError, SelectError
class DbClient:
"""
General database client using sqlalchemy
"""
class DatabaseSession:
def __init__(self, session: Session):
self.__session = session
__sessions: list[Session]
def __enter__(self):
self.__session.begin()
return self
def __init__(self, url: str, echo=False) -> None:
self._engine = create_engine(url, echo=echo)
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any):
if exc_type:
self.__session.rollback()
else:
self.__session.commit()
self.__session.close()
def insert(self, sequence: Sequence[Any]) -> None:
self.__session.add_all(sequence)
T = TypeVar("T")
def select(self, what: Type[T], exists: Optional[Any] = None) -> Sequence[T]:
if exists:
stmt = select(what).filter(exists)
else:
stmt = select(what)
return self.__session.scalars(stmt).all()
class Client:
def __init__(self, url: str, **kwargs: Any):
assert url, "Database URL is empty!"
self._engine = create_engine(url, **kwargs)
self._sessionmaker = sessionmaker(self._engine)
def insert(self, sequence: Sequence[Any]) -> None:
new = deepcopy(sequence)
with self.session as session:
session.insert(new)
T = TypeVar("T")
def select(self, what: Type[T], exists: Optional[Any] = None) -> Sequence[T]:
return self.session.select(what, exists)
def update(self, what: Type[Any], values: Sequence[Mapping[str, Any]]) -> None:
with self._sessionmaker() as session, session.begin():
session.execute(update(what), values)
def delete(self, what: Type[Any], column: Any, values: Sequence[str]) -> None:
with self._sessionmaker() as session, session.begin():
session.execute(delete(what).where(column.in_(values)))
@property
def engine(self):
def engine(self) -> Engine:
return self._engine
class ClientSession:
def __init__(self, engine):
self.__engine = engine
def __enter__(self):
self.__session = Session(self.__engine)
return self
def __exit__(self, exc_type, exc_value, exc_tb):
self.commit()
self.__session.close()
def commit(self):
self.__session.commit()
def expunge_all(self):
self.__session.expunge_all()
T = TypeVar("T")
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)
else:
stmt = select(type).where(column)
else:
stmt = select(type)
return self.__session.scalars(stmt).all()
def uncategorized(self) -> Sequence[Transaction]:
"""Selects all valid uncategorized transactions
At this moment that includes:
- Categories w/o category
- AND non-split categories
Returns:
Sequence[Transaction]: transactions left uncategorized
"""
stmt = (
select(Transaction)
.where(~Transaction.category.has())
.where(Transaction.split == false())
)
return self.__session.scalars(stmt).all()
def add(self, rows: list):
self.__session.add_all(rows)
def remove_by_name(self, type, 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):
stmt = (
update(Category)
.where(Category.name.in_([cat.name for cat in categories]))
.values(group=group)
)
self.__session.execute(stmt)
def updateschedules(self, schedules: list[CategorySchedule]):
stmt = insert(CategorySchedule).values([asdict(s) for s in schedules])
stmt = stmt.on_conflict_do_update(
index_elements=[CategorySchedule.name],
set_=dict(
recurring=stmt.excluded.recurring,
period=stmt.excluded.period,
period_multiplier=stmt.excluded.period_multiplier,
),
)
self.__session.execute(stmt)
def remove_by_id(self, type, ids: list[int]):
stmt = delete(type).where(type.id.in_(ids))
self.__session.execute(stmt)
def update(self, type, values: list[dict]):
print(type, values)
self.__session.execute(update(type), values)
def remove_links(self, original: int, links: list[int]):
stmt = delete(Link).where(
Link.original == original, Link.link.in_(link for link in links)
)
self.__session.execute(stmt)
def session(self) -> ClientSession:
return self.ClientSession(self.engine)
@property
def session(self) -> DatabaseSession:
return DatabaseSession(self._sessionmaker())

View File

@ -0,0 +1,6 @@
class InsertError(Exception):
pass
class SelectError(Exception):
pass

View File

@ -9,6 +9,7 @@ from sqlalchemy import (
BigInteger,
Enum,
ForeignKey,
Integer,
MetaData,
Numeric,
String,
@ -25,7 +26,7 @@ from sqlalchemy.orm import (
class Base(MappedAsDataclass, DeclarativeBase):
metadata = MetaData(
schema="transactions",
schema="pfbudget",
naming_convention={
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
@ -58,12 +59,11 @@ class Export:
class Bank(Base, Export):
__table_args__ = {"schema": "bank"}
__tablename__ = "banks"
name: Mapped[str] = mapped_column(unique=True)
BIC: Mapped[str] = mapped_column(String(8), primary_key=True)
type: Mapped[accounttype] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(primary_key=True)
BIC: Mapped[str] = mapped_column(String(8))
type: Mapped[accounttype]
nordigen: Mapped[Optional[Nordigen]] = relationship(lazy="joined", init=False)
@ -79,7 +79,14 @@ class Bank(Base, Export):
bankfk = Annotated[str, mapped_column(Text, ForeignKey(Bank.name))]
idpk = Annotated[int, mapped_column(BigInteger, primary_key=True, autoincrement=True)]
idpk = Annotated[
int,
mapped_column(
BigInteger().with_variant(Integer, "sqlite"),
primary_key=True,
autoincrement=True,
),
]
money = Annotated[decimal.Decimal, mapped_column(Numeric(16, 2))]
@ -151,8 +158,7 @@ class SplitTransaction(Transaction):
class CategoryGroup(Base, Export):
__table_args__ = {"schema": "category"}
__tablename__ = "groups"
__tablename__ = "category_groups"
name: Mapped[str] = mapped_column(primary_key=True)
@ -162,8 +168,7 @@ class CategoryGroup(Base, Export):
class Category(Base, Export):
__table_args__ = {"schema": "category"}
__tablename__ = "available"
__tablename__ = "categories"
name: Mapped[str] = mapped_column(primary_key=True)
group: Mapped[Optional[str]] = mapped_column(
@ -200,7 +205,7 @@ catfk = Annotated[
class TransactionCategory(Base, Export):
__tablename__ = "categorized"
__tablename__ = "transactions_categorized"
id: Mapped[idfk] = mapped_column(primary_key=True, init=False)
name: Mapped[catfk]
@ -211,7 +216,9 @@ class TransactionCategory(Base, Export):
@property
def format(self):
return dict(name=self.name, selector=self.selector.format)
return dict(
name=self.name, selector=self.selector.format if self.selector else None
)
class Note(Base):
@ -222,8 +229,7 @@ class Note(Base):
class Nordigen(Base, Export):
__table_args__ = {"schema": "bank"}
__tablename__ = "nordigen"
__tablename__ = "banks_nordigen"
name: Mapped[bankfk] = mapped_column(primary_key=True)
bank_id: Mapped[Optional[str]]
@ -241,8 +247,7 @@ class Nordigen(Base, Export):
class Tag(Base):
__table_args__ = {"schema": "tag"}
__tablename__ = "available"
__tablename__ = "tags"
name: Mapped[str] = mapped_column(primary_key=True)
@ -252,7 +257,7 @@ class Tag(Base):
class TransactionTag(Base, Export):
__tablename__ = "tagged"
__tablename__ = "transactions_tagged"
id: Mapped[idfk] = mapped_column(primary_key=True, init=False)
tag: Mapped[str] = mapped_column(ForeignKey(Tag.name), primary_key=True)
@ -281,7 +286,7 @@ categoryselector = Annotated[
class CategorySelector(Base, Export):
__tablename__ = "selector"
__tablename__ = "category_selectors"
id: Mapped[int] = mapped_column(
BigInteger,
@ -307,8 +312,7 @@ scheduleperiod = Annotated[Selector_T, mapped_column(Enum(Period, inherit_schema
class CategorySchedule(Base, Export):
__table_args__ = {"schema": "category"}
__tablename__ = "schedules"
__tablename__ = "category_schedules"
name: Mapped[catfk] = mapped_column(primary_key=True)
period: Mapped[Optional[scheduleperiod]]
@ -393,8 +397,7 @@ class Rule(Base, Export):
class CategoryRule(Rule):
__table_args__ = {"schema": "category"}
__tablename__ = "rules"
__tablename__ = "category_rules"
id: Mapped[int] = mapped_column(
BigInteger,
@ -417,8 +420,7 @@ class CategoryRule(Rule):
class TagRule(Rule):
__table_args__ = {"schema": "tag"}
__tablename__ = "rules"
__tablename__ = "tag_rules"
id: Mapped[int] = mapped_column(
BigInteger,

View File

@ -1,9 +1,13 @@
from abc import ABC, abstractmethod
from datetime import date
from typing import Sequence
from pfbudget.db.model import Transaction
from pfbudget.db.model import Bank, Transaction
class Extract(ABC):
class Extractor(ABC):
@abstractmethod
def extract(self) -> list[Transaction]:
return NotImplementedError
def extract(
self, bank: Bank, start: date = date.min, end: date = date.max
) -> Sequence[Transaction]:
raise NotImplementedError

View File

@ -1,21 +1,20 @@
import datetime as dt
from datetime import date
from typing import Sequence
from pfbudget.db.model import Bank, BankTransaction
from pfbudget.utils.converters import convert
from .exceptions import BankError, DownloadError, ExtractError
from .extract import Extract
from .extract import Extractor
from .nordigen import NordigenClient
class PSD2Extractor(Extract):
class PSD2Extractor(Extractor):
def __init__(self, client: NordigenClient):
self.__client = client
def extract(
self, bank: Bank, start=dt.date.min, end=dt.date.max
self, bank: Bank, start: date = date.min, end: date = date.max
) -> Sequence[BankTransaction]:
if not bank.nordigen:
raise BankError("Bank doesn't have Nordigen info")

View File

14
pfbudget/load/database.py Normal file
View File

@ -0,0 +1,14 @@
from typing import Sequence
from pfbudget.db.client import Client
from pfbudget.db.model import Transaction
from .load import Loader
class DatabaseLoader(Loader):
def __init__(self, client: Client) -> None:
self.client = client
def load(self, transactions: Sequence[Transaction]) -> None:
self.client.insert(transactions)

10
pfbudget/load/load.py Normal file
View File

@ -0,0 +1,10 @@
from abc import ABC, abstractmethod
from typing import Sequence
from pfbudget.db.model import Transaction
class Loader(ABC):
@abstractmethod
def load(self, transactions: Sequence[Transaction]) -> None:
raise NotImplementedError

View File

@ -1,5 +1,5 @@
from copy import deepcopy
from typing import Sequence
from typing import Iterable, Sequence
from pfbudget.db.model import (
CategoryRule,
@ -13,7 +13,7 @@ from .transform import Transformer
class Categorizer(Transformer):
def __init__(self, rules: Sequence[CategoryRule]):
def __init__(self, rules: Iterable[CategoryRule]):
self.rules = rules
def transform(self, transactions: Sequence[Transaction]) -> Sequence[Transaction]:

View File

@ -1,12 +1,12 @@
from copy import deepcopy
from typing import Sequence
from typing import Iterable, Sequence
from pfbudget.db.model import TagRule, Transaction, TransactionTag
from .transform import Transformer
class Tagger(Transformer):
def __init__(self, rules: Sequence[TagRule]):
def __init__(self, rules: Iterable[TagRule]):
self.rules = rules
def transform(self, transactions: Sequence[Transaction]) -> Sequence[Transaction]:
@ -18,7 +18,7 @@ class Tagger(Transformer):
def transform_inplace(self, transactions: Sequence[Transaction]) -> None:
for rule in self.rules:
for transaction in transactions:
if rule.tag in transaction.tags:
if rule.tag in [tag.tag for tag in transaction.tags]:
continue
if not rule.matches(transaction):

View File

@ -6,9 +6,9 @@ from pfbudget.db.model import Transaction
class Transformer(ABC):
@abstractmethod
def transform(self, _: Sequence[Transaction]) -> Sequence[Transaction]:
def transform(self, transactions: Sequence[Transaction]) -> Sequence[Transaction]:
raise NotImplementedError
@abstractmethod
def transform_inplace(self, _: Sequence[Transaction]) -> None:
def transform_inplace(self, transactions: Sequence[Transaction]) -> None:
raise NotImplementedError

102
poetry.lock generated
View File

@ -1,5 +1,25 @@
# This file is automatically @generated by Poetry and should not be changed by hand.
[[package]]
name = "alembic"
version = "1.10.3"
description = "A database migration tool for SQLAlchemy."
category = "dev"
optional = false
python-versions = ">=3.7"
files = [
{file = "alembic-1.10.3-py3-none-any.whl", hash = "sha256:b2e0a6cfd3a8ce936a1168320bcbe94aefa3f4463cd773a968a55071beb3cd37"},
{file = "alembic-1.10.3.tar.gz", hash = "sha256:32a69b13a613aeb7e8093f242da60eff9daed13c0df02fff279c1b06c32965d2"},
]
[package.dependencies]
Mako = "*"
SQLAlchemy = ">=1.3.0"
typing-extensions = ">=4"
[package.extras]
tz = ["python-dateutil"]
[[package]]
name = "black"
version = "23.3.0"
@ -577,6 +597,86 @@ files = [
{file = "kiwisolver-1.4.4.tar.gz", hash = "sha256:d41997519fcba4a1e46eb4a2fe31bc12f0ff957b2b81bac28db24744f333e955"},
]
[[package]]
name = "mako"
version = "1.2.4"
description = "A super-fast templating language that borrows the best ideas from the existing templating languages."
category = "dev"
optional = false
python-versions = ">=3.7"
files = [
{file = "Mako-1.2.4-py3-none-any.whl", hash = "sha256:c97c79c018b9165ac9922ae4f32da095ffd3c4e6872b45eded42926deea46818"},
{file = "Mako-1.2.4.tar.gz", hash = "sha256:d60a3903dc3bb01a18ad6a89cdbe2e4eadc69c0bc8ef1e3773ba53d44c3f7a34"},
]
[package.dependencies]
MarkupSafe = ">=0.9.2"
[package.extras]
babel = ["Babel"]
lingua = ["lingua"]
testing = ["pytest"]
[[package]]
name = "markupsafe"
version = "2.1.2"
description = "Safely add untrusted strings to HTML/XML markup."
category = "dev"
optional = false
python-versions = ">=3.7"
files = [
{file = "MarkupSafe-2.1.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:665a36ae6f8f20a4676b53224e33d456a6f5a72657d9c83c2aa00765072f31f7"},
{file = "MarkupSafe-2.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:340bea174e9761308703ae988e982005aedf427de816d1afe98147668cc03036"},
{file = "MarkupSafe-2.1.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22152d00bf4a9c7c83960521fc558f55a1adbc0631fbb00a9471e097b19d72e1"},
{file = "MarkupSafe-2.1.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28057e985dace2f478e042eaa15606c7efccb700797660629da387eb289b9323"},
{file = "MarkupSafe-2.1.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca244fa73f50a800cf8c3ebf7fd93149ec37f5cb9596aa8873ae2c1d23498601"},
{file = "MarkupSafe-2.1.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d9d971ec1e79906046aa3ca266de79eac42f1dbf3612a05dc9368125952bd1a1"},
{file = "MarkupSafe-2.1.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:7e007132af78ea9df29495dbf7b5824cb71648d7133cf7848a2a5dd00d36f9ff"},
{file = "MarkupSafe-2.1.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7313ce6a199651c4ed9d7e4cfb4aa56fe923b1adf9af3b420ee14e6d9a73df65"},
{file = "MarkupSafe-2.1.2-cp310-cp310-win32.whl", hash = "sha256:c4a549890a45f57f1ebf99c067a4ad0cb423a05544accaf2b065246827ed9603"},
{file = "MarkupSafe-2.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:835fb5e38fd89328e9c81067fd642b3593c33e1e17e2fdbf77f5676abb14a156"},
{file = "MarkupSafe-2.1.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2ec4f2d48ae59bbb9d1f9d7efb9236ab81429a764dedca114f5fdabbc3788013"},
{file = "MarkupSafe-2.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:608e7073dfa9e38a85d38474c082d4281f4ce276ac0010224eaba11e929dd53a"},
{file = "MarkupSafe-2.1.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:65608c35bfb8a76763f37036547f7adfd09270fbdbf96608be2bead319728fcd"},
{file = "MarkupSafe-2.1.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2bfb563d0211ce16b63c7cb9395d2c682a23187f54c3d79bfec33e6705473c6"},
{file = "MarkupSafe-2.1.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da25303d91526aac3672ee6d49a2f3db2d9502a4a60b55519feb1a4c7714e07d"},
{file = "MarkupSafe-2.1.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:9cad97ab29dfc3f0249b483412c85c8ef4766d96cdf9dcf5a1e3caa3f3661cf1"},
{file = "MarkupSafe-2.1.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:085fd3201e7b12809f9e6e9bc1e5c96a368c8523fad5afb02afe3c051ae4afcc"},
{file = "MarkupSafe-2.1.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1bea30e9bf331f3fef67e0a3877b2288593c98a21ccb2cf29b74c581a4eb3af0"},
{file = "MarkupSafe-2.1.2-cp311-cp311-win32.whl", hash = "sha256:7df70907e00c970c60b9ef2938d894a9381f38e6b9db73c5be35e59d92e06625"},
{file = "MarkupSafe-2.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:e55e40ff0cc8cc5c07996915ad367fa47da6b3fc091fdadca7f5403239c5fec3"},
{file = "MarkupSafe-2.1.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a6e40afa7f45939ca356f348c8e23048e02cb109ced1eb8420961b2f40fb373a"},
{file = "MarkupSafe-2.1.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf877ab4ed6e302ec1d04952ca358b381a882fbd9d1b07cccbfd61783561f98a"},
{file = "MarkupSafe-2.1.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63ba06c9941e46fa389d389644e2d8225e0e3e5ebcc4ff1ea8506dce646f8c8a"},
{file = "MarkupSafe-2.1.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f1cd098434e83e656abf198f103a8207a8187c0fc110306691a2e94a78d0abb2"},
{file = "MarkupSafe-2.1.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:55f44b440d491028addb3b88f72207d71eeebfb7b5dbf0643f7c023ae1fba619"},
{file = "MarkupSafe-2.1.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:a6f2fcca746e8d5910e18782f976489939d54a91f9411c32051b4aab2bd7c513"},
{file = "MarkupSafe-2.1.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:0b462104ba25f1ac006fdab8b6a01ebbfbce9ed37fd37fd4acd70c67c973e460"},
{file = "MarkupSafe-2.1.2-cp37-cp37m-win32.whl", hash = "sha256:7668b52e102d0ed87cb082380a7e2e1e78737ddecdde129acadb0eccc5423859"},
{file = "MarkupSafe-2.1.2-cp37-cp37m-win_amd64.whl", hash = "sha256:6d6607f98fcf17e534162f0709aaad3ab7a96032723d8ac8750ffe17ae5a0666"},
{file = "MarkupSafe-2.1.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:a806db027852538d2ad7555b203300173dd1b77ba116de92da9afbc3a3be3eed"},
{file = "MarkupSafe-2.1.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a4abaec6ca3ad8660690236d11bfe28dfd707778e2442b45addd2f086d6ef094"},
{file = "MarkupSafe-2.1.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f03a532d7dee1bed20bc4884194a16160a2de9ffc6354b3878ec9682bb623c54"},
{file = "MarkupSafe-2.1.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4cf06cdc1dda95223e9d2d3c58d3b178aa5dacb35ee7e3bbac10e4e1faacb419"},
{file = "MarkupSafe-2.1.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:22731d79ed2eb25059ae3df1dfc9cb1546691cc41f4e3130fe6bfbc3ecbbecfa"},
{file = "MarkupSafe-2.1.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f8ffb705ffcf5ddd0e80b65ddf7bed7ee4f5a441ea7d3419e861a12eaf41af58"},
{file = "MarkupSafe-2.1.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8db032bf0ce9022a8e41a22598eefc802314e81b879ae093f36ce9ddf39ab1ba"},
{file = "MarkupSafe-2.1.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2298c859cfc5463f1b64bd55cb3e602528db6fa0f3cfd568d3605c50678f8f03"},
{file = "MarkupSafe-2.1.2-cp38-cp38-win32.whl", hash = "sha256:50c42830a633fa0cf9e7d27664637532791bfc31c731a87b202d2d8ac40c3ea2"},
{file = "MarkupSafe-2.1.2-cp38-cp38-win_amd64.whl", hash = "sha256:bb06feb762bade6bf3c8b844462274db0c76acc95c52abe8dbed28ae3d44a147"},
{file = "MarkupSafe-2.1.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:99625a92da8229df6d44335e6fcc558a5037dd0a760e11d84be2260e6f37002f"},
{file = "MarkupSafe-2.1.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8bca7e26c1dd751236cfb0c6c72d4ad61d986e9a41bbf76cb445f69488b2a2bd"},
{file = "MarkupSafe-2.1.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40627dcf047dadb22cd25ea7ecfe9cbf3bbbad0482ee5920b582f3809c97654f"},
{file = "MarkupSafe-2.1.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40dfd3fefbef579ee058f139733ac336312663c6706d1163b82b3003fb1925c4"},
{file = "MarkupSafe-2.1.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:090376d812fb6ac5f171e5938e82e7f2d7adc2b629101cec0db8b267815c85e2"},
{file = "MarkupSafe-2.1.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:2e7821bffe00aa6bd07a23913b7f4e01328c3d5cc0b40b36c0bd81d362faeb65"},
{file = "MarkupSafe-2.1.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:c0a33bc9f02c2b17c3ea382f91b4db0e6cde90b63b296422a939886a7a80de1c"},
{file = "MarkupSafe-2.1.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:b8526c6d437855442cdd3d87eede9c425c4445ea011ca38d937db299382e6fa3"},
{file = "MarkupSafe-2.1.2-cp39-cp39-win32.whl", hash = "sha256:137678c63c977754abe9086a3ec011e8fd985ab90631145dfb9294ad09c102a7"},
{file = "MarkupSafe-2.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:0576fe974b40a400449768941d5d0858cc624e3249dfd1e0c33674e5c7ca7aed"},
{file = "MarkupSafe-2.1.2.tar.gz", hash = "sha256:abcabc8c2b26036d62d4c746381a6f7cf60aafcc653198ad678306986b09450d"},
]
[[package]]
name = "matplotlib"
version = "3.7.1"
@ -1258,4 +1358,4 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
[metadata]
lock-version = "2.0"
python-versions = "^3.10"
content-hash = "cc31ed1ff1c9e446fcadda094494251b015a103df8024c45e442436856977bbb"
content-hash = "e38058bfbc3f73450af9c72519ab32b96dc12406712ba18688e31deb39e2f2a1"

View File

@ -12,7 +12,7 @@ python = "^3.10"
codetiming = "^1.4.0"
matplotlib = "^3.7.1"
nordigen = "^1.3.1"
psycopg2 = "^2.9.6"
psycopg2 = {extras = ["binary"], version = "^2.9.6"}
python-dateutil = "^2.8.2"
python-dotenv = "^1.0.0"
pyyaml = "^6.0"
@ -20,6 +20,7 @@ sqlalchemy = "^2.0.9"
[tool.poetry.group.dev.dependencies]
alembic = "^1.10.3"
black = "^23.3.0"
flake8 = "^6.0.0"
mypy = "^1.2.0"

149
tests/test_database.py Normal file
View File

@ -0,0 +1,149 @@
from datetime import date
from decimal import Decimal
import pytest
from pfbudget.db.client import Client
from pfbudget.db.model import (
AccountType,
Bank,
Base,
CategorySelector,
Nordigen,
Selector_T,
Transaction,
TransactionCategory,
)
@pytest.fixture
def client() -> Client:
url = "sqlite://"
client = Client(url, execution_options={"schema_translate_map": {"pfbudget": None}})
Base.metadata.create_all(client.engine)
return client
@pytest.fixture
def banks(client: Client) -> list[Bank]:
banks = [
Bank("bank", "BANK", AccountType.checking),
Bank("broker", "BROKER", AccountType.investment),
Bank("creditcard", "CC", AccountType.MASTERCARD),
]
banks[0].nordigen = Nordigen("bank", None, "req", None)
client.insert(banks)
return banks
@pytest.fixture
def transactions(client: Client) -> list[Transaction]:
transactions = [
Transaction(date(2023, 1, 1), "", Decimal("-10")),
Transaction(date(2023, 1, 2), "", Decimal("-50")),
]
transactions[0].category = TransactionCategory(
"name", CategorySelector(Selector_T.algorithm)
)
client.insert(transactions)
for i, transaction in enumerate(transactions):
transaction.id = i + 1
transaction.split = False # default
transactions[0].category.id = 1
transactions[0].category.selector.id = 1
return transactions
class TestDatabase:
def test_initialization(self, client: Client):
pass
def test_insert_with_session(self, client: Client):
transactions = [
Transaction(date(2023, 1, 1), "", Decimal("-10")),
Transaction(date(2023, 1, 2), "", Decimal("-50")),
]
with client.session as session:
session.insert(transactions)
assert session.select(Transaction) == transactions
def test_insert_transactions(self, client: Client, transactions: list[Transaction]):
result = client.select(Transaction)
assert result == transactions
def test_select_transactions_without_category(
self, client: Client, transactions: list[Transaction]
):
result = client.select(Transaction, lambda: ~Transaction.category.has())
assert result == [transactions[1]]
def test_select_banks(self, client: Client, banks: list[Bank]):
result = client.select(Bank)
assert result == banks
def test_select_banks_with_nordigen(self, client: Client, banks: list[Bank]):
result = client.select(Bank, Bank.nordigen)
assert result == [banks[0]]
def test_select_banks_by_name(self, client: Client, banks: list[Bank]):
name = banks[0].name
result = client.select(Bank, lambda: Bank.name == name)
assert result == [banks[0]]
names = [banks[0].name, banks[1].name]
result = client.select(Bank, lambda: Bank.name.in_(names))
assert result == [banks[0], banks[1]]
def test_update_bank_with_session(self, client: Client, banks: list[Bank]):
with client.session as session:
name = banks[0].name
bank = session.select(Bank, lambda: Bank.name == name)[0]
bank.name = "anotherbank"
result = client.select(Bank, lambda: Bank.name == "anotherbank")
assert len(result) == 1
def test_update_bank(self, client: Client, banks: list[Bank]):
name = banks[0].name
result = client.select(Bank, lambda: Bank.name == name)
assert result[0].type == AccountType.checking
update = {"name": name, "type": AccountType.savings}
client.update(Bank, [update])
result = client.select(Bank, lambda: Bank.name == name)
assert result[0].type == AccountType.savings
def test_update_nordigen(self, client: Client, banks: list[Bank]):
name = banks[0].name
result = client.select(Nordigen, lambda: Nordigen.name == name)
assert result[0].requisition_id == "req"
update = {"name": name, "requisition_id": "anotherreq"}
client.update(Nordigen, [update])
result = client.select(Nordigen, lambda: Nordigen.name == name)
assert result[0].requisition_id == "anotherreq"
result = client.select(Bank, lambda: Bank.name == name)
assert getattr(result[0].nordigen, "requisition_id", None) == "anotherreq"
def test_remove_bank(self, client: Client, banks: list[Bank]):
name = banks[0].name
result = client.select(Bank)
assert len(result) == 3
client.delete(Bank, Bank.name, [name])
result = client.select(Bank)
assert len(result) == 2
names = [banks[1].name, banks[2].name]
client.delete(Bank, Bank.name, names)
result = client.select(Bank)
assert len(result) == 0

38
tests/test_load.py Normal file
View File

@ -0,0 +1,38 @@
from datetime import date
from decimal import Decimal
from typing import Sequence
import pytest
from pfbudget.db.client import Client
from pfbudget.db.model import BankTransaction, Transaction
from pfbudget.load.database import DatabaseLoader
from pfbudget.load.load import Loader
class FakeDatabaseClient(Client):
def __init__(self, url: str) -> None:
super().__init__(url)
def insert(self, transactions: Sequence[Transaction]) -> None:
pass
@pytest.fixture
def loader() -> Loader:
url = "postgresql://user:pass@127.0.0.1:5432/db"
client = FakeDatabaseClient(url)
return DatabaseLoader(client)
class TestDatabaseLoad:
def test_empty_url(self):
with pytest.raises(AssertionError):
_ = FakeDatabaseClient("")
def test_insert(self, loader: Loader):
transactions = [
BankTransaction(date(2023, 1, 1), "", Decimal("-500"), "Bank#1"),
BankTransaction(date(2023, 1, 2), "", Decimal("500"), "Bank#2"),
]
loader.load(transactions)

View File

@ -1,30 +1,32 @@
import datetime as dt
from decimal import Decimal
from typing import Any, Optional
import pytest
import requests
import mocks.nordigen as mock
from pfbudget.db.model import Bank, BankTransaction, Nordigen
from pfbudget.db.model import AccountType, Bank, BankTransaction, Nordigen
from pfbudget.extract.exceptions import BankError, CredentialsError
from pfbudget.extract.extract import Extractor
from pfbudget.extract.nordigen import NordigenClient, NordigenCredentials
from pfbudget.extract.psd2 import PSD2Extractor
class MockGet:
def __init__(self, mock_exception=None):
def __init__(self, mock_exception: Optional[Exception] = None):
self._status_code = 200
self._mock_exception = mock_exception
def __call__(self, *args, **kwargs):
def __call__(self, *args: Any, **kwargs: Any):
if self._mock_exception:
raise self._mock_exception
self._headers = kwargs["headers"]
self._headers: dict[str, str] = kwargs["headers"]
if "Authorization" not in self._headers or not self._headers["Authorization"]:
self._status_code = 401
self.url = kwargs["url"]
self.url: str = kwargs["url"]
return self
@property
@ -47,7 +49,7 @@ class MockGet:
@pytest.fixture(autouse=True)
def mock_requests(monkeypatch):
def mock_requests(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr("requests.get", MockGet())
monkeypatch.delattr("requests.post")
monkeypatch.delattr("requests.put")
@ -55,14 +57,14 @@ def mock_requests(monkeypatch):
@pytest.fixture
def extractor() -> NordigenClient:
def extractor() -> Extractor:
credentials = NordigenCredentials("ID", "KEY", "TOKEN")
return PSD2Extractor(NordigenClient(credentials))
@pytest.fixture
def bank() -> list[Bank]:
bank = Bank("Bank#1", "", "")
def bank() -> Bank:
bank = Bank("Bank#1", "", AccountType.checking)
bank.nordigen = Nordigen("", "", mock.id, False)
return bank
@ -73,18 +75,20 @@ class TestExtractPSD2:
with pytest.raises(CredentialsError):
NordigenClient(cred)
def test_no_psd2_bank(self, extractor):
def test_no_psd2_bank(self, extractor: Extractor):
with pytest.raises(BankError):
extractor.extract(Bank("", "", ""))
extractor.extract(Bank("", "", AccountType.checking))
def test_timeout(self, monkeypatch, extractor, bank):
def test_timeout(
self, monkeypatch: pytest.MonkeyPatch, extractor: Extractor, bank: Bank
):
monkeypatch.setattr(
"requests.get", MockGet(mock_exception=requests.ReadTimeout)
"requests.get", MockGet(mock_exception=requests.ReadTimeout())
)
with pytest.raises(requests.Timeout):
extractor.extract(bank)
def test_extract(self, extractor, bank):
def test_extract(self, extractor: Extractor, bank: Bank):
assert extractor.extract(bank) == [
BankTransaction(
dt.date(2023, 1, 14), "string", Decimal("328.18"), "Bank#1"

View File

@ -4,7 +4,6 @@ from decimal import Decimal
import mocks.categories as mock
from pfbudget.db.model import (
Bank,
BankTransaction,
CategoryRule,
CategorySelector,
@ -102,7 +101,7 @@ class TestTransform:
assert not t.category
categorizer: Transformer = Categorizer(mock.category1.rules)
transactions: Transformer = categorizer.transform(transactions)
transactions = categorizer.transform(transactions)
for t in transactions:
assert t.category == TransactionCategory(