Initial project structure added.

The following are in a minimal working state:

- Database schema
- Basic database interaction
- Configuration file parsing
- Command line interface
- Basic route handling for categories, auth and health
- Simple static webapp files
This commit is contained in:
2025-12-23 23:25:48 +01:00
parent b744e7f0c0
commit b4c84ab7ea
18 changed files with 711 additions and 0 deletions

1
.gitignore vendored
View File

@@ -2,3 +2,4 @@
*.pyc *.pyc
__pycache__ __pycache__
*.egg-info/ *.egg-info/
/dev/*

40
README.md Normal file
View File

@@ -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.

1
mft/__init__.py Normal file
View File

@@ -0,0 +1 @@
__version__ = "0.1.0"

37
mft/app.py Normal file
View File

@@ -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()

44
mft/auth.py Normal file
View File

@@ -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"]

74
mft/cli.py Normal file
View File

@@ -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()

46
mft/config.py Normal file
View File

@@ -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)

16
mft/database.py Normal file
View File

@@ -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()

13
mft/routes/__init__.py Normal file
View File

@@ -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"])

26
mft/routes/auth.py Normal file
View File

@@ -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"
}

38
mft/routes/categories.py Normal file
View File

@@ -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]

12
mft/routes/health.py Normal file
View File

@@ -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"}

49
mft/schema/schema.sql Normal file
View File

@@ -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);

35
mft/settings.py Normal file
View File

@@ -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)

117
mft/static/app.js Normal file
View File

@@ -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 = '<option value="">Error loading categories</option>';
}
}

52
mft/static/index.html Normal file
View File

@@ -0,0 +1,52 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Minimal Finance Tracker</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div class="container">
<h1>Minimal Finance Tracker</h1>
<div id="login-section">
<h2>Enter Token</h2>
<form id="token-form">
<div class="form-group">
<label for="token">Access Token</label>
<input type="text" id="token" name="token" required autocomplete="off">
</div>
<button type="submit">Login</button>
</form>
</div>
<div id="app-section" style="display: none;">
<div class="header-bar">
<h2>Add Expense</h2>
<button id="logout-btn">Logout</button>
</div>
<form id="expense-form">
<div class="form-group">
<label for="category">Category</label>
<select id="category" name="category" required>
<option value="">Loading...</option>
</select>
</div>
<div class="form-group">
<label for="value">Amount</label>
<input type="number" id="value" name="value" step="0.01" min="0" required>
</div>
<div class="form-group">
<label for="note">Note</label>
<input type="text" id="note" name="note">
</div>
<button type="submit">Add Expense</button>
</form>
</div>
</div>
<script src="/app.js"></script>
</body>
</html>

89
mft/static/style.css Normal file
View File

@@ -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;
}

21
pyproject.toml Normal file
View File

@@ -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"