Upload files to "/"
copy current month budgets to last 3 years
This commit is contained in:
324
apply_budget_limits_gemini.py
Normal file
324
apply_budget_limits_gemini.py
Normal file
@@ -0,0 +1,324 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
apply_budget_limits.py
|
||||
|
||||
Inspects the existing limit configured for the current month on each budget
|
||||
to determine its period (weekly, monthly, custom) and limit amount. It then backfills
|
||||
that exact same limit and period length going back N years (default 3).
|
||||
|
||||
USAGE:
|
||||
|
||||
export FIREFLY_URL="http://192.168.1.239:8012"
|
||||
export FIREFLY_TOKEN="..."
|
||||
|
||||
python3 apply_budget_limits.py --list # list current budgets and their limits/periods
|
||||
python3 apply_budget_limits.py # dry run preview of backfill
|
||||
python3 apply_budget_limits.py --apply # create missing limits
|
||||
python3 apply_budget_limits.py --apply --overwrite # overwrite existing past limits
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import calendar
|
||||
import datetime
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
sys.exit("This script needs the 'requests' package: pip install requests")
|
||||
|
||||
|
||||
def clean(value):
|
||||
if value is None:
|
||||
return ""
|
||||
return "".join(ch for ch in str(value).strip().strip('"').strip("'") if ch.isprintable())
|
||||
|
||||
|
||||
class FireflyClient:
|
||||
def __init__(self, base_url, token, timeout=30):
|
||||
base_url = clean(base_url)
|
||||
token = clean(token)
|
||||
if not token:
|
||||
sys.exit("No Firefly API token provided. Check --token / FIREFLY_TOKEN.")
|
||||
if not base_url:
|
||||
sys.exit("No Firefly base URL provided. Check --url / FIREFLY_URL.")
|
||||
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
self.timeout = timeout
|
||||
|
||||
def _url(self, path):
|
||||
return f"{self.base_url}/api/v1{path}"
|
||||
|
||||
def _diagnose_and_raise(self, resp):
|
||||
try:
|
||||
body = resp.text[:1000]
|
||||
except Exception:
|
||||
body = "<no body>"
|
||||
raise RuntimeError(
|
||||
f"{resp.status_code} {resp.reason} for {resp.request.method} {resp.url}\n"
|
||||
f"Response body: {body}"
|
||||
)
|
||||
|
||||
def _get_all(self, path, params=None):
|
||||
items = []
|
||||
url = self._url(path)
|
||||
first = True
|
||||
while url:
|
||||
resp = self.session.get(url, params=params if first else None, timeout=self.timeout)
|
||||
if not resp.ok:
|
||||
self._diagnose_and_raise(resp)
|
||||
payload = resp.json()
|
||||
items.extend(payload.get("data", []))
|
||||
url = payload.get("links", {}).get("next")
|
||||
first = False
|
||||
return items
|
||||
|
||||
def about(self):
|
||||
resp = self.session.get(self._url("/about"), timeout=self.timeout)
|
||||
if not resp.ok:
|
||||
self._diagnose_and_raise(resp)
|
||||
return resp.json().get("data", {})
|
||||
|
||||
def list_budgets(self):
|
||||
return self._get_all("/budgets")
|
||||
|
||||
def get_budget_limits(self, budget_id, start, end):
|
||||
return self._get_all(f"/budgets/{budget_id}/limits", params={"start": start, "end": end})
|
||||
|
||||
# def create_budget_limit(self, budget_id, start, end, amount, currency_id=None, currency_code=None):
|
||||
# body = {
|
||||
# "budget_id": str(budget_id),
|
||||
# "start": start,
|
||||
# "end": end,
|
||||
# "amount": str(amount),
|
||||
# }
|
||||
# if currency_id:
|
||||
# body["currency_id"] = str(currency_id)
|
||||
# elif currency_code:
|
||||
# body["currency_code"] = currency_code
|
||||
|
||||
# resp = self.session.post(self._url("/budget-limits"), json=body, timeout=self.timeout)
|
||||
# if not resp.ok:
|
||||
# self._diagnose_and_raise(resp)
|
||||
# return resp.json()["data"]
|
||||
|
||||
def create_budget_limit(self, budget_id, start, end, amount, currency_id=None, currency_code=None):
|
||||
body = {
|
||||
"start": start,
|
||||
"end": end,
|
||||
"amount": str(amount),
|
||||
}
|
||||
if currency_id is not None and str(currency_id).isdigit():
|
||||
body["currency_id"] = int(currency_id) # Must be an integer
|
||||
elif currency_code:
|
||||
body["currency_code"] = currency_code
|
||||
|
||||
resp = self.session.post(self._url(f"/budgets/{budget_id}/limits"), json=body, timeout=self.timeout)
|
||||
if not resp.ok:
|
||||
self._diagnose_and_raise(resp)
|
||||
return resp.json()["data"]
|
||||
|
||||
def update_budget_limit(self, limit_id, amount):
|
||||
resp = self.session.put(self._url(f"/budget-limits/{limit_id}"),
|
||||
json={"amount": str(amount)}, timeout=self.timeout)
|
||||
if not resp.ok:
|
||||
self._diagnose_and_raise(resp)
|
||||
return resp.json()["data"]
|
||||
|
||||
|
||||
def month_bounds(year, month):
|
||||
start = datetime.date(year, month, 1)
|
||||
last_day = calendar.monthrange(year, month)[1]
|
||||
end = datetime.date(year, month, last_day)
|
||||
return start.isoformat(), end.isoformat()
|
||||
|
||||
|
||||
def detect_period_type(limit_start_str, limit_end_str):
|
||||
"""Detects whether a limit date range represents a monthly or weekly period."""
|
||||
s = datetime.date.fromisoformat(limit_start_str[:10])
|
||||
e = datetime.date.fromisoformat(limit_end_str[:10])
|
||||
days_diff = (e - s).days + 1
|
||||
|
||||
if 6 <= days_diff <= 8:
|
||||
return "weekly"
|
||||
elif 27 <= days_diff <= 32:
|
||||
return "monthly"
|
||||
return f"custom ({days_diff} days)"
|
||||
|
||||
|
||||
def generate_past_periods(today, years_back, period_type):
|
||||
"""Generates (start_str, end_str) tuples based on auto-detected period type."""
|
||||
start_history = datetime.date(today.year - years_back, today.month, 1)
|
||||
_, last_day = calendar.monthrange(today.year, today.month)
|
||||
end_history = datetime.date(today.year, today.month, last_day)
|
||||
|
||||
periods = []
|
||||
|
||||
if period_type == "weekly":
|
||||
# Align to Monday–Sunday weeks
|
||||
curr_start = start_history - datetime.timedelta(days=start_history.weekday())
|
||||
while curr_start <= end_history:
|
||||
curr_end = curr_start + datetime.timedelta(days=6)
|
||||
periods.append((curr_start.isoformat(), curr_end.isoformat()))
|
||||
curr_start += datetime.timedelta(days=7)
|
||||
|
||||
else: # monthly / default fallback
|
||||
curr_y, curr_m = start_history.year, start_history.month
|
||||
while (curr_y, curr_m) <= (today.year, today.month):
|
||||
s, e = month_bounds(curr_y, curr_m)
|
||||
periods.append((s, e))
|
||||
curr_m += 1
|
||||
if curr_m > 12:
|
||||
curr_m = 1
|
||||
curr_y += 1
|
||||
|
||||
return periods
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--url", default=os.environ.get("FIREFLY_URL"))
|
||||
parser.add_argument("--token", default=os.environ.get("FIREFLY_TOKEN"))
|
||||
parser.add_argument("--years", type=int, default=3,
|
||||
help="How many years back to apply the limit (default: 3)")
|
||||
parser.add_argument("-l", "--list", action="store_true",
|
||||
help="Print defined budget limits for the current month and exit.")
|
||||
parser.add_argument("--apply", action="store_true",
|
||||
help="Actually create/update budget limits. Without this, only preview.")
|
||||
parser.add_argument("--overwrite", action="store_true",
|
||||
help="Also overwrite limits that already exist in past periods.")
|
||||
parser.add_argument("--delay", type=float, default=0.2,
|
||||
help="Seconds to sleep between API calls (default: 0.2)")
|
||||
args = parser.parse_args()
|
||||
|
||||
client = FireflyClient(args.url, args.token)
|
||||
|
||||
print(f"Connecting to {client.base_url} ...")
|
||||
about = client.about()
|
||||
print(f" Connected OK. Firefly III version: {about.get('version', 'unknown')}\n")
|
||||
|
||||
today = datetime.date.today()
|
||||
cur_start, cur_end = month_bounds(today.year, today.month)
|
||||
|
||||
budgets = client.list_budgets()
|
||||
|
||||
# --- LIST MODE (--list / -l) ---
|
||||
if args.list:
|
||||
print(f"Current Month Budget Summary ({cur_start} to {cur_end}):")
|
||||
print("=" * 75)
|
||||
print(f"{'Budget Name':<25} | {'Amount':<10} | {'Period':<10} | {'Start Date':<10} | {'End Date':<10}")
|
||||
print("-" * 75)
|
||||
|
||||
no_limits_count = 0
|
||||
for b in budgets:
|
||||
budget_id = b["id"]
|
||||
budget_name = b["attributes"]["name"]
|
||||
cur_limits = client.get_budget_limits(budget_id, cur_start, cur_end)
|
||||
|
||||
if not cur_limits:
|
||||
print(f"{budget_name:<25} | {'NO LIMIT':<10} | {'N/A':<10} | {'N/A':<10} | {'N/A':<10}")
|
||||
no_limits_count += 1
|
||||
continue
|
||||
|
||||
for limit in cur_limits:
|
||||
amt = limit["attributes"]["amount"]
|
||||
start = limit["attributes"]["start"][:10]
|
||||
end = limit["attributes"]["end"][:10]
|
||||
period = detect_period_type(start, end)
|
||||
print(f"{budget_name:<25} | {amt:<10} | {period:<10} | {start:<10} | {end:<10}")
|
||||
|
||||
print("=" * 75)
|
||||
print(f"Total Budgets: {len(budgets)} | Budgets without active limit: {no_limits_count}\n")
|
||||
return
|
||||
|
||||
# --- NORMAL BACKFILL MODE ---
|
||||
print(f"Scanning current month window ({cur_start} .. {cur_end}) for active budget configs...\n")
|
||||
print(f"Found {len(budgets)} budget(s)\n")
|
||||
|
||||
to_create = [] # (budget_id, budget_name, start, end, amount, currency_id)
|
||||
to_update = [] # (limit_id, budget_id, budget_name, start, end, old_amount, new_amount)
|
||||
no_current_limit = []
|
||||
|
||||
for b in budgets:
|
||||
budget_id = b["id"]
|
||||
budget_name = b["attributes"]["name"]
|
||||
|
||||
cur_limits = client.get_budget_limits(budget_id, cur_start, cur_end)
|
||||
if not cur_limits:
|
||||
no_current_limit.append(budget_name)
|
||||
continue
|
||||
|
||||
source = cur_limits[0]
|
||||
amount = source["attributes"]["amount"]
|
||||
currency_id = source["attributes"].get("currency_id")
|
||||
|
||||
period_type = detect_period_type(source["attributes"]["start"], source["attributes"]["end"])
|
||||
periods = generate_past_periods(today, args.years, period_type)
|
||||
|
||||
range_start = periods[0][0]
|
||||
range_end = periods[-1][1]
|
||||
|
||||
existing_range = client.get_budget_limits(budget_id, range_start, range_end)
|
||||
existing_by_start = {l["attributes"]["start"][:10]: l for l in existing_range}
|
||||
|
||||
for start, end in periods:
|
||||
existing = existing_by_start.get(start)
|
||||
if existing is None:
|
||||
to_create.append((budget_id, budget_name, start, end, amount, currency_id))
|
||||
elif args.overwrite and existing["attributes"]["amount"] != amount:
|
||||
to_update.append((existing["id"], budget_id, budget_name, start, end,
|
||||
existing["attributes"]["amount"], amount))
|
||||
|
||||
print(f"Budgets skipped (no active limit found in current month): {len(no_current_limit)}")
|
||||
for name in no_current_limit:
|
||||
print(f" - {name}")
|
||||
|
||||
print(f"\nBudget limits to CREATE: {len(to_create)}")
|
||||
print(f"Budget limits to UPDATE (--overwrite): {len(to_update)}")
|
||||
|
||||
if not args.apply:
|
||||
print("\n[DRY RUN] No --apply flag given, nothing was sent to the API.")
|
||||
print("Sample of planned creates:")
|
||||
for row in to_create[:10]:
|
||||
_, name, start, end, amount, _ = row
|
||||
print(f" {name}: {start} .. {end} -> {amount}")
|
||||
if len(to_create) > 10:
|
||||
print(f" ... and {len(to_create) - 10} more")
|
||||
print("\nRe-run with --apply to apply changes.")
|
||||
return
|
||||
|
||||
print("\nApplying...")
|
||||
created, updated, failed = 0, 0, 0
|
||||
|
||||
for budget_id, name, start, end, amount, currency_id in to_create:
|
||||
try:
|
||||
client.create_budget_limit(budget_id, start, end, amount, currency_id=currency_id)
|
||||
created += 1
|
||||
except Exception as e:
|
||||
print(f" [FAIL create] {name} {start}: {e}")
|
||||
failed += 1
|
||||
time.sleep(args.delay)
|
||||
|
||||
for limit_id, budget_id, name, start, end, old_amount, new_amount in to_update:
|
||||
try:
|
||||
client.update_budget_limit(limit_id, new_amount)
|
||||
updated += 1
|
||||
except Exception as e:
|
||||
print(f" [FAIL update] {name} {start}: {e}")
|
||||
failed += 1
|
||||
time.sleep(args.delay)
|
||||
|
||||
print(f"\nDone. Created={created}, Updated={updated}, Failed={failed}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user