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
|
||||
__pycache__
|
||||
*.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