Skip to content

Commit

Permalink
feat: Support rules and actions on the latest version of Actual. (#75)
Browse files Browse the repository at this point in the history
  • Loading branch information
bvanelli authored Sep 15, 2024
1 parent a05baca commit 3a5a406
Show file tree
Hide file tree
Showing 4 changed files with 90 additions and 3 deletions.
38 changes: 37 additions & 1 deletion actual/rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,15 @@ class ConditionType(enum.Enum):
NOT_ONE_OF = "notOneOf"
IS_BETWEEN = "isbetween"
MATCHES = "matches"
HAS_TAGS = "hasTags"


class ActionType(enum.Enum):
SET = "set"
SET_SPLIT_AMOUNT = "set-split-amount"
LINK_SCHEDULE = "link-schedule"
PREPEND_NOTES = "prepend-notes"
APPEND_NOTES = "append-notes"


class BetweenValue(pydantic.BaseModel):
Expand Down Expand Up @@ -83,7 +86,16 @@ def is_valid(self, operation: ConditionType) -> bool:
if self == ValueType.DATE:
return operation.value in ("is", "isapprox", "gt", "gte", "lt", "lte")
elif self in (ValueType.STRING, ValueType.IMPORTED_PAYEE):
return operation.value in ("is", "contains", "oneOf", "isNot", "doesNotContain", "notOneOf", "matches")
return operation.value in (
"is",
"contains",
"oneOf",
"isNot",
"doesNotContain",
"notOneOf",
"matches",
"hasTags",
)
elif self == ValueType.ID:
return operation.value in ("is", "isNot", "oneOf", "notOneOf")
elif self == ValueType.NUMBER:
Expand Down Expand Up @@ -211,6 +223,11 @@ def condition_evaluation(
return self_value >= true_value
elif op == ConditionType.IS_BETWEEN:
return self_value.num_1 <= true_value <= self_value.num_2
elif op == ConditionType.HAS_TAGS:
# this regex is not correct, but is good enough according to testing
# taken from https://stackoverflow.com/a/26740753/12681470
tags = re.findall(r"\#[\U00002600-\U000027BF\U0001f300-\U0001f64F\U0001f680-\U0001f6FF\w-]+", self_value)
return any(tag in true_value for tag in tags)
else:
raise ActualError(f"Operation {op} not supported")

Expand Down Expand Up @@ -346,6 +363,12 @@ def __str__(self) -> str:
method = self.options.get("method") or ""
split_index = self.options.get("splitIndex") or ""
return f"allocate a {method} at Split {split_index}: {self.value}"
elif self.op in (ActionType.APPEND_NOTES, ActionType.PREPEND_NOTES):
return (
f"append to notes '{self.value}'"
if self.op == ActionType.APPEND_NOTES
else f"prepend to notes '{self.value}'"
)

def as_dict(self):
"""Returns valid dict for database insertion."""
Expand All @@ -372,6 +395,9 @@ def check_operation_type(self):
self.type = ValueType.ID
elif self.op == ActionType.SET_SPLIT_AMOUNT:
self.type = ValueType.NUMBER
# questionable choice from the developers to set it to ID, I hope they fix it at some point, but we change it
if self.op in (ActionType.APPEND_NOTES, ActionType.PREPEND_NOTES):
self.type = ValueType.STRING
# if a pydantic object is provided and id is expected, extract the id
if isinstance(self.value, pydantic.BaseModel) and hasattr(self.value, "id"):
self.value = str(self.value.id)
Expand All @@ -395,6 +421,16 @@ def run(self, transaction: Transactions) -> None:
setattr(transaction, attr, value)
elif self.op == ActionType.LINK_SCHEDULE:
transaction.schedule_id = self.value
# for the notes rule, check if the rule was already applied since actual does not do that.
# this should ensure the prefix or suffix is not applied multiple times
elif self.op == ActionType.APPEND_NOTES:
notes = transaction.notes or ""
if not notes.endswith(self.value):
transaction.notes = f"{notes}{self.value}"
elif self.op == ActionType.PREPEND_NOTES:
notes = transaction.notes or ""
if not notes.startswith(self.value):
transaction.notes = f"{self.value}{notes}"
else:
raise ActualError(f"Operation {self.op} not supported")

Expand Down
2 changes: 1 addition & 1 deletion actual/version.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
__version_info__ = ("0", "4", "1")
__version_info__ = ("0", "5", "0")
__version__ = ".".join(__version_info__)
34 changes: 33 additions & 1 deletion tests/test_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ def test_string_condition():
assert Condition(field="notes", op="matches", value="g.*").run(t) is False
assert Condition(field="notes", op="doesNotContain", value="foo").run(t) is False
assert Condition(field="notes", op="doesNotContain", value="foobar").run(t) is True
# test the cases where the case do not match
# case insensitive entries
assert Condition(field="notes", op="oneOf", value=["FOO", "BAR"]).run(t) is True
assert Condition(field="notes", op="notOneOf", value=["FOO", "BAR"]).run(t) is False
assert Condition(field="notes", op="contains", value="FO").run(t) is True
Expand All @@ -98,6 +98,18 @@ def test_string_condition():
assert Condition(field="notes", op="doesNotContain", value="FOOBAR").run(t) is True


def test_has_tags():
mock = MagicMock()
acct = create_account(mock, "Bank")
t = create_transaction(mock, datetime.date(2024, 1, 1), acct, "", "foo #bar #✨ #🙂‍↔️")
assert Condition(field="notes", op="hasTags", value="#bar").run(t) is True
assert Condition(field="notes", op="hasTags", value="#foo").run(t) is False
# test other unicode entries
assert Condition(field="notes", op="hasTags", value="#emoji #✨").run(t) is True
assert Condition(field="notes", op="hasTags", value="#🙂‍↔️").run(t) is True # new emojis should be supported
assert Condition(field="notes", op="hasTags", value="bar").run(t) is False # individual string will not match


@pytest.mark.parametrize(
"op,condition_value,value,expected_result",
[
Expand Down Expand Up @@ -353,3 +365,23 @@ def test_set_split_amount_exception(session, mocker):
session.flush()
with pytest.raises(ActualSplitTransactionError):
rs.run(t)


@pytest.mark.parametrize(
"operation,value,note,expected",
[
("append-notes", "bar", "foo", "foobar"),
("prepend-notes", "bar", "foo", "barfoo"),
("append-notes", "bar", None, "bar"),
("prepend-notes", "bar", None, "bar"),
],
)
def test_preppend_append_notes(operation, value, note, expected):
mock = MagicMock()
t = create_transaction(mock, datetime.date(2024, 1, 1), "Bank", "", notes=note)
action = Action(field="description", op=operation, value=value)
action.run(t)
assert t.notes == expected
action.run(t) # second iteration should not update the result
assert t.notes == expected
assert f"{operation.split('-')[0]} to notes '{value}'" in str(action)
19 changes: 19 additions & 0 deletions tests/test_schedules.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,25 @@ def test_complex_schedules():
assert str(s) == "Every month on the last Sunday, 2nd Saturday, 10th, 31st, 5th (before weekend)"


def test_skip_weekend_schedule():
s = Schedule.model_validate(
{
"start": "2024-09-14",
"interval": 1,
"frequency": "monthly",
"patterns": [],
"skipWeekend": True,
"weekendSolveMode": "after",
"endMode": "on_date",
"endOccurrences": 1,
"endDate": "2024-09-14",
}
)
after = s.xafter(date(2024, 8, 14), count=2)
# we should ensure that dates that fall outside the endDate are not covered, even though actual will accept it
assert after == []


def test_is_approx():
# create schedule for every 1st and last day of the month (30th or 31st)
s = Schedule.model_validate(
Expand Down

0 comments on commit 3a5a406

Please sign in to comment.