Skip to content

Commit

Permalink
feat: Add first draft of schedule suppport.
Browse files Browse the repository at this point in the history
  • Loading branch information
bvanelli committed May 10, 2024
1 parent dd7aad2 commit 3d93f3f
Show file tree
Hide file tree
Showing 7 changed files with 227 additions and 17 deletions.
10 changes: 8 additions & 2 deletions actual/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ def get_attribute_by_table_name(table_name: str, column_name: str, reverse: bool


class BaseModel(SQLModel):
id: str = Field(sa_column=Column("id", Text, primary_key=True))

def convert(self, is_new: bool = True) -> List[Message]:
"""Convert the object into distinct entries for sync method. Based on the original implementation:
Expand Down Expand Up @@ -309,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))
rule: 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 @@ -319,6 +321,9 @@ 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})
transactions: List["Transactions"] = Relationship(back_populates="schedule")


class SchedulesJsonPaths(SQLModel, table=True):
__tablename__ = "schedules_json_paths"
Expand Down Expand Up @@ -388,12 +393,13 @@ class Transactions(BaseModel, table=True):
cleared: Optional[int] = Field(default=None, sa_column=Column("cleared", Integer, server_default=text("1")))
pending: Optional[int] = Field(default=None, sa_column=Column("pending", Integer, server_default=text("0")))
parent_id: Optional[str] = Field(default=None, sa_column=Column("parent_id", Text))
schedule: Optional[str] = Field(default=None, sa_column=Column("schedule", Text))
schedule_id: Optional[str] = Field(default=None, sa_column=Column("schedule", Text, ForeignKey("schedules.id")))
reconciled: Optional[int] = Field(default=None, sa_column=Column("reconciled", Integer, server_default=text("0")))

account: Optional["Accounts"] = Relationship(back_populates="transactions")
category: Optional["Categories"] = Relationship(back_populates="transactions")
payee: Optional["Payees"] = Relationship(back_populates="transactions")
schedule: Optional["Schedules"] = Relationship(back_populates="transactions")

def get_date(self) -> datetime.date:
return datetime.datetime.strptime(str(self.date), "%Y%m%d").date()
Expand Down
5 changes: 5 additions & 0 deletions actual/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
PayeeMapping,
Payees,
Rules,
Schedules,
Transactions,
)
from actual.exceptions import ActualError
Expand Down Expand Up @@ -414,3 +415,7 @@ def create_rule(
if rule.run(t):
s.add(t)
return database_rule


def get_schedules(s: Session) -> typing.List[Schedules]:
return s.query(Schedules).all()
41 changes: 27 additions & 14 deletions actual/rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@

from actual import ActualError
from actual.crypto import is_uuid
from actual.database import Transactions, get_attribute_by_table_name
from actual.database import BaseModel, Transactions, get_attribute_by_table_name
from actual.schedules import Schedule


class ConditionType(enum.Enum):
Expand Down Expand Up @@ -78,17 +79,19 @@ def validate(self, value: typing.Union[int, list[str], str, None], as_list: bool
return isinstance(value, bool)

@classmethod
def from_field(cls, field: str) -> ValueType:
def from_field(cls, field: str | None) -> ValueType:
if field in ("acct", "category", "description"):
return ValueType.ID
elif field in ("notes", "imported_description"):
return ValueType.STRING
elif field in ("date",):
return ValueType.DATE
elif field in ("cleared",):
elif field in ("cleared", "reconciled"):
return ValueType.BOOLEAN
elif field in ("amount",):
return ValueType.NUMBER
elif field is None:
return ValueType.ID # link-schedule
else:
raise ValueError(f"Field '{field}' does not have a matching ValueType.")

Expand Down Expand Up @@ -122,9 +125,14 @@ def condition_evaluation(
elif op == ConditionType.IS_NOT:
return self_value != true_value
elif op == ConditionType.IS_APPROX:
# Actual uses two days as reference
# https://github.com/actualbudget/actual/blob/98a7aac73667241da350169e55edd2fc16a6687f/packages/loot-core/src/server/accounts/rules.ts#L302-L304
interval = datetime.timedelta(days=2)
if isinstance(true_value, datetime.date):
# Actual uses two days as reference
# https://github.com/actualbudget/actual/blob/98a7aac73667241da350169e55edd2fc16a6687f/packages/loot-core/src/server/accounts/rules.ts#L302-L304
interval = datetime.timedelta(days=2)
else:
# Actual uses 7.5% of the value as threshold
# https://github.com/actualbudget/actual/blob/243703b2f70532ec1acbd3088dda879b5d07a5b3/packages/loot-core/src/shared/rules.ts#L261-L263
interval = round(abs(self_value) * 0.075, 2)
return self_value - interval <= true_value <= self_value + interval
elif op in (ConditionType.ONE_OF, ConditionType.CONTAINS):
return true_value in self_value
Expand Down Expand Up @@ -162,9 +170,9 @@ class Condition(pydantic.BaseModel):

field: typing.Literal["imported_description", "acct", "category", "date", "description", "notes", "amount"]
op: ConditionType
value: typing.Union[int, list[str], list[pydantic.BaseModel], str, pydantic.BaseModel, datetime.date, None]
type: ValueType = None
options: dict = None
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)
Expand All @@ -182,7 +190,7 @@ 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) and self.options is None:
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)
return self
Expand All @@ -195,8 +203,11 @@ def check_operation_type(self):
if not self.type.is_valid(self.op):
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, pydantic.BaseModel) and hasattr(self.value, "id"):
self.value = str(self.value.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")
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 Down Expand Up @@ -227,10 +238,10 @@ class Action(pydantic.BaseModel):
- date: 'type' must be 'date' and 'value' a string in the date format '2024-04-11'
"""

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

def __str__(self) -> str:
Expand Down Expand Up @@ -263,6 +274,8 @@ def run(self, transaction: Transactions) -> None:
transaction.set_date(value)
else:
setattr(transaction, attr, value)
elif self.op == ActionType.LINK_SCHEDULE:
transaction.schedule_id = self.value
else:
raise ActualError(f"Operation {self.op} not supported")

Expand Down
138 changes: 138 additions & 0 deletions actual/schedules.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import datetime
import enum

import pydantic
from dateutil.rrule import (
DAILY,
MONTHLY,
WEEKLY,
YEARLY,
rrule,
rruleset,
weekday,
weekdays,
)


class EndMode(enum.Enum):
AFTER_N_OCCURRENCES = "after_n_occurrences"
ON_DATE = "on_date"
NEVER = "never"


class Frequency(enum.Enum):
DAILY = "daily"
WEEKLY = "weekly"
MONTHLY = "monthly"
YEARLY = "yearly"

def as_dateutil(self):
frequency_map = {"YEARLY": YEARLY, "MONTHLY": MONTHLY, "WEEKLY": WEEKLY, "DAILY": DAILY}
return frequency_map[self.name]


class WeekendSolveMode(enum.Enum):
BEFORE = "before"
AFTER = "after"


class PatternType(enum.Enum):
SUNDAY = "SU"
MONDAY = "MO"
TUESDAY = "TU"
WEDNESDAY = "WE"
THURSDAY = "TH"
FRIDAY = "FR"
SATURDAY = "SA"
DAY = "day"

def as_dateutil(self) -> weekday:
weekday_map = {str(w): w for w in weekdays}
return weekday_map[self.value]


class Pattern(pydantic.BaseModel):
value: int
type: PatternType


class Schedule(pydantic.BaseModel):
start: datetime.date = pydantic.Field(..., description="Start date of the schedule.")
interval: int = pydantic.Field(1, description="Repeat every interval at frequency unit.")
frequency: Frequency = pydantic.Field(Frequency.MONTHLY, description="Unit for the defined interval.")
patterns: list[Pattern] = pydantic.Field(default_factory=list)
skip_weekend: bool = pydantic.Field(
alias="skipWeekend", description="If should move schedule before or after a weekend."
)
weekend_solve_mode: WeekendSolveMode = pydantic.Field(
alias="weekendSolveMode",
description="When skipping weekend, the value should be set before or after the weekend interval.",
)
end_mode: EndMode = pydantic.Field(
EndMode.NEVER,
alias="endMode",
description="If the schedule should run forever or end at a certain date or number of occurrences.",
)
end_occurrences: int = pydantic.Field(
WeekendSolveMode.AFTER, alias="endOccurrences", description="Number of occurrences before the schedule ends."
)
end_date: datetime.date = pydantic.Field(alias="endDate")

def is_approx(self, date: datetime.date) -> bool:
pass

def rruleset(self) -> rruleset:
rule_sets_configs = []
config = dict(freq=self.frequency.as_dateutil(), dtstart=self.start, interval=self.interval)
# add termination options
if self.end_mode == EndMode.ON_DATE:
config["until"] = self.end_date
elif self.end_mode == EndMode.AFTER_N_OCCURRENCES:
config["count"] = self.end_occurrences
if self.frequency == Frequency.MONTHLY and self.patterns:
by_month_day, by_weekday = [], []
for p in self.patterns:
if p.type == PatternType.DAY:
by_month_day.append(p.value)
else: # it's a weekday
by_weekday.append(p.type.as_dateutil()(p.value))
if by_month_day:
monthly_config = config.copy() | {"bymonthday": by_month_day}
rule_sets_configs.append(monthly_config)
if by_weekday:
rule_sets_configs.append(config.copy() | {"byweekday": by_weekday})
if not rule_sets_configs:
rule_sets_configs.append(config)
# create rule set
rs = rruleset(cache=True)
for cfg in rule_sets_configs:
rs.rrule(rrule(**cfg))
return rs

def xafter(self, date: datetime.date = None, count: int = 1) -> list[datetime.date]:
if not date:
date = datetime.date.today()
# dateutils only accepts datetime for evaluation
dt_start = datetime.datetime.combine(date, datetime.time.min)
# we also always use the day before since today can also be a valid entry for our time
rs = self.rruleset()

ret, i = [], 0
for value in rs:
value: datetime.datetime
if value.weekday() in (5, 6) and self.skip_weekend:
if self.weekend_solve_mode == WeekendSolveMode.AFTER:
value = value + datetime.timedelta(days=7 - value.weekday())
else: # BEFORE
value_after = value - datetime.timedelta(days=value.weekday() - 4)
if value_after < dt_start:
# value is in the past, skip and look for another
continue
value = value_after
i += 1
dt = value
# convert back to date
ret.append(dt.date())
if len(ret) == count:
break
return sorted(ret)
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ sqlalchemy>=1.4
proto-plus>=1
protobuf>=4
cryptography>=42
python-dateutil>=2.9.0
7 changes: 6 additions & 1 deletion tests/test_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ def test_numeric_condition():
c2 = Condition(field="amount", op="lt", value=-10)
assert "outflow" in c2.options
assert c2.run(t) is True # 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
c3 = Condition(field="amount", op="isapprox", value=5.5)
assert c3.run(t) is False


def test_complex_rule():
Expand Down Expand Up @@ -130,7 +135,7 @@ def test_invalid_inputs():
with pytest.raises(ValueError):
Condition(field="description", op="is", value="foo") # not an uuid
with pytest.raises(ActualError):
Action(field="notes", op="link-schedule", value="foo").run(None) # noqa: use None instead of transaction
Action(field="notes", op="set-split-amount", value="foo").run(None) # noqa: use None instead of transaction
with pytest.raises(ActualError):
condition_evaluation(None, "foo", "foo") # noqa: use None instead of transaction

Expand Down
42 changes: 42 additions & 0 deletions tests/test_schedules.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import datetime

from actual.schedules import Schedule


def test_basic_schedules():
s = Schedule.parse_obj(
{
"start": "2024-05-08",
"frequency": "monthly",
"patterns": [
{"value": -1, "type": "SU"},
{"value": 2, "type": "SA"},
{"value": 10, "type": "day"},
{"value": 31, "type": "day"},
{"value": 5, "type": "day"},
],
"skipWeekend": True,
"weekendSolveMode": "after",
"endMode": "never",
"endOccurrences": 1,
"endDate": "2024-05-08",
"interval": 1,
}
)
assert s.xafter(datetime.date(2024, 5, 10), count=5) == [
datetime.date(2024, 5, 10),
datetime.date(2024, 5, 13),
datetime.date(2024, 5, 27),
datetime.date(2024, 5, 31),
datetime.date(2024, 6, 5),
]
# change the solve mode to before
s.weekend_solve_mode = "before"
assert s.xafter(datetime.date(2024, 5, 10), count=5) == [
datetime.date(2024, 5, 10),
# according to frontend, this entry happens twice
datetime.date(2024, 5, 10),
datetime.date(2024, 5, 24),
datetime.date(2024, 5, 31),
datetime.date(2024, 6, 5),
]

0 comments on commit 3d93f3f

Please sign in to comment.