Initial commit

This commit is contained in:
nabeel
2026-05-27 13:58:05 +10:00
commit c2202ea0bb
89 changed files with 28855 additions and 0 deletions

795
init_project.py Normal file
View File

@@ -0,0 +1,795 @@
#!/usr/bin/env python3
"""
Project initialization script for Firefly III Analytics
Creates the complete directory structure and scaffolding
"""
import os
import sys
from pathlib import Path
def create_dir(path):
"""Create directory if it doesn't exist"""
Path(path).mkdir(parents=True, exist_ok=True)
print(f"✓ Created directory: {path}")
def create_file(path, content):
"""Create file with content"""
Path(path).parent.mkdir(parents=True, exist_ok=True)
with open(path, 'w', encoding='utf-8') as f:
f.write(content)
print(f"✓ Created file: {path}")
# Backend files
backend_pyproject = '''[project]
name = "firefly-analytics-backend"
version = "0.1.0"
description = "Firefly III Analytics & Reporting Backend"
requires-python = ">=3.11"
dependencies = [
"fastapi==0.104.1",
"uvicorn[standard]==0.24.0",
"httpx==0.25.2",
"python-dotenv==1.0.0",
"pydantic==2.5.0",
"pydantic-settings==2.1.0",
"sqlalchemy==2.0.23",
"python-dateutil==2.8.2",
"apscheduler==3.10.4",
]
[project.optional-dependencies]
dev = [
"pytest==7.4.3",
"pytest-asyncio==0.21.1",
"black==23.12.0",
"ruff==0.1.8",
]
'''
backend_dockerfile = '''FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \\
gcc \\
&& rm -rf /var/lib/apt/lists/*
COPY pyproject.toml pyproject.toml
COPY app/ app/
RUN pip install --no-cache-dir -e .
RUN mkdir -p /app/data
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
'''
backend_config = '''"""
Firefly III Analytics Backend - Configuration
"""
import os
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
"""Application settings from environment variables"""
# Firefly III
FIREFLY_URL: str = os.getenv("FIREFLY_URL", "https://firefly.scsimedia.duckdns.org")
FIREFLY_API_TOKEN: str = os.getenv("FIREFLY_API_TOKEN", "")
# Database
DATABASE_URL: str = os.getenv("DATABASE_URL", "sqlite:///./data/app.db")
# Sync
SYNC_INTERVAL_MINUTES: int = int(os.getenv("SYNC_INTERVAL_MINUTES", "30"))
# API
API_TIMEOUT: int = 30
class Config:
env_file = ".env"
settings = Settings()
'''
backend_models = '''"""
Database models for Firefly III Analytics
"""
from sqlalchemy import Column, Integer, String, Float, DateTime, Boolean, Text, ForeignKey
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship
from datetime import datetime
Base = declarative_base()
class Transaction(Base):
"""Transaction model"""
__tablename__ = "transactions"
id = Column(Integer, primary_key=True, index=True)
firefly_id = Column(Integer, unique=True, index=True)
date = Column(DateTime, index=True)
amount = Column(Float)
description = Column(String)
type = Column(String) # withdrawal, deposit, transfer
category_id = Column(Integer, ForeignKey("categories.id"))
from_account_id = Column(Integer, ForeignKey("accounts.id"))
to_account_id = Column(Integer, ForeignKey("accounts.id"))
notes = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
category = relationship("Category", foreign_keys=[category_id])
from_account = relationship("Account", foreign_keys=[from_account_id])
to_account = relationship("Account", foreign_keys=[to_account_id])
class Category(Base):
"""Category model"""
__tablename__ = "categories"
id = Column(Integer, primary_key=True, index=True)
firefly_id = Column(Integer, unique=True, index=True)
name = Column(String, unique=True, index=True)
type = Column(String) # expense, revenue, etc
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
transactions = relationship("Transaction", foreign_keys=[Transaction.category_id])
class Account(Base):
"""Account model"""
__tablename__ = "accounts"
id = Column(Integer, primary_key=True, index=True)
firefly_id = Column(Integer, unique=True, index=True)
name = Column(String, unique=True, index=True)
type = Column(String) # asset, expense, revenue, liability, etc
balance = Column(Float)
currency_code = Column(String)
active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class SyncLog(Base):
"""Track sync operations"""
__tablename__ = "sync_logs"
id = Column(Integer, primary_key=True, index=True)
entity_type = Column(String) # transactions, categories, accounts
last_sync = Column(DateTime)
status = Column(String) # success, failure
message = Column(String, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
'''
backend_database = '''"""
Database initialization and connection
"""
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, Session
from app.config import settings
from app.models import Base
engine = create_engine(
settings.DATABASE_URL,
connect_args={"check_same_thread": False} if "sqlite" in settings.DATABASE_URL else {}
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def init_db():
"""Initialize database tables"""
Base.metadata.create_all(bind=engine)
def get_db() -> Session:
"""Get database session"""
db = SessionLocal()
try:
yield db
finally:
db.close()
'''
backend_firefly_client = '''"""
Firefly III API Client
"""
import httpx
from typing import Optional, List, Dict, Any
from datetime import datetime
from app.config import settings
import logging
logger = logging.getLogger(__name__)
class FireflyClient:
"""Client for Firefly III API"""
def __init__(self):
self.base_url = settings.FIREFLY_URL.rstrip('/')
self.headers = {
"Authorization": f"Bearer {settings.FIREFLY_API_TOKEN}",
"Accept": "application/vnd.api+json",
"Content-Type": "application/json"
}
async def get_transactions(
self,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
limit: int = 50
) -> List[Dict[str, Any]]:
"""Fetch transactions from Firefly III"""
async with httpx.AsyncClient() as client:
params = {"limit": limit}
if start_date:
params["start"] = start_date
if end_date:
params["end"] = end_date
try:
response = await client.get(
f"{self.base_url}/api/v1/transactions",
headers=self.headers,
params=params,
timeout=settings.API_TIMEOUT
)
response.raise_for_status()
data = response.json()
return data.get("data", [])
except Exception as e:
logger.error(f"Error fetching transactions: {e}")
return []
async def get_categories(self) -> List[Dict[str, Any]]:
"""Fetch categories from Firefly III"""
async with httpx.AsyncClient() as client:
try:
response = await client.get(
f"{self.base_url}/api/v1/categories",
headers=self.headers,
timeout=settings.API_TIMEOUT
)
response.raise_for_status()
data = response.json()
return data.get("data", [])
except Exception as e:
logger.error(f"Error fetching categories: {e}")
return []
async def get_accounts(self) -> List[Dict[str, Any]]:
"""Fetch accounts from Firefly III"""
async with httpx.AsyncClient() as client:
try:
response = await client.get(
f"{self.base_url}/api/v1/accounts",
headers=self.headers,
timeout=settings.API_TIMEOUT
)
response.raise_for_status()
data = response.json()
return data.get("data", [])
except Exception as e:
logger.error(f"Error fetching accounts: {e}")
return []
firefly_client = FireflyClient()
'''
backend_main = '''"""
Firefly III Analytics Backend - Main Application
"""
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from dotenv import load_dotenv
from app.database import init_db
from app.routers import transactions, categories, accounts, reports, summary
from app.services.sync_service import start_sync_scheduler, stop_sync_scheduler
load_dotenv()
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Startup and shutdown events"""
# Startup
init_db()
start_sync_scheduler()
yield
# Shutdown
stop_sync_scheduler()
app = FastAPI(
title="Firefly III Analytics API",
description="Analytics and reporting for Firefly III",
version="0.1.0",
lifespan=lifespan
)
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000", "http://localhost"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include routers
app.include_router(transactions.router, prefix="/api/transactions", tags=["transactions"])
app.include_router(categories.router, prefix="/api/categories", tags=["categories"])
app.include_router(accounts.router, prefix="/api/accounts", tags=["accounts"])
app.include_router(reports.router, prefix="/api/reports", tags=["reports"])
app.include_router(summary.router, prefix="/api/summary", tags=["summary"])
@app.get("/")
async def root():
"""Health check"""
return {"status": "ok", "message": "Firefly III Analytics API"}
@app.get("/health")
async def health():
"""Health check endpoint"""
return {"status": "healthy"}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
'''
backend_routers_init = '''"""
API routers
"""
'''
backend_routers_transactions = '''"""
Transactions API endpoints
"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.database import get_db
from app.services.transaction_service import TransactionService
from datetime import datetime, timedelta
router = APIRouter()
transaction_service = TransactionService()
@router.get("/")
async def get_transactions(
start_date: str = Query(None),
end_date: str = Query(None),
category_id: int = Query(None),
db: Session = Depends(get_db)
):
"""Get transactions with optional filtering"""
filters = {}
if start_date:
filters["start_date"] = datetime.fromisoformat(start_date)
if end_date:
filters["end_date"] = datetime.fromisoformat(end_date)
if category_id:
filters["category_id"] = category_id
transactions = await transaction_service.get_transactions(db, filters)
return {"data": transactions, "count": len(transactions)}
'''
backend_routers_categories = '''"""
Categories API endpoints
"""
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.database import get_db
from app.services.category_service import CategoryService
router = APIRouter()
category_service = CategoryService()
@router.get("/")
async def get_categories(db: Session = Depends(get_db)):
"""Get all categories"""
categories = category_service.get_all_categories(db)
return {"data": categories, "count": len(categories)}
'''
backend_routers_accounts = '''"""
Accounts API endpoints
"""
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.database import get_db
from app.services.account_service import AccountService
router = APIRouter()
account_service = AccountService()
@router.get("/")
async def get_accounts(db: Session = Depends(get_db)):
"""Get all accounts"""
accounts = account_service.get_all_accounts(db)
return {"data": accounts, "count": len(accounts)}
'''
backend_routers_reports = '''"""
Reports API endpoints
"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.database import get_db
from app.services.report_service import ReportService
from datetime import datetime
router = APIRouter()
report_service = ReportService()
@router.get("/spending-by-category")
async def spending_by_category(
start_date: str = Query(...),
end_date: str = Query(...),
db: Session = Depends(get_db)
):
"""Get spending by category for date range"""
start = datetime.fromisoformat(start_date)
end = datetime.fromisoformat(end_date)
data = await report_service.get_spending_by_category(db, start, end)
return {"data": data}
@router.get("/income-vs-expenses")
async def income_vs_expenses(
start_date: str = Query(...),
end_date: str = Query(...),
db: Session = Depends(get_db)
):
"""Get income vs expenses for date range"""
start = datetime.fromisoformat(start_date)
end = datetime.fromisoformat(end_date)
data = await report_service.get_income_vs_expenses(db, start, end)
return {"data": data}
@router.get("/trends")
async def trends(
start_date: str = Query(...),
end_date: str = Query(...),
db: Session = Depends(get_db)
):
"""Get financial trends over time"""
start = datetime.fromisoformat(start_date)
end = datetime.fromisoformat(end_date)
data = await report_service.get_trends(db, start, end)
return {"data": data}
@router.get("/budget-vs-actual")
async def budget_vs_actual(
start_date: str = Query(...),
end_date: str = Query(...),
db: Session = Depends(get_db)
):
"""Get budget vs actual spending"""
start = datetime.fromisoformat(start_date)
end = datetime.fromisoformat(end_date)
data = await report_service.get_budget_vs_actual(db, start, end)
return {"data": data}
'''
backend_routers_summary = '''"""
Summary API endpoints
"""
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.database import get_db
from app.services.summary_service import SummaryService
router = APIRouter()
summary_service = SummaryService()
@router.get("/")
async def get_summary(db: Session = Depends(get_db)):
"""Get dashboard summary metrics"""
summary = await summary_service.get_summary(db)
return summary
'''
backend_sync_service = '''"""
Background sync service for Firefly III data
"""
from apscheduler.schedulers.background import BackgroundScheduler
from app.config import settings
from app.clients.firefly_client import firefly_client
from app.database import SessionLocal
from app.models import Transaction, Category, Account, SyncLog
from datetime import datetime
import logging
import asyncio
logger = logging.getLogger(__name__)
scheduler = BackgroundScheduler()
async def sync_data():
"""Sync data from Firefly III"""
db = SessionLocal()
try:
# Sync categories
categories = await firefly_client.get_categories()
for cat in categories:
attrs = cat.get("attributes", {})
existing = db.query(Category).filter_by(firefly_id=cat["id"]).first()
if existing:
existing.name = attrs.get("name")
existing.type = attrs.get("type")
existing.updated_at = datetime.utcnow()
else:
db.add(Category(
firefly_id=cat["id"],
name=attrs.get("name"),
type=attrs.get("type")
))
# Sync accounts
accounts = await firefly_client.get_accounts()
for acc in accounts:
attrs = acc.get("attributes", {})
existing = db.query(Account).filter_by(firefly_id=acc["id"]).first()
if existing:
existing.name = attrs.get("name")
existing.type = attrs.get("type")
existing.balance = float(attrs.get("current_balance", 0))
existing.currency_code = attrs.get("currency_code")
existing.updated_at = datetime.utcnow()
else:
db.add(Account(
firefly_id=acc["id"],
name=attrs.get("name"),
type=attrs.get("type"),
balance=float(attrs.get("current_balance", 0)),
currency_code=attrs.get("currency_code")
))
# Sync transactions
transactions = await firefly_client.get_transactions()
for txn in transactions:
attrs = txn.get("attributes", {})
existing = db.query(Transaction).filter_by(firefly_id=txn["id"]).first()
if not existing:
db.add(Transaction(
firefly_id=txn["id"],
date=datetime.fromisoformat(attrs.get("date")),
amount=float(attrs.get("amount", 0)),
description=attrs.get("description"),
type=attrs.get("type"),
notes=attrs.get("notes")
))
db.commit()
logger.info("Data sync completed successfully")
except Exception as e:
logger.error(f"Error syncing data: {e}")
db.rollback()
finally:
db.close()
def sync_job():
"""Wrapper for async sync job"""
asyncio.run(sync_data())
def start_sync_scheduler():
"""Start background sync scheduler"""
if not scheduler.running:
scheduler.add_job(sync_job, 'interval', minutes=settings.SYNC_INTERVAL_MINUTES)
scheduler.start()
logger.info(f"Sync scheduler started (interval: {settings.SYNC_INTERVAL_MINUTES} minutes)")
def stop_sync_scheduler():
"""Stop background sync scheduler"""
if scheduler.running:
scheduler.shutdown()
logger.info("Sync scheduler stopped")
'''
backend_transaction_service = '''"""
Transaction service business logic
"""
from sqlalchemy.orm import Session
from sqlalchemy import func
from app.models import Transaction
from datetime import datetime
class TransactionService:
def get_transactions(self, db: Session, filters: dict):
"""Get transactions with filters"""
query = db.query(Transaction)
if "start_date" in filters:
query = query.filter(Transaction.date >= filters["start_date"])
if "end_date" in filters:
query = query.filter(Transaction.date <= filters["end_date"])
if "category_id" in filters:
query = query.filter(Transaction.category_id == filters["category_id"])
return query.order_by(Transaction.date.desc()).all()
'''
backend_category_service = '''"""
Category service business logic
"""
from sqlalchemy.orm import Session
from app.models import Category
class CategoryService:
def get_all_categories(self, db: Session):
"""Get all categories"""
return db.query(Category).all()
'''
backend_account_service = '''"""
Account service business logic
"""
from sqlalchemy.orm import Session
from app.models import Account
class AccountService:
def get_all_accounts(self, db: Session):
"""Get all accounts"""
return db.query(Account).all()
'''
backend_report_service = '''"""
Report service for generating analytics
"""
from sqlalchemy.orm import Session
from sqlalchemy import func
from app.models import Transaction, Category, Account
from datetime import datetime
from typing import Dict, List, Any
class ReportService:
async def get_spending_by_category(self, db: Session, start: datetime, end: datetime) -> Dict[str, Any]:
"""Get spending breakdown by category"""
results = db.query(
Category.name,
func.sum(Transaction.amount).label("total")
).join(
Transaction
).filter(
Transaction.date >= start,
Transaction.date <= end,
Transaction.type == "withdrawal"
).group_by(Category.name).all()
return {
"period": {"start": start.isoformat(), "end": end.isoformat()},
"data": [{"category": r[0], "amount": float(r[1]) if r[1] else 0} for r in results]
}
async def get_income_vs_expenses(self, db: Session, start: datetime, end: datetime) -> Dict[str, Any]:
"""Get income vs expenses"""
income = db.query(func.sum(Transaction.amount)).filter(
Transaction.date >= start,
Transaction.date <= end,
Transaction.type == "deposit"
).scalar() or 0
expenses = db.query(func.sum(Transaction.amount)).filter(
Transaction.date >= start,
Transaction.date <= end,
Transaction.type == "withdrawal"
).scalar() or 0
return {
"period": {"start": start.isoformat(), "end": end.isoformat()},
"income": float(income),
"expenses": float(expenses),
"net": float(income - expenses)
}
async def get_trends(self, db: Session, start: datetime, end: datetime) -> Dict[str, Any]:
"""Get financial trends over time"""
results = db.query(
func.date(Transaction.date).label("date"),
func.sum(Transaction.amount).label("amount")
).filter(
Transaction.date >= start,
Transaction.date <= end
).group_by(func.date(Transaction.date)).order_by("date").all()
return {
"period": {"start": start.isoformat(), "end": end.isoformat()},
"data": [{"date": str(r[0]), "amount": float(r[1]) if r[1] else 0} for r in results]
}
async def get_budget_vs_actual(self, db: Session, start: datetime, end: datetime) -> Dict[str, Any]:
"""Get budget vs actual spending (requires budget data)"""
return {
"period": {"start": start.isoformat(), "end": end.isoformat()},
"data": [],
"note": "Budget comparison requires budget data from Firefly III"
}
'''
backend_summary_service = '''"""
Summary service for dashboard metrics
"""
from sqlalchemy.orm import Session
from sqlalchemy import func
from app.models import Transaction, Account
from datetime import datetime, timedelta
class SummaryService:
async def get_summary(self, db: Session):
"""Get dashboard summary"""
# Last 30 days
thirty_days_ago = datetime.utcnow() - timedelta(days=30)
total_assets = db.query(func.sum(Account.balance)).scalar() or 0
recent_transactions = db.query(func.count(Transaction.id)).filter(
Transaction.date >= thirty_days_ago
).scalar() or 0
last_30_income = db.query(func.sum(Transaction.amount)).filter(
Transaction.date >= thirty_days_ago,
Transaction.type == "deposit"
).scalar() or 0
last_30_expenses = db.query(func.sum(Transaction.amount)).filter(
Transaction.date >= thirty_days_ago,
Transaction.type == "withdrawal"
).scalar() or 0
return {
"total_assets": float(total_assets),
"recent_transactions": recent_transactions,
"last_30_days": {
"income": float(last_30_income),
"expenses": float(last_30_expenses),
"net": float(last_30_income - last_30_expenses)
}
}
'''
def main():
base_path = Path(os.getcwd())
# Create backend structure
print("Setting up Firefly III Analytics Backend...")
create_file("backend/pyproject.toml", backend_pyproject)
create_file("backend/Dockerfile", backend_dockerfile)
create_file("backend/app/__init__.py", "")
create_file("backend/app/config.py", backend_config)
create_file("backend/app/models.py", backend_models)
create_file("backend/app/database.py", backend_database)
create_file("backend/app/main.py", backend_main)
# Routers
create_file("backend/app/routers/__init__.py", backend_routers_init)
create_file("backend/app/routers/transactions.py", backend_routers_transactions)
create_file("backend/app/routers/categories.py", backend_routers_categories)
create_file("backend/app/routers/accounts.py", backend_routers_accounts)
create_file("backend/app/routers/reports.py", backend_routers_reports)
create_file("backend/app/routers/summary.py", backend_routers_summary)
# Clients
create_file("backend/app/clients/__init__.py", "")
create_file("backend/app/clients/firefly_client.py", backend_firefly_client)
# Services
create_file("backend/app/services/__init__.py", "")
create_file("backend/app/services/sync_service.py", backend_sync_service)
create_file("backend/app/services/transaction_service.py", backend_transaction_service)
create_file("backend/app/services/category_service.py", backend_category_service)
create_file("backend/app/services/account_service.py", backend_account_service)
create_file("backend/app/services/report_service.py", backend_report_service)
create_file("backend/app/services/summary_service.py", backend_summary_service)
print("\n✓ Backend scaffolding complete!")
print("\nNext steps:")
print("1. Create .env file with your Firefly III API token")
print("2. cd backend && python -m venv venv")
print("3. source venv/bin/activate")
print("4. pip install -e .")
print("5. uvicorn app.main:app --reload")
if __name__ == "__main__":
main()