diff --git a/.gitignore b/.gitignore index 1f476c3..da82e55 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ *.pyc __pycache__ *.egg-info/ +/dev/* diff --git a/README.md b/README.md new file mode 100644 index 0000000..f4713e6 --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# MinimalFinanceTracker + +A simple expense tracking application for single household use. + +## Setup + +It's recommended to install in a virtual environment. The extra `dev` +dependencies can be optionally installed. +```bash +# Create and activate the virtual environment +python -m venv venv +source ./venv/bin/activate + +# Install an editable version for development, including the optional +# development dependencies +pip install -e .[dev] + +# Or otherwise just install the application: +pip install . +``` + +## Running the Application + +After activating the virtual environment and installing the python package you +can use the mft command to run and manage the service. +```bash +# Print cli help +mft --help +mft run --help +# Run the server using the default config file location +mft run +# Run the server using a specified config file +mft --config /path/to/cfg.toml run +# Run the server in debug/development mode, making it reload itself when the +# files change +mft run --debug +``` + +The config file _must_ exist, but it may be empty. + diff --git a/mft/__init__.py b/mft/__init__.py new file mode 100644 index 0000000..3dc1f76 --- /dev/null +++ b/mft/__init__.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/mft/app.py b/mft/app.py new file mode 100644 index 0000000..c60ae0e --- /dev/null +++ b/mft/app.py @@ -0,0 +1,37 @@ +from pathlib import Path + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles + +from mft.settings import settings + + +def create_app() -> FastAPI: + app = FastAPI( + title=settings.app_name, + version=settings.version, + description="A simple expense tracking application", + ) + + # Configure CORS + app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + # Register routes + from mft.routes import api_router + + app.include_router(api_router, prefix=settings.api_prefix) + + static_dir = Path(__file__).parent / "static" + app.mount("/", StaticFiles(directory=static_dir, html=True), name="static") + + return app + + +app = create_app() diff --git a/mft/auth.py b/mft/auth.py new file mode 100644 index 0000000..c4c1fbc --- /dev/null +++ b/mft/auth.py @@ -0,0 +1,44 @@ +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials + +from mft.database import get_db + + +security = HTTPBearer() + + +def verify_token( + credentials: HTTPAuthorizationCredentials = Depends(security), +) -> int: + """ + Verify bearer token and return user ID. + + Args: + credentials: The HTTP authorization credentials containing the bearer token + + Returns: + The user ID associated with the valid token + + Raises: + HTTPException: If token is invalid or disabled + """ + token = credentials.credentials + + with get_db() as conn: + cursor = conn.cursor() + result = cursor.execute( + """ + SELECT uid, enabled + FROM auth + WHERE token = ? + """, + (token,), + ).fetchone() + + if result is None or not result["enabled"]: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication token", + headers={"WWW-Authenticate": "Bearer"}, + ) + return result["uid"] diff --git a/mft/cli.py b/mft/cli.py new file mode 100644 index 0000000..fc08c8c --- /dev/null +++ b/mft/cli.py @@ -0,0 +1,74 @@ +import os +import sys +import argparse +import uvicorn +import mft.config +from pathlib import Path + + +def transform_args(args: argparse.Namespace) -> argparse.Namespace: + args.config = args.config.expanduser().absolute() + return args + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="MinimalFinanceTracker - Simple expense tracking" + ) + + # Global flags + parser.add_argument( + "-c", + "--config", + type=Path, + default=mft.config.get_default_config_path(), + help="Path to config file", + ) + + # Subcommands + subparsers = parser.add_subparsers(dest="command", required=True) + + # run subcommand + run_parser = subparsers.add_parser("run", help="Run the web application") + run_parser.add_argument( + "--debug", action="store_true", help="Run in debug mode with auto-reload" + ) + return transform_args(parser.parse_args()) + + +def delayed_import_settings(): + # NOTE: Because uvicorn starts new python processes we need some way to + # communicate the config file path to these other processes. This also adds + # some complexity to creating the settings object, because it can't be + # passed around to the child process. The chosen solution is to set + # MFT_CONFIG_PATH in the environment of the cli (parent) and to use this + # value in the mft.settings module to create a global settings singleton. + from mft.settings import settings + + return settings + + +def main(): + args = parse_args() + if not args.config.exists(): + print(f"Error: Config file `{args.config}` does not exist.", file=sys.stderr) + sys.exit(1) + os.environ["MFT_CONFIG_PATH"] = str(args.config) + settings = delayed_import_settings() + + if args.command == "run": + run_command(args, settings) + + +def run_command(args, settings): + uvicorn.run( + "mft.app:app", + host=settings.host, + port=settings.port, + reload=args.debug, + log_level="debug" if args.debug else "info", + ) + + +if __name__ == "__main__": + main() diff --git a/mft/config.py b/mft/config.py new file mode 100644 index 0000000..aec98f2 --- /dev/null +++ b/mft/config.py @@ -0,0 +1,46 @@ +# defines and deals with everything related to config file parsing +import tomllib +from pathlib import Path +from pydantic import BaseModel, Field, ConfigDict + +DEFAULT_CONFIG = """ +[server] +host = "127.0.0.1" +port = 8080 + +[mft] +database = "~/.local/var/db/mft.db" +cors_origins = ["http://127.0.0.1:8080"] +""" + + +def get_default_config_path() -> Path: + return Path.home() / ".local/etc/mft/mft.toml" + + +# Config file schemas +class ServerConfig(BaseModel): + model_config = ConfigDict(extra="forbid") + + host: str = "127.0.0.1" + port: int = 8080 + + +class MftConfig(BaseModel): + model_config = ConfigDict(extra="forbid") + + database: str = "~/.local/var/db/mft.db" + cors_origins: list[str] = Field(default_factory=lambda: ["http://127.0.0.1:8080"]) + + +class AppConfig(BaseModel): + model_config = ConfigDict(extra="forbid") + + server: ServerConfig = Field(default_factory=ServerConfig) + mft: MftConfig = Field(default_factory=MftConfig) + + +def load_config(config_file: Path) -> AppConfig: + with open(config_file, "rb") as f: + data = tomllib.load(f) + return AppConfig(**data) diff --git a/mft/database.py b/mft/database.py new file mode 100644 index 0000000..6559930 --- /dev/null +++ b/mft/database.py @@ -0,0 +1,16 @@ +import sqlite3 +from contextlib import contextmanager +from typing import Generator + +from mft.settings import settings + + +@contextmanager +def get_db() -> Generator[sqlite3.Connection, None, None]: + """Get a database connection context manager.""" + conn = sqlite3.connect(settings.database_path) + conn.row_factory = sqlite3.Row # Enable dict-like access to rows + try: + yield conn + finally: + conn.close() diff --git a/mft/routes/__init__.py b/mft/routes/__init__.py new file mode 100644 index 0000000..b558fd5 --- /dev/null +++ b/mft/routes/__init__.py @@ -0,0 +1,13 @@ +"""API routes aggregation.""" + +from fastapi import APIRouter + +from mft.routes import auth, categories, health + + +api_router = APIRouter() + +# Include all route modules +api_router.include_router(health.router, tags=["health"]) +api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) +api_router.include_router(categories.router, tags=["categories"]) diff --git a/mft/routes/auth.py b/mft/routes/auth.py new file mode 100644 index 0000000..1a9b5dd --- /dev/null +++ b/mft/routes/auth.py @@ -0,0 +1,26 @@ +"""Authentication routes.""" + +from fastapi import APIRouter, Depends + +from mft.auth import verify_token + + +router = APIRouter() + + +@router.get("/validate") +async def validate(user_id: int = Depends(verify_token)): + """ + Validate authentication token. + + Args: + user_id: The authenticated user's ID (injected by verify_token dependency) + + Returns: + A simple status response with the authenticated user ID + """ + return { + "status": "ok", + "user_id": user_id, + "message": "Successfully authenticated" + } diff --git a/mft/routes/categories.py b/mft/routes/categories.py new file mode 100644 index 0000000..9e46813 --- /dev/null +++ b/mft/routes/categories.py @@ -0,0 +1,38 @@ +"""Category endpoints.""" + +from fastapi import APIRouter, Depends +from pydantic import BaseModel + +from mft.auth import verify_token +from mft.database import get_db + + +router = APIRouter() + + +class Category(BaseModel): + """Category response model.""" + + id: int + name: str + + +@router.get("/categories", response_model=list[Category]) +def get_categories(uid: int = Depends(verify_token)): + """ + Get all categories. + + Returns: + List of categories with id and name, sorted alphabetically by name + """ + with get_db() as conn: + cursor = conn.cursor() + rows = cursor.execute( + """ + SELECT id, name + FROM category + ORDER BY name + """ + ).fetchall() + + return [Category(id=row["id"], name=row["name"]) for row in rows] diff --git a/mft/routes/health.py b/mft/routes/health.py new file mode 100644 index 0000000..8349c6a --- /dev/null +++ b/mft/routes/health.py @@ -0,0 +1,12 @@ +"""Health check and status endpoints.""" + +from fastapi import APIRouter + + +router = APIRouter() + + +@router.get("/status") +async def status(): + """Minimal status check to verify the API is up.""" + return {"status": "ok"} diff --git a/mft/schema/schema.sql b/mft/schema/schema.sql new file mode 100644 index 0000000..3b0a0db --- /dev/null +++ b/mft/schema/schema.sql @@ -0,0 +1,49 @@ +CREATE TABLE config ( + key TEXT PRIMARY KEY, + value BLOB +); + +INSERT INTO config (key, value) VALUES ('schema.version', '1'); + +CREATE TABLE user ( + id INTEGER PRIMARY KEY, + name TEXT UNIQUE NOT NULL +); + + +CREATE TABLE auth ( + id INTEGER PRIMARY KEY, + uid INTEGER NOT NULL, + created TEXT NOT NULL, + token TEXT UNIQUE NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT 1, + + FOREIGN KEY (uid) REFERENCES user(id) ON DELETE RESTRICT +); + +CREATE TABLE category ( + id INTEGER PRIMARY KEY, + name TEXT UNIQUE NOT NULL +); + +INSERT INTO category (name) VALUES + ('Food/Drink'), + ('Household consumables'), + ('Household Other'), + ('Hobby'), + ('Utilities'); + +CREATE TABLE expense ( + id INTEGER PRIMARY KEY, + uid INTEGER NOT NULL, + cid INTEGER NOT NULL, + ts TEXT NOT NULL, + value INTEGER NOT NULL CHECK (value >= 0), -- Value in cents + note TEXT, + + FOREIGN KEY (uid) REFERENCES user(id) ON DELETE RESTRICT, + FOREIGN KEY (cid) REFERENCES category(id) ON DELETE RESTRICT +); + +CREATE INDEX idx_expense_ts ON expense(ts); +CREATE INDEX idx_expense_cid_ts ON expense(cid, ts); diff --git a/mft/settings.py b/mft/settings.py new file mode 100644 index 0000000..882c875 --- /dev/null +++ b/mft/settings.py @@ -0,0 +1,35 @@ +# contains all the settings the app uses at runtime, these are transformed +# versions of the config file and flags values +import os +import mft +import mft.config +from pathlib import Path + + +class Settings: + def __init__(self, config_path): + self.set_constants() + self.config_path = config_path + if not config_path.exists(): + raise RuntimeError("Configuration file does not exist") + config = mft.config.load_config(self.config_path) + self.set_config_file_values(config) + + def set_constants(self): + self.app_name: str = "MinimalFinanceTracker" + self.version: str = mft.__version__ + self.api_prefix: str = "/api" + + def set_config_file_values(self, config): + self.database_path: Path = Path(config.mft.database).expanduser().absolute() + self.cors_origins = config.mft.cors_origins + self.host = config.server.host + self.port = config.server.port + + +# NOTE: This variable is set by the parent cli process. See +# mft.cli:delayed_settings_import for more information and justification +config_path = Path(os.environ["MFT_CONFIG_PATH"]) +if not config_path.exists(): + raise RuntimeError("Config file does not exist") +settings = Settings(config_path) diff --git a/mft/static/app.js b/mft/static/app.js new file mode 100644 index 0000000..0106075 --- /dev/null +++ b/mft/static/app.js @@ -0,0 +1,117 @@ +const TOKEN_KEY = 'mft_token'; + +// Validate token with the API +async function validateToken(token) { + try { + const response = await fetch('/api/auth/validate', { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}` + } + }); + return response.ok; + } catch (error) { + console.error('Token validation error:', error); + return false; + } +} + +// Check for token on load +document.addEventListener('DOMContentLoaded', async () => { + const token = localStorage.getItem(TOKEN_KEY); + + if (token) { + const isValid = await validateToken(token); + if (isValid) { + showApp(); + } else { + localStorage.removeItem(TOKEN_KEY); + showLogin(); + alert('Your token is invalid or expired. Please log in again.'); + } + } else { + showLogin(); + } +}); + +// Handle token form submission +document.getElementById('token-form').addEventListener('submit', async (e) => { + e.preventDefault(); + + const token = document.getElementById('token').value.trim(); + + if (token) { + const isValid = await validateToken(token); + if (isValid) { + localStorage.setItem(TOKEN_KEY, token); + showApp(); + } else { + alert('Invalid token. Please check and try again.'); + } + } +}); + +// Handle logout +document.getElementById('logout-btn').addEventListener('click', () => { + localStorage.removeItem(TOKEN_KEY); + showLogin(); + document.getElementById('token-form').reset(); +}); + +// Handle expense form submission (no-op for now) +document.getElementById('expense-form').addEventListener('submit', (e) => { + e.preventDefault(); + // TODO: Implement expense submission + console.log('Expense form submitted (no-op)'); +}); + +function showLogin() { + document.getElementById('login-section').style.display = 'block'; + document.getElementById('app-section').style.display = 'none'; +} + +async function showApp() { + document.getElementById('login-section').style.display = 'none'; + document.getElementById('app-section').style.display = 'block'; + await loadCategories(); +} + +async function loadCategories() { + const token = localStorage.getItem(TOKEN_KEY); + const categorySelect = document.getElementById('category'); + + try { + const response = await fetch('/api/categories', { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (!response.ok) { + throw new Error('Failed to load categories'); + } + + const categories = await response.json(); + + // Clear loading option + categorySelect.innerHTML = ''; + + // Add default option + const defaultOption = document.createElement('option'); + defaultOption.value = ''; + defaultOption.textContent = 'Select a category'; + categorySelect.appendChild(defaultOption); + + // Add category options + categories.forEach(category => { + const option = document.createElement('option'); + option.value = category.id; + option.textContent = category.name; + categorySelect.appendChild(option); + }); + } catch (error) { + console.error('Error loading categories:', error); + categorySelect.innerHTML = ''; + } +} diff --git a/mft/static/index.html b/mft/static/index.html new file mode 100644 index 0000000..d0fc67b --- /dev/null +++ b/mft/static/index.html @@ -0,0 +1,52 @@ + + + + + + Minimal Finance Tracker + + + +
+

Minimal Finance Tracker

+ +
+

Enter Token

+
+
+ + +
+ +
+
+ + +
+ + + + diff --git a/mft/static/style.css b/mft/static/style.css new file mode 100644 index 0000000..67e0fe5 --- /dev/null +++ b/mft/static/style.css @@ -0,0 +1,89 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + line-height: 1.6; + color: #333; + background: #f4f4f4; + padding: 20px; +} + +.container { + max-width: 600px; + margin: 0 auto; + background: white; + padding: 30px; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +h1 { + margin-bottom: 30px; + color: #2c3e50; +} + +h2 { + margin-bottom: 20px; + font-size: 1.3em; + color: #34495e; +} + +.form-group { + margin-bottom: 20px; +} + +label { + display: block; + margin-bottom: 5px; + font-weight: 500; + color: #555; +} + +input { + width: 100%; + padding: 10px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; +} + +input:focus { + outline: none; + border-color: #3498db; +} + +button { + padding: 12px 20px; + background: #3498db; + color: white; + border: none; + border-radius: 4px; + font-size: 16px; + cursor: pointer; + font-weight: 500; +} + +button:hover { + background: #2980b9; +} + +button:active { + transform: translateY(1px); +} + +select { + width: 100%; + padding: 10px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; +} + +select:focus { + outline: none; + border-color: #3498db; +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..365475c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "mft" +version = "0.1.0" +description = "Minimal Finance Tracker - A simple expense tracking application" +requires-python = ">=3.12" +dependencies = [ + "fastapi", + "uvicorn[standard]", +] + +[project.optional-dependencies] +dev = [ + "pytest" +] + +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project.scripts] +mft = "mft.cli:main"