mirror of
https://github.com/elisiariocouto/leggen.git
synced 2025-12-13 23:12:16 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc3522220a | ||
|
|
1693b3a50d | ||
|
|
460c5af6ea | ||
|
|
5a8614e019 | ||
|
|
ae5d034d4b | ||
|
|
d4edf69f2c |
28
CHANGELOG.md
28
CHANGELOG.md
@@ -1,4 +1,32 @@
|
||||
|
||||
## 2025.9.23 (2025/09/24)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **cli:** Fix API URL handling for subpaths and improve client robustness. ([ae5d034d](https://github.com/elisiariocouto/leggen/commit/ae5d034d4b1da785e3dc240c1d60c2cae7de8010))
|
||||
- Correct sync trigger types from manual to scheduled/retry. ([460c5af6](https://github.com/elisiariocouto/leggen/commit/460c5af6ea343ef5685b716413d01d7a30fa9acf))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
- **frontend:** Add version-based cache invalidation for PWA updates ([d4edf69f](https://github.com/elisiariocouto/leggen/commit/d4edf69f2cea2515a00435ee974116948057148d))
|
||||
|
||||
|
||||
|
||||
## 2025.9.23 (2025/09/24)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **cli:** Fix API URL handling for subpaths and improve client robustness. ([ae5d034d](https://github.com/elisiariocouto/leggen/commit/ae5d034d4b1da785e3dc240c1d60c2cae7de8010))
|
||||
- Correct sync trigger types from manual to scheduled/retry. ([460c5af6](https://github.com/elisiariocouto/leggen/commit/460c5af6ea343ef5685b716413d01d7a30fa9acf))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
- **frontend:** Add version-based cache invalidation for PWA updates ([d4edf69f](https://github.com/elisiariocouto/leggen/commit/d4edf69f2cea2515a00435ee974116948057148d))
|
||||
|
||||
|
||||
|
||||
## 2025.9.22 (2025/09/24)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -210,7 +210,7 @@ export default function System() {
|
||||
: "Sync Failed"}
|
||||
</h4>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{operation.trigger_type}
|
||||
{operation.trigger_type.charAt(0).toUpperCase() + operation.trigger_type.slice(1)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4 mt-1 text-xs text-muted-foreground">
|
||||
@@ -272,7 +272,7 @@ export default function System() {
|
||||
: "Sync Failed"}
|
||||
</h4>
|
||||
<Badge variant="outline" className="text-xs mt-1">
|
||||
{operation.trigger_type}
|
||||
{operation.trigger_type.charAt(0).toUpperCase() + operation.trigger_type.slice(1)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useEffect, useState } from "react";
|
||||
interface PWAUpdate {
|
||||
updateAvailable: boolean;
|
||||
updateSW: () => Promise<void>;
|
||||
forceReload: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function usePWA(): PWAUpdate {
|
||||
@@ -11,6 +12,33 @@ export function usePWA(): PWAUpdate {
|
||||
() => async () => {},
|
||||
);
|
||||
|
||||
const forceReload = async (): Promise<void> => {
|
||||
try {
|
||||
// Clear all caches
|
||||
if ('caches' in window) {
|
||||
const cacheNames = await caches.keys();
|
||||
await Promise.all(
|
||||
cacheNames.map(cacheName => caches.delete(cacheName))
|
||||
);
|
||||
console.log("All caches cleared");
|
||||
}
|
||||
|
||||
// Unregister service worker
|
||||
if ('serviceWorker' in navigator) {
|
||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||
await Promise.all(registrations.map(registration => registration.unregister()));
|
||||
console.log("All service workers unregistered");
|
||||
}
|
||||
|
||||
// Force reload
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error("Error during force reload:", error);
|
||||
// Fallback: just reload the page
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Check if SW registration is available
|
||||
if ("serviceWorker" in navigator) {
|
||||
@@ -37,5 +65,6 @@ export function usePWA(): PWAUpdate {
|
||||
return {
|
||||
updateAvailable,
|
||||
updateSW,
|
||||
forceReload,
|
||||
};
|
||||
}
|
||||
|
||||
40
frontend/src/hooks/useVersionCheck.ts
Normal file
40
frontend/src/hooks/useVersionCheck.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useEffect } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiClient } from "../lib/api";
|
||||
|
||||
const VERSION_STORAGE_KEY = "leggen_app_version";
|
||||
|
||||
export function useVersionCheck(forceReload: () => Promise<void>) {
|
||||
const {
|
||||
data: healthStatus,
|
||||
isSuccess: healthSuccess,
|
||||
} = useQuery({
|
||||
queryKey: ["health"],
|
||||
queryFn: apiClient.getHealth,
|
||||
refetchInterval: 30000,
|
||||
retry: false,
|
||||
staleTime: 0, // Always consider data stale to ensure fresh version checks
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (healthSuccess && healthStatus?.version) {
|
||||
const currentVersion = healthStatus.version;
|
||||
const storedVersion = localStorage.getItem(VERSION_STORAGE_KEY);
|
||||
|
||||
if (storedVersion && storedVersion !== currentVersion) {
|
||||
console.log(`Version mismatch detected: stored=${storedVersion}, current=${currentVersion}`);
|
||||
console.log("Clearing cache and reloading...");
|
||||
|
||||
// Update stored version first
|
||||
localStorage.setItem(VERSION_STORAGE_KEY, currentVersion);
|
||||
|
||||
// Force reload to clear cache
|
||||
forceReload();
|
||||
} else if (!storedVersion) {
|
||||
// First time loading, store the version
|
||||
localStorage.setItem(VERSION_STORAGE_KEY, currentVersion);
|
||||
console.log(`Version stored: ${currentVersion}`);
|
||||
}
|
||||
}
|
||||
}, [healthSuccess, healthStatus?.version, forceReload]);
|
||||
}
|
||||
@@ -3,10 +3,14 @@ import { AppSidebar } from "../components/AppSidebar";
|
||||
import { SiteHeader } from "../components/SiteHeader";
|
||||
import { PWAInstallPrompt, PWAUpdatePrompt } from "../components/PWAPrompts";
|
||||
import { usePWA } from "../hooks/usePWA";
|
||||
import { useVersionCheck } from "../hooks/useVersionCheck";
|
||||
import { SidebarInset, SidebarProvider } from "../components/ui/sidebar";
|
||||
|
||||
function RootLayout() {
|
||||
const { updateAvailable, updateSW } = usePWA();
|
||||
const { updateAvailable, updateSW, forceReload } = usePWA();
|
||||
|
||||
// Check for version mismatches and force reload if needed
|
||||
useVersionCheck(forceReload);
|
||||
|
||||
const handlePWAInstall = () => {
|
||||
console.log("PWA installed successfully");
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { TanStackRouterVite } from "@tanstack/router-vite-plugin";
|
||||
import { tanstackRouter } from "@tanstack/router-vite-plugin";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
TanStackRouterVite(),
|
||||
tanstackRouter(),
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: "autoUpdate",
|
||||
|
||||
@@ -18,7 +18,7 @@ class SyncOperation(BaseModel):
|
||||
duration_seconds: Optional[float] = None
|
||||
errors: list[str] = []
|
||||
logs: list[str] = []
|
||||
trigger_type: str = "manual" # manual, scheduled, api
|
||||
trigger_type: str = "manual" # manual, scheduled, retry, api
|
||||
|
||||
class Config:
|
||||
json_encoders = {datetime: lambda v: v.isoformat() if v else None}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
from urllib.parse import urljoin
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
import requests
|
||||
|
||||
@@ -13,11 +13,22 @@ class LeggenAPIClient:
|
||||
base_url: str
|
||||
|
||||
def __init__(self, base_url: Optional[str] = None):
|
||||
self.base_url = (
|
||||
raw_url = (
|
||||
base_url
|
||||
or os.environ.get("LEGGEN_API_URL", "http://localhost:8000")
|
||||
or "http://localhost:8000"
|
||||
)
|
||||
# Ensure base_url includes /api/v1 path if not already present
|
||||
parsed = urlparse(raw_url)
|
||||
if not parsed.path or parsed.path == "/":
|
||||
# No path or just root, add /api/v1
|
||||
self.base_url = f"{raw_url.rstrip('/')}/api/v1"
|
||||
elif not parsed.path.startswith("/api/v1"):
|
||||
# Has a path but not /api/v1, add it
|
||||
self.base_url = f"{raw_url.rstrip('/')}/api/v1"
|
||||
else:
|
||||
# Already has /api/v1 path
|
||||
self.base_url = raw_url.rstrip("/")
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update(
|
||||
{"Content-Type": "application/json", "Accept": "application/json"}
|
||||
@@ -25,7 +36,14 @@ class LeggenAPIClient:
|
||||
|
||||
def _make_request(self, method: str, endpoint: str, **kwargs) -> Dict[str, Any]:
|
||||
"""Make HTTP request to the API"""
|
||||
url = urljoin(self.base_url, endpoint)
|
||||
# Construct URL by joining base_url with endpoint
|
||||
# Handle both relative endpoints (starting with /) and paths
|
||||
if endpoint.startswith("/"):
|
||||
# Absolute endpoint path - append to base_url
|
||||
url = f"{self.base_url}{endpoint}"
|
||||
else:
|
||||
# Relative endpoint, use urljoin
|
||||
url = urljoin(f"{self.base_url}/", endpoint)
|
||||
|
||||
try:
|
||||
response = self.session.request(method, url, **kwargs)
|
||||
@@ -52,7 +70,9 @@ class LeggenAPIClient:
|
||||
"""Check if the leggen server is healthy"""
|
||||
try:
|
||||
response = self._make_request("GET", "/health")
|
||||
return response.get("status") == "healthy"
|
||||
# The API now returns nested data structure
|
||||
data = response.get("data", {})
|
||||
return data.get("status") == "healthy"
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@@ -60,7 +80,7 @@ class LeggenAPIClient:
|
||||
def get_institutions(self, country: str = "PT") -> List[Dict[str, Any]]:
|
||||
"""Get bank institutions for a country"""
|
||||
response = self._make_request(
|
||||
"GET", "/api/v1/banks/institutions", params={"country": country}
|
||||
"GET", "/banks/institutions", params={"country": country}
|
||||
)
|
||||
return response.get("data", [])
|
||||
|
||||
@@ -70,35 +90,35 @@ class LeggenAPIClient:
|
||||
"""Connect to a bank"""
|
||||
response = self._make_request(
|
||||
"POST",
|
||||
"/api/v1/banks/connect",
|
||||
"/banks/connect",
|
||||
json={"institution_id": institution_id, "redirect_url": redirect_url},
|
||||
)
|
||||
return response.get("data", {})
|
||||
|
||||
def get_bank_status(self) -> List[Dict[str, Any]]:
|
||||
"""Get bank connection status"""
|
||||
response = self._make_request("GET", "/api/v1/banks/status")
|
||||
response = self._make_request("GET", "/banks/status")
|
||||
return response.get("data", [])
|
||||
|
||||
def get_supported_countries(self) -> List[Dict[str, Any]]:
|
||||
"""Get supported countries"""
|
||||
response = self._make_request("GET", "/api/v1/banks/countries")
|
||||
response = self._make_request("GET", "/banks/countries")
|
||||
return response.get("data", [])
|
||||
|
||||
# Account endpoints
|
||||
def get_accounts(self) -> List[Dict[str, Any]]:
|
||||
"""Get all accounts"""
|
||||
response = self._make_request("GET", "/api/v1/accounts")
|
||||
response = self._make_request("GET", "/accounts")
|
||||
return response.get("data", [])
|
||||
|
||||
def get_account_details(self, account_id: str) -> Dict[str, Any]:
|
||||
"""Get account details"""
|
||||
response = self._make_request("GET", f"/api/v1/accounts/{account_id}")
|
||||
response = self._make_request("GET", f"/accounts/{account_id}")
|
||||
return response.get("data", {})
|
||||
|
||||
def get_account_balances(self, account_id: str) -> List[Dict[str, Any]]:
|
||||
"""Get account balances"""
|
||||
response = self._make_request("GET", f"/api/v1/accounts/{account_id}/balances")
|
||||
response = self._make_request("GET", f"/accounts/{account_id}/balances")
|
||||
return response.get("data", [])
|
||||
|
||||
def get_account_transactions(
|
||||
@@ -107,7 +127,7 @@ class LeggenAPIClient:
|
||||
"""Get account transactions"""
|
||||
response = self._make_request(
|
||||
"GET",
|
||||
f"/api/v1/accounts/{account_id}/transactions",
|
||||
f"/accounts/{account_id}/transactions",
|
||||
params={"limit": limit, "summary_only": summary_only},
|
||||
)
|
||||
return response.get("data", [])
|
||||
@@ -120,7 +140,7 @@ class LeggenAPIClient:
|
||||
params = {"limit": limit, "summary_only": summary_only}
|
||||
params.update(filters)
|
||||
|
||||
response = self._make_request("GET", "/api/v1/transactions", params=params)
|
||||
response = self._make_request("GET", "/transactions", params=params)
|
||||
return response.get("data", [])
|
||||
|
||||
def get_transaction_stats(
|
||||
@@ -131,15 +151,13 @@ class LeggenAPIClient:
|
||||
if account_id:
|
||||
params["account_id"] = account_id
|
||||
|
||||
response = self._make_request(
|
||||
"GET", "/api/v1/transactions/stats", params=params
|
||||
)
|
||||
response = self._make_request("GET", "/transactions/stats", params=params)
|
||||
return response.get("data", {})
|
||||
|
||||
# Sync endpoints
|
||||
def get_sync_status(self) -> Dict[str, Any]:
|
||||
"""Get sync status"""
|
||||
response = self._make_request("GET", "/api/v1/sync/status")
|
||||
response = self._make_request("GET", "/sync/status")
|
||||
return response.get("data", {})
|
||||
|
||||
def trigger_sync(
|
||||
@@ -150,7 +168,7 @@ class LeggenAPIClient:
|
||||
if account_ids:
|
||||
data["account_ids"] = account_ids
|
||||
|
||||
response = self._make_request("POST", "/api/v1/sync", json=data)
|
||||
response = self._make_request("POST", "/sync", json=data)
|
||||
return response.get("data", {})
|
||||
|
||||
def sync_now(
|
||||
@@ -161,12 +179,12 @@ class LeggenAPIClient:
|
||||
if account_ids:
|
||||
data["account_ids"] = account_ids
|
||||
|
||||
response = self._make_request("POST", "/api/v1/sync/now", json=data)
|
||||
response = self._make_request("POST", "/sync/now", json=data)
|
||||
return response.get("data", {})
|
||||
|
||||
def get_scheduler_config(self) -> Dict[str, Any]:
|
||||
"""Get scheduler configuration"""
|
||||
response = self._make_request("GET", "/api/v1/sync/scheduler")
|
||||
response = self._make_request("GET", "/sync/scheduler")
|
||||
return response.get("data", {})
|
||||
|
||||
def update_scheduler_config(
|
||||
@@ -185,5 +203,5 @@ class LeggenAPIClient:
|
||||
if cron:
|
||||
data["cron"] = cron
|
||||
|
||||
response = self._make_request("PUT", "/api/v1/sync/scheduler", json=data)
|
||||
response = self._make_request("PUT", "/sync/scheduler", json=data)
|
||||
return response.get("data", {})
|
||||
|
||||
@@ -102,12 +102,14 @@ class BackgroundScheduler:
|
||||
async def _run_sync(self, retry_count: int = 0):
|
||||
"""Run sync with enhanced error handling and retry logic"""
|
||||
try:
|
||||
logger.info("Starting scheduled sync job")
|
||||
await self.sync_service.sync_all_accounts()
|
||||
logger.info("Scheduled sync job completed successfully")
|
||||
trigger_type = "retry" if retry_count > 0 else "scheduled"
|
||||
logger.info(f"Starting {trigger_type} sync job")
|
||||
await self.sync_service.sync_all_accounts(trigger_type=trigger_type)
|
||||
logger.info(f"{trigger_type.capitalize()} sync job completed successfully")
|
||||
except Exception as e:
|
||||
trigger_type = "retry" if retry_count > 0 else "scheduled"
|
||||
logger.error(
|
||||
f"Scheduled sync job failed (attempt {retry_count + 1}/{self.max_retries}): {e}"
|
||||
f"{trigger_type.capitalize()} sync job failed (attempt {retry_count + 1}/{self.max_retries}): {e}"
|
||||
)
|
||||
|
||||
# Send notification about the failure
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import click
|
||||
|
||||
from leggen.api_client import LeggenAPIClient
|
||||
from leggen.main import cli
|
||||
from leggen.utils.text import info, success
|
||||
|
||||
@@ -15,12 +16,11 @@ def delete(ctx, requisition_id: str):
|
||||
|
||||
Check `leggen status` to get the REQUISITION_ID
|
||||
"""
|
||||
import requests
|
||||
api_client = LeggenAPIClient(ctx.obj.get("api_url"))
|
||||
|
||||
info(f"Deleting Bank Requisition: {requisition_id}")
|
||||
|
||||
api_url = ctx.obj.get("api_url", "http://localhost:8000")
|
||||
res = requests.delete(f"{api_url}/requisitions/{requisition_id}")
|
||||
res.raise_for_status()
|
||||
# Use API client to make the delete request
|
||||
api_client._make_request("DELETE", f"/requisitions/{requisition_id}")
|
||||
|
||||
success(f"Bank Requisition {requisition_id} deleted")
|
||||
|
||||
@@ -39,13 +39,11 @@ class Config:
|
||||
try:
|
||||
with open(config_path, "rb") as f:
|
||||
raw_config = tomllib.load(f)
|
||||
logger.info(f"Configuration loaded from {config_path}")
|
||||
|
||||
# Validate configuration using Pydantic
|
||||
try:
|
||||
self._config_model = ConfigModel(**raw_config)
|
||||
self._config = self._config_model.dict(by_alias=True, exclude_none=True)
|
||||
logger.info("Configuration validation successful")
|
||||
except ValidationError as e:
|
||||
logger.error(f"Configuration validation failed: {e}")
|
||||
raise ValueError(f"Invalid configuration: {e}") from e
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "leggen"
|
||||
version = "2025.9.22"
|
||||
version = "2025.9.23"
|
||||
description = "An Open Banking CLI"
|
||||
authors = [{ name = "Elisiário Couto", email = "elisiario@couto.io" }]
|
||||
requires-python = "~=3.13.0"
|
||||
|
||||
@@ -18,7 +18,10 @@ class TestLeggenAPIClient:
|
||||
client = LeggenAPIClient("http://localhost:8000")
|
||||
|
||||
with requests_mock.Mocker() as m:
|
||||
m.get("http://localhost:8000/health", json={"status": "healthy"})
|
||||
m.get(
|
||||
"http://localhost:8000/api/v1/health",
|
||||
json={"data": {"status": "healthy"}},
|
||||
)
|
||||
|
||||
result = client.health_check()
|
||||
assert result is True
|
||||
@@ -112,13 +115,13 @@ class TestLeggenAPIClient:
|
||||
custom_url = "http://custom-host:9000"
|
||||
client = LeggenAPIClient(custom_url)
|
||||
|
||||
assert client.base_url == custom_url
|
||||
assert client.base_url == f"{custom_url}/api/v1"
|
||||
|
||||
def test_environment_variable_url(self):
|
||||
"""Test using environment variable for API URL."""
|
||||
with patch.dict("os.environ", {"LEGGEN_API_URL": "http://env-host:7000"}):
|
||||
client = LeggenAPIClient()
|
||||
assert client.base_url == "http://env-host:7000"
|
||||
assert client.base_url == "http://env-host:7000/api/v1"
|
||||
|
||||
def test_sync_with_options(self):
|
||||
"""Test sync with various options."""
|
||||
|
||||
Reference in New Issue
Block a user