Compare commits

..

No commits in common. "bdd7cac4be597cba3232ffc60c9e539bb13fcbd5" and "ef9ca1d1eac2b8891d4519f433c2192cfc255133" have entirely different histories.

25 changed files with 289 additions and 1240 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.home.arpa/pf-budget
sqlalchemy.url = postgresql://pf-budget:muster-neutron-omega@database.lan/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 == "pfbudget"
return name in ["bank", "category", "tag", "transactions"]
else:
return True

View File

@ -1,95 +0,0 @@
"""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

@ -1,594 +0,0 @@
"""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(None)
params.append([])
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 = [{"name": cat, "group": args["group"]} for cat in args["category"]]
params = [type.Category(cat) for cat in args["category"]]
params.append(args["group"])
case Operation.CategoryRemove:
assert "category" in args, "argparser ill defined"
params = args["category"]
params = [type.Category(cat) for cat in 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 = args["group"]
params = [type.CategoryGroup(group) for group in 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)
file_options(pimport)
pimport.add_argument("file", nargs=1, type=str)
# Parse from .csv
parse = subparsers.add_parser("parse")

View File

@ -57,7 +57,7 @@ class Interactive:
case "split":
new = self.split(next)
session.insert(new)
session.add(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.insert([Tag(tag)])
session.add([Tag(tag)])
self.tags = session.get(Tag)
next.tags.add(TransactionTag(tag))

View File

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

View File

@ -1,69 +1,123 @@
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 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 pfbudget.db.exceptions import InsertError, SelectError
from pfbudget.db.model import (
Category,
CategoryGroup,
CategorySchedule,
Link,
Transaction,
)
class DatabaseSession:
def __init__(self, session: Session):
self.__session = session
class DbClient:
"""
General database client using sqlalchemy
"""
def __enter__(self):
self.__session.begin()
return self
__sessions: list[Session]
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)))
def __init__(self, url: str, echo=False) -> None:
self._engine = create_engine(url, echo=echo)
@property
def engine(self) -> Engine:
def engine(self):
return self._engine
@property
def session(self) -> DatabaseSession:
return DatabaseSession(self._sessionmaker())
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)

View File

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

View File

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

View File

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

View File

@ -1,20 +1,21 @@
from datetime import date
import datetime as dt
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 Extractor
from .extract import Extract
from .nordigen import NordigenClient
class PSD2Extractor(Extractor):
class PSD2Extractor(Extract):
def __init__(self, client: NordigenClient):
self.__client = client
def extract(
self, bank: Bank, start: date = date.min, end: date = date.max
self, bank: Bank, start=dt.date.min, end=dt.date.max
) -> Sequence[BankTransaction]:
if not bank.nordigen:
raise BankError("Bank doesn't have Nordigen info")

View File

@ -1,14 +0,0 @@
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)

View File

@ -1,10 +0,0 @@
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 Iterable, Sequence
from typing import Sequence
from pfbudget.db.model import (
CategoryRule,
@ -13,7 +13,7 @@ from .transform import Transformer
class Categorizer(Transformer):
def __init__(self, rules: Iterable[CategoryRule]):
def __init__(self, rules: Sequence[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 Iterable, Sequence
from typing import Sequence
from pfbudget.db.model import TagRule, Transaction, TransactionTag
from .transform import Transformer
class Tagger(Transformer):
def __init__(self, rules: Iterable[TagRule]):
def __init__(self, rules: Sequence[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 [tag.tag for tag in transaction.tags]:
if rule.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, transactions: Sequence[Transaction]) -> Sequence[Transaction]:
def transform(self, _: Sequence[Transaction]) -> Sequence[Transaction]:
raise NotImplementedError
@abstractmethod
def transform_inplace(self, transactions: Sequence[Transaction]) -> None:
def transform_inplace(self, _: Sequence[Transaction]) -> None:
raise NotImplementedError

102
poetry.lock generated
View File

@ -1,25 +1,5 @@
# 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"
@ -597,86 +577,6 @@ 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"
@ -1358,4 +1258,4 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
[metadata]
lock-version = "2.0"
python-versions = "^3.10"
content-hash = "e38058bfbc3f73450af9c72519ab32b96dc12406712ba18688e31deb39e2f2a1"
content-hash = "cc31ed1ff1c9e446fcadda094494251b015a103df8024c45e442436856977bbb"

View File

@ -12,7 +12,7 @@ python = "^3.10"
codetiming = "^1.4.0"
matplotlib = "^3.7.1"
nordigen = "^1.3.1"
psycopg2 = {extras = ["binary"], version = "^2.9.6"}
psycopg2 = "^2.9.6"
python-dateutil = "^2.8.2"
python-dotenv = "^1.0.0"
pyyaml = "^6.0"
@ -20,7 +20,6 @@ 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"

View File

@ -1,149 +0,0 @@
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

View File

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

View File

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