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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,3 +2,4 @@
|
|||||||
*.pyc
|
*.pyc
|
||||||
__pycache__
|
__pycache__
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
|
/dev/*
|
||||||
|
|||||||
40
README.md
Normal file
40
README.md
Normal 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
1
mft/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
__version__ = "0.1.0"
|
||||||
37
mft/app.py
Normal file
37
mft/app.py
Normal 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
44
mft/auth.py
Normal 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
74
mft/cli.py
Normal 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
46
mft/config.py
Normal 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
16
mft/database.py
Normal 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
13
mft/routes/__init__.py
Normal 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
26
mft/routes/auth.py
Normal 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
38
mft/routes/categories.py
Normal 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
12
mft/routes/health.py
Normal 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
49
mft/schema/schema.sql
Normal 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
35
mft/settings.py
Normal 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
117
mft/static/app.js
Normal 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
52
mft/static/index.html
Normal 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
89
mft/static/style.css
Normal 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
21
pyproject.toml
Normal 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"
|
||||||
Reference in New Issue
Block a user