Harden JSON storage (atomic writes + crash-safe loads) + suggestions lightbulb
- storage.py: atomic save_json (temp+fsync+os.replace) and load_json that survives missing/corrupt files (moves corrupt aside). Closes #11/#12. - route all data modules + app.py helpers through storage (closes #31 core); fixes mutable DEFAULT_FUNDS/DEFAULT_CARS return (#23). - gunicorn --workers 1 --threads 4 to remove the write race (#13); strip Dockerfile template scaffolding (#40). - base.html: 💡 suggestions link in the shared nav (shows on every page). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
16
Dockerfile
16
Dockerfile
@@ -24,16 +24,6 @@ RUN chmod +x /entrypoint.sh
|
|||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
ENTRYPOINT ["/entrypoint.sh"]
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
|
|
||||||
# ---- ADJUST THIS LINE to the app's real entry point ----
|
# Single worker + threads: the app stores state in JSON/SQLite files on the
|
||||||
# Flask (app object named `app` in app.py):
|
# volume, so multiple processes would race. Threads handle light concurrency.
|
||||||
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "app:app"]
|
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "1", "--threads", "4", "app:app"]
|
||||||
#
|
|
||||||
# FastAPI (app object named `app` in main.py) — swap the CMD for:
|
|
||||||
# RUN pip install "uvicorn[standard]"
|
|
||||||
# CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
|
||||||
#
|
|
||||||
# Django (project package `mysite`):
|
|
||||||
# CMD ["gunicorn", "--bind", "0.0.0.0:8000", "mysite.wsgi:application"]
|
|
||||||
#
|
|
||||||
# Streamlit:
|
|
||||||
# CMD ["streamlit", "run", "app.py", "--server.port=8000", "--server.address=0.0.0.0"]
|
|
||||||
|
|||||||
29
app.py
29
app.py
@@ -5,6 +5,7 @@ import pdfplumber
|
|||||||
import re
|
import re
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
import calendar as cal_module
|
import calendar as cal_module
|
||||||
|
from storage import load_json, save_json
|
||||||
from categories import CATEGORIES, CATEGORY_COLORS
|
from categories import CATEGORIES, CATEGORY_COLORS
|
||||||
from bills import load_bills, save_bills, bills_for_month, calendar_grid, get_week_num
|
from bills import load_bills, save_bills, bills_for_month, calendar_grid, get_week_num
|
||||||
from sinking import (load_funds, save_funds, FUND_COLORS, monthly_needed,
|
from sinking import (load_funds, save_funds, FUND_COLORS, monthly_needed,
|
||||||
@@ -57,14 +58,10 @@ def money_filter(value):
|
|||||||
TAX_FILE = os.path.join(os.environ.get("DATA_DIR", os.path.join(os.path.dirname(__file__), "data")), "taxes.json")
|
TAX_FILE = os.path.join(os.environ.get("DATA_DIR", os.path.join(os.path.dirname(__file__), "data")), "taxes.json")
|
||||||
|
|
||||||
def load_taxes():
|
def load_taxes():
|
||||||
if os.path.exists(TAX_FILE):
|
return load_json(TAX_FILE, {"rates": {"se_tax": 15.3, "federal": 22.0, "wi_state": 5.3}, "set_aside": []})
|
||||||
with open(TAX_FILE) as f:
|
|
||||||
return json.load(f)
|
|
||||||
return {"rates": {"se_tax": 15.3, "federal": 22.0, "wi_state": 5.3}, "set_aside": []}
|
|
||||||
|
|
||||||
def save_taxes(data):
|
def save_taxes(data):
|
||||||
with open(TAX_FILE, "w") as f:
|
save_json(TAX_FILE, data)
|
||||||
json.dump(data, f, indent=2)
|
|
||||||
|
|
||||||
STANDARD_DEDUCTION_MFJ = 30000 # 2025 married filing jointly
|
STANDARD_DEDUCTION_MFJ = 30000 # 2025 married filing jointly
|
||||||
|
|
||||||
@@ -177,27 +174,11 @@ NET_WORTH_FILE = os.path.join(DATA_DIR, "net_worth_accounts.json")
|
|||||||
|
|
||||||
|
|
||||||
def load_nw_accounts():
|
def load_nw_accounts():
|
||||||
if os.path.exists(NET_WORTH_FILE):
|
return load_json(NET_WORTH_FILE, [])
|
||||||
with open(NET_WORTH_FILE) as f:
|
|
||||||
return json.load(f)
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def save_nw_accounts(accounts):
|
def save_nw_accounts(accounts):
|
||||||
with open(NET_WORTH_FILE, "w") as f:
|
save_json(NET_WORTH_FILE, accounts)
|
||||||
json.dump(accounts, f, indent=2)
|
|
||||||
|
|
||||||
|
|
||||||
def load_json(path, default):
|
|
||||||
if os.path.exists(path):
|
|
||||||
with open(path) as f:
|
|
||||||
return json.load(f)
|
|
||||||
return default
|
|
||||||
|
|
||||||
|
|
||||||
def save_json(path, data):
|
|
||||||
with open(path, "w") as f:
|
|
||||||
json.dump(data, f, indent=2)
|
|
||||||
|
|
||||||
|
|
||||||
def get_transactions():
|
def get_transactions():
|
||||||
|
|||||||
23
assets.py
23
assets.py
@@ -1,5 +1,7 @@
|
|||||||
|
import copy
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
from storage import load_json, save_json
|
||||||
|
|
||||||
DATA_DIR = os.environ.get("DATA_DIR", os.path.join(os.path.dirname(__file__), "data"))
|
DATA_DIR = os.environ.get("DATA_DIR", os.path.join(os.path.dirname(__file__), "data"))
|
||||||
ASSETS_FILE = os.path.join(DATA_DIR, "assets.json")
|
ASSETS_FILE = os.path.join(DATA_DIR, "assets.json")
|
||||||
@@ -44,20 +46,19 @@ ASSET_CATEGORIES = ["Investments", "Real Estate", "Equipment", "Other Valuables"
|
|||||||
|
|
||||||
|
|
||||||
def load_assets():
|
def load_assets():
|
||||||
if os.path.exists(ASSETS_FILE):
|
data = load_json(ASSETS_FILE, None)
|
||||||
with open(ASSETS_FILE) as f:
|
if data is None:
|
||||||
data = json.load(f)
|
data = {"personal": copy.deepcopy(DEFAULT_CARS), "flips": [], "other_assets": []}
|
||||||
# migrate old format if needed
|
save_assets(data)
|
||||||
if "other_assets" not in data:
|
return data
|
||||||
data["other_assets"] = []
|
# migrate old format if needed
|
||||||
return data
|
if "other_assets" not in data:
|
||||||
save_assets({"personal": DEFAULT_CARS, "flips": [], "other_assets": []})
|
data["other_assets"] = []
|
||||||
return {"personal": DEFAULT_CARS, "flips": [], "other_assets": []}
|
return data
|
||||||
|
|
||||||
|
|
||||||
def save_assets(data):
|
def save_assets(data):
|
||||||
with open(ASSETS_FILE, "w") as f:
|
save_json(ASSETS_FILE, data)
|
||||||
json.dump(data, f, indent=2)
|
|
||||||
|
|
||||||
|
|
||||||
def car_display_name(car):
|
def car_display_name(car):
|
||||||
|
|||||||
9
bills.py
9
bills.py
@@ -2,21 +2,18 @@ import json
|
|||||||
import os
|
import os
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
import calendar
|
import calendar
|
||||||
|
from storage import load_json, save_json
|
||||||
|
|
||||||
DATA_DIR = os.environ.get("DATA_DIR", os.path.join(os.path.dirname(__file__), "data"))
|
DATA_DIR = os.environ.get("DATA_DIR", os.path.join(os.path.dirname(__file__), "data"))
|
||||||
BILLS_FILE = os.path.join(DATA_DIR, "bills.json")
|
BILLS_FILE = os.path.join(DATA_DIR, "bills.json")
|
||||||
|
|
||||||
|
|
||||||
def load_bills():
|
def load_bills():
|
||||||
if os.path.exists(BILLS_FILE):
|
return load_json(BILLS_FILE, [])
|
||||||
with open(BILLS_FILE) as f:
|
|
||||||
return json.load(f)
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def save_bills(bills):
|
def save_bills(bills):
|
||||||
with open(BILLS_FILE, "w") as f:
|
save_json(BILLS_FILE, bills)
|
||||||
json.dump(bills, f, indent=2)
|
|
||||||
|
|
||||||
|
|
||||||
def get_week_label(day):
|
def get_week_label(day):
|
||||||
|
|||||||
17
debts.py
17
debts.py
@@ -2,6 +2,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
from datetime import date
|
from datetime import date
|
||||||
import math
|
import math
|
||||||
|
from storage import load_json, save_json
|
||||||
|
|
||||||
DATA_DIR = os.environ.get("DATA_DIR", os.path.join(os.path.dirname(__file__), "data"))
|
DATA_DIR = os.environ.get("DATA_DIR", os.path.join(os.path.dirname(__file__), "data"))
|
||||||
DEBTS_FILE = os.path.join(DATA_DIR, "debts.json")
|
DEBTS_FILE = os.path.join(DATA_DIR, "debts.json")
|
||||||
@@ -116,27 +117,19 @@ OWNER_COLORS = {
|
|||||||
|
|
||||||
|
|
||||||
def load_debts():
|
def load_debts():
|
||||||
if os.path.exists(DEBTS_FILE):
|
return load_json(DEBTS_FILE, [])
|
||||||
with open(DEBTS_FILE) as f:
|
|
||||||
return json.load(f)
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def save_debts(debts):
|
def save_debts(debts):
|
||||||
with open(DEBTS_FILE, "w") as f:
|
save_json(DEBTS_FILE, debts)
|
||||||
json.dump(debts, f, indent=2)
|
|
||||||
|
|
||||||
|
|
||||||
def load_debt_log():
|
def load_debt_log():
|
||||||
if os.path.exists(DEBT_LOG_FILE):
|
return load_json(DEBT_LOG_FILE, {})
|
||||||
with open(DEBT_LOG_FILE) as f:
|
|
||||||
return json.load(f)
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def save_debt_log(log):
|
def save_debt_log(log):
|
||||||
with open(DEBT_LOG_FILE, "w") as f:
|
save_json(DEBT_LOG_FILE, log)
|
||||||
json.dump(log, f, indent=2)
|
|
||||||
|
|
||||||
|
|
||||||
def month_key(year, month):
|
def month_key(year, month):
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
from storage import load_json, save_json
|
||||||
|
|
||||||
DATA_DIR = os.environ.get("DATA_DIR", os.path.join(os.path.dirname(__file__), "data"))
|
DATA_DIR = os.environ.get("DATA_DIR", os.path.join(os.path.dirname(__file__), "data"))
|
||||||
INCOME_FILE = os.path.join(DATA_DIR, "income_log.json")
|
INCOME_FILE = os.path.join(DATA_DIR, "income_log.json")
|
||||||
@@ -21,15 +22,11 @@ OWNER_COLORS = {
|
|||||||
|
|
||||||
|
|
||||||
def load_income_log():
|
def load_income_log():
|
||||||
if os.path.exists(INCOME_FILE):
|
return load_json(INCOME_FILE, [])
|
||||||
with open(INCOME_FILE) as f:
|
|
||||||
return json.load(f)
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def save_income_log(log):
|
def save_income_log(log):
|
||||||
with open(INCOME_FILE, "w") as f:
|
save_json(INCOME_FILE, log)
|
||||||
json.dump(log, f, indent=2)
|
|
||||||
|
|
||||||
|
|
||||||
def month_key(year, month):
|
def month_key(year, month):
|
||||||
|
|||||||
@@ -3,21 +3,18 @@ import os
|
|||||||
from datetime import date
|
from datetime import date
|
||||||
from dateutil.relativedelta import relativedelta
|
from dateutil.relativedelta import relativedelta
|
||||||
import math
|
import math
|
||||||
|
from storage import load_json, save_json
|
||||||
|
|
||||||
DATA_DIR = os.environ.get("DATA_DIR", os.path.join(os.path.dirname(__file__), "data"))
|
DATA_DIR = os.environ.get("DATA_DIR", os.path.join(os.path.dirname(__file__), "data"))
|
||||||
MORTGAGE_FILE = os.path.join(DATA_DIR, "mortgage.json")
|
MORTGAGE_FILE = os.path.join(DATA_DIR, "mortgage.json")
|
||||||
|
|
||||||
|
|
||||||
def load_mortgage():
|
def load_mortgage():
|
||||||
if os.path.exists(MORTGAGE_FILE):
|
return load_json(MORTGAGE_FILE, {})
|
||||||
with open(MORTGAGE_FILE) as f:
|
|
||||||
return json.load(f)
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def save_mortgage(data):
|
def save_mortgage(data):
|
||||||
with open(MORTGAGE_FILE, "w") as f:
|
save_json(MORTGAGE_FILE, data)
|
||||||
json.dump(data, f, indent=2)
|
|
||||||
|
|
||||||
|
|
||||||
def monthly_payment(principal, annual_rate, term_years):
|
def monthly_payment(principal, annual_rate, term_years):
|
||||||
|
|||||||
18
sinking.py
18
sinking.py
@@ -1,6 +1,8 @@
|
|||||||
|
import copy
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
from storage import load_json, save_json
|
||||||
|
|
||||||
DATA_DIR = os.environ.get("DATA_DIR", os.path.join(os.path.dirname(__file__), "data"))
|
DATA_DIR = os.environ.get("DATA_DIR", os.path.join(os.path.dirname(__file__), "data"))
|
||||||
SINKING_FILE = os.path.join(DATA_DIR, "sinking_funds.json")
|
SINKING_FILE = os.path.join(DATA_DIR, "sinking_funds.json")
|
||||||
@@ -25,17 +27,17 @@ DEFAULT_FUNDS = [
|
|||||||
|
|
||||||
|
|
||||||
def load_funds():
|
def load_funds():
|
||||||
if os.path.exists(SINKING_FILE):
|
funds = load_json(SINKING_FILE, None)
|
||||||
with open(SINKING_FILE) as f:
|
if funds is None:
|
||||||
return json.load(f)
|
# First run — seed with a copy of the defaults (never hand out the
|
||||||
# First run — seed with defaults
|
# module-level list, which callers mutate in place).
|
||||||
save_funds(DEFAULT_FUNDS)
|
funds = copy.deepcopy(DEFAULT_FUNDS)
|
||||||
return DEFAULT_FUNDS
|
save_funds(funds)
|
||||||
|
return funds
|
||||||
|
|
||||||
|
|
||||||
def save_funds(funds):
|
def save_funds(funds):
|
||||||
with open(SINKING_FILE, "w") as f:
|
save_json(SINKING_FILE, funds)
|
||||||
json.dump(funds, f, indent=2)
|
|
||||||
|
|
||||||
|
|
||||||
def months_until(target_date_str):
|
def months_until(target_date_str):
|
||||||
|
|||||||
50
storage.py
Normal file
50
storage.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
"""Crash-safe JSON persistence shared across all data modules.
|
||||||
|
|
||||||
|
- load_json: returns `default` if the file is missing, empty, or corrupt
|
||||||
|
(e.g. a partial/interrupted write). A corrupt file is moved aside to
|
||||||
|
`<path>.corrupt` so it can be inspected rather than silently overwritten.
|
||||||
|
- save_json: atomic write — serialize to a temp file in the same directory,
|
||||||
|
fsync, then os.replace() (atomic on POSIX). An interrupted write can never
|
||||||
|
leave a truncated or empty target file.
|
||||||
|
|
||||||
|
This is the durability backbone for the app's JSON storage (issues #11/#12).
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
DATA_DIR = os.environ.get("DATA_DIR", os.path.join(os.path.dirname(__file__), "data"))
|
||||||
|
|
||||||
|
|
||||||
|
def load_json(path, default):
|
||||||
|
if not os.path.exists(path):
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
with open(path) as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
# Corrupt/partial file — preserve it for inspection, fall back to default
|
||||||
|
# instead of crashing the request.
|
||||||
|
try:
|
||||||
|
os.replace(path, path + ".corrupt")
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def save_json(path, data):
|
||||||
|
directory = os.path.dirname(path) or "."
|
||||||
|
os.makedirs(directory, exist_ok=True)
|
||||||
|
fd, tmp = tempfile.mkstemp(dir=directory, prefix=".tmp-", suffix=".json")
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w") as f:
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
f.flush()
|
||||||
|
os.fsync(f.fileno())
|
||||||
|
os.replace(tmp, path)
|
||||||
|
except BaseException:
|
||||||
|
try:
|
||||||
|
os.remove(tmp)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
<a href="{{ url_for('debts_view') }}">Debt</a>
|
<a href="{{ url_for('debts_view') }}">Debt</a>
|
||||||
<a href="{{ url_for('net_worth_view') }}">Net Worth</a>
|
<a href="{{ url_for('net_worth_view') }}">Net Worth</a>
|
||||||
<a href="{{ url_for('summary') }}">Spending Report</a>
|
<a href="{{ url_for('summary') }}">Spending Report</a>
|
||||||
|
<a href="{{ url_for('suggestions.suggestions_page') }}" class="nav-suggest" title="Suggestions & feedback" aria-label="Suggestions">💡</a>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<main>
|
<main>
|
||||||
|
|||||||
Reference in New Issue
Block a user