diff --git a/README.md b/README.md index ab31761..36dc912 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,11 @@ Amounts and worked/vacation/sick statuses selected for dates are stored persiste python3 -m venv .venv source .venv/bin/activate pip install -r requirements.txt -waitress-serve --host=0.0.0.0 --port=9009 fatima:app +waitress-serve --host=0.0.0.0 --port=9009 flask_fatima:app ``` To use a different port, substitute it in the command (for example, -`--port=8080`). The application exposes its WSGI callable as `fatima:app`, so it +`--port=8080`). The application exposes its WSGI callable as `flask_fatima:app`, so it can also be used by platforms that ask for an application entry point. Set the username, password, and Flask signing key in `config.yaml` before starting. This file is excluded from Git. Use `config.example.yaml` as the documented template when setting up another machine. @@ -38,6 +38,7 @@ The app is served below `/fatima` by default. Configure the external path in ```yaml flask: url_prefix: "/fatima" + log_file: "/tmp/fatima.log" ``` Set `URL_PREFIX` in the environment to override this value at deployment time. @@ -77,4 +78,14 @@ On the iPhone, visit `http://:9009/fatima/`. Waitress listens on all network interfaces with the command above. For anything exposed beyond a trusted home network, put it behind an HTTPS reverse proxy. -For deployments, `APP_CONFIG` can point to a YAML file elsewhere. `SECRET_KEY`, `APP_USERNAME`, and `APP_PASSWORD` environment variables optionally override the corresponding YAML values. +For deployments, `APP_CONFIG` can point to a YAML file elsewhere. `SECRET_KEY`, +`APP_USERNAME`, `APP_PASSWORD`, `DATABASE`, and `LOG_FILE` environment variables optionally +override the corresponding values. `DATABASE` should be an absolute path to the +SQLite file used in production. + +At startup, the app prints a database summary to the Waitress log containing the +resolved path, amount and status totals, distinct recorded-date total, and date +range. Compare that path and those totals with the importer's output to confirm +that both processes use the same database. The timestamped summary is also +appended to the configured `flask.log_file` each time the app starts. It defaults +to `/tmp/fatima.log` and can be overridden at deployment time with `LOG_FILE`. diff --git a/config.example.yaml b/config.example.yaml index 6b308a8..0089807 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -3,6 +3,8 @@ flask: secret_key: "replace-with-a-long-random-value" # External path used by the reverse proxy. Use "" to serve from the domain root. url_prefix: "/fatima" + # Startup database summary log. + log_file: "/tmp/fatima.log" auth: username: "your-username" diff --git a/flask_fatima.py b/flask_fatima.py index bb6980e..5369891 100644 --- a/flask_fatima.py +++ b/flask_fatima.py @@ -1,7 +1,7 @@ import os import secrets import sqlite3 -from datetime import date +from datetime import date, datetime from decimal import Decimal, InvalidOperation, ROUND_HALF_UP from functools import wraps from pathlib import Path @@ -57,6 +57,7 @@ def load_yaml_config(path): "APP_USERNAME": auth.get("username"), "APP_PASSWORD": auth.get("password"), "URL_PREFIX": normalize_url_prefix(flask_config.get("url_prefix", "/fatima")), + "LOG_FILE": flask_config.get("log_file", "/tmp/fatima.log"), } missing = [ name for name, value in required.items() @@ -80,6 +81,40 @@ def load_yaml_config(path): return required +def database_summary(database_path): + with sqlite3.connect(database_path) as database: + amount_count = database.execute("SELECT COUNT(*) FROM date_choices").fetchone()[0] + status_count = database.execute("SELECT COUNT(*) FROM date_statuses").fetchone()[0] + recorded_count, first_date, last_date = database.execute( + """ + SELECT COUNT(*), MIN(recorded_date), MAX(recorded_date) FROM ( + SELECT choice_date AS recorded_date FROM date_choices + UNION + SELECT status_date AS recorded_date FROM date_statuses + ) + """ + ).fetchone() + return amount_count, status_count, recorded_count, first_date, last_date + + +def log_database_summary(database_path, log_path): + amounts, statuses, recorded_dates, first_date, last_date = database_summary(database_path) + date_range = f"{first_date} through {last_date}" if first_date else "no recorded dates" + message = ( + "Fatima database: " + f"path={database_path} | " + f"amounts={amounts} | statuses={statuses} | " + f"recorded_dates={recorded_dates} | range={date_range}" + ) + print(message, flush=True) + try: + with log_path.open("a", encoding="utf-8") as log_file: + timestamp = datetime.now().astimezone().isoformat(timespec="seconds") + log_file.write(f"{timestamp} | {message}\n") + except OSError as error: + print(f"Could not write database summary to {log_path}: {error}", flush=True) + + def create_app(test_config=None): app = Flask(__name__) config_path = os.environ.get("APP_CONFIG", Path(app.root_path) / "config.yaml") @@ -87,7 +122,7 @@ def create_app(test_config=None): app.config.setdefault("DATABASE", str(Path(app.instance_path) / "calendar.sqlite")) # Environment variables remain useful when deploying without a local file. - for name in ("SECRET_KEY", "APP_USERNAME", "APP_PASSWORD"): + for name in ("SECRET_KEY", "APP_USERNAME", "APP_PASSWORD", "DATABASE", "LOG_FILE"): if os.environ.get(name): app.config[name] = os.environ[name] if "URL_PREFIX" in os.environ: @@ -95,6 +130,8 @@ def create_app(test_config=None): if test_config: app.config.update(test_config) app.config["URL_PREFIX"] = normalize_url_prefix(app.config["URL_PREFIX"]) + app.config["DATABASE"] = str(Path(app.config["DATABASE"]).expanduser().resolve()) + app.config["LOG_FILE"] = str(Path(app.config["LOG_FILE"]).expanduser().resolve()) app.wsgi_app = UrlPrefixMiddleware(app.wsgi_app, app.config["URL_PREFIX"]) Path(app.config["DATABASE"]).parent.mkdir(parents=True, exist_ok=True) @@ -119,6 +156,9 @@ def create_app(test_config=None): """ ) + if not app.config.get("TESTING"): + log_database_summary(app.config["DATABASE"], Path(app.config["LOG_FILE"])) + def get_database(): connection = sqlite3.connect(app.config["DATABASE"]) connection.row_factory = sqlite3.Row diff --git a/tests/test_app.py b/tests/test_app.py index afa7590..3f89bad 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,6 +1,7 @@ import tempfile +from pathlib import Path -from flask_fatima import create_app +from flask_fatima import create_app, log_database_summary URL_PREFIX = "/fatima" @@ -30,6 +31,19 @@ def test_calendar_requires_login(): assert response.headers["Location"].endswith(url("/login")) +def test_database_summary_is_appended_to_log(capsys): + test_client = client() + with tempfile.TemporaryDirectory() as directory: + log_path = Path(directory) / "fatima.log" + log_database_summary(test_client.application.config["DATABASE"], log_path) + + logged = log_path.read_text(encoding="utf-8") + assert "Fatima database:" in logged + assert f"path={test_client.application.config['DATABASE']}" in logged + assert "amounts=0 | statuses=0 | recorded_dates=0" in logged + assert "Fatima database:" in capsys.readouterr().out + + def test_prefix_applies_to_generated_links_and_unprefixed_routes_are_rejected(): test_client = client() test_client.post(url("/login"), data={"username": "user", "password": "pass"})