Skip to content

Commit

Permalink
fix: Bug fixes, finish adding rules and test full interaction with sc…
Browse files Browse the repository at this point in the history
…hedule.
  • Loading branch information
bvanelli committed May 14, 2024
1 parent 6a30da9 commit 45d923a
Show file tree
Hide file tree
Showing 5 changed files with 117 additions and 27 deletions.
4 changes: 2 additions & 2 deletions actual/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,7 @@ class Rules(BaseModel, table=True):

class Schedules(SQLModel, table=True):
id: Optional[str] = Field(default=None, sa_column=Column("id", Text, primary_key=True))
rule: Optional[str] = Field(default=None, sa_column=Column("rule", Text, ForeignKey("rules.id")))
rule_id: Optional[str] = Field(default=None, sa_column=Column("rule", Text, ForeignKey("rules.id")))
active: Optional[int] = Field(default=None, sa_column=Column("active", Integer, server_default=text("0")))
completed: Optional[int] = Field(default=None, sa_column=Column("completed", Integer, server_default=text("0")))
posts_transaction: Optional[int] = Field(
Expand All @@ -321,7 +321,7 @@ class Schedules(SQLModel, table=True):
tombstone: Optional[int] = Field(default=None, sa_column=Column("tombstone", Integer, server_default=text("0")))
name: Optional[str] = Field(default=None, sa_column=Column("name", Text, server_default=text("NULL")))

config: "Rules" = Relationship(sa_relationship_kwargs={"uselist": False})
rule: "Rules" = Relationship(sa_relationship_kwargs={"uselist": False})
transactions: List["Transactions"] = Relationship(back_populates="schedule")


Expand Down
18 changes: 14 additions & 4 deletions actual/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,9 @@ def create_transaction(
return create_transaction_from_ids(s, date, acct.id, payee.id, notes, category_id, amount)


def base_query(s: Session, instance: typing.Type[T], name: str = None, include_deleted: bool = False) -> typing.List[T]:
def base_query(
s: Session, instance: typing.Type[T], name: str = None, include_deleted: bool = False
) -> sqlalchemy.orm.Query:
"""Internal method to reduce querying complexity on sub-functions."""
query = s.query(instance)
if not include_deleted:
Expand Down Expand Up @@ -369,7 +371,7 @@ def get_rules(s: Session, include_deleted: bool = False) -> list[Rules]:
:param include_deleted: includes all payees which were deleted via frontend. They would not show normally.
:return: list of rules.
"""
return base_query(s, Rules, None, include_deleted).all() # noqa
return base_query(s, Rules, None, include_deleted).all()


def get_ruleset(s: Session) -> RuleSet:
Expand Down Expand Up @@ -417,5 +419,13 @@ def create_rule(
return database_rule


def get_schedules(s: Session) -> typing.List[Schedules]:
return s.query(Schedules).all()
def get_schedules(s: Session, name: str = None, include_deleted: bool = False) -> typing.List[Schedules]:
"""
Returns a list of all available schedules.
:param s: session from Actual local database.
:param name: pattern name of the payee, case-insensitive.
:param include_deleted: includes all payees which were deleted via frontend. They would not show normally.
:return: list of schedules.
"""
return base_query(s, Schedules, name, include_deleted).all()
72 changes: 55 additions & 17 deletions actual/rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ def validate(self, value: typing.Union[int, list[str], str, None], as_list: bool
res = False
return res
elif self == ValueType.NUMBER:
return (isinstance(value, int) or isinstance(value, float)) and value >= 0
return isinstance(value, int)
else:
# must be BOOLEAN
return isinstance(value, bool)
Expand Down Expand Up @@ -114,12 +114,23 @@ def condition_evaluation(
op: ConditionType,
true_value: typing.Union[int, list[str], str, datetime.date, None],
self_value: typing.Union[int, list[str], str, datetime.date, None],
options: dict = None,
) -> bool:
"""Helper function to evaluate the condition based on the true_value, value found on the transaction, and the
self_value, value defined on rule condition."""
if true_value is None:
# short circuit as comparisons with NoneType are useless
return False
if isinstance(options, dict):
# short circuit if the transaction should be and in/outflow but it isn't
if options.get("outflow") is True and true_value > 0:
return False
if options.get("inflow") is True and true_value < 0:
return False
if isinstance(self_value, int) and isinstance(options, dict) and options.get("outflow") is True:
# if it's an outflow we use the negative value of self_value, that is positive
self_value = -self_value
# do comparison
if op == ConditionType.IS:
return self_value == true_value
elif op == ConditionType.IS_NOT:
Expand Down Expand Up @@ -158,6 +169,10 @@ class Condition(pydantic.BaseModel):
set to IS or CONTAINS, and the operation applied to a 'field' with certain 'value'. If the transaction value matches
the condition, the `run` method returns `True`, otherwise it returns `False`.
**Important**: Actual shows the amount on frontend as decimal but handles it internally as cents. Make sure that, if
you provide the 'amount' rule manually, you either provide number of cents or a float that get automatically
converted to cents.
The 'field' can be one of the following ('type' will be set automatically):
- imported_description: 'type' must be 'string' and 'value' any string
Expand All @@ -166,19 +181,30 @@ class Condition(pydantic.BaseModel):
- date: 'type' must be 'date' and 'value' a string in the date format '2024-04-11'
- description: 'type' must be 'id' and 'value' a valid uuid (means payee_id)
- notes: 'type' must be 'string' and 'value' any string
- amount: 'type' must be 'number' and format in cents (additional "options":{"inflow":true} or
"options":{"outflow":true} for inflow/outflow distinction, set automatically based on the amount sign)
- amount: 'type' must be 'number' and format in cents
- amount_inflow: 'type' must be 'number' and format in cents, will set "options":{"inflow":true}
- amount_outflow: 'type' must be 'number' and format in cents, will set "options":{"outflow":true}
"""

field: typing.Literal["imported_description", "acct", "category", "date", "description", "notes", "amount"]
field: typing.Literal[
"imported_description",
"acct",
"category",
"date",
"description",
"notes",
"amount",
"amount_inflow",
"amount_outflow",
]
op: ConditionType
value: typing.Union[int, float, str, list[str], Schedule, list[BaseModel], BaseModel, datetime.date, None]
type: typing.Optional[ValueType] = None
options: typing.Optional[dict] = None

def __str__(self) -> str:
value = f"'{self.value}'" if isinstance(self.value, str) else str(self.value)
return f"'{self.field}' {self.op.value} {value}"
v = f"'{self.value}'" if isinstance(self.value, str) or isinstance(self.value, Schedule) else str(self.value)
return f"'{self.field}' {self.op.value} {v}"

def as_dict(self):
"""Returns valid dict for database insertion."""
Expand All @@ -192,9 +218,13 @@ def get_value(self) -> typing.Union[int, datetime.date, list[str], str, None]:

@pydantic.model_validator(mode="after")
def convert_value(self):
if (isinstance(self.value, int) or isinstance(self.value, float)) and self.options is None:
self.options = {"inflow": True} if self.value > 0 else {"outflow": True}
self.value = int(abs(self.value) * 100)
if self.field in ("amount_inflow", "amount_outflow") and self.options is None:
self.options = {self.field.split("_")[1]: True}
self.value = abs(self.value)
self.field = "amount"
if isinstance(self.value, float):
# convert silently in the background to a valid number
self.value = int(self.value * 100)
return self

@pydantic.model_validator(mode="after")
Expand All @@ -206,10 +236,7 @@ def check_operation_type(self):
raise ValueError(f"Operation {self.op} not supported for type {self.type}")
# if a pydantic object is provided and id is expected, extract the id
if isinstance(self.value, BaseModel):
if hasattr(self.value, "id"):
self.value = str(self.value.id)
else:
raise ValueError(f"Value {self.value} is not valid pydantic model")
self.value = str(self.value.id)
elif isinstance(self.value, list) and len(self.value) and isinstance(self.value[0], pydantic.BaseModel):
self.value = [v.id if hasattr(v, "id") else v for v in self.value]
# make sure the data matches the value type
Expand All @@ -222,7 +249,7 @@ def run(self, transaction: Transactions) -> bool:
attr = get_attribute_by_table_name(Transactions.__tablename__, self.field)
true_value = get_value(getattr(transaction, attr), self.type)
self_value = self.get_value()
return condition_evaluation(self.op, true_value, self_value)
return condition_evaluation(self.op, true_value, self_value, self.options)


class Action(pydantic.BaseModel):
Expand All @@ -238,16 +265,20 @@ class Action(pydantic.BaseModel):
- cleared: 'type' must be 'boolean' and value is a literal True/False (additional "options":{"splitIndex":0})
- acct: 'type' must be 'id' and 'value' an uuid
- date: 'type' must be 'date' and 'value' a string in the date format '2024-04-11'
- amount: 'type' must be 'number' and format in cents
"""

field: typing.Optional[typing.Literal["category", "description", "notes", "cleared", "acct", "date"]] = None
field: typing.Optional[typing.Literal["category", "description", "notes", "cleared", "acct", "date", "amount"]] = (
None
)
op: ActionType = pydantic.Field(ActionType.SET, description="Action type to apply (default changes a column).")
value: typing.Union[str, bool, pydantic.BaseModel, None]
value: typing.Union[str, bool, int, float, pydantic.BaseModel, None]
type: typing.Optional[ValueType] = None
options: dict = None

def __str__(self) -> str:
return f"{self.op.value} '{self.field}' to '{self.value}'"
field_str = f" '{self.field}'" if self.field else ""
return f"{self.op.value}{field_str} to '{self.value}'"

def as_dict(self):
"""Returns valid dict for database insertion."""
Expand All @@ -256,6 +287,13 @@ def as_dict(self):
ret.pop("options", None)
return ret

@pydantic.model_validator(mode="after")
def convert_value(self):
if isinstance(self.value, float):
# convert silently in the background to a valid number
self.value = int(self.value * 100)
return self

@pydantic.model_validator(mode="after")
def check_operation_type(self):
if not self.type:
Expand Down
8 changes: 4 additions & 4 deletions tests/test_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,12 +76,12 @@ def test_datetime_rule():

def test_numeric_condition():
t = create_transaction(MagicMock(), datetime.date(2024, 1, 1), "Bank", "", amount=5)
c1 = Condition(field="amount", op="gt", value=10)
c1 = Condition(field="amount_inflow", op="gt", value=10.0)
assert "inflow" in c1.options
assert c1.run(t) is False
c2 = Condition(field="amount", op="lt", value=-10)
c2 = Condition(field="amount_outflow", op="lt", value=-10.0)
assert "outflow" in c2.options
assert c2.run(t) is True # outflow, so the comparison should be with the positive value
assert c2.run(t) is False # outflow, so the comparison should be with the positive value
# isapprox condition
c2 = Condition(field="amount", op="isapprox", value=5.1)
assert c2.run(t) is True
Expand Down Expand Up @@ -159,7 +159,7 @@ def test_value_type_value_validation():
assert ValueType.STRING.validate("") is True
assert ValueType.STRING.validate(123) is False
assert ValueType.NUMBER.validate(123) is True
assert ValueType.NUMBER.validate(1.23) is True # noqa: test just in case
assert ValueType.NUMBER.validate(1.23) is False # noqa: test just in case
assert ValueType.NUMBER.validate("123") is False
assert ValueType.ID.validate("1c1a1707-15ea-4051-b98a-e400ee2900c7") is True
assert ValueType.ID.validate("foo") is False
Expand Down
42 changes: 42 additions & 0 deletions tests/test_schedules.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
from datetime import date
from unittest.mock import MagicMock

import pytest

from actual.queries import create_account, create_transaction
from actual.rules import Rule
from actual.schedules import Schedule, date_to_datetime


Expand Down Expand Up @@ -135,3 +138,42 @@ def test_strings():
assert str(Schedule(start="2024-05-12", frequency="yearly")) == "Every year on May 12"
assert str(Schedule(start="2024-05-12", frequency="weekly")) == "Every week on Sunday"
assert str(Schedule(start="2024-05-12", frequency="daily")) == "Every day"


def test_scheduled_rule():
mock = MagicMock()
acct = create_account(mock, "Bank")
rule = Rule(
id="d84d1400-4245-4bb9-95d0-be4524edafe9",
conditions=[
{
"op": "isapprox",
"field": "date",
"value": {
"start": "2024-05-01",
"frequency": "monthly",
"patterns": [],
"skipWeekend": False,
"weekendSolveMode": "after",
"endMode": "never",
"endOccurrences": 1,
"endDate": "2024-05-14",
"interval": 1,
},
},
{"op": "isapprox", "field": "amount", "value": -2000},
{"op": "is", "field": "acct", "value": acct.id},
],
stage=None,
actions=[{"op": "link-schedule", "value": "df1e464f-13ae-4a97-a07e-990faeb48b2f"}],
conditions_op="and",
)
assert "'date' isapprox 'Every month on the 1st'" in str(rule)

transaction_matching = create_transaction(mock, date(2024, 5, 2), acct, None, amount=-19)
transaction_not_matching = create_transaction(mock, date(2024, 5, 2), acct, None, amount=-15)
rule.run(transaction_matching)
rule.run(transaction_not_matching)

assert transaction_matching.schedule_id == "df1e464f-13ae-4a97-a07e-990faeb48b2f"
assert transaction_not_matching.schedule_id is None

0 comments on commit 45d923a

Please sign in to comment.