mirror of
https://github.com/elisiariocouto/leggen.git
synced 2025-12-11 15:02:23 +00:00
refactor: Unify leggen and leggend packages into single leggen package
- Merge leggend API components into leggen (api/, services/, background/) - Replace leggend command with 'leggen server' subcommand - Consolidate configuration systems into leggen.utils.config - Update environment variables: LEGGEND_API_URL -> LEGGEN_API_URL - Rename LeggendAPIClient -> LeggenAPIClient - Update all documentation, Docker configs, and compose files - Fix all import statements and test references - Remove duplicate utility files and clean up package structure All tests passing (101/101), linting clean, server functionality preserved. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
committed by
Elisiário Couto
parent
0e645d9bae
commit
318ca517f7
@@ -15,8 +15,8 @@ repos:
|
||||
hooks:
|
||||
- id: mypy
|
||||
name: Static type check with mypy
|
||||
entry: uv run mypy leggen leggend --check-untyped-defs
|
||||
files: "^leggen(d)?/.*"
|
||||
entry: uv run mypy leggen --check-untyped-defs
|
||||
files: "^leggen/.*"
|
||||
language: "system"
|
||||
types: ["python"]
|
||||
always_run: true
|
||||
|
||||
@@ -38,9 +38,9 @@ The command outputs instructions for setting the required environment variable t
|
||||
```
|
||||
4. Start the API server:
|
||||
```bash
|
||||
uv run leggend
|
||||
uv run leggen server
|
||||
```
|
||||
- For development mode with auto-reload: `uv run leggend --reload`
|
||||
- For development mode with auto-reload: `uv run leggen server --reload`
|
||||
- API will be available at `http://localhost:8000` with docs at `http://localhost:8000/docs`
|
||||
|
||||
### Start the Frontend
|
||||
@@ -60,7 +60,7 @@ The command outputs instructions for setting the required environment variable t
|
||||
### Backend (Python)
|
||||
- **Lint**: `uv run ruff check .`
|
||||
- **Format**: `uv run ruff format .`
|
||||
- **Type check**: `uv run mypy leggen leggend --check-untyped-defs`
|
||||
- **Type check**: `uv run mypy leggen --check-untyped-defs`
|
||||
- **All checks**: `uv run pre-commit run --all-files`
|
||||
- **Run all tests**: `uv run pytest`
|
||||
- **Run single test**: `uv run pytest tests/unit/test_api_accounts.py::TestAccountsAPI::test_get_all_accounts_success -v`
|
||||
|
||||
@@ -18,7 +18,7 @@ FROM python:3.13-alpine
|
||||
LABEL org.opencontainers.image.source="https://github.com/elisiariocouto/leggen"
|
||||
LABEL org.opencontainers.image.authors="Elisiário Couto <elisiario@couto.io>"
|
||||
LABEL org.opencontainers.image.licenses="MIT"
|
||||
LABEL org.opencontainers.image.title="Leggend API"
|
||||
LABEL org.opencontainers.image.title="Leggen API"
|
||||
LABEL org.opencontainers.image.description="Open Banking API for Leggen"
|
||||
LABEL org.opencontainers.image.url="https://github.com/elisiariocouto/leggen"
|
||||
|
||||
@@ -30,4 +30,4 @@ EXPOSE 8000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s CMD wget -q --spider http://127.0.0.1:8000/api/v1/health || exit 1
|
||||
|
||||
CMD ["/app/.venv/bin/leggend"]
|
||||
CMD ["/app/.venv/bin/leggen", "server"]
|
||||
|
||||
29
README.md
29
README.md
@@ -2,14 +2,14 @@
|
||||
|
||||
An Open Banking CLI and API service for managing bank connections and transactions.
|
||||
|
||||
This tool provides **FastAPI backend service** (`leggend`), a **React Web Interface** and a **command-line interface** (`leggen`) to connect to banks using the GoCardless Open Banking API.
|
||||
This tool provides a **unified command-line interface** (`leggen`) with both CLI commands and an integrated **FastAPI backend service**, plus a **React Web Interface** to connect to banks using the GoCardless Open Banking API.
|
||||
|
||||
Having your bank data accessible through both CLI and REST API gives you the power to backup, analyze, create reports, and integrate with other applications.
|
||||
|
||||
## 🛠️ Technologies
|
||||
|
||||
### 🔌 API & Backend
|
||||
- [FastAPI](https://fastapi.tiangolo.com/): High-performance async API backend (`leggend` service)
|
||||
- [FastAPI](https://fastapi.tiangolo.com/): High-performance async API backend (integrated into `leggen server`)
|
||||
- [GoCardless Open Banking API](https://developer.gocardless.com/bank-account-data/overview): for connecting to banks
|
||||
- [APScheduler](https://apscheduler.readthedocs.io/): Background job scheduling with configurable cron
|
||||
|
||||
@@ -107,7 +107,7 @@ For development or local installation:
|
||||
uv sync # or pip install -e .
|
||||
|
||||
# Start the API service
|
||||
uv run leggend --reload # Development mode with auto-reload
|
||||
uv run leggen server --reload # Development mode with auto-reload
|
||||
|
||||
# Use the CLI (in another terminal)
|
||||
uv run leggen --help
|
||||
@@ -152,19 +152,19 @@ case-sensitive = ["SpecificStore"]
|
||||
|
||||
## 📖 Usage
|
||||
|
||||
### API Service (`leggend`)
|
||||
### API Service (`leggen server`)
|
||||
|
||||
Start the FastAPI backend service:
|
||||
|
||||
```bash
|
||||
# Production mode
|
||||
leggend
|
||||
leggen server
|
||||
|
||||
# Development mode with auto-reload
|
||||
leggend --reload
|
||||
leggen server --reload
|
||||
|
||||
# Custom host and port
|
||||
leggend --host 127.0.0.1 --port 8080
|
||||
leggen server --host 127.0.0.1 --port 8080
|
||||
```
|
||||
|
||||
**API Documentation**: Visit `http://localhost:8000/docs` for interactive API documentation.
|
||||
@@ -207,7 +207,7 @@ leggen sync --force --wait
|
||||
leggen --api-url http://localhost:8080 status
|
||||
|
||||
# Set via environment variable
|
||||
export LEGGEND_API_URL=http://localhost:8080
|
||||
export LEGGEN_API_URL=http://localhost:8080
|
||||
leggen status
|
||||
```
|
||||
|
||||
@@ -223,7 +223,7 @@ docker compose -f compose.dev.yml ps
|
||||
|
||||
# Check logs
|
||||
docker compose -f compose.dev.yml logs frontend
|
||||
docker compose -f compose.dev.yml logs leggend
|
||||
docker compose -f compose.dev.yml logs leggen-server
|
||||
|
||||
# Stop development services
|
||||
docker compose -f compose.dev.yml down
|
||||
@@ -239,7 +239,7 @@ docker compose ps
|
||||
|
||||
# Check logs
|
||||
docker compose logs frontend
|
||||
docker compose logs leggend
|
||||
docker compose logs leggen-server
|
||||
|
||||
# Access the web interface at http://localhost:3000
|
||||
# API documentation at http://localhost:8000/docs
|
||||
@@ -290,7 +290,7 @@ cd leggen
|
||||
uv sync
|
||||
|
||||
# Start API service with auto-reload
|
||||
uv run leggend --reload
|
||||
uv run leggen server --reload
|
||||
|
||||
# Use CLI commands
|
||||
uv run leggen status
|
||||
@@ -333,13 +333,10 @@ The test suite includes:
|
||||
leggen/ # CLI application
|
||||
├── commands/ # CLI command implementations
|
||||
├── utils/ # Shared utilities
|
||||
└── api_client.py # API client for leggend service
|
||||
|
||||
leggend/ # FastAPI backend service
|
||||
├── api/ # API routes and models
|
||||
├── api/ # FastAPI API routes and models
|
||||
├── services/ # Business logic
|
||||
├── background/ # Background job scheduler
|
||||
└── main.py # FastAPI application
|
||||
└── api_client.py # API client for server communication
|
||||
|
||||
tests/ # Test suite
|
||||
├── conftest.py # Shared test fixtures
|
||||
|
||||
@@ -8,13 +8,13 @@ services:
|
||||
ports:
|
||||
- "127.0.0.1:3000:80"
|
||||
environment:
|
||||
- API_BACKEND_URL=${API_BACKEND_URL:-http://leggend:8000}
|
||||
- API_BACKEND_URL=${API_BACKEND_URL:-http://leggen-server:8000}
|
||||
depends_on:
|
||||
leggend:
|
||||
leggen-server:
|
||||
condition: service_healthy
|
||||
|
||||
# FastAPI backend service
|
||||
leggend:
|
||||
leggen-server:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
|
||||
@@ -6,11 +6,11 @@ services:
|
||||
ports:
|
||||
- "127.0.0.1:3000:80"
|
||||
depends_on:
|
||||
leggend:
|
||||
leggen-server:
|
||||
condition: service_healthy
|
||||
|
||||
# FastAPI backend service
|
||||
leggend:
|
||||
leggen-server:
|
||||
image: ghcr.io/elisiariocouto/leggen:latest
|
||||
restart: "unless-stopped"
|
||||
ports:
|
||||
|
||||
@@ -25,7 +25,7 @@ COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY default.conf.template /etc/nginx/templates/default.conf.template
|
||||
|
||||
# Set default API backend URL (can be overridden at runtime)
|
||||
ENV API_BACKEND_URL=http://leggend:8000
|
||||
ENV API_BACKEND_URL=http://leggen-server:8000
|
||||
|
||||
# Expose port 80
|
||||
EXPOSE 80
|
||||
|
||||
@@ -93,7 +93,7 @@ The frontend supports configurable API URLs through environment variables:
|
||||
|
||||
- Uses relative URLs (`/api/v1`) that nginx proxies to the backend
|
||||
- Configure nginx proxy target via `API_BACKEND_URL` environment variable
|
||||
- Default: `http://leggend:8000`
|
||||
- Default: `http://leggen-server:8000`
|
||||
|
||||
**Docker Compose:**
|
||||
|
||||
|
||||
@@ -2,15 +2,15 @@ from typing import Optional, List, Union
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from loguru import logger
|
||||
|
||||
from leggend.api.models.common import APIResponse
|
||||
from leggend.api.models.accounts import (
|
||||
from leggen.api.models.common import APIResponse
|
||||
from leggen.api.models.accounts import (
|
||||
AccountDetails,
|
||||
AccountBalance,
|
||||
Transaction,
|
||||
TransactionSummary,
|
||||
AccountUpdate,
|
||||
)
|
||||
from leggend.services.database_service import DatabaseService
|
||||
from leggen.services.database_service import DatabaseService
|
||||
|
||||
router = APIRouter()
|
||||
database_service = DatabaseService()
|
||||
@@ -217,8 +217,12 @@ async def get_all_balances() -> APIResponse:
|
||||
|
||||
@router.get("/balances/history", response_model=APIResponse)
|
||||
async def get_historical_balances(
|
||||
days: Optional[int] = Query(default=365, le=1095, ge=1, description="Number of days of history to retrieve"),
|
||||
account_id: Optional[str] = Query(default=None, description="Filter by specific account ID")
|
||||
days: Optional[int] = Query(
|
||||
default=365, le=1095, ge=1, description="Number of days of history to retrieve"
|
||||
),
|
||||
account_id: Optional[str] = Query(
|
||||
default=None, description="Filter by specific account ID"
|
||||
),
|
||||
) -> APIResponse:
|
||||
"""Get historical balance progression calculated from transaction history"""
|
||||
try:
|
||||
@@ -1,15 +1,15 @@
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from loguru import logger
|
||||
|
||||
from leggend.api.models.common import APIResponse
|
||||
from leggend.api.models.banks import (
|
||||
from leggen.api.models.common import APIResponse
|
||||
from leggen.api.models.banks import (
|
||||
BankInstitution,
|
||||
BankConnectionRequest,
|
||||
BankRequisition,
|
||||
BankConnectionStatus,
|
||||
)
|
||||
from leggend.services.gocardless_service import GoCardlessService
|
||||
from leggend.utils.gocardless import REQUISITION_STATUS
|
||||
from leggen.services.gocardless_service import GoCardlessService
|
||||
from leggen.utils.gocardless import REQUISITION_STATUS
|
||||
|
||||
router = APIRouter()
|
||||
gocardless_service = GoCardlessService()
|
||||
@@ -2,16 +2,16 @@ from typing import Dict, Any
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from loguru import logger
|
||||
|
||||
from leggend.api.models.common import APIResponse
|
||||
from leggend.api.models.notifications import (
|
||||
from leggen.api.models.common import APIResponse
|
||||
from leggen.api.models.notifications import (
|
||||
NotificationSettings,
|
||||
NotificationTest,
|
||||
DiscordConfig,
|
||||
TelegramConfig,
|
||||
NotificationFilters,
|
||||
)
|
||||
from leggend.services.notification_service import NotificationService
|
||||
from leggend.config import config
|
||||
from leggen.services.notification_service import NotificationService
|
||||
from leggen.utils.config import config
|
||||
|
||||
router = APIRouter()
|
||||
notification_service = NotificationService()
|
||||
@@ -2,11 +2,11 @@ from typing import Optional
|
||||
from fastapi import APIRouter, HTTPException, BackgroundTasks
|
||||
from loguru import logger
|
||||
|
||||
from leggend.api.models.common import APIResponse
|
||||
from leggend.api.models.sync import SyncRequest, SchedulerConfig
|
||||
from leggend.services.sync_service import SyncService
|
||||
from leggend.background.scheduler import scheduler
|
||||
from leggend.config import config
|
||||
from leggen.api.models.common import APIResponse
|
||||
from leggen.api.models.sync import SyncRequest, SchedulerConfig
|
||||
from leggen.services.sync_service import SyncService
|
||||
from leggen.background.scheduler import scheduler
|
||||
from leggen.utils.config import config
|
||||
|
||||
router = APIRouter()
|
||||
sync_service = SyncService()
|
||||
@@ -3,9 +3,9 @@ from datetime import datetime, timedelta
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from loguru import logger
|
||||
|
||||
from leggend.api.models.common import APIResponse, PaginatedResponse
|
||||
from leggend.api.models.accounts import Transaction, TransactionSummary
|
||||
from leggend.services.database_service import DatabaseService
|
||||
from leggen.api.models.common import APIResponse, PaginatedResponse
|
||||
from leggen.api.models.accounts import Transaction, TransactionSummary
|
||||
from leggen.services.database_service import DatabaseService
|
||||
|
||||
router = APIRouter()
|
||||
database_service = DatabaseService()
|
||||
@@ -252,5 +252,3 @@ async def get_transactions_for_analytics(
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to get analytics transactions: {str(e)}"
|
||||
) from e
|
||||
|
||||
|
||||
@@ -6,15 +6,15 @@ from urllib.parse import urljoin
|
||||
from leggen.utils.text import error
|
||||
|
||||
|
||||
class LeggendAPIClient:
|
||||
"""Client for communicating with the leggend FastAPI service"""
|
||||
class LeggenAPIClient:
|
||||
"""Client for communicating with the leggen FastAPI service"""
|
||||
|
||||
base_url: str
|
||||
|
||||
def __init__(self, base_url: Optional[str] = None):
|
||||
self.base_url = (
|
||||
base_url
|
||||
or os.environ.get("LEGGEND_API_URL", "http://localhost:8000")
|
||||
or os.environ.get("LEGGEN_API_URL", "http://localhost:8000")
|
||||
or "http://localhost:8000"
|
||||
)
|
||||
self.session = requests.Session()
|
||||
@@ -31,7 +31,7 @@ class LeggendAPIClient:
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.exceptions.ConnectionError:
|
||||
error("Could not connect to leggend service. Is it running?")
|
||||
error("Could not connect to leggen server. Is it running?")
|
||||
error(f"Trying to connect to: {self.base_url}")
|
||||
raise
|
||||
except requests.exceptions.HTTPError as e:
|
||||
@@ -48,7 +48,7 @@ class LeggendAPIClient:
|
||||
raise
|
||||
|
||||
def health_check(self) -> bool:
|
||||
"""Check if the leggend service is healthy"""
|
||||
"""Check if the leggen server is healthy"""
|
||||
try:
|
||||
response = self._make_request("GET", "/health")
|
||||
return response.get("status") == "healthy"
|
||||
|
||||
@@ -2,9 +2,9 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from loguru import logger
|
||||
|
||||
from leggend.config import config
|
||||
from leggend.services.sync_service import SyncService
|
||||
from leggend.services.notification_service import NotificationService
|
||||
from leggen.utils.config import config
|
||||
from leggen.services.sync_service import SyncService
|
||||
from leggen.services.notification_service import NotificationService
|
||||
|
||||
|
||||
class BackgroundScheduler:
|
||||
@@ -1,7 +1,7 @@
|
||||
import click
|
||||
|
||||
from leggen.main import cli
|
||||
from leggen.api_client import LeggendAPIClient
|
||||
from leggen.api_client import LeggenAPIClient
|
||||
from leggen.utils.text import datefmt, print_table
|
||||
|
||||
|
||||
@@ -11,12 +11,12 @@ def balances(ctx: click.Context):
|
||||
"""
|
||||
List balances of all connected accounts
|
||||
"""
|
||||
api_client = LeggendAPIClient(ctx.obj.get("api_url"))
|
||||
api_client = LeggenAPIClient(ctx.obj.get("api_url"))
|
||||
|
||||
# Check if leggend service is available
|
||||
# Check if leggen server is available
|
||||
if not api_client.health_check():
|
||||
click.echo(
|
||||
"Error: Cannot connect to leggend service. Please ensure it's running."
|
||||
"Error: Cannot connect to leggen server. Please ensure it's running."
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import click
|
||||
|
||||
from leggen.main import cli
|
||||
from leggen.api_client import LeggendAPIClient
|
||||
from leggen.api_client import LeggenAPIClient
|
||||
from leggen.utils.disk import save_file
|
||||
from leggen.utils.text import info, print_table, warning, success
|
||||
|
||||
@@ -12,12 +12,12 @@ def add(ctx):
|
||||
"""
|
||||
Connect to a bank
|
||||
"""
|
||||
api_client = LeggendAPIClient(ctx.obj.get("api_url"))
|
||||
api_client = LeggenAPIClient(ctx.obj.get("api_url"))
|
||||
|
||||
# Check if leggend service is available
|
||||
# Check if leggen server is available
|
||||
if not api_client.health_check():
|
||||
click.echo(
|
||||
"Error: Cannot connect to leggend service. Please ensure it's running."
|
||||
"Error: Cannot connect to leggen server. Please ensure it's running."
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import click
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--database",
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from importlib import metadata
|
||||
|
||||
import click
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from loguru import logger
|
||||
|
||||
from leggend.api.routes import banks, accounts, sync, notifications, transactions
|
||||
from leggend.background.scheduler import scheduler
|
||||
from leggend.config import config
|
||||
from leggen.api.routes import banks, accounts, sync, notifications, transactions
|
||||
from leggen.background.scheduler import scheduler
|
||||
from leggen.utils.config import config
|
||||
from leggen.utils.paths import path_manager
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup
|
||||
logger.info("Starting leggend service...")
|
||||
logger.info("Starting leggen server...")
|
||||
|
||||
# Load configuration
|
||||
try:
|
||||
@@ -26,7 +28,7 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
# Run database migrations
|
||||
try:
|
||||
from leggend.services.database_service import DatabaseService
|
||||
from leggen.services.database_service import DatabaseService
|
||||
|
||||
db_service = DatabaseService()
|
||||
await db_service.run_migrations_if_needed()
|
||||
@@ -42,7 +44,7 @@ async def lifespan(app: FastAPI):
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
logger.info("Shutting down leggend service...")
|
||||
logger.info("Shutting down leggen server...")
|
||||
scheduler.shutdown()
|
||||
|
||||
|
||||
@@ -54,7 +56,7 @@ def create_app() -> FastAPI:
|
||||
version = "unknown"
|
||||
|
||||
app = FastAPI(
|
||||
title="Leggend API",
|
||||
title="Leggen API",
|
||||
description="Open Banking API for Leggen",
|
||||
version=version,
|
||||
lifespan=lifespan,
|
||||
@@ -87,13 +89,13 @@ def create_app() -> FastAPI:
|
||||
version = metadata.version("leggen")
|
||||
except metadata.PackageNotFoundError:
|
||||
version = "unknown"
|
||||
return {"message": "Leggend API is running", "version": version}
|
||||
return {"message": "Leggen API is running", "version": version}
|
||||
|
||||
@app.get("/api/v1/health")
|
||||
async def health():
|
||||
"""Health check endpoint for API connectivity"""
|
||||
try:
|
||||
from leggend.api.models.common import APIResponse
|
||||
from leggen.api.models.common import APIResponse
|
||||
|
||||
config_loaded = config._config is not None
|
||||
|
||||
@@ -108,7 +110,7 @@ def create_app() -> FastAPI:
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Health check failed: {e}")
|
||||
from leggend.api.models.common import APIResponse
|
||||
from leggen.api.models.common import APIResponse
|
||||
|
||||
return APIResponse(
|
||||
success=False,
|
||||
@@ -119,61 +121,58 @@ def create_app() -> FastAPI:
|
||||
return app
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from leggen.utils.paths import path_manager
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--reload",
|
||||
is_flag=True,
|
||||
help="Enable auto-reload for development",
|
||||
)
|
||||
@click.option(
|
||||
"--host",
|
||||
default="0.0.0.0",
|
||||
help="Host to bind to (default: 0.0.0.0)",
|
||||
)
|
||||
@click.option(
|
||||
"--port",
|
||||
type=int,
|
||||
default=8000,
|
||||
help="Port to bind to (default: 8000)",
|
||||
)
|
||||
@click.pass_context
|
||||
def server(ctx: click.Context, reload: bool, host: str, port: int):
|
||||
"""Start the Leggen API server"""
|
||||
|
||||
parser = argparse.ArgumentParser(description="Start the Leggend API service")
|
||||
parser.add_argument(
|
||||
"--reload", action="store_true", help="Enable auto-reload for development"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--host", default="0.0.0.0", help="Host to bind to (default: 0.0.0.0)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--port", type=int, default=8000, help="Port to bind to (default: 8000)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--config-dir",
|
||||
type=Path,
|
||||
help="Directory containing configuration files (default: ~/.config/leggen)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--database",
|
||||
type=Path,
|
||||
help="Path to SQLite database file (default: <config-dir>/leggen.db)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
# Get config_dir and database from main CLI context
|
||||
config_dir = None
|
||||
database = None
|
||||
if ctx.parent:
|
||||
config_dir = ctx.parent.params.get("config_dir")
|
||||
database = ctx.parent.params.get("database")
|
||||
|
||||
# Set up path manager with user-provided paths
|
||||
if args.config_dir:
|
||||
path_manager.set_config_dir(args.config_dir)
|
||||
if args.database:
|
||||
path_manager.set_database_path(args.database)
|
||||
if config_dir:
|
||||
path_manager.set_config_dir(config_dir)
|
||||
if database:
|
||||
path_manager.set_database_path(database)
|
||||
|
||||
if args.reload:
|
||||
if reload:
|
||||
# Use string import for reload to work properly
|
||||
uvicorn.run(
|
||||
"leggend.main:create_app",
|
||||
"leggen.commands.server:create_app",
|
||||
factory=True,
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
host=host,
|
||||
port=port,
|
||||
log_level="info",
|
||||
access_log=True,
|
||||
reload=True,
|
||||
reload_dirs=["leggend", "leggen"], # Watch both directories
|
||||
reload_dirs=["leggen"], # Watch leggen directory
|
||||
)
|
||||
else:
|
||||
app = create_app()
|
||||
uvicorn.run(
|
||||
app,
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
host=host,
|
||||
port=port,
|
||||
log_level="info",
|
||||
access_log=True,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,7 +1,7 @@
|
||||
import click
|
||||
|
||||
from leggen.main import cli
|
||||
from leggen.api_client import LeggendAPIClient
|
||||
from leggen.api_client import LeggenAPIClient
|
||||
from leggen.utils.text import datefmt, echo, info, print_table
|
||||
|
||||
|
||||
@@ -11,12 +11,12 @@ def status(ctx: click.Context):
|
||||
"""
|
||||
List all connected banks and their status
|
||||
"""
|
||||
api_client = LeggendAPIClient(ctx.obj.get("api_url"))
|
||||
api_client = LeggenAPIClient(ctx.obj.get("api_url"))
|
||||
|
||||
# Check if leggend service is available
|
||||
# Check if leggen server is available
|
||||
if not api_client.health_check():
|
||||
click.echo(
|
||||
"Error: Cannot connect to leggend service. Please ensure it's running."
|
||||
"Error: Cannot connect to leggen server. Please ensure it's running."
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import click
|
||||
|
||||
from leggen.main import cli
|
||||
from leggen.api_client import LeggendAPIClient
|
||||
from leggen.api_client import LeggenAPIClient
|
||||
from leggen.utils.text import error, info, success
|
||||
|
||||
|
||||
@@ -13,11 +13,11 @@ def sync(ctx: click.Context, wait: bool, force: bool):
|
||||
"""
|
||||
Sync all transactions with database
|
||||
"""
|
||||
api_client = LeggendAPIClient(ctx.obj.get("api_url"))
|
||||
api_client = LeggenAPIClient(ctx.obj.get("api_url"))
|
||||
|
||||
# Check if leggend service is available
|
||||
# Check if leggen server is available
|
||||
if not api_client.health_check():
|
||||
error("Cannot connect to leggend service. Please ensure it's running.")
|
||||
error("Cannot connect to leggen server. Please ensure it's running.")
|
||||
return
|
||||
|
||||
try:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import click
|
||||
|
||||
from leggen.main import cli
|
||||
from leggen.api_client import LeggendAPIClient
|
||||
from leggen.api_client import LeggenAPIClient
|
||||
from leggen.utils.text import datefmt, info, print_table
|
||||
|
||||
|
||||
@@ -20,12 +20,12 @@ def transactions(ctx: click.Context, account: str, limit: int, full: bool):
|
||||
|
||||
If the --account option is used, it will only list transactions for that account.
|
||||
"""
|
||||
api_client = LeggendAPIClient(ctx.obj.get("api_url"))
|
||||
api_client = LeggenAPIClient(ctx.obj.get("api_url"))
|
||||
|
||||
# Check if leggend service is available
|
||||
# Check if leggen server is available
|
||||
if not api_client.health_check():
|
||||
click.echo(
|
||||
"Error: Cannot connect to leggend service. Please ensure it's running."
|
||||
"Error: Cannot connect to leggen server. Please ensure it's running."
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
@@ -527,11 +527,11 @@ def get_historical_balances(account_id=None, days=365):
|
||||
db_path = path_manager.get_database_path()
|
||||
if not db_path.exists():
|
||||
return []
|
||||
|
||||
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
|
||||
|
||||
try:
|
||||
# Get current balance for each account/type to use as the final balance
|
||||
current_balances_query = """
|
||||
@@ -544,107 +544,115 @@ def get_historical_balances(account_id=None, days=365):
|
||||
)
|
||||
"""
|
||||
params = []
|
||||
|
||||
|
||||
if account_id:
|
||||
current_balances_query += " AND b1.account_id = ?"
|
||||
params.append(account_id)
|
||||
|
||||
|
||||
cursor.execute(current_balances_query, params)
|
||||
current_balances = {
|
||||
(row['account_id'], row['type']): {
|
||||
'amount': row['amount'],
|
||||
'currency': row['currency']
|
||||
(row["account_id"], row["type"]): {
|
||||
"amount": row["amount"],
|
||||
"currency": row["currency"],
|
||||
}
|
||||
for row in cursor.fetchall()
|
||||
}
|
||||
|
||||
|
||||
# Get transactions for the specified period, ordered by date descending
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
cutoff_date = (datetime.now() - timedelta(days=days)).isoformat()
|
||||
|
||||
|
||||
transactions_query = """
|
||||
SELECT accountId, transactionDate, transactionValue
|
||||
FROM transactions
|
||||
WHERE transactionDate >= ?
|
||||
"""
|
||||
|
||||
|
||||
if account_id:
|
||||
transactions_query += " AND accountId = ?"
|
||||
params = [cutoff_date, account_id]
|
||||
else:
|
||||
params = [cutoff_date]
|
||||
|
||||
|
||||
transactions_query += " ORDER BY transactionDate DESC"
|
||||
|
||||
|
||||
cursor.execute(transactions_query, params)
|
||||
transactions = cursor.fetchall()
|
||||
|
||||
|
||||
# Calculate historical balances by working backwards from current balance
|
||||
historical_balances = []
|
||||
account_running_balances: dict[str, dict[str, float]] = {}
|
||||
|
||||
|
||||
# Initialize running balances with current balances
|
||||
for (acc_id, balance_type), balance_info in current_balances.items():
|
||||
if acc_id not in account_running_balances:
|
||||
account_running_balances[acc_id] = {}
|
||||
account_running_balances[acc_id][balance_type] = balance_info['amount']
|
||||
|
||||
account_running_balances[acc_id][balance_type] = balance_info["amount"]
|
||||
|
||||
# Group transactions by date
|
||||
from collections import defaultdict
|
||||
|
||||
transactions_by_date = defaultdict(list)
|
||||
|
||||
|
||||
for txn in transactions:
|
||||
date_str = txn['transactionDate'][:10] # Extract just the date part
|
||||
date_str = txn["transactionDate"][:10] # Extract just the date part
|
||||
transactions_by_date[date_str].append(txn)
|
||||
|
||||
|
||||
# Generate historical balance points
|
||||
# Start from today and work backwards
|
||||
current_date = datetime.now().date()
|
||||
|
||||
|
||||
for day_offset in range(0, days, 7): # Sample every 7 days for performance
|
||||
target_date = current_date - timedelta(days=day_offset)
|
||||
target_date_str = target_date.isoformat()
|
||||
|
||||
|
||||
# For each account, create balance entries
|
||||
for acc_id in account_running_balances:
|
||||
for balance_type in ['closingBooked']: # Focus on closingBooked for the chart
|
||||
for balance_type in [
|
||||
"closingBooked"
|
||||
]: # Focus on closingBooked for the chart
|
||||
if balance_type in account_running_balances[acc_id]:
|
||||
balance_amount = account_running_balances[acc_id][balance_type]
|
||||
currency = current_balances.get((acc_id, balance_type), {}).get('currency', 'EUR')
|
||||
|
||||
historical_balances.append({
|
||||
'id': f"{acc_id}_{balance_type}_{target_date_str}",
|
||||
'account_id': acc_id,
|
||||
'balance_amount': balance_amount,
|
||||
'balance_type': balance_type,
|
||||
'currency': currency,
|
||||
'reference_date': target_date_str,
|
||||
'created_at': None,
|
||||
'updated_at': None
|
||||
})
|
||||
|
||||
currency = current_balances.get((acc_id, balance_type), {}).get(
|
||||
"currency", "EUR"
|
||||
)
|
||||
|
||||
historical_balances.append(
|
||||
{
|
||||
"id": f"{acc_id}_{balance_type}_{target_date_str}",
|
||||
"account_id": acc_id,
|
||||
"balance_amount": balance_amount,
|
||||
"balance_type": balance_type,
|
||||
"currency": currency,
|
||||
"reference_date": target_date_str,
|
||||
"created_at": None,
|
||||
"updated_at": None,
|
||||
}
|
||||
)
|
||||
|
||||
# Subtract transactions that occurred on this date and later dates
|
||||
# to simulate going back in time
|
||||
for date_str in list(transactions_by_date.keys()):
|
||||
if date_str >= target_date_str:
|
||||
for txn in transactions_by_date[date_str]:
|
||||
acc_id = txn['accountId']
|
||||
amount = txn['transactionValue']
|
||||
|
||||
acc_id = txn["accountId"]
|
||||
amount = txn["transactionValue"]
|
||||
|
||||
if acc_id in account_running_balances:
|
||||
for balance_type in account_running_balances[acc_id]:
|
||||
account_running_balances[acc_id][balance_type] -= amount
|
||||
|
||||
|
||||
# Remove processed transactions to avoid double-processing
|
||||
del transactions_by_date[date_str]
|
||||
|
||||
|
||||
conn.close()
|
||||
|
||||
|
||||
# Sort by date for proper chronological order
|
||||
historical_balances.sort(key=lambda x: x['reference_date'])
|
||||
|
||||
historical_balances.sort(key=lambda x: x["reference_date"])
|
||||
|
||||
return historical_balances
|
||||
|
||||
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
raise e
|
||||
|
||||
@@ -105,9 +105,9 @@ class Group(click.Group):
|
||||
"--api-url",
|
||||
type=str,
|
||||
default="http://localhost:8000",
|
||||
envvar="LEGGEND_API_URL",
|
||||
envvar="LEGGEN_API_URL",
|
||||
show_envvar=True,
|
||||
help="URL of the leggend API service",
|
||||
help="URL of the leggen API service",
|
||||
)
|
||||
@click.group(
|
||||
cls=Group,
|
||||
|
||||
@@ -4,7 +4,7 @@ import sqlite3
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from leggend.config import config
|
||||
from leggen.utils.config import config
|
||||
import leggen.database.sqlite as sqlite_db
|
||||
from leggen.utils.paths import path_manager
|
||||
|
||||
@@ -204,8 +204,12 @@ class DatabaseService:
|
||||
return []
|
||||
|
||||
try:
|
||||
balances = sqlite_db.get_historical_balances(account_id=account_id, days=days)
|
||||
logger.debug(f"Retrieved {len(balances)} historical balance points from database")
|
||||
balances = sqlite_db.get_historical_balances(
|
||||
account_id=account_id, days=days
|
||||
)
|
||||
logger.debug(
|
||||
f"Retrieved {len(balances)} historical balance points from database"
|
||||
)
|
||||
return balances
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get historical balances from database: {e}")
|
||||
@@ -5,7 +5,7 @@ from typing import Dict, Any, List
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from leggend.config import config
|
||||
from leggen.utils.config import config
|
||||
from leggen.utils.paths import path_manager
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ from typing import List, Dict, Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from leggend.config import config
|
||||
from leggen.utils.config import config
|
||||
|
||||
|
||||
class NotificationService:
|
||||
@@ -3,10 +3,10 @@ from typing import List
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from leggend.api.models.sync import SyncResult, SyncStatus
|
||||
from leggend.services.gocardless_service import GoCardlessService
|
||||
from leggend.services.database_service import DatabaseService
|
||||
from leggend.services.notification_service import NotificationService
|
||||
from leggen.api.models.sync import SyncResult, SyncStatus
|
||||
from leggen.services.gocardless_service import GoCardlessService
|
||||
from leggen.services.database_service import DatabaseService
|
||||
from leggen.services.notification_service import NotificationService
|
||||
|
||||
|
||||
class SyncService:
|
||||
@@ -1,9 +1,146 @@
|
||||
import os
|
||||
import sys
|
||||
import tomllib
|
||||
import tomli_w
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
import click
|
||||
from loguru import logger
|
||||
|
||||
from leggen.utils.text import error
|
||||
from leggen.utils.paths import path_manager
|
||||
|
||||
|
||||
class Config:
|
||||
_instance = None
|
||||
_config = None
|
||||
_config_path = None
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def load_config(self, config_path: Optional[str] = None) -> Dict[str, Any]:
|
||||
if self._config is not None:
|
||||
return self._config
|
||||
|
||||
if config_path is None:
|
||||
config_path = os.environ.get("LEGGEN_CONFIG_FILE")
|
||||
if not config_path:
|
||||
config_path = str(path_manager.get_config_file_path())
|
||||
|
||||
self._config_path = config_path
|
||||
|
||||
try:
|
||||
with open(config_path, "rb") as f:
|
||||
self._config = tomllib.load(f)
|
||||
logger.info(f"Configuration loaded from {config_path}")
|
||||
except FileNotFoundError:
|
||||
logger.error(f"Configuration file not found: {config_path}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading configuration: {e}")
|
||||
raise
|
||||
|
||||
return self._config
|
||||
|
||||
def save_config(
|
||||
self,
|
||||
config_data: Optional[Dict[str, Any]] = None,
|
||||
config_path: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Save configuration to TOML file"""
|
||||
if config_data is None:
|
||||
config_data = self._config
|
||||
|
||||
if config_path is None:
|
||||
config_path = self._config_path or os.environ.get("LEGGEN_CONFIG_FILE")
|
||||
if not config_path:
|
||||
config_path = str(path_manager.get_config_file_path())
|
||||
|
||||
if config_path is None:
|
||||
raise ValueError("No config path specified")
|
||||
if config_data is None:
|
||||
raise ValueError("No config data to save")
|
||||
|
||||
# Ensure directory exists
|
||||
Path(config_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
with open(config_path, "wb") as f:
|
||||
tomli_w.dump(config_data, f)
|
||||
|
||||
# Update in-memory config
|
||||
self._config = config_data
|
||||
self._config_path = config_path
|
||||
logger.info(f"Configuration saved to {config_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving configuration: {e}")
|
||||
raise
|
||||
|
||||
def update_config(self, section: str, key: str, value: Any) -> None:
|
||||
"""Update a specific configuration value"""
|
||||
if self._config is None:
|
||||
self.load_config()
|
||||
|
||||
if self._config is None:
|
||||
raise RuntimeError("Failed to load config")
|
||||
|
||||
if section not in self._config:
|
||||
self._config[section] = {}
|
||||
|
||||
self._config[section][key] = value
|
||||
self.save_config()
|
||||
|
||||
def update_section(self, section: str, data: Dict[str, Any]) -> None:
|
||||
"""Update an entire configuration section"""
|
||||
if self._config is None:
|
||||
self.load_config()
|
||||
|
||||
if self._config is None:
|
||||
raise RuntimeError("Failed to load config")
|
||||
|
||||
self._config[section] = data
|
||||
self.save_config()
|
||||
|
||||
@property
|
||||
def config(self) -> Dict[str, Any]:
|
||||
if self._config is None:
|
||||
self.load_config()
|
||||
if self._config is None:
|
||||
raise RuntimeError("Failed to load config")
|
||||
return self._config
|
||||
|
||||
@property
|
||||
def gocardless_config(self) -> Dict[str, str]:
|
||||
return self.config.get("gocardless", {})
|
||||
|
||||
@property
|
||||
def database_config(self) -> Dict[str, Any]:
|
||||
return self.config.get("database", {})
|
||||
|
||||
@property
|
||||
def notifications_config(self) -> Dict[str, Any]:
|
||||
return self.config.get("notifications", {})
|
||||
|
||||
@property
|
||||
def filters_config(self) -> Dict[str, Any]:
|
||||
return self.config.get("filters", {})
|
||||
|
||||
@property
|
||||
def scheduler_config(self) -> Dict[str, Any]:
|
||||
"""Get scheduler configuration with defaults"""
|
||||
default_schedule = {
|
||||
"sync": {
|
||||
"enabled": True,
|
||||
"hour": 3,
|
||||
"minute": 0,
|
||||
"cron": None, # Optional custom cron expression
|
||||
}
|
||||
}
|
||||
return self.config.get("scheduler", default_schedule)
|
||||
|
||||
|
||||
def load_config(ctx: click.Context, _, filename):
|
||||
@@ -16,3 +153,7 @@ def load_config(ctx: click.Context, _, filename):
|
||||
"Configuration file not found. Provide a valid configuration file path with leggen --config <path> or LEGGEN_CONFIG=<path> environment variable."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# Global singleton instance
|
||||
config = Config()
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
import os
|
||||
import tomllib
|
||||
import tomli_w
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
from loguru import logger
|
||||
from leggen.utils.paths import path_manager
|
||||
|
||||
|
||||
class Config:
|
||||
_instance = None
|
||||
_config = None
|
||||
_config_path = None
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def load_config(self, config_path: Optional[str] = None) -> Dict[str, Any]:
|
||||
if self._config is not None:
|
||||
return self._config
|
||||
|
||||
if config_path is None:
|
||||
config_path = os.environ.get("LEGGEN_CONFIG_FILE")
|
||||
if not config_path:
|
||||
config_path = str(path_manager.get_config_file_path())
|
||||
|
||||
self._config_path = config_path
|
||||
|
||||
try:
|
||||
with open(config_path, "rb") as f:
|
||||
self._config = tomllib.load(f)
|
||||
logger.info(f"Configuration loaded from {config_path}")
|
||||
except FileNotFoundError:
|
||||
logger.error(f"Configuration file not found: {config_path}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading configuration: {e}")
|
||||
raise
|
||||
|
||||
return self._config
|
||||
|
||||
def save_config(
|
||||
self,
|
||||
config_data: Optional[Dict[str, Any]] = None,
|
||||
config_path: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Save configuration to TOML file"""
|
||||
if config_data is None:
|
||||
config_data = self._config
|
||||
|
||||
if config_path is None:
|
||||
config_path = self._config_path or os.environ.get("LEGGEN_CONFIG_FILE")
|
||||
if not config_path:
|
||||
config_path = str(path_manager.get_config_file_path())
|
||||
|
||||
if config_path is None:
|
||||
raise ValueError("No config path specified")
|
||||
if config_data is None:
|
||||
raise ValueError("No config data to save")
|
||||
|
||||
# Ensure directory exists
|
||||
Path(config_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
with open(config_path, "wb") as f:
|
||||
tomli_w.dump(config_data, f)
|
||||
|
||||
# Update in-memory config
|
||||
self._config = config_data
|
||||
self._config_path = config_path
|
||||
logger.info(f"Configuration saved to {config_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving configuration: {e}")
|
||||
raise
|
||||
|
||||
def update_config(self, section: str, key: str, value: Any) -> None:
|
||||
"""Update a specific configuration value"""
|
||||
if self._config is None:
|
||||
self.load_config()
|
||||
|
||||
if self._config is None:
|
||||
raise RuntimeError("Failed to load config")
|
||||
|
||||
if section not in self._config:
|
||||
self._config[section] = {}
|
||||
|
||||
self._config[section][key] = value
|
||||
self.save_config()
|
||||
|
||||
def update_section(self, section: str, data: Dict[str, Any]) -> None:
|
||||
"""Update an entire configuration section"""
|
||||
if self._config is None:
|
||||
self.load_config()
|
||||
|
||||
if self._config is None:
|
||||
raise RuntimeError("Failed to load config")
|
||||
|
||||
self._config[section] = data
|
||||
self.save_config()
|
||||
|
||||
@property
|
||||
def config(self) -> Dict[str, Any]:
|
||||
if self._config is None:
|
||||
self.load_config()
|
||||
if self._config is None:
|
||||
raise RuntimeError("Failed to load config")
|
||||
return self._config
|
||||
|
||||
@property
|
||||
def gocardless_config(self) -> Dict[str, str]:
|
||||
return self.config.get("gocardless", {})
|
||||
|
||||
@property
|
||||
def database_config(self) -> Dict[str, Any]:
|
||||
return self.config.get("database", {})
|
||||
|
||||
@property
|
||||
def notifications_config(self) -> Dict[str, Any]:
|
||||
return self.config.get("notifications", {})
|
||||
|
||||
@property
|
||||
def filters_config(self) -> Dict[str, Any]:
|
||||
return self.config.get("filters", {})
|
||||
|
||||
@property
|
||||
def scheduler_config(self) -> Dict[str, Any]:
|
||||
"""Get scheduler configuration with defaults"""
|
||||
default_schedule = {
|
||||
"sync": {
|
||||
"enabled": True,
|
||||
"hour": 3,
|
||||
"minute": 0,
|
||||
"cron": None, # Optional custom cron expression
|
||||
}
|
||||
}
|
||||
return self.config.get("scheduler", default_schedule)
|
||||
|
||||
|
||||
config = Config()
|
||||
@@ -1,10 +0,0 @@
|
||||
REQUISITION_STATUS = {
|
||||
"CR": "CREATED",
|
||||
"GC": "GIVING_CONSENT",
|
||||
"UA": "UNDERGOING_AUTHENTICATION",
|
||||
"RJ": "REJECTED",
|
||||
"SA": "SELECTING_ACCOUNTS",
|
||||
"GA": "GRANTING_ACCESS",
|
||||
"LN": "LINKED",
|
||||
"EX": "EXPIRED",
|
||||
}
|
||||
@@ -41,7 +41,6 @@ Repository = "https://github.com/elisiariocouto/leggen"
|
||||
|
||||
[project.scripts]
|
||||
leggen = "leggen.main:cli"
|
||||
leggend = "leggend.main:main"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
@@ -58,10 +57,10 @@ dev = [
|
||||
]
|
||||
|
||||
[tool.hatch.build.targets.sdist]
|
||||
include = ["leggen", "leggend"]
|
||||
include = ["leggen"]
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
include = ["leggen", "leggend"]
|
||||
include = ["leggen"]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
|
||||
@@ -372,7 +372,7 @@ class SampleDataGenerator:
|
||||
for account in accounts:
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO accounts
|
||||
INSERT OR REPLACE INTO accounts
|
||||
(id, institution_id, status, iban, name, currency, created, last_accessed, last_updated)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
@@ -393,9 +393,9 @@ class SampleDataGenerator:
|
||||
for transaction in transactions:
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO transactions
|
||||
(accountId, transactionId, internalTransactionId, institutionId, iban,
|
||||
transactionDate, description, transactionValue, transactionCurrency,
|
||||
INSERT OR REPLACE INTO transactions
|
||||
(accountId, transactionId, internalTransactionId, institutionId, iban,
|
||||
transactionDate, description, transactionValue, transactionCurrency,
|
||||
transactionStatus, rawTransaction)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
@@ -418,7 +418,7 @@ class SampleDataGenerator:
|
||||
for balance in balances:
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO balances
|
||||
INSERT INTO balances
|
||||
(account_id, bank, status, iban, amount, currency, type, timestamp)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
@@ -538,8 +538,8 @@ def main(database: Path, accounts: int, transactions: int, force: bool):
|
||||
click.echo(f" export LEGGEN_DATABASE_PATH={db_path}")
|
||||
click.echo(" leggen transactions")
|
||||
click.echo("")
|
||||
click.echo("To use this sample database with leggend API:")
|
||||
click.echo(f" leggend --database {db_path}")
|
||||
click.echo("To use this sample database with leggen server:")
|
||||
click.echo(f" leggen server --database {db_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -7,8 +7,8 @@ from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from leggend.main import create_app
|
||||
from leggend.config import Config
|
||||
from leggen.commands.server import create_app
|
||||
from leggen.utils.config import Config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -5,8 +5,8 @@ from datetime import datetime, timedelta
|
||||
from unittest.mock import Mock, AsyncMock, patch
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from leggend.main import create_app
|
||||
from leggend.services.database_service import DatabaseService
|
||||
from leggen.commands.server import create_app
|
||||
from leggen.services.database_service import DatabaseService
|
||||
|
||||
|
||||
class TestAnalyticsFix:
|
||||
@@ -27,78 +27,112 @@ class TestAnalyticsFix:
|
||||
# Mock data for 600 transactions (simulating the issue)
|
||||
mock_transactions = []
|
||||
for i in range(600):
|
||||
mock_transactions.append({
|
||||
"transactionId": f"txn-{i}",
|
||||
"transactionDate": (datetime.now() - timedelta(days=i % 365)).isoformat(),
|
||||
"description": f"Transaction {i}",
|
||||
"transactionValue": 10.0 if i % 2 == 0 else -5.0,
|
||||
"transactionCurrency": "EUR",
|
||||
"transactionStatus": "booked",
|
||||
"accountId": f"account-{i % 3}",
|
||||
})
|
||||
mock_transactions.append(
|
||||
{
|
||||
"transactionId": f"txn-{i}",
|
||||
"transactionDate": (
|
||||
datetime.now() - timedelta(days=i % 365)
|
||||
).isoformat(),
|
||||
"description": f"Transaction {i}",
|
||||
"transactionValue": 10.0 if i % 2 == 0 else -5.0,
|
||||
"transactionCurrency": "EUR",
|
||||
"transactionStatus": "booked",
|
||||
"accountId": f"account-{i % 3}",
|
||||
}
|
||||
)
|
||||
|
||||
mock_database_service.get_transactions_from_db = AsyncMock(return_value=mock_transactions)
|
||||
mock_database_service.get_transactions_from_db = AsyncMock(
|
||||
return_value=mock_transactions
|
||||
)
|
||||
|
||||
# Test that the endpoint calls get_transactions_from_db with limit=None
|
||||
with patch('leggend.api.routes.transactions.database_service', mock_database_service):
|
||||
with patch(
|
||||
"leggen.api.routes.transactions.database_service", mock_database_service
|
||||
):
|
||||
app = create_app()
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
response = client.get("/api/v1/transactions/stats?days=365")
|
||||
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
|
||||
# Verify that limit=None was passed to get all transactions
|
||||
mock_database_service.get_transactions_from_db.assert_called_once()
|
||||
call_args = mock_database_service.get_transactions_from_db.call_args
|
||||
assert call_args.kwargs.get("limit") is None, "Stats endpoint should pass limit=None to get all transactions"
|
||||
|
||||
assert call_args.kwargs.get("limit") is None, (
|
||||
"Stats endpoint should pass limit=None to get all transactions"
|
||||
)
|
||||
|
||||
# Verify that the response contains stats for all 600 transactions
|
||||
assert data["success"] is True
|
||||
stats = data["data"]
|
||||
assert stats["total_transactions"] == 600, "Should process all 600 transactions, not just 100"
|
||||
|
||||
assert stats["total_transactions"] == 600, (
|
||||
"Should process all 600 transactions, not just 100"
|
||||
)
|
||||
|
||||
# Verify calculations are correct for all transactions
|
||||
expected_income = sum(txn["transactionValue"] for txn in mock_transactions if txn["transactionValue"] > 0)
|
||||
expected_expenses = sum(abs(txn["transactionValue"]) for txn in mock_transactions if txn["transactionValue"] < 0)
|
||||
|
||||
expected_income = sum(
|
||||
txn["transactionValue"]
|
||||
for txn in mock_transactions
|
||||
if txn["transactionValue"] > 0
|
||||
)
|
||||
expected_expenses = sum(
|
||||
abs(txn["transactionValue"])
|
||||
for txn in mock_transactions
|
||||
if txn["transactionValue"] < 0
|
||||
)
|
||||
|
||||
assert stats["total_income"] == expected_income
|
||||
assert stats["total_expenses"] == expected_expenses
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_analytics_endpoint_returns_all_transactions(self, mock_database_service):
|
||||
@pytest.mark.asyncio
|
||||
async def test_analytics_endpoint_returns_all_transactions(
|
||||
self, mock_database_service
|
||||
):
|
||||
"""Test that the new analytics endpoint returns all transactions without pagination"""
|
||||
# Mock data for 600 transactions
|
||||
mock_transactions = []
|
||||
for i in range(600):
|
||||
mock_transactions.append({
|
||||
"transactionId": f"txn-{i}",
|
||||
"transactionDate": (datetime.now() - timedelta(days=i % 365)).isoformat(),
|
||||
"description": f"Transaction {i}",
|
||||
"transactionValue": 10.0 if i % 2 == 0 else -5.0,
|
||||
"transactionCurrency": "EUR",
|
||||
"transactionStatus": "booked",
|
||||
"accountId": f"account-{i % 3}",
|
||||
})
|
||||
mock_transactions.append(
|
||||
{
|
||||
"transactionId": f"txn-{i}",
|
||||
"transactionDate": (
|
||||
datetime.now() - timedelta(days=i % 365)
|
||||
).isoformat(),
|
||||
"description": f"Transaction {i}",
|
||||
"transactionValue": 10.0 if i % 2 == 0 else -5.0,
|
||||
"transactionCurrency": "EUR",
|
||||
"transactionStatus": "booked",
|
||||
"accountId": f"account-{i % 3}",
|
||||
}
|
||||
)
|
||||
|
||||
mock_database_service.get_transactions_from_db = AsyncMock(return_value=mock_transactions)
|
||||
mock_database_service.get_transactions_from_db = AsyncMock(
|
||||
return_value=mock_transactions
|
||||
)
|
||||
|
||||
with patch('leggend.api.routes.transactions.database_service', mock_database_service):
|
||||
with patch(
|
||||
"leggen.api.routes.transactions.database_service", mock_database_service
|
||||
):
|
||||
app = create_app()
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
response = client.get("/api/v1/transactions/analytics?days=365")
|
||||
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
|
||||
# Verify that limit=None was passed to get all transactions
|
||||
mock_database_service.get_transactions_from_db.assert_called_once()
|
||||
call_args = mock_database_service.get_transactions_from_db.call_args
|
||||
assert call_args.kwargs.get("limit") is None, "Analytics endpoint should pass limit=None"
|
||||
|
||||
assert call_args.kwargs.get("limit") is None, (
|
||||
"Analytics endpoint should pass limit=None"
|
||||
)
|
||||
|
||||
# Verify that all 600 transactions are returned
|
||||
assert data["success"] is True
|
||||
transactions_data = data["data"]
|
||||
assert len(transactions_data) == 600, "Analytics endpoint should return all 600 transactions"
|
||||
assert len(transactions_data) == 600, (
|
||||
"Analytics endpoint should return all 600 transactions"
|
||||
)
|
||||
|
||||
@@ -43,13 +43,13 @@ class TestAccountsAPI:
|
||||
]
|
||||
|
||||
with (
|
||||
patch("leggend.config.config", mock_config),
|
||||
patch("leggen.utils.config.config", mock_config),
|
||||
patch(
|
||||
"leggend.api.routes.accounts.database_service.get_accounts_from_db",
|
||||
"leggen.api.routes.accounts.database_service.get_accounts_from_db",
|
||||
return_value=mock_accounts,
|
||||
),
|
||||
patch(
|
||||
"leggend.api.routes.accounts.database_service.get_balances_from_db",
|
||||
"leggen.api.routes.accounts.database_service.get_balances_from_db",
|
||||
return_value=mock_balances,
|
||||
),
|
||||
):
|
||||
@@ -98,13 +98,13 @@ class TestAccountsAPI:
|
||||
]
|
||||
|
||||
with (
|
||||
patch("leggend.config.config", mock_config),
|
||||
patch("leggen.utils.config.config", mock_config),
|
||||
patch(
|
||||
"leggend.api.routes.accounts.database_service.get_account_details_from_db",
|
||||
"leggen.api.routes.accounts.database_service.get_account_details_from_db",
|
||||
return_value=mock_account,
|
||||
),
|
||||
patch(
|
||||
"leggend.api.routes.accounts.database_service.get_balances_from_db",
|
||||
"leggen.api.routes.accounts.database_service.get_balances_from_db",
|
||||
return_value=mock_balances,
|
||||
),
|
||||
):
|
||||
@@ -148,9 +148,9 @@ class TestAccountsAPI:
|
||||
]
|
||||
|
||||
with (
|
||||
patch("leggend.config.config", mock_config),
|
||||
patch("leggen.utils.config.config", mock_config),
|
||||
patch(
|
||||
"leggend.api.routes.accounts.database_service.get_balances_from_db",
|
||||
"leggen.api.routes.accounts.database_service.get_balances_from_db",
|
||||
return_value=mock_balances,
|
||||
),
|
||||
):
|
||||
@@ -191,13 +191,13 @@ class TestAccountsAPI:
|
||||
]
|
||||
|
||||
with (
|
||||
patch("leggend.config.config", mock_config),
|
||||
patch("leggen.utils.config.config", mock_config),
|
||||
patch(
|
||||
"leggend.api.routes.accounts.database_service.get_transactions_from_db",
|
||||
"leggen.api.routes.accounts.database_service.get_transactions_from_db",
|
||||
return_value=mock_transactions,
|
||||
),
|
||||
patch(
|
||||
"leggend.api.routes.accounts.database_service.get_transaction_count_from_db",
|
||||
"leggen.api.routes.accounts.database_service.get_transaction_count_from_db",
|
||||
return_value=1,
|
||||
),
|
||||
):
|
||||
@@ -243,13 +243,13 @@ class TestAccountsAPI:
|
||||
]
|
||||
|
||||
with (
|
||||
patch("leggend.config.config", mock_config),
|
||||
patch("leggen.utils.config.config", mock_config),
|
||||
patch(
|
||||
"leggend.api.routes.accounts.database_service.get_transactions_from_db",
|
||||
"leggen.api.routes.accounts.database_service.get_transactions_from_db",
|
||||
return_value=mock_transactions,
|
||||
),
|
||||
patch(
|
||||
"leggend.api.routes.accounts.database_service.get_transaction_count_from_db",
|
||||
"leggen.api.routes.accounts.database_service.get_transaction_count_from_db",
|
||||
return_value=1,
|
||||
),
|
||||
):
|
||||
@@ -273,9 +273,9 @@ class TestAccountsAPI:
|
||||
):
|
||||
"""Test handling of non-existent account."""
|
||||
with (
|
||||
patch("leggend.config.config", mock_config),
|
||||
patch("leggen.utils.config.config", mock_config),
|
||||
patch(
|
||||
"leggend.api.routes.accounts.database_service.get_account_details_from_db",
|
||||
"leggen.api.routes.accounts.database_service.get_account_details_from_db",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
|
||||
@@ -27,7 +27,7 @@ class TestBanksAPI:
|
||||
return_value=httpx.Response(200, json=sample_bank_data)
|
||||
)
|
||||
|
||||
with patch("leggend.config.config", mock_config):
|
||||
with patch("leggen.utils.config.config", mock_config):
|
||||
response = api_client.get("/api/v1/banks/institutions?country=PT")
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -52,7 +52,7 @@ class TestBanksAPI:
|
||||
return_value=httpx.Response(200, json=[])
|
||||
)
|
||||
|
||||
with patch("leggend.config.config", mock_config):
|
||||
with patch("leggen.utils.config.config", mock_config):
|
||||
response = api_client.get("/api/v1/banks/institutions?country=XX")
|
||||
|
||||
# Should still work but return empty or filtered results
|
||||
@@ -86,7 +86,7 @@ class TestBanksAPI:
|
||||
"redirect_url": "http://localhost:8000/",
|
||||
}
|
||||
|
||||
with patch("leggend.config.config", mock_config):
|
||||
with patch("leggen.utils.config.config", mock_config):
|
||||
response = api_client.post("/api/v1/banks/connect", json=request_data)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -122,7 +122,7 @@ class TestBanksAPI:
|
||||
return_value=httpx.Response(200, json=requisitions_data)
|
||||
)
|
||||
|
||||
with patch("leggend.config.config", mock_config):
|
||||
with patch("leggen.utils.config.config", mock_config):
|
||||
response = api_client.get("/api/v1/banks/status")
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -155,7 +155,7 @@ class TestBanksAPI:
|
||||
return_value=httpx.Response(401, json={"detail": "Invalid credentials"})
|
||||
)
|
||||
|
||||
with patch("leggend.config.config", mock_config):
|
||||
with patch("leggen.utils.config.config", mock_config):
|
||||
response = api_client.get("/api/v1/banks/institutions")
|
||||
|
||||
assert response.status_code == 500
|
||||
|
||||
@@ -5,16 +5,16 @@ import requests
|
||||
import requests_mock
|
||||
from unittest.mock import patch
|
||||
|
||||
from leggen.api_client import LeggendAPIClient
|
||||
from leggen.api_client import LeggenAPIClient
|
||||
|
||||
|
||||
@pytest.mark.cli
|
||||
class TestLeggendAPIClient:
|
||||
class TestLeggenAPIClient:
|
||||
"""Test the CLI API client."""
|
||||
|
||||
def test_health_check_success(self):
|
||||
"""Test successful health check."""
|
||||
client = LeggendAPIClient("http://localhost:8000")
|
||||
client = LeggenAPIClient("http://localhost:8000")
|
||||
|
||||
with requests_mock.Mocker() as m:
|
||||
m.get("http://localhost:8000/health", json={"status": "healthy"})
|
||||
@@ -24,7 +24,7 @@ class TestLeggendAPIClient:
|
||||
|
||||
def test_health_check_failure(self):
|
||||
"""Test health check failure."""
|
||||
client = LeggendAPIClient("http://localhost:8000")
|
||||
client = LeggenAPIClient("http://localhost:8000")
|
||||
|
||||
with requests_mock.Mocker() as m:
|
||||
m.get("http://localhost:8000/health", status_code=500)
|
||||
@@ -34,7 +34,7 @@ class TestLeggendAPIClient:
|
||||
|
||||
def test_get_institutions_success(self, sample_bank_data):
|
||||
"""Test getting institutions via API client."""
|
||||
client = LeggendAPIClient("http://localhost:8000")
|
||||
client = LeggenAPIClient("http://localhost:8000")
|
||||
|
||||
api_response = {
|
||||
"success": True,
|
||||
@@ -51,7 +51,7 @@ class TestLeggendAPIClient:
|
||||
|
||||
def test_get_accounts_success(self, sample_account_data):
|
||||
"""Test getting accounts via API client."""
|
||||
client = LeggendAPIClient("http://localhost:8000")
|
||||
client = LeggenAPIClient("http://localhost:8000")
|
||||
|
||||
api_response = {
|
||||
"success": True,
|
||||
@@ -68,7 +68,7 @@ class TestLeggendAPIClient:
|
||||
|
||||
def test_trigger_sync_success(self):
|
||||
"""Test triggering sync via API client."""
|
||||
client = LeggendAPIClient("http://localhost:8000")
|
||||
client = LeggenAPIClient("http://localhost:8000")
|
||||
|
||||
api_response = {
|
||||
"success": True,
|
||||
@@ -84,14 +84,14 @@ class TestLeggendAPIClient:
|
||||
|
||||
def test_connection_error_handling(self):
|
||||
"""Test handling of connection errors."""
|
||||
client = LeggendAPIClient("http://localhost:9999") # Non-existent service
|
||||
client = LeggenAPIClient("http://localhost:9999") # Non-existent service
|
||||
|
||||
with pytest.raises((requests.ConnectionError, requests.RequestException)):
|
||||
client.get_accounts()
|
||||
|
||||
def test_http_error_handling(self):
|
||||
"""Test handling of HTTP errors."""
|
||||
client = LeggendAPIClient("http://localhost:8000")
|
||||
client = LeggenAPIClient("http://localhost:8000")
|
||||
|
||||
with requests_mock.Mocker() as m:
|
||||
m.get(
|
||||
@@ -106,19 +106,19 @@ class TestLeggendAPIClient:
|
||||
def test_custom_api_url(self):
|
||||
"""Test using custom API URL."""
|
||||
custom_url = "http://custom-host:9000"
|
||||
client = LeggendAPIClient(custom_url)
|
||||
client = LeggenAPIClient(custom_url)
|
||||
|
||||
assert client.base_url == custom_url
|
||||
|
||||
def test_environment_variable_url(self):
|
||||
"""Test using environment variable for API URL."""
|
||||
with patch.dict("os.environ", {"LEGGEND_API_URL": "http://env-host:7000"}):
|
||||
client = LeggendAPIClient()
|
||||
with patch.dict("os.environ", {"LEGGEN_API_URL": "http://env-host:7000"}):
|
||||
client = LeggenAPIClient()
|
||||
assert client.base_url == "http://env-host:7000"
|
||||
|
||||
def test_sync_with_options(self):
|
||||
"""Test sync with various options."""
|
||||
client = LeggendAPIClient("http://localhost:8000")
|
||||
client = LeggenAPIClient("http://localhost:8000")
|
||||
|
||||
api_response = {
|
||||
"success": True,
|
||||
@@ -135,7 +135,7 @@ class TestLeggendAPIClient:
|
||||
|
||||
def test_get_scheduler_config(self):
|
||||
"""Test getting scheduler configuration."""
|
||||
client = LeggendAPIClient("http://localhost:8000")
|
||||
client = LeggenAPIClient("http://localhost:8000")
|
||||
|
||||
api_response = {
|
||||
"success": True,
|
||||
|
||||
@@ -43,13 +43,13 @@ class TestTransactionsAPI:
|
||||
]
|
||||
|
||||
with (
|
||||
patch("leggend.config.config", mock_config),
|
||||
patch("leggen.utils.config.config", mock_config),
|
||||
patch(
|
||||
"leggend.api.routes.transactions.database_service.get_transactions_from_db",
|
||||
"leggen.api.routes.transactions.database_service.get_transactions_from_db",
|
||||
return_value=mock_transactions,
|
||||
),
|
||||
patch(
|
||||
"leggend.api.routes.transactions.database_service.get_transaction_count_from_db",
|
||||
"leggen.api.routes.transactions.database_service.get_transaction_count_from_db",
|
||||
return_value=2,
|
||||
),
|
||||
):
|
||||
@@ -90,13 +90,13 @@ class TestTransactionsAPI:
|
||||
]
|
||||
|
||||
with (
|
||||
patch("leggend.config.config", mock_config),
|
||||
patch("leggen.utils.config.config", mock_config),
|
||||
patch(
|
||||
"leggend.api.routes.transactions.database_service.get_transactions_from_db",
|
||||
"leggen.api.routes.transactions.database_service.get_transactions_from_db",
|
||||
return_value=mock_transactions,
|
||||
),
|
||||
patch(
|
||||
"leggend.api.routes.transactions.database_service.get_transaction_count_from_db",
|
||||
"leggen.api.routes.transactions.database_service.get_transaction_count_from_db",
|
||||
return_value=1,
|
||||
),
|
||||
):
|
||||
@@ -135,13 +135,13 @@ class TestTransactionsAPI:
|
||||
]
|
||||
|
||||
with (
|
||||
patch("leggend.config.config", mock_config),
|
||||
patch("leggen.utils.config.config", mock_config),
|
||||
patch(
|
||||
"leggend.api.routes.transactions.database_service.get_transactions_from_db",
|
||||
"leggen.api.routes.transactions.database_service.get_transactions_from_db",
|
||||
return_value=mock_transactions,
|
||||
) as mock_get_transactions,
|
||||
patch(
|
||||
"leggend.api.routes.transactions.database_service.get_transaction_count_from_db",
|
||||
"leggen.api.routes.transactions.database_service.get_transaction_count_from_db",
|
||||
return_value=1,
|
||||
),
|
||||
):
|
||||
@@ -178,13 +178,13 @@ class TestTransactionsAPI:
|
||||
):
|
||||
"""Test getting transactions when database returns empty result."""
|
||||
with (
|
||||
patch("leggend.config.config", mock_config),
|
||||
patch("leggen.utils.config.config", mock_config),
|
||||
patch(
|
||||
"leggend.api.routes.transactions.database_service.get_transactions_from_db",
|
||||
"leggen.api.routes.transactions.database_service.get_transactions_from_db",
|
||||
return_value=[],
|
||||
),
|
||||
patch(
|
||||
"leggend.api.routes.transactions.database_service.get_transaction_count_from_db",
|
||||
"leggen.api.routes.transactions.database_service.get_transaction_count_from_db",
|
||||
return_value=0,
|
||||
),
|
||||
):
|
||||
@@ -203,9 +203,9 @@ class TestTransactionsAPI:
|
||||
):
|
||||
"""Test handling database error when getting transactions."""
|
||||
with (
|
||||
patch("leggend.config.config", mock_config),
|
||||
patch("leggen.utils.config.config", mock_config),
|
||||
patch(
|
||||
"leggend.api.routes.transactions.database_service.get_transactions_from_db",
|
||||
"leggen.api.routes.transactions.database_service.get_transactions_from_db",
|
||||
side_effect=Exception("Database connection failed"),
|
||||
),
|
||||
):
|
||||
@@ -243,9 +243,9 @@ class TestTransactionsAPI:
|
||||
]
|
||||
|
||||
with (
|
||||
patch("leggend.config.config", mock_config),
|
||||
patch("leggen.utils.config.config", mock_config),
|
||||
patch(
|
||||
"leggend.api.routes.transactions.database_service.get_transactions_from_db",
|
||||
"leggen.api.routes.transactions.database_service.get_transactions_from_db",
|
||||
return_value=mock_transactions,
|
||||
),
|
||||
):
|
||||
@@ -284,9 +284,9 @@ class TestTransactionsAPI:
|
||||
]
|
||||
|
||||
with (
|
||||
patch("leggend.config.config", mock_config),
|
||||
patch("leggen.utils.config.config", mock_config),
|
||||
patch(
|
||||
"leggend.api.routes.transactions.database_service.get_transactions_from_db",
|
||||
"leggen.api.routes.transactions.database_service.get_transactions_from_db",
|
||||
return_value=mock_transactions,
|
||||
) as mock_get_transactions,
|
||||
):
|
||||
@@ -306,9 +306,9 @@ class TestTransactionsAPI:
|
||||
):
|
||||
"""Test getting stats when no transactions match criteria."""
|
||||
with (
|
||||
patch("leggend.config.config", mock_config),
|
||||
patch("leggen.utils.config.config", mock_config),
|
||||
patch(
|
||||
"leggend.api.routes.transactions.database_service.get_transactions_from_db",
|
||||
"leggen.api.routes.transactions.database_service.get_transactions_from_db",
|
||||
return_value=[],
|
||||
),
|
||||
):
|
||||
@@ -331,9 +331,9 @@ class TestTransactionsAPI:
|
||||
):
|
||||
"""Test handling database error when getting stats."""
|
||||
with (
|
||||
patch("leggend.config.config", mock_config),
|
||||
patch("leggen.utils.config.config", mock_config),
|
||||
patch(
|
||||
"leggend.api.routes.transactions.database_service.get_transactions_from_db",
|
||||
"leggen.api.routes.transactions.database_service.get_transactions_from_db",
|
||||
side_effect=Exception("Database connection failed"),
|
||||
),
|
||||
):
|
||||
@@ -357,9 +357,9 @@ class TestTransactionsAPI:
|
||||
]
|
||||
|
||||
with (
|
||||
patch("leggend.config.config", mock_config),
|
||||
patch("leggen.utils.config.config", mock_config),
|
||||
patch(
|
||||
"leggend.api.routes.transactions.database_service.get_transactions_from_db",
|
||||
"leggen.api.routes.transactions.database_service.get_transactions_from_db",
|
||||
return_value=mock_transactions,
|
||||
) as mock_get_transactions,
|
||||
):
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
from leggend.config import Config
|
||||
from leggen.utils.config import Config
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
|
||||
@@ -14,7 +14,6 @@ class MockContext:
|
||||
"""Mock context for testing."""
|
||||
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestConfigurablePaths:
|
||||
"""Test configurable path management."""
|
||||
|
||||
@@ -4,7 +4,7 @@ import pytest
|
||||
from unittest.mock import patch
|
||||
from datetime import datetime
|
||||
|
||||
from leggend.services.database_service import DatabaseService
|
||||
from leggen.services.database_service import DatabaseService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -4,7 +4,7 @@ import pytest
|
||||
from unittest.mock import patch, AsyncMock, MagicMock
|
||||
from datetime import datetime
|
||||
|
||||
from leggend.background.scheduler import BackgroundScheduler
|
||||
from leggen.background.scheduler import BackgroundScheduler
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@@ -20,8 +20,8 @@ class TestBackgroundScheduler:
|
||||
def scheduler(self):
|
||||
"""Create scheduler instance for testing."""
|
||||
with (
|
||||
patch("leggend.background.scheduler.SyncService"),
|
||||
patch("leggend.background.scheduler.config") as mock_config,
|
||||
patch("leggen.background.scheduler.SyncService"),
|
||||
patch("leggen.background.scheduler.config") as mock_config,
|
||||
):
|
||||
mock_config.scheduler_config = {
|
||||
"sync": {"enabled": True, "hour": 3, "minute": 0}
|
||||
@@ -37,7 +37,7 @@ class TestBackgroundScheduler:
|
||||
|
||||
def test_scheduler_start_default_config(self, scheduler, mock_config):
|
||||
"""Test starting scheduler with default configuration."""
|
||||
with patch("leggend.config.config") as mock_config_obj:
|
||||
with patch("leggen.utils.config.config") as mock_config_obj:
|
||||
mock_config_obj.scheduler_config = mock_config
|
||||
|
||||
# Mock the job that gets added
|
||||
@@ -58,7 +58,7 @@ class TestBackgroundScheduler:
|
||||
|
||||
with (
|
||||
patch.object(scheduler, "scheduler") as mock_scheduler,
|
||||
patch("leggend.background.scheduler.config") as mock_config_obj,
|
||||
patch("leggen.background.scheduler.config") as mock_config_obj,
|
||||
):
|
||||
mock_config_obj.scheduler_config = disabled_config
|
||||
mock_scheduler.running = False
|
||||
@@ -79,7 +79,7 @@ class TestBackgroundScheduler:
|
||||
}
|
||||
}
|
||||
|
||||
with patch("leggend.config.config") as mock_config_obj:
|
||||
with patch("leggen.utils.config.config") as mock_config_obj:
|
||||
mock_config_obj.scheduler_config = cron_config
|
||||
|
||||
scheduler.start()
|
||||
@@ -97,7 +97,7 @@ class TestBackgroundScheduler:
|
||||
|
||||
with (
|
||||
patch.object(scheduler, "scheduler") as mock_scheduler,
|
||||
patch("leggend.background.scheduler.config") as mock_config_obj,
|
||||
patch("leggen.background.scheduler.config") as mock_config_obj,
|
||||
):
|
||||
mock_config_obj.scheduler_config = invalid_cron_config
|
||||
mock_scheduler.running = False
|
||||
@@ -187,7 +187,7 @@ class TestBackgroundScheduler:
|
||||
|
||||
def test_scheduler_job_max_instances(self, scheduler, mock_config):
|
||||
"""Test that sync jobs have max_instances=1."""
|
||||
with patch("leggend.config.config") as mock_config_obj:
|
||||
with patch("leggen.utils.config.config") as mock_config_obj:
|
||||
mock_config_obj.scheduler_config = mock_config
|
||||
scheduler.start()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user