refactor: Remove API response wrapper pattern.

Replace wrapped responses {success, data, message} with direct data returns
following REST best practices. Simplifies 41 endpoints across 7 route files
and updates all 109 tests. Also fixes test config setup to not require
user home directory config file.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Elisiário Couto
2025-12-07 00:54:51 +00:00
parent a75365d805
commit fabea404ef
17 changed files with 1171 additions and 835 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@ import type {
Transaction, Transaction,
AnalyticsTransaction, AnalyticsTransaction,
Balance, Balance,
ApiResponse, PaginatedResponse,
NotificationSettings, NotificationSettings,
NotificationTest, NotificationTest,
NotificationService, NotificationService,
@@ -36,14 +36,14 @@ const api = axios.create({
export const apiClient = { export const apiClient = {
// Get all accounts // Get all accounts
getAccounts: async (): Promise<Account[]> => { getAccounts: async (): Promise<Account[]> => {
const response = await api.get<ApiResponse<Account[]>>("/accounts"); const response = await api.get<Account[]>("/accounts");
return response.data.data; return response.data;
}, },
// Get account by ID // Get account by ID
getAccount: async (id: string): Promise<Account> => { getAccount: async (id: string): Promise<Account> => {
const response = await api.get<ApiResponse<Account>>(`/accounts/${id}`); const response = await api.get<Account>(`/accounts/${id}`);
return response.data.data; return response.data;
}, },
// Update account details // Update account details
@@ -51,16 +51,17 @@ export const apiClient = {
id: string, id: string,
updates: AccountUpdate, updates: AccountUpdate,
): Promise<{ id: string; display_name?: string }> => { ): Promise<{ id: string; display_name?: string }> => {
const response = await api.put< const response = await api.put<{ id: string; display_name?: string }>(
ApiResponse<{ id: string; display_name?: string }> `/accounts/${id}`,
>(`/accounts/${id}`, updates); updates,
return response.data.data; );
return response.data;
}, },
// Get all balances // Get all balances
getBalances: async (): Promise<Balance[]> => { getBalances: async (): Promise<Balance[]> => {
const response = await api.get<ApiResponse<Balance[]>>("/balances"); const response = await api.get<Balance[]>("/balances");
return response.data.data; return response.data;
}, },
// Get historical balances for balance progression chart // Get historical balances for balance progression chart
@@ -72,18 +73,18 @@ export const apiClient = {
if (days) queryParams.append("days", days.toString()); if (days) queryParams.append("days", days.toString());
if (accountId) queryParams.append("account_id", accountId); if (accountId) queryParams.append("account_id", accountId);
const response = await api.get<ApiResponse<Balance[]>>( const response = await api.get<Balance[]>(
`/balances/history?${queryParams.toString()}`, `/balances/history?${queryParams.toString()}`,
); );
return response.data.data; return response.data;
}, },
// Get balances for specific account // Get balances for specific account
getAccountBalances: async (accountId: string): Promise<Balance[]> => { getAccountBalances: async (accountId: string): Promise<Balance[]> => {
const response = await api.get<ApiResponse<Balance[]>>( const response = await api.get<Balance[]>(
`/accounts/${accountId}/balances`, `/accounts/${accountId}/balances`,
); );
return response.data.data; return response.data;
}, },
// Get transactions with optional filters // Get transactions with optional filters
@@ -97,7 +98,7 @@ export const apiClient = {
summaryOnly?: boolean; summaryOnly?: boolean;
minAmount?: number; minAmount?: number;
maxAmount?: number; maxAmount?: number;
}): Promise<ApiResponse<Transaction[]>> => { }): Promise<PaginatedResponse<Transaction>> => {
const queryParams = new URLSearchParams(); const queryParams = new URLSearchParams();
if (params?.accountId) queryParams.append("account_id", params.accountId); if (params?.accountId) queryParams.append("account_id", params.accountId);
@@ -117,7 +118,7 @@ export const apiClient = {
queryParams.append("max_amount", params.maxAmount.toString()); queryParams.append("max_amount", params.maxAmount.toString());
} }
const response = await api.get<ApiResponse<Transaction[]>>( const response = await api.get<PaginatedResponse<Transaction>>(
`/transactions?${queryParams.toString()}`, `/transactions?${queryParams.toString()}`,
); );
return response.data; return response.data;
@@ -125,29 +126,27 @@ export const apiClient = {
// Get transaction by ID // Get transaction by ID
getTransaction: async (id: string): Promise<Transaction> => { getTransaction: async (id: string): Promise<Transaction> => {
const response = await api.get<ApiResponse<Transaction>>( const response = await api.get<Transaction>(`/transactions/${id}`);
`/transactions/${id}`, return response.data;
);
return response.data.data;
}, },
// Get notification settings // Get notification settings
getNotificationSettings: async (): Promise<NotificationSettings> => { getNotificationSettings: async (): Promise<NotificationSettings> => {
const response = await api.get<ApiResponse<NotificationSettings>>( const response = await api.get<NotificationSettings>(
"/notifications/settings", "/notifications/settings",
); );
return response.data.data; return response.data;
}, },
// Update notification settings // Update notification settings
updateNotificationSettings: async ( updateNotificationSettings: async (
settings: NotificationSettings, settings: NotificationSettings,
): Promise<NotificationSettings> => { ): Promise<NotificationSettings> => {
const response = await api.put<ApiResponse<NotificationSettings>>( const response = await api.put<NotificationSettings>(
"/notifications/settings", "/notifications/settings",
settings, settings,
); );
return response.data.data; return response.data;
}, },
// Test notification // Test notification
@@ -157,11 +156,11 @@ export const apiClient = {
// Get notification services // Get notification services
getNotificationServices: async (): Promise<NotificationService[]> => { getNotificationServices: async (): Promise<NotificationService[]> => {
const response = await api.get<ApiResponse<NotificationServicesResponse>>( const response = await api.get<NotificationServicesResponse>(
"/notifications/services", "/notifications/services",
); );
// Convert object to array format // Convert object to array format
const servicesData = response.data.data; const servicesData = response.data;
return Object.values(servicesData); return Object.values(servicesData);
}, },
@@ -172,8 +171,8 @@ export const apiClient = {
// Health check // Health check
getHealth: async (): Promise<HealthData> => { getHealth: async (): Promise<HealthData> => {
const response = await api.get<ApiResponse<HealthData>>("/health"); const response = await api.get<HealthData>("/health");
return response.data.data; return response.data;
}, },
// Analytics endpoints // Analytics endpoints
@@ -181,10 +180,10 @@ export const apiClient = {
const queryParams = new URLSearchParams(); const queryParams = new URLSearchParams();
if (days) queryParams.append("days", days.toString()); if (days) queryParams.append("days", days.toString());
const response = await api.get<ApiResponse<TransactionStats>>( const response = await api.get<TransactionStats>(
`/transactions/stats?${queryParams.toString()}`, `/transactions/stats?${queryParams.toString()}`,
); );
return response.data.data; return response.data;
}, },
// Get all transactions for analytics (no pagination) // Get all transactions for analytics (no pagination)
@@ -194,10 +193,10 @@ export const apiClient = {
const queryParams = new URLSearchParams(); const queryParams = new URLSearchParams();
if (days) queryParams.append("days", days.toString()); if (days) queryParams.append("days", days.toString());
const response = await api.get<ApiResponse<AnalyticsTransaction[]>>( const response = await api.get<AnalyticsTransaction[]>(
`/transactions/analytics?${queryParams.toString()}`, `/transactions/analytics?${queryParams.toString()}`,
); );
return response.data.data; return response.data;
}, },
// Get monthly transaction statistics (pre-calculated) // Get monthly transaction statistics (pre-calculated)
@@ -215,16 +214,14 @@ export const apiClient = {
if (days) queryParams.append("days", days.toString()); if (days) queryParams.append("days", days.toString());
const response = await api.get< const response = await api.get<
ApiResponse<
Array<{ Array<{
month: string; month: string;
income: number; income: number;
expenses: number; expenses: number;
net: number; net: number;
}> }>
>
>(`/transactions/monthly-stats?${queryParams.toString()}`); >(`/transactions/monthly-stats?${queryParams.toString()}`);
return response.data.data; return response.data;
}, },
// Get sync operations history // Get sync operations history
@@ -232,24 +229,23 @@ export const apiClient = {
limit: number = 50, limit: number = 50,
offset: number = 0, offset: number = 0,
): Promise<SyncOperationsResponse> => { ): Promise<SyncOperationsResponse> => {
const response = await api.get<ApiResponse<SyncOperationsResponse>>( const response = await api.get<SyncOperationsResponse>(
`/sync/operations?limit=${limit}&offset=${offset}`, `/sync/operations?limit=${limit}&offset=${offset}`,
); );
return response.data.data; return response.data;
}, },
// Bank management endpoints // Bank management endpoints
getBankInstitutions: async (country: string): Promise<BankInstitution[]> => { getBankInstitutions: async (country: string): Promise<BankInstitution[]> => {
const response = await api.get<ApiResponse<BankInstitution[]>>( const response = await api.get<BankInstitution[]>(
`/banks/institutions?country=${country}`, `/banks/institutions?country=${country}`,
); );
return response.data.data; return response.data;
}, },
getBankConnectionsStatus: async (): Promise<BankConnectionStatus[]> => { getBankConnectionsStatus: async (): Promise<BankConnectionStatus[]> => {
const response = const response = await api.get<BankConnectionStatus[]>("/banks/status");
await api.get<ApiResponse<BankConnectionStatus[]>>("/banks/status"); return response.data;
return response.data.data;
}, },
createBankConnection: async ( createBankConnection: async (
@@ -260,14 +256,11 @@ export const apiClient = {
const finalRedirectUrl = const finalRedirectUrl =
redirectUrl || `${window.location.origin}/bank-connected`; redirectUrl || `${window.location.origin}/bank-connected`;
const response = await api.post<ApiResponse<BankRequisition>>( const response = await api.post<BankRequisition>("/banks/connect", {
"/banks/connect",
{
institution_id: institutionId, institution_id: institutionId,
redirect_url: finalRedirectUrl, redirect_url: finalRedirectUrl,
}, });
); return response.data;
return response.data.data;
}, },
deleteBankConnection: async (requisitionId: string): Promise<void> => { deleteBankConnection: async (requisitionId: string): Promise<void> => {
@@ -275,39 +268,44 @@ export const apiClient = {
}, },
getSupportedCountries: async (): Promise<Country[]> => { getSupportedCountries: async (): Promise<Country[]> => {
const response = await api.get<ApiResponse<Country[]>>("/banks/countries"); const response = await api.get<Country[]>("/banks/countries");
return response.data.data; return response.data;
}, },
// Backup endpoints // Backup endpoints
getBackupSettings: async (): Promise<BackupSettings> => { getBackupSettings: async (): Promise<BackupSettings> => {
const response = const response = await api.get<BackupSettings>("/backup/settings");
await api.get<ApiResponse<BackupSettings>>("/backup/settings"); return response.data;
return response.data.data;
}, },
updateBackupSettings: async ( updateBackupSettings: async (
settings: BackupSettings, settings: BackupSettings,
): Promise<BackupSettings> => { ): Promise<BackupSettings> => {
const response = await api.put<ApiResponse<BackupSettings>>( const response = await api.put<BackupSettings>(
"/backup/settings", "/backup/settings",
settings, settings,
); );
return response.data.data; return response.data;
}, },
testBackupConnection: async (test: BackupTest): Promise<ApiResponse<{ connected?: boolean }>> => { testBackupConnection: async (test: BackupTest): Promise<{ connected?: boolean }> => {
const response = await api.post<ApiResponse<{ connected?: boolean }>>("/backup/test", test); const response = await api.post<{ connected?: boolean }>(
"/backup/test",
test,
);
return response.data; return response.data;
}, },
listBackups: async (): Promise<BackupInfo[]> => { listBackups: async (): Promise<BackupInfo[]> => {
const response = await api.get<ApiResponse<BackupInfo[]>>("/backup/list"); const response = await api.get<BackupInfo[]>("/backup/list");
return response.data.data; return response.data;
}, },
performBackupOperation: async (operation: BackupOperation): Promise<ApiResponse<{ operation: string; completed: boolean }>> => { performBackupOperation: async (operation: BackupOperation): Promise<{ operation: string; completed: boolean }> => {
const response = await api.post<ApiResponse<{ operation: string; completed: boolean }>>("/backup/operation", operation); const response = await api.post<{ operation: string; completed: boolean }>(
"/backup/operation",
operation,
);
return response.data; return response.data;
}, },
}; };

View File

@@ -133,26 +133,14 @@ export interface Bank {
logo_url?: string; logo_url?: string;
} }
export interface ApiResponse<T> {
data: T;
message?: string;
success: boolean;
pagination?: {
total: number;
page: number;
per_page: number;
total_pages: number;
has_next: boolean;
has_prev: boolean;
};
}
export interface PaginatedResponse<T> { export interface PaginatedResponse<T> {
data: T[]; data: T[];
total: number; total: number;
page: number; page: number;
per_page: number; per_page: number;
total_pages: number; total_pages: number;
has_next: boolean;
has_prev: boolean;
} }
// Notification types // Notification types

View File

@@ -1,29 +1,17 @@
from typing import Any, Dict, Optional from typing import Any, Dict, Generic, List, TypeVar
from pydantic import BaseModel from pydantic import BaseModel
T = TypeVar("T")
class APIResponse(BaseModel):
"""Base API response model"""
success: bool = True
message: Optional[str] = None
data: Optional[Any] = None
class ErrorResponse(BaseModel): class PaginatedResponse(BaseModel, Generic[T]):
"""Error response model"""
success: bool = False
message: str
error_code: Optional[str] = None
details: Optional[Dict[str, Any]] = None
class PaginatedResponse(BaseModel):
"""Paginated response model""" """Paginated response model"""
success: bool = True data: List[T]
data: list total: int
pagination: Dict[str, Any] page: int
message: Optional[str] = None per_page: int
total_pages: int
has_next: bool
has_prev: bool

View File

@@ -10,15 +10,14 @@ from leggen.api.models.accounts import (
Transaction, Transaction,
TransactionSummary, TransactionSummary,
) )
from leggen.api.models.common import APIResponse
from leggen.services.database_service import DatabaseService from leggen.services.database_service import DatabaseService
router = APIRouter() router = APIRouter()
database_service = DatabaseService() database_service = DatabaseService()
@router.get("/accounts", response_model=APIResponse) @router.get("/accounts")
async def get_all_accounts() -> APIResponse: async def get_all_accounts() -> List[AccountDetails]:
"""Get all connected accounts from database""" """Get all connected accounts from database"""
try: try:
accounts = [] accounts = []
@@ -68,11 +67,7 @@ async def get_all_accounts() -> APIResponse:
) )
continue continue
return APIResponse( return accounts
success=True,
data=accounts,
message=f"Retrieved {len(accounts)} accounts from database",
)
except Exception as e: except Exception as e:
logger.error(f"Failed to get accounts: {e}") logger.error(f"Failed to get accounts: {e}")
@@ -81,8 +76,8 @@ async def get_all_accounts() -> APIResponse:
) from e ) from e
@router.get("/accounts/{account_id}", response_model=APIResponse) @router.get("/accounts/{account_id}")
async def get_account_details(account_id: str) -> APIResponse: async def get_account_details(account_id: str) -> AccountDetails:
"""Get details for a specific account from database""" """Get details for a specific account from database"""
try: try:
# Get account details from database # Get account details from database
@@ -122,11 +117,7 @@ async def get_account_details(account_id: str) -> APIResponse:
balances=balances, balances=balances,
) )
return APIResponse( return account
success=True,
data=account,
message=f"Account details retrieved from database for {account_id}",
)
except HTTPException: except HTTPException:
raise raise
@@ -137,8 +128,8 @@ async def get_account_details(account_id: str) -> APIResponse:
) from e ) from e
@router.get("/accounts/{account_id}/balances", response_model=APIResponse) @router.get("/accounts/{account_id}/balances")
async def get_account_balances(account_id: str) -> APIResponse: async def get_account_balances(account_id: str) -> List[AccountBalance]:
"""Get balances for a specific account from database""" """Get balances for a specific account from database"""
try: try:
# Get balances from database instead of GoCardless API # Get balances from database instead of GoCardless API
@@ -155,11 +146,7 @@ async def get_account_balances(account_id: str) -> APIResponse:
) )
) )
return APIResponse( return balances
success=True,
data=balances,
message=f"Retrieved {len(balances)} balances for account {account_id}",
)
except Exception as e: except Exception as e:
logger.error( logger.error(
@@ -170,8 +157,8 @@ async def get_account_balances(account_id: str) -> APIResponse:
) from e ) from e
@router.get("/balances", response_model=APIResponse) @router.get("/balances")
async def get_all_balances() -> APIResponse: async def get_all_balances() -> List[dict]:
"""Get all balances from all accounts in database""" """Get all balances from all accounts in database"""
try: try:
# Get all accounts first to iterate through them # Get all accounts first to iterate through them
@@ -207,11 +194,7 @@ async def get_all_balances() -> APIResponse:
) )
continue continue
return APIResponse( return all_balances
success=True,
data=all_balances,
message=f"Retrieved {len(all_balances)} balances from {len(db_accounts)} accounts",
)
except Exception as e: except Exception as e:
logger.error(f"Failed to get all balances: {e}") logger.error(f"Failed to get all balances: {e}")
@@ -220,7 +203,7 @@ async def get_all_balances() -> APIResponse:
) from e ) from e
@router.get("/balances/history", response_model=APIResponse) @router.get("/balances/history")
async def get_historical_balances( async def get_historical_balances(
days: Optional[int] = Query( days: Optional[int] = Query(
default=365, le=1095, ge=1, description="Number of days of history to retrieve" default=365, le=1095, ge=1, description="Number of days of history to retrieve"
@@ -228,7 +211,7 @@ async def get_historical_balances(
account_id: Optional[str] = Query( account_id: Optional[str] = Query(
default=None, description="Filter by specific account ID" default=None, description="Filter by specific account ID"
), ),
) -> APIResponse: ) -> List[dict]:
"""Get historical balance progression calculated from transaction history""" """Get historical balance progression calculated from transaction history"""
try: try:
# Get historical balances from database # Get historical balances from database
@@ -236,11 +219,7 @@ async def get_historical_balances(
account_id=account_id, days=days or 365 account_id=account_id, days=days or 365
) )
return APIResponse( return historical_balances
success=True,
data=historical_balances,
message=f"Retrieved {len(historical_balances)} historical balance points over {days} days",
)
except Exception as e: except Exception as e:
logger.error(f"Failed to get historical balances: {e}") logger.error(f"Failed to get historical balances: {e}")
@@ -249,7 +228,7 @@ async def get_historical_balances(
) from e ) from e
@router.get("/accounts/{account_id}/transactions", response_model=APIResponse) @router.get("/accounts/{account_id}/transactions")
async def get_account_transactions( async def get_account_transactions(
account_id: str, account_id: str,
limit: Optional[int] = Query(default=100, le=500), limit: Optional[int] = Query(default=100, le=500),
@@ -257,7 +236,7 @@ async def get_account_transactions(
summary_only: bool = Query( summary_only: bool = Query(
default=False, description="Return transaction summaries only" default=False, description="Return transaction summaries only"
), ),
) -> APIResponse: ) -> Union[List[TransactionSummary], List[Transaction]]:
"""Get transactions for a specific account from database""" """Get transactions for a specific account from database"""
try: try:
# Get transactions from database instead of GoCardless API # Get transactions from database instead of GoCardless API
@@ -308,12 +287,7 @@ async def get_account_transactions(
for txn in db_transactions for txn in db_transactions
] ]
actual_offset = offset or 0 return data
return APIResponse(
success=True,
data=data,
message=f"Retrieved {len(data)} transactions (showing {actual_offset + 1}-{actual_offset + len(data)} of {total_transactions})",
)
except Exception as e: except Exception as e:
logger.error( logger.error(
@@ -324,10 +298,10 @@ async def get_account_transactions(
) from e ) from e
@router.put("/accounts/{account_id}", response_model=APIResponse) @router.put("/accounts/{account_id}")
async def update_account_details( async def update_account_details(
account_id: str, update_data: AccountUpdate account_id: str, update_data: AccountUpdate
) -> APIResponse: ) -> dict:
"""Update account details (currently only display_name)""" """Update account details (currently only display_name)"""
try: try:
# Get current account details # Get current account details
@@ -346,11 +320,7 @@ async def update_account_details(
# Persist updated account details # Persist updated account details
await database_service.persist_account_details(updated_account_data) await database_service.persist_account_details(updated_account_data)
return APIResponse( return {"id": account_id, "display_name": update_data.display_name}
success=True,
data={"id": account_id, "display_name": update_data.display_name},
message=f"Account {account_id} display name updated successfully",
)
except HTTPException: except HTTPException:
raise raise

View File

@@ -9,7 +9,6 @@ from leggen.api.models.backup import (
BackupTest, BackupTest,
S3Config, S3Config,
) )
from leggen.api.models.common import APIResponse
from leggen.models.config import S3BackupConfig from leggen.models.config import S3BackupConfig
from leggen.services.backup_service import BackupService from leggen.services.backup_service import BackupService
from leggen.utils.config import config from leggen.utils.config import config
@@ -18,8 +17,8 @@ from leggen.utils.paths import path_manager
router = APIRouter() router = APIRouter()
@router.get("/backup/settings", response_model=APIResponse) @router.get("/backup/settings")
async def get_backup_settings() -> APIResponse: async def get_backup_settings() -> BackupSettings:
"""Get current backup settings.""" """Get current backup settings."""
try: try:
backup_config = config.backup_config backup_config = config.backup_config
@@ -41,11 +40,7 @@ async def get_backup_settings() -> APIResponse:
else None, else None,
) )
return APIResponse( return settings
success=True,
data=settings,
message="Backup settings retrieved successfully",
)
except Exception as e: except Exception as e:
logger.error(f"Failed to get backup settings: {e}") logger.error(f"Failed to get backup settings: {e}")
@@ -54,8 +49,8 @@ async def get_backup_settings() -> APIResponse:
) from e ) from e
@router.put("/backup/settings", response_model=APIResponse) @router.put("/backup/settings")
async def update_backup_settings(settings: BackupSettings) -> APIResponse: async def update_backup_settings(settings: BackupSettings) -> dict:
"""Update backup settings.""" """Update backup settings."""
try: try:
# First test the connection if S3 config is provided # First test the connection if S3 config is provided
@@ -99,11 +94,7 @@ async def update_backup_settings(settings: BackupSettings) -> APIResponse:
if backup_config: if backup_config:
config.update_section("backup", backup_config) config.update_section("backup", backup_config)
return APIResponse( return {"updated": True}
success=True,
data={"updated": True},
message="Backup settings updated successfully",
)
except HTTPException: except HTTPException:
raise raise
@@ -114,8 +105,8 @@ async def update_backup_settings(settings: BackupSettings) -> APIResponse:
) from e ) from e
@router.post("/backup/test", response_model=APIResponse) @router.post("/backup/test")
async def test_backup_connection(test_request: BackupTest) -> APIResponse: async def test_backup_connection(test_request: BackupTest) -> dict:
"""Test backup connection.""" """Test backup connection."""
try: try:
if test_request.service != "s3": if test_request.service != "s3":
@@ -137,18 +128,13 @@ async def test_backup_connection(test_request: BackupTest) -> APIResponse:
backup_service = BackupService() backup_service = BackupService()
success = await backup_service.test_connection(s3_config) success = await backup_service.test_connection(s3_config)
if success: if not success:
return APIResponse( raise HTTPException(
success=True, status_code=400, detail="S3 connection test failed"
data={"connected": True},
message="S3 connection test successful",
)
else:
return APIResponse(
success=False,
message="S3 connection test failed",
) )
return {"connected": True}
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
@@ -158,18 +144,14 @@ async def test_backup_connection(test_request: BackupTest) -> APIResponse:
) from e ) from e
@router.get("/backup/list", response_model=APIResponse) @router.get("/backup/list")
async def list_backups() -> APIResponse: async def list_backups() -> list:
"""List available backups.""" """List available backups."""
try: try:
backup_config = config.backup_config.get("s3", {}) backup_config = config.backup_config.get("s3", {})
if not backup_config.get("bucket_name"): if not backup_config.get("bucket_name"):
return APIResponse( return []
success=True,
data=[],
message="No S3 backup configuration found",
)
# Convert config to model # Convert config to model
s3_config = S3BackupConfig(**backup_config) s3_config = S3BackupConfig(**backup_config)
@@ -177,11 +159,7 @@ async def list_backups() -> APIResponse:
backups = await backup_service.list_backups() backups = await backup_service.list_backups()
return APIResponse( return backups
success=True,
data=backups,
message=f"Found {len(backups)} backups",
)
except Exception as e: except Exception as e:
logger.error(f"Failed to list backups: {e}") logger.error(f"Failed to list backups: {e}")
@@ -190,8 +168,8 @@ async def list_backups() -> APIResponse:
) from e ) from e
@router.post("/backup/operation", response_model=APIResponse) @router.post("/backup/operation")
async def backup_operation(operation_request: BackupOperation) -> APIResponse: async def backup_operation(operation_request: BackupOperation) -> dict:
"""Perform backup operation (backup or restore).""" """Perform backup operation (backup or restore)."""
try: try:
backup_config = config.backup_config.get("s3", {}) backup_config = config.backup_config.get("s3", {})
@@ -214,18 +192,13 @@ async def backup_operation(operation_request: BackupOperation) -> APIResponse:
database_path = path_manager.get_database_path() database_path = path_manager.get_database_path()
success = await backup_service.backup_database(database_path) success = await backup_service.backup_database(database_path)
if success: if not success:
return APIResponse( raise HTTPException(
success=True, status_code=500, detail="Database backup failed"
data={"operation": "backup", "completed": True},
message="Database backup completed successfully",
)
else:
return APIResponse(
success=False,
message="Database backup failed",
) )
return {"operation": "backup", "completed": True}
elif operation_request.operation == "restore": elif operation_request.operation == "restore":
if not operation_request.backup_key: if not operation_request.backup_key:
raise HTTPException( raise HTTPException(
@@ -239,17 +212,12 @@ async def backup_operation(operation_request: BackupOperation) -> APIResponse:
operation_request.backup_key, database_path operation_request.backup_key, database_path
) )
if success: if not success:
return APIResponse( raise HTTPException(
success=True, status_code=500, detail="Database restore failed"
data={"operation": "restore", "completed": True},
message="Database restore completed successfully",
)
else:
return APIResponse(
success=False,
message="Database restore failed",
) )
return {"operation": "restore", "completed": True}
else: else:
raise HTTPException( raise HTTPException(
status_code=400, detail="Invalid operation. Use 'backup' or 'restore'" status_code=400, detail="Invalid operation. Use 'backup' or 'restore'"

View File

@@ -8,7 +8,6 @@ from leggen.api.models.banks import (
BankInstitution, BankInstitution,
BankRequisition, BankRequisition,
) )
from leggen.api.models.common import APIResponse
from leggen.services.gocardless_service import GoCardlessService from leggen.services.gocardless_service import GoCardlessService
from leggen.utils.gocardless import REQUISITION_STATUS from leggen.utils.gocardless import REQUISITION_STATUS
@@ -16,10 +15,10 @@ router = APIRouter()
gocardless_service = GoCardlessService() gocardless_service = GoCardlessService()
@router.get("/banks/institutions", response_model=APIResponse) @router.get("/banks/institutions")
async def get_bank_institutions( async def get_bank_institutions(
country: str = Query(default="PT", description="Country code (e.g., PT, ES, FR)"), country: str = Query(default="PT", description="Country code (e.g., PT, ES, FR)"),
) -> APIResponse: ) -> list[BankInstitution]:
"""Get available bank institutions for a country""" """Get available bank institutions for a country"""
try: try:
institutions_response = await gocardless_service.get_institutions(country) institutions_response = await gocardless_service.get_institutions(country)
@@ -41,11 +40,7 @@ async def get_bank_institutions(
for inst in institutions_data for inst in institutions_data
] ]
return APIResponse( return institutions
success=True,
data=institutions,
message=f"Found {len(institutions)} institutions for {country}",
)
except Exception as e: except Exception as e:
logger.error(f"Failed to get institutions for {country}: {e}") logger.error(f"Failed to get institutions for {country}: {e}")
@@ -54,8 +49,8 @@ async def get_bank_institutions(
) from e ) from e
@router.post("/banks/connect", response_model=APIResponse) @router.post("/banks/connect")
async def connect_to_bank(request: BankConnectionRequest) -> APIResponse: async def connect_to_bank(request: BankConnectionRequest) -> BankRequisition:
"""Create a connection to a bank (requisition)""" """Create a connection to a bank (requisition)"""
try: try:
redirect_url = request.redirect_url or "http://localhost:8000/" redirect_url = request.redirect_url or "http://localhost:8000/"
@@ -72,11 +67,7 @@ async def connect_to_bank(request: BankConnectionRequest) -> APIResponse:
accounts=requisition_data.get("accounts", []), accounts=requisition_data.get("accounts", []),
) )
return APIResponse( return requisition
success=True,
data=requisition,
message="Bank connection created. Please visit the link to authorize.",
)
except Exception as e: except Exception as e:
logger.error(f"Failed to connect to bank {request.institution_id}: {e}") logger.error(f"Failed to connect to bank {request.institution_id}: {e}")
@@ -85,8 +76,8 @@ async def connect_to_bank(request: BankConnectionRequest) -> APIResponse:
) from e ) from e
@router.get("/banks/status", response_model=APIResponse) @router.get("/banks/status")
async def get_bank_connections_status() -> APIResponse: async def get_bank_connections_status() -> list[BankConnectionStatus]:
"""Get status of all bank connections""" """Get status of all bank connections"""
try: try:
requisitions_data = await gocardless_service.get_requisitions() requisitions_data = await gocardless_service.get_requisitions()
@@ -110,11 +101,7 @@ async def get_bank_connections_status() -> APIResponse:
) )
) )
return APIResponse( return connections
success=True,
data=connections,
message=f"Found {len(connections)} bank connections",
)
except Exception as e: except Exception as e:
logger.error(f"Failed to get bank connection status: {e}") logger.error(f"Failed to get bank connection status: {e}")
@@ -123,8 +110,8 @@ async def get_bank_connections_status() -> APIResponse:
) from e ) from e
@router.delete("/banks/connections/{requisition_id}", response_model=APIResponse) @router.delete("/banks/connections/{requisition_id}")
async def delete_bank_connection(requisition_id: str) -> APIResponse: async def delete_bank_connection(requisition_id: str) -> dict:
"""Delete a bank connection""" """Delete a bank connection"""
try: try:
# Delete the requisition from GoCardless # Delete the requisition from GoCardless
@@ -134,10 +121,7 @@ async def delete_bank_connection(requisition_id: str) -> APIResponse:
# We should check if the operation was actually successful # We should check if the operation was actually successful
logger.info(f"GoCardless delete response for {requisition_id}: {result}") logger.info(f"GoCardless delete response for {requisition_id}: {result}")
return APIResponse( return {"deleted": requisition_id}
success=True,
message=f"Bank connection {requisition_id} deleted successfully",
)
except httpx.HTTPStatusError as http_err: except httpx.HTTPStatusError as http_err:
logger.error( logger.error(
@@ -164,8 +148,8 @@ async def delete_bank_connection(requisition_id: str) -> APIResponse:
) from e ) from e
@router.get("/banks/countries", response_model=APIResponse) @router.get("/banks/countries")
async def get_supported_countries() -> APIResponse: async def get_supported_countries() -> list[dict]:
"""Get list of supported countries""" """Get list of supported countries"""
countries = [ countries = [
{"code": "AT", "name": "Austria"}, {"code": "AT", "name": "Austria"},
@@ -201,8 +185,4 @@ async def get_supported_countries() -> APIResponse:
{"code": "GB", "name": "United Kingdom"}, {"code": "GB", "name": "United Kingdom"},
] ]
return APIResponse( return countries
success=True,
data=countries,
message="Supported countries retrieved successfully",
)

View File

@@ -3,7 +3,6 @@ from typing import Any, Dict
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from loguru import logger from loguru import logger
from leggen.api.models.common import APIResponse
from leggen.api.models.notifications import ( from leggen.api.models.notifications import (
DiscordConfig, DiscordConfig,
NotificationFilters, NotificationFilters,
@@ -18,8 +17,8 @@ router = APIRouter()
notification_service = NotificationService() notification_service = NotificationService()
@router.get("/notifications/settings", response_model=APIResponse) @router.get("/notifications/settings")
async def get_notification_settings() -> APIResponse: async def get_notification_settings() -> NotificationSettings:
"""Get current notification settings""" """Get current notification settings"""
try: try:
notifications_config = config.notifications_config notifications_config = config.notifications_config
@@ -49,11 +48,7 @@ async def get_notification_settings() -> APIResponse:
), ),
) )
return APIResponse( return settings
success=True,
data=settings,
message="Notification settings retrieved successfully",
)
except Exception as e: except Exception as e:
logger.error(f"Failed to get notification settings: {e}") logger.error(f"Failed to get notification settings: {e}")
@@ -62,8 +57,8 @@ async def get_notification_settings() -> APIResponse:
) from e ) from e
@router.put("/notifications/settings", response_model=APIResponse) @router.put("/notifications/settings")
async def update_notification_settings(settings: NotificationSettings) -> APIResponse: async def update_notification_settings(settings: NotificationSettings) -> dict:
"""Update notification settings""" """Update notification settings"""
try: try:
# Update notifications config # Update notifications config
@@ -95,11 +90,7 @@ async def update_notification_settings(settings: NotificationSettings) -> APIRes
if filters_config: if filters_config:
config.update_section("filters", filters_config) config.update_section("filters", filters_config)
return APIResponse( return {"updated": True}
success=True,
data={"updated": True},
message="Notification settings updated successfully",
)
except Exception as e: except Exception as e:
logger.error(f"Failed to update notification settings: {e}") logger.error(f"Failed to update notification settings: {e}")
@@ -108,26 +99,24 @@ async def update_notification_settings(settings: NotificationSettings) -> APIRes
) from e ) from e
@router.post("/notifications/test", response_model=APIResponse) @router.post("/notifications/test")
async def test_notification(test_request: NotificationTest) -> APIResponse: async def test_notification(test_request: NotificationTest) -> dict:
"""Send a test notification""" """Send a test notification"""
try: try:
success = await notification_service.send_test_notification( success = await notification_service.send_test_notification(
test_request.service, test_request.message test_request.service, test_request.message
) )
if success: if not success:
return APIResponse( raise HTTPException(
success=True, status_code=400,
data={"sent": True}, detail=f"Failed to send test notification to {test_request.service}",
message=f"Test notification sent to {test_request.service} successfully",
)
else:
return APIResponse(
success=False,
message=f"Failed to send test notification to {test_request.service}",
) )
return {"sent": True}
except HTTPException:
raise
except Exception as e: except Exception as e:
logger.error(f"Failed to send test notification: {e}") logger.error(f"Failed to send test notification: {e}")
raise HTTPException( raise HTTPException(
@@ -135,8 +124,8 @@ async def test_notification(test_request: NotificationTest) -> APIResponse:
) from e ) from e
@router.get("/notifications/services", response_model=APIResponse) @router.get("/notifications/services")
async def get_notification_services() -> APIResponse: async def get_notification_services() -> dict:
"""Get available notification services and their status""" """Get available notification services and their status"""
try: try:
notifications_config = config.notifications_config notifications_config = config.notifications_config
@@ -164,11 +153,7 @@ async def get_notification_services() -> APIResponse:
}, },
} }
return APIResponse( return services
success=True,
data=services,
message="Notification services status retrieved successfully",
)
except Exception as e: except Exception as e:
logger.error(f"Failed to get notification services: {e}") logger.error(f"Failed to get notification services: {e}")
@@ -177,8 +162,8 @@ async def get_notification_services() -> APIResponse:
) from e ) from e
@router.delete("/notifications/settings/{service}", response_model=APIResponse) @router.delete("/notifications/settings/{service}")
async def delete_notification_service(service: str) -> APIResponse: async def delete_notification_service(service: str) -> dict:
"""Delete/disable a notification service""" """Delete/disable a notification service"""
try: try:
if service not in ["discord", "telegram"]: if service not in ["discord", "telegram"]:
@@ -191,12 +176,10 @@ async def delete_notification_service(service: str) -> APIResponse:
del notifications_config[service] del notifications_config[service]
config.update_section("notifications", notifications_config) config.update_section("notifications", notifications_config)
return APIResponse( return {"deleted": service}
success=True,
data={"deleted": service},
message=f"{service.capitalize()} notification service deleted successfully",
)
except HTTPException:
raise
except Exception as e: except Exception as e:
logger.error(f"Failed to delete notification service {service}: {e}") logger.error(f"Failed to delete notification service {service}: {e}")
raise HTTPException( raise HTTPException(

View File

@@ -3,7 +3,6 @@ from typing import Optional
from fastapi import APIRouter, BackgroundTasks, HTTPException from fastapi import APIRouter, BackgroundTasks, HTTPException
from loguru import logger from loguru import logger
from leggen.api.models.common import APIResponse
from leggen.api.models.sync import SchedulerConfig, SyncRequest from leggen.api.models.sync import SchedulerConfig, SyncRequest
from leggen.background.scheduler import scheduler from leggen.background.scheduler import scheduler
from leggen.services.sync_service import SyncService from leggen.services.sync_service import SyncService
@@ -13,8 +12,8 @@ router = APIRouter()
sync_service = SyncService() sync_service = SyncService()
@router.get("/sync/status", response_model=APIResponse) @router.get("/sync/status")
async def get_sync_status() -> APIResponse: async def get_sync_status() -> dict:
"""Get current sync status""" """Get current sync status"""
try: try:
status = await sync_service.get_sync_status() status = await sync_service.get_sync_status()
@@ -24,9 +23,7 @@ async def get_sync_status() -> APIResponse:
if next_sync_time: if next_sync_time:
status.next_sync = next_sync_time status.next_sync = next_sync_time
return APIResponse( return status
success=True, data=status, message="Sync status retrieved successfully"
)
except Exception as e: except Exception as e:
logger.error(f"Failed to get sync status: {e}") logger.error(f"Failed to get sync status: {e}")
@@ -35,18 +32,18 @@ async def get_sync_status() -> APIResponse:
) from e ) from e
@router.post("/sync", response_model=APIResponse) @router.post("/sync")
async def trigger_sync( async def trigger_sync(
background_tasks: BackgroundTasks, sync_request: Optional[SyncRequest] = None background_tasks: BackgroundTasks, sync_request: Optional[SyncRequest] = None
) -> APIResponse: ) -> dict:
"""Trigger a manual sync operation""" """Trigger a manual sync operation"""
try: try:
# Check if sync is already running # Check if sync is already running
status = await sync_service.get_sync_status() status = await sync_service.get_sync_status()
if status.is_running and not (sync_request and sync_request.force): if status.is_running and not (sync_request and sync_request.force):
return APIResponse( raise HTTPException(
success=False, status_code=409,
message="Sync is already running. Use 'force: true' to override.", detail="Sync is already running. Use 'force: true' to override.",
) )
# Determine what to sync # Determine what to sync
@@ -58,9 +55,6 @@ async def trigger_sync(
sync_request.force if sync_request else False, sync_request.force if sync_request else False,
"api", # trigger_type "api", # trigger_type
) )
message = (
f"Started sync for {len(sync_request.account_ids)} specific accounts"
)
else: else:
# Sync all accounts in background # Sync all accounts in background
background_tasks.add_task( background_tasks.add_task(
@@ -68,17 +62,14 @@ async def trigger_sync(
sync_request.force if sync_request else False, sync_request.force if sync_request else False,
"api", # trigger_type "api", # trigger_type
) )
message = "Started sync for all accounts"
return APIResponse( return {
success=True,
data={
"sync_started": True, "sync_started": True,
"force": sync_request.force if sync_request else False, "force": sync_request.force if sync_request else False,
}, }
message=message,
)
except HTTPException:
raise
except Exception as e: except Exception as e:
logger.error(f"Failed to trigger sync: {e}") logger.error(f"Failed to trigger sync: {e}")
raise HTTPException( raise HTTPException(
@@ -86,8 +77,8 @@ async def trigger_sync(
) from e ) from e
@router.post("/sync/now", response_model=APIResponse) @router.post("/sync/now")
async def sync_now(sync_request: Optional[SyncRequest] = None) -> APIResponse: async def sync_now(sync_request: Optional[SyncRequest] = None) -> dict:
"""Run sync synchronously and return results (slower, for testing)""" """Run sync synchronously and return results (slower, for testing)"""
try: try:
if sync_request and sync_request.account_ids: if sync_request and sync_request.account_ids:
@@ -99,13 +90,7 @@ async def sync_now(sync_request: Optional[SyncRequest] = None) -> APIResponse:
sync_request.force if sync_request else False, "api" sync_request.force if sync_request else False, "api"
) )
return APIResponse( return result
success=result.success,
data=result,
message="Sync completed"
if result.success
else f"Sync failed with {len(result.errors)} errors",
)
except Exception as e: except Exception as e:
logger.error(f"Failed to run sync: {e}") logger.error(f"Failed to run sync: {e}")
@@ -114,8 +99,8 @@ async def sync_now(sync_request: Optional[SyncRequest] = None) -> APIResponse:
) from e ) from e
@router.get("/sync/scheduler", response_model=APIResponse) @router.get("/sync/scheduler")
async def get_scheduler_config() -> APIResponse: async def get_scheduler_config() -> dict:
"""Get current scheduler configuration""" """Get current scheduler configuration"""
try: try:
scheduler_config = config.scheduler_config scheduler_config = config.scheduler_config
@@ -131,11 +116,7 @@ async def get_scheduler_config() -> APIResponse:
else False, else False,
} }
return APIResponse( return response_data
success=True,
data=response_data,
message="Scheduler configuration retrieved successfully",
)
except Exception as e: except Exception as e:
logger.error(f"Failed to get scheduler config: {e}") logger.error(f"Failed to get scheduler config: {e}")
@@ -144,8 +125,8 @@ async def get_scheduler_config() -> APIResponse:
) from e ) from e
@router.put("/sync/scheduler", response_model=APIResponse) @router.put("/sync/scheduler")
async def update_scheduler_config(scheduler_config: SchedulerConfig) -> APIResponse: async def update_scheduler_config(scheduler_config: SchedulerConfig) -> dict:
"""Update scheduler configuration""" """Update scheduler configuration"""
try: try:
# Validate cron expression if provided # Validate cron expression if provided
@@ -168,12 +149,10 @@ async def update_scheduler_config(scheduler_config: SchedulerConfig) -> APIRespo
# Reschedule the job # Reschedule the job
scheduler.reschedule_sync(schedule_data) scheduler.reschedule_sync(schedule_data)
return APIResponse( return schedule_data
success=True,
data=schedule_data,
message="Scheduler configuration updated successfully",
)
except HTTPException:
raise
except Exception as e: except Exception as e:
logger.error(f"Failed to update scheduler config: {e}") logger.error(f"Failed to update scheduler config: {e}")
raise HTTPException( raise HTTPException(
@@ -181,15 +160,15 @@ async def update_scheduler_config(scheduler_config: SchedulerConfig) -> APIRespo
) from e ) from e
@router.post("/sync/scheduler/start", response_model=APIResponse) @router.post("/sync/scheduler/start")
async def start_scheduler() -> APIResponse: async def start_scheduler() -> dict:
"""Start the background scheduler""" """Start the background scheduler"""
try: try:
if not scheduler.scheduler.running: if not scheduler.scheduler.running:
scheduler.start() scheduler.start()
return APIResponse(success=True, message="Scheduler started successfully") return {"started": True}
else: else:
return APIResponse(success=True, message="Scheduler is already running") return {"started": False, "message": "Scheduler is already running"}
except Exception as e: except Exception as e:
logger.error(f"Failed to start scheduler: {e}") logger.error(f"Failed to start scheduler: {e}")
@@ -198,15 +177,15 @@ async def start_scheduler() -> APIResponse:
) from e ) from e
@router.post("/sync/scheduler/stop", response_model=APIResponse) @router.post("/sync/scheduler/stop")
async def stop_scheduler() -> APIResponse: async def stop_scheduler() -> dict:
"""Stop the background scheduler""" """Stop the background scheduler"""
try: try:
if scheduler.scheduler.running: if scheduler.scheduler.running:
scheduler.shutdown() scheduler.shutdown()
return APIResponse(success=True, message="Scheduler stopped successfully") return {"stopped": True}
else: else:
return APIResponse(success=True, message="Scheduler is already stopped") return {"stopped": False, "message": "Scheduler is already stopped"}
except Exception as e: except Exception as e:
logger.error(f"Failed to stop scheduler: {e}") logger.error(f"Failed to stop scheduler: {e}")
@@ -215,19 +194,15 @@ async def stop_scheduler() -> APIResponse:
) from e ) from e
@router.get("/sync/operations", response_model=APIResponse) @router.get("/sync/operations")
async def get_sync_operations(limit: int = 50, offset: int = 0) -> APIResponse: async def get_sync_operations(limit: int = 50, offset: int = 0) -> dict:
"""Get sync operations history""" """Get sync operations history"""
try: try:
operations = await sync_service.database.get_sync_operations( operations = await sync_service.database.get_sync_operations(
limit=limit, offset=offset limit=limit, offset=offset
) )
return APIResponse( return {"operations": operations, "count": len(operations)}
success=True,
data={"operations": operations, "count": len(operations)},
message="Sync operations retrieved successfully",
)
except Exception as e: except Exception as e:
logger.error(f"Failed to get sync operations: {e}") logger.error(f"Failed to get sync operations: {e}")

View File

@@ -5,14 +5,14 @@ from fastapi import APIRouter, HTTPException, Query
from loguru import logger from loguru import logger
from leggen.api.models.accounts import Transaction, TransactionSummary from leggen.api.models.accounts import Transaction, TransactionSummary
from leggen.api.models.common import APIResponse, PaginatedResponse from leggen.api.models.common import PaginatedResponse
from leggen.services.database_service import DatabaseService from leggen.services.database_service import DatabaseService
router = APIRouter() router = APIRouter()
database_service = DatabaseService() database_service = DatabaseService()
@router.get("/transactions", response_model=PaginatedResponse) @router.get("/transactions")
async def get_all_transactions( async def get_all_transactions(
page: int = Query(default=1, ge=1, description="Page number (1-based)"), page: int = Query(default=1, ge=1, description="Page number (1-based)"),
per_page: int = Query(default=50, le=500, description="Items per page"), per_page: int = Query(default=50, le=500, description="Items per page"),
@@ -35,7 +35,7 @@ async def get_all_transactions(
default=None, description="Search in transaction descriptions" default=None, description="Search in transaction descriptions"
), ),
account_id: Optional[str] = Query(default=None, description="Filter by account ID"), account_id: Optional[str] = Query(default=None, description="Filter by account ID"),
) -> PaginatedResponse: ) -> PaginatedResponse[Union[TransactionSummary, Transaction]]:
"""Get all transactions from database with filtering options""" """Get all transactions from database with filtering options"""
try: try:
# Calculate offset from page and per_page # Calculate offset from page and per_page
@@ -103,16 +103,13 @@ async def get_all_transactions(
total_pages = (total_transactions + per_page - 1) // per_page total_pages = (total_transactions + per_page - 1) // per_page
return PaginatedResponse( return PaginatedResponse(
success=True,
data=data, data=data,
pagination={ total=total_transactions,
"total": total_transactions, page=page,
"page": page, per_page=per_page,
"per_page": per_page, total_pages=total_pages,
"total_pages": total_pages, has_next=page < total_pages,
"has_next": page < total_pages, has_prev=page > 1,
"has_prev": page > 1,
},
) )
except Exception as e: except Exception as e:
@@ -122,11 +119,11 @@ async def get_all_transactions(
) from e ) from e
@router.get("/transactions/stats", response_model=APIResponse) @router.get("/transactions/stats")
async def get_transaction_stats( async def get_transaction_stats(
days: int = Query(default=30, description="Number of days to include in stats"), days: int = Query(default=30, description="Number of days to include in stats"),
account_id: Optional[str] = Query(default=None, description="Filter by account ID"), account_id: Optional[str] = Query(default=None, description="Filter by account ID"),
) -> APIResponse: ) -> dict:
"""Get transaction statistics for the last N days from database""" """Get transaction statistics for the last N days from database"""
try: try:
# Date range for stats # Date range for stats
@@ -192,11 +189,7 @@ async def get_transaction_stats(
"accounts_included": unique_accounts, "accounts_included": unique_accounts,
} }
return APIResponse( return stats
success=True,
data=stats,
message=f"Transaction statistics for last {days} days",
)
except Exception as e: except Exception as e:
logger.error(f"Failed to get transaction stats from database: {e}") logger.error(f"Failed to get transaction stats from database: {e}")
@@ -205,11 +198,11 @@ async def get_transaction_stats(
) from e ) from e
@router.get("/transactions/analytics", response_model=APIResponse) @router.get("/transactions/analytics")
async def get_transactions_for_analytics( async def get_transactions_for_analytics(
days: int = Query(default=365, description="Number of days to include"), days: int = Query(default=365, description="Number of days to include"),
account_id: Optional[str] = Query(default=None, description="Filter by account ID"), account_id: Optional[str] = Query(default=None, description="Filter by account ID"),
) -> APIResponse: ) -> List[dict]:
"""Get all transactions for analytics (no pagination) for the last N days""" """Get all transactions for analytics (no pagination) for the last N days"""
try: try:
# Date range for analytics # Date range for analytics
@@ -242,11 +235,7 @@ async def get_transactions_for_analytics(
for txn in transactions for txn in transactions
] ]
return APIResponse( return transaction_summaries
success=True,
data=transaction_summaries,
message=f"Retrieved {len(transaction_summaries)} transactions for analytics",
)
except Exception as e: except Exception as e:
logger.error(f"Failed to get transactions for analytics: {e}") logger.error(f"Failed to get transactions for analytics: {e}")
@@ -255,11 +244,11 @@ async def get_transactions_for_analytics(
) from e ) from e
@router.get("/transactions/monthly-stats", response_model=APIResponse) @router.get("/transactions/monthly-stats")
async def get_monthly_transaction_stats( async def get_monthly_transaction_stats(
days: int = Query(default=365, description="Number of days to include"), days: int = Query(default=365, description="Number of days to include"),
account_id: Optional[str] = Query(default=None, description="Filter by account ID"), account_id: Optional[str] = Query(default=None, description="Filter by account ID"),
) -> APIResponse: ) -> List[dict]:
"""Get monthly transaction statistics aggregated by the database""" """Get monthly transaction statistics aggregated by the database"""
try: try:
# Date range for monthly stats # Date range for monthly stats
@@ -277,11 +266,7 @@ async def get_monthly_transaction_stats(
date_to=date_to, date_to=date_to,
) )
return APIResponse( return monthly_stats
success=True,
data=monthly_stats,
message=f"Retrieved monthly stats for last {days} days",
)
except Exception as e: except Exception as e:
logger.error(f"Failed to get monthly transaction stats: {e}") logger.error(f"Failed to get monthly transaction stats: {e}")

View File

@@ -89,8 +89,6 @@ def create_app() -> FastAPI:
async def health(): async def health():
"""Health check endpoint for API connectivity""" """Health check endpoint for API connectivity"""
try: try:
from leggen.api.models.common import APIResponse
config_loaded = config._config is not None config_loaded = config._config is not None
# Get version dynamically # Get version dynamically
@@ -99,25 +97,17 @@ def create_app() -> FastAPI:
except metadata.PackageNotFoundError: except metadata.PackageNotFoundError:
version = "dev" version = "dev"
return APIResponse( return {
success=True,
data={
"status": "healthy", "status": "healthy",
"config_loaded": config_loaded, "config_loaded": config_loaded,
"version": version, "version": version,
"message": "API is running and responsive", }
},
message="Health check successful",
)
except Exception as e: except Exception as e:
logger.error(f"Health check failed: {e}") logger.error(f"Health check failed: {e}")
from leggen.api.models.common import APIResponse return {
"status": "unhealthy",
return APIResponse( "error": str(e),
success=False, }
data={"status": "unhealthy", "error": str(e)},
message="Health check failed",
)
return app return app

View File

@@ -1,6 +1,8 @@
"""Pytest configuration and shared fixtures.""" """Pytest configuration and shared fixtures."""
import json import json
import os
import shutil
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
@@ -8,9 +10,45 @@ from unittest.mock import patch
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from leggen.commands.server import create_app
from leggen.utils.config import Config from leggen.utils.config import Config
# Create test config before any imports that might load it
_test_config_dir = tempfile.mkdtemp(prefix="leggen_test_")
_test_config_path = Path(_test_config_dir) / "config.toml"
# Create minimal test config
_config_data = {
"gocardless": {
"key": "test-key",
"secret": "test-secret",
"url": "https://bankaccountdata.gocardless.com/api/v2",
},
"database": {"sqlite": True},
"scheduler": {"sync": {"enabled": True, "hour": 3, "minute": 0}},
}
import tomli_w
with open(_test_config_path, "wb") as f:
tomli_w.dump(_config_data, f)
# Set environment variables to point to test config BEFORE importing the app
os.environ["LEGGEN_CONFIG_FILE"] = str(_test_config_path)
from leggen.commands.server import create_app
def pytest_configure(config):
"""Pytest hook called before test collection."""
# Ensure test config is set
os.environ["LEGGEN_CONFIG_FILE"] = str(_test_config_path)
def pytest_unconfigure(config):
"""Pytest hook called after all tests."""
# Cleanup test config directory
if Path(_test_config_dir).exists():
shutil.rmtree(_test_config_dir)
@pytest.fixture @pytest.fixture
def temp_config_dir(): def temp_config_dir():
@@ -73,9 +111,12 @@ def mock_auth_token(temp_config_dir):
@pytest.fixture @pytest.fixture
def fastapi_app(): def fastapi_app(mock_db_path):
"""Create FastAPI test application.""" """Create FastAPI test application."""
return create_app() # Patch the database path for the app
with patch("leggen.utils.paths.path_manager.get_database_path", return_value=mock_db_path):
app = create_app()
yield app
@pytest.fixture @pytest.fixture

View File

@@ -66,8 +66,7 @@ class TestAnalyticsFix:
) )
# Verify that the response contains stats for all 600 transactions # Verify that the response contains stats for all 600 transactions
assert data["success"] is True stats = data
stats = data["data"]
assert stats["total_transactions"] == 600, ( assert stats["total_transactions"] == 600, (
"Should process all 600 transactions, not just 100" "Should process all 600 transactions, not just 100"
) )
@@ -132,8 +131,7 @@ class TestAnalyticsFix:
) )
# Verify that all 600 transactions are returned # Verify that all 600 transactions are returned
assert data["success"] is True transactions_data = data
transactions_data = data["data"]
assert len(transactions_data) == 600, ( assert len(transactions_data) == 600, (
"Analytics endpoint should return all 600 transactions" "Analytics endpoint should return all 600 transactions"
) )

View File

@@ -60,9 +60,8 @@ class TestAccountsAPI:
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["success"] is True assert len(data) == 1
assert len(data["data"]) == 1 account = data[0]
account = data["data"][0]
assert account["id"] == "test-account-123" assert account["id"] == "test-account-123"
assert account["institution_id"] == "REVOLUT_REVOLT21" assert account["institution_id"] == "REVOLUT_REVOLT21"
assert len(account["balances"]) == 1 assert len(account["balances"]) == 1
@@ -117,11 +116,9 @@ class TestAccountsAPI:
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["success"] is True assert data["id"] == "test-account-123"
account = data["data"] assert data["iban"] == "LT313250081177977789"
assert account["id"] == "test-account-123" assert len(data["balances"]) == 1
assert account["iban"] == "LT313250081177977789"
assert len(account["balances"]) == 1
def test_get_account_balances_success( def test_get_account_balances_success(
self, api_client, mock_config, mock_auth_token, mock_db_path self, api_client, mock_config, mock_auth_token, mock_db_path
@@ -163,11 +160,10 @@ class TestAccountsAPI:
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["success"] is True assert len(data) == 2
assert len(data["data"]) == 2 assert data[0]["amount"] == 1000.00
assert data["data"][0]["amount"] == 1000.00 assert data[0]["currency"] == "EUR"
assert data["data"][0]["currency"] == "EUR" assert data[0]["balance_type"] == "interimAvailable"
assert data["data"][0]["balance_type"] == "interimAvailable"
def test_get_account_transactions_success( def test_get_account_transactions_success(
self, self,
@@ -212,10 +208,9 @@ class TestAccountsAPI:
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["success"] is True assert len(data) == 1
assert len(data["data"]) == 1
transaction = data["data"][0] transaction = data[0]
assert transaction["internal_transaction_id"] == "txn-123" assert transaction["internal_transaction_id"] == "txn-123"
assert transaction["amount"] == -10.50 assert transaction["amount"] == -10.50
assert transaction["currency"] == "EUR" assert transaction["currency"] == "EUR"
@@ -264,10 +259,9 @@ class TestAccountsAPI:
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["success"] is True assert len(data) == 1
assert len(data["data"]) == 1
transaction = data["data"][0] transaction = data[0]
assert transaction["internal_transaction_id"] == "txn-123" assert transaction["internal_transaction_id"] == "txn-123"
assert transaction["institution_id"] == "REVOLUT_REVOLT21" assert transaction["institution_id"] == "REVOLUT_REVOLT21"
assert transaction["iban"] == "LT313250081177977789" assert transaction["iban"] == "LT313250081177977789"
@@ -321,9 +315,8 @@ class TestAccountsAPI:
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["success"] is True assert data["id"] == "test-account-123"
assert data["data"]["id"] == "test-account-123" assert data["display_name"] == "My Custom Account Name"
assert data["data"]["display_name"] == "My Custom Account Name"
def test_update_account_not_found( def test_update_account_not_found(
self, api_client, mock_config, mock_auth_token, mock_db_path self, api_client, mock_config, mock_auth_token, mock_db_path

View File

@@ -19,8 +19,7 @@ class TestBackupAPI:
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["success"] is True assert data["s3"] is None
assert data["data"]["s3"] is None
def test_get_backup_settings_with_s3_config(self, api_client, mock_config): def test_get_backup_settings_with_s3_config(self, api_client, mock_config):
"""Test getting backup settings with S3 configuration.""" """Test getting backup settings with S3 configuration."""
@@ -42,10 +41,9 @@ class TestBackupAPI:
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["success"] is True assert data["s3"] is not None
assert data["data"]["s3"] is not None
s3_config = data["data"]["s3"] s3_config = data["s3"]
assert s3_config["access_key_id"] == "***" # Masked assert s3_config["access_key_id"] == "***" # Masked
assert s3_config["secret_access_key"] == "***" # Masked assert s3_config["secret_access_key"] == "***" # Masked
assert s3_config["bucket_name"] == "test-bucket" assert s3_config["bucket_name"] == "test-bucket"
@@ -77,8 +75,7 @@ class TestBackupAPI:
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["success"] is True assert data["updated"] is True
assert data["data"]["updated"] is True
# Verify connection test was called # Verify connection test was called
mock_test_connection.assert_called_once() mock_test_connection.assert_called_once()
@@ -132,8 +129,7 @@ class TestBackupAPI:
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["success"] is True assert data["connected"] is True
assert data["data"]["connected"] is True
# Verify connection test was called # Verify connection test was called
mock_test_connection.assert_called_once() mock_test_connection.assert_called_once()
@@ -158,9 +154,9 @@ class TestBackupAPI:
response = api_client.post("/api/v1/backup/test", json=request_data) response = api_client.post("/api/v1/backup/test", json=request_data)
assert response.status_code == 200 assert response.status_code == 400
data = response.json() data = response.json()
assert data["success"] is False assert "S3 connection test failed" in data["detail"]
def test_test_backup_connection_invalid_service(self, api_client): def test_test_backup_connection_invalid_service(self, api_client):
"""Test backup connection test with invalid service.""" """Test backup connection test with invalid service."""
@@ -214,10 +210,9 @@ class TestBackupAPI:
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["success"] is True assert len(data) == 2
assert len(data["data"]) == 2
assert ( assert (
data["data"][0]["key"] data[0]["key"]
== "leggen_backups/database_backup_20250101_120000.db" == "leggen_backups/database_backup_20250101_120000.db"
) )
@@ -230,8 +225,7 @@ class TestBackupAPI:
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["success"] is True assert data == []
assert data["data"] == []
@patch("leggen.services.backup_service.BackupService.backup_database") @patch("leggen.services.backup_service.BackupService.backup_database")
@patch("leggen.utils.paths.path_manager.get_database_path") @patch("leggen.utils.paths.path_manager.get_database_path")
@@ -261,9 +255,8 @@ class TestBackupAPI:
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["success"] is True assert data["operation"] == "backup"
assert data["data"]["operation"] == "backup" assert data["completed"] is True
assert data["data"]["completed"] is True
# Verify backup was called # Verify backup was called
mock_backup_db.assert_called_once() mock_backup_db.assert_called_once()

View File

@@ -33,10 +33,9 @@ class TestBanksAPI:
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["success"] is True assert len(data) == 2
assert len(data["data"]) == 2 assert data[0]["id"] == "REVOLUT_REVOLT21"
assert data["data"][0]["id"] == "REVOLUT_REVOLT21" assert data[1]["id"] == "BANCOBPI_BBPIPTPL"
assert data["data"][1]["id"] == "BANCOBPI_BBPIPTPL"
@respx.mock @respx.mock
def test_get_institutions_invalid_country(self, api_client, mock_config): def test_get_institutions_invalid_country(self, api_client, mock_config):
@@ -92,9 +91,8 @@ class TestBanksAPI:
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["success"] is True assert data["id"] == "req-123"
assert data["data"]["id"] == "req-123" assert data["institution_id"] == "REVOLUT_REVOLT21"
assert data["data"]["institution_id"] == "REVOLUT_REVOLT21"
@respx.mock @respx.mock
def test_get_bank_status_success(self, api_client, mock_config, mock_auth_token): def test_get_bank_status_success(self, api_client, mock_config, mock_auth_token):
@@ -128,10 +126,9 @@ class TestBanksAPI:
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["success"] is True assert len(data) == 1
assert len(data["data"]) == 1 assert data[0]["bank_id"] == "REVOLUT_REVOLT21"
assert data["data"][0]["bank_id"] == "REVOLUT_REVOLT21" assert data[0]["status_display"] == "LINKED"
assert data["data"][0]["status_display"] == "LINKED"
def test_get_supported_countries(self, api_client): def test_get_supported_countries(self, api_client):
"""Test supported countries endpoint.""" """Test supported countries endpoint."""
@@ -139,11 +136,10 @@ class TestBanksAPI:
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["success"] is True assert len(data) > 0
assert len(data["data"]) > 0
# Check some expected countries # Check some expected countries
country_codes = [country["code"] for country in data["data"]] country_codes = [country["code"] for country in data]
assert "PT" in country_codes assert "PT" in country_codes
assert "GB" in country_codes assert "GB" in country_codes
assert "DE" in country_codes assert "DE" in country_codes

View File

@@ -58,7 +58,6 @@ class TestTransactionsAPI:
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["success"] is True
assert len(data["data"]) == 2 assert len(data["data"]) == 2
# Check first transaction summary # Check first transaction summary
@@ -105,7 +104,6 @@ class TestTransactionsAPI:
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["success"] is True
assert len(data["data"]) == 1 assert len(data["data"]) == 1
transaction = data["data"][0] transaction = data["data"][0]
@@ -160,7 +158,6 @@ class TestTransactionsAPI:
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["success"] is True
# Verify the database service was called with correct filters # Verify the database service was called with correct filters
mock_get_transactions.assert_called_once_with( mock_get_transactions.assert_called_once_with(
@@ -193,11 +190,10 @@ class TestTransactionsAPI:
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["success"] is True
assert len(data["data"]) == 0 assert len(data["data"]) == 0
assert data["pagination"]["total"] == 0 assert data["total"] == 0
assert data["pagination"]["page"] == 1 assert data["page"] == 1
assert data["pagination"]["total_pages"] == 0 assert data["total_pages"] == 0
def test_get_transactions_database_error( def test_get_transactions_database_error(
self, api_client, mock_config, mock_auth_token self, api_client, mock_config, mock_auth_token
@@ -254,21 +250,19 @@ class TestTransactionsAPI:
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["success"] is True
stats = data["data"] assert data["period_days"] == 30
assert stats["period_days"] == 30 assert data["total_transactions"] == 3
assert stats["total_transactions"] == 3 assert data["booked_transactions"] == 2
assert stats["booked_transactions"] == 2 assert data["pending_transactions"] == 1
assert stats["pending_transactions"] == 1 assert data["total_income"] == 100.00
assert stats["total_income"] == 100.00 assert data["total_expenses"] == 35.80 # abs(-10.50) + abs(-25.30)
assert stats["total_expenses"] == 35.80 # abs(-10.50) + abs(-25.30) assert data["net_change"] == 64.20 # 100.00 - 35.80
assert stats["net_change"] == 64.20 # 100.00 - 35.80 assert data["accounts_included"] == 2 # Two unique account IDs
assert stats["accounts_included"] == 2 # Two unique account IDs
# Average transaction: ((-10.50) + 100.00 + (-25.30)) / 3 = 64.20 / 3 = 21.4 # Average transaction: ((-10.50) + 100.00 + (-25.30)) / 3 = 64.20 / 3 = 21.4
expected_avg = round(64.20 / 3, 2) expected_avg = round(64.20 / 3, 2)
assert stats["average_transaction"] == expected_avg assert data["average_transaction"] == expected_avg
def test_get_transaction_stats_with_account_filter( def test_get_transaction_stats_with_account_filter(
self, api_client, mock_config, mock_auth_token self, api_client, mock_config, mock_auth_token
@@ -317,15 +311,13 @@ class TestTransactionsAPI:
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["success"] is True
stats = data["data"] assert data["total_transactions"] == 0
assert stats["total_transactions"] == 0 assert data["total_income"] == 0.0
assert stats["total_income"] == 0.0 assert data["total_expenses"] == 0.0
assert stats["total_expenses"] == 0.0 assert data["net_change"] == 0.0
assert stats["net_change"] == 0.0 assert data["average_transaction"] == 0 # Division by zero handled
assert stats["average_transaction"] == 0 # Division by zero handled assert data["accounts_included"] == 0
assert stats["accounts_included"] == 0
def test_get_transaction_stats_database_error( def test_get_transaction_stats_database_error(
self, api_client, mock_config, mock_auth_token self, api_client, mock_config, mock_auth_token
@@ -368,7 +360,7 @@ class TestTransactionsAPI:
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["data"]["period_days"] == 7 assert data["period_days"] == 7
# Verify the date range was calculated correctly for 7 days # Verify the date range was calculated correctly for 7 days
mock_get_transactions.assert_called_once() mock_get_transactions.assert_called_once()