mirror of
https://github.com/elisiariocouto/leggen.git
synced 2025-12-13 11:22:21 +00:00
feat: Add mypy to pre-commit.
This commit is contained in:
committed by
Elisiário Couto
parent
de3da84dff
commit
ec8ef8346a
@@ -4,9 +4,11 @@
|
|||||||
"Bash(mkdir:*)",
|
"Bash(mkdir:*)",
|
||||||
"Bash(uv sync:*)",
|
"Bash(uv sync:*)",
|
||||||
"Bash(uv run pytest:*)",
|
"Bash(uv run pytest:*)",
|
||||||
"Bash(git commit:*)"
|
"Bash(git commit:*)",
|
||||||
|
"Bash(ruff check:*)",
|
||||||
|
"Bash(git add:*)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,23 @@
|
|||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/charliermarsh/ruff-pre-commit
|
- repo: https://github.com/charliermarsh/ruff-pre-commit
|
||||||
rev: "v0.9.1"
|
rev: "v0.12.11"
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff
|
- id: ruff
|
||||||
- id: ruff-format
|
- id: ruff-format
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
rev: v5.0.0
|
rev: v6.0.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: trailing-whitespace
|
- id: trailing-whitespace
|
||||||
exclude: ".*\\.md$"
|
exclude: ".*\\.md$"
|
||||||
- id: end-of-file-fixer
|
- id: end-of-file-fixer
|
||||||
- id: check-added-large-files
|
- id: check-added-large-files
|
||||||
|
- repo: local
|
||||||
|
hooks:
|
||||||
|
- id: mypy
|
||||||
|
name: Static type check with mypy
|
||||||
|
entry: uv run mypy leggen leggend --check-untyped-defs
|
||||||
|
files: "^leggen(d)?/.*"
|
||||||
|
language: "system"
|
||||||
|
types: ["python"]
|
||||||
|
always_run: true
|
||||||
|
pass_filenames: false
|
||||||
|
|||||||
@@ -66,4 +66,4 @@ All operations require a valid `config.toml` file with GoCardless API credential
|
|||||||
- `[gocardless]` - API credentials and endpoint
|
- `[gocardless]` - API credentials and endpoint
|
||||||
- `[database]` - Storage backend selection
|
- `[database]` - Storage backend selection
|
||||||
- `[notifications]` - Discord/Telegram webhook settings
|
- `[notifications]` - Discord/Telegram webhook settings
|
||||||
- `[filters]` - Transaction matching patterns for notifications
|
- `[filters]` - Transaction matching patterns for notifications
|
||||||
|
|||||||
@@ -88,4 +88,4 @@ Transform leggen from CLI-only to web application with FastAPI backend (`leggend
|
|||||||
- **APScheduler**: For internal job scheduling (replacing Ofelia)
|
- **APScheduler**: For internal job scheduling (replacing Ofelia)
|
||||||
- **SvelteKit**: For modern, reactive frontend
|
- **SvelteKit**: For modern, reactive frontend
|
||||||
- **Existing Logic**: Reuse all business logic from current CLI commands
|
- **Existing Logic**: Reuse all business logic from current CLI commands
|
||||||
- **Configuration**: Centralize in `leggend` service, maintain TOML compatibility
|
- **Configuration**: Centralize in `leggend` service, maintain TOML compatibility
|
||||||
|
|||||||
0
leggen/__init__.py
Normal file
0
leggen/__init__.py
Normal file
@@ -1,6 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
import requests
|
import requests
|
||||||
from typing import Dict, Any, Optional, List
|
from typing import Dict, Any, Optional, List, Union
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
from leggen.utils.text import error
|
from leggen.utils.text import error
|
||||||
@@ -9,9 +9,13 @@ from leggen.utils.text import error
|
|||||||
class LeggendAPIClient:
|
class LeggendAPIClient:
|
||||||
"""Client for communicating with the leggend FastAPI service"""
|
"""Client for communicating with the leggend FastAPI service"""
|
||||||
|
|
||||||
|
base_url: str
|
||||||
|
|
||||||
def __init__(self, base_url: Optional[str] = None):
|
def __init__(self, base_url: Optional[str] = None):
|
||||||
self.base_url = base_url or os.environ.get(
|
self.base_url = (
|
||||||
"LEGGEND_API_URL", "http://localhost:8000"
|
base_url
|
||||||
|
or os.environ.get("LEGGEND_API_URL", "http://localhost:8000")
|
||||||
|
or "http://localhost:8000"
|
||||||
)
|
)
|
||||||
self.session = requests.Session()
|
self.session = requests.Session()
|
||||||
self.session.headers.update(
|
self.session.headers.update(
|
||||||
@@ -36,7 +40,7 @@ class LeggendAPIClient:
|
|||||||
try:
|
try:
|
||||||
error_data = response.json()
|
error_data = response.json()
|
||||||
error(f"Error details: {error_data.get('detail', 'Unknown error')}")
|
error(f"Error details: {error_data.get('detail', 'Unknown error')}")
|
||||||
except:
|
except Exception:
|
||||||
error(f"Response: {response.text}")
|
error(f"Response: {response.text}")
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -48,7 +52,7 @@ class LeggendAPIClient:
|
|||||||
try:
|
try:
|
||||||
response = self._make_request("GET", "/health")
|
response = self._make_request("GET", "/health")
|
||||||
return response.get("status") == "healthy"
|
return response.get("status") == "healthy"
|
||||||
except:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Bank endpoints
|
# Bank endpoints
|
||||||
@@ -122,7 +126,7 @@ class LeggendAPIClient:
|
|||||||
self, days: int = 30, account_id: Optional[str] = None
|
self, days: int = 30, account_id: Optional[str] = None
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Get transaction statistics"""
|
"""Get transaction statistics"""
|
||||||
params = {"days": days}
|
params: Dict[str, Union[int, str]] = {"days": days}
|
||||||
if account_id:
|
if account_id:
|
||||||
params["account_id"] = account_id
|
params["account_id"] = account_id
|
||||||
|
|
||||||
@@ -141,7 +145,7 @@ class LeggendAPIClient:
|
|||||||
self, account_ids: Optional[List[str]] = None, force: bool = False
|
self, account_ids: Optional[List[str]] = None, force: bool = False
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Trigger a sync"""
|
"""Trigger a sync"""
|
||||||
data = {"force": force}
|
data: Dict[str, Union[bool, List[str]]] = {"force": force}
|
||||||
if account_ids:
|
if account_ids:
|
||||||
data["account_ids"] = account_ids
|
data["account_ids"] = account_ids
|
||||||
|
|
||||||
@@ -152,7 +156,7 @@ class LeggendAPIClient:
|
|||||||
self, account_ids: Optional[List[str]] = None, force: bool = False
|
self, account_ids: Optional[List[str]] = None, force: bool = False
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Run sync synchronously"""
|
"""Run sync synchronously"""
|
||||||
data = {"force": force}
|
data: Dict[str, Union[bool, List[str]]] = {"force": force}
|
||||||
if account_ids:
|
if account_ids:
|
||||||
data["account_ids"] = account_ids
|
data["account_ids"] = account_ids
|
||||||
|
|
||||||
@@ -172,7 +176,11 @@ class LeggendAPIClient:
|
|||||||
cron: Optional[str] = None,
|
cron: Optional[str] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Update scheduler configuration"""
|
"""Update scheduler configuration"""
|
||||||
data = {"enabled": enabled, "hour": hour, "minute": minute}
|
data: Dict[str, Union[bool, int, str]] = {
|
||||||
|
"enabled": enabled,
|
||||||
|
"hour": hour,
|
||||||
|
"minute": minute,
|
||||||
|
}
|
||||||
if cron:
|
if cron:
|
||||||
data["cron"] = cron
|
data["cron"] = cron
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ def add(ctx):
|
|||||||
|
|
||||||
success("Bank connection request created successfully!")
|
success("Bank connection request created successfully!")
|
||||||
warning(
|
warning(
|
||||||
f"Please open the following URL in your browser to complete the authorization:"
|
"Please open the following URL in your browser to complete the authorization:"
|
||||||
)
|
)
|
||||||
click.echo(f"\n{result['link']}\n")
|
click.echo(f"\n{result['link']}\n")
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import click
|
import click
|
||||||
|
|
||||||
from leggen.main import cli
|
from leggen.main import cli
|
||||||
from leggen.utils.network import delete as http_delete
|
|
||||||
from leggen.utils.text import info, success
|
from leggen.utils.text import info, success
|
||||||
|
|
||||||
|
|
||||||
@@ -16,11 +15,12 @@ def delete(ctx, requisition_id: str):
|
|||||||
|
|
||||||
Check `leggen status` to get the REQUISITION_ID
|
Check `leggen status` to get the REQUISITION_ID
|
||||||
"""
|
"""
|
||||||
|
import requests
|
||||||
|
|
||||||
info(f"Deleting Bank Requisition: {requisition_id}")
|
info(f"Deleting Bank Requisition: {requisition_id}")
|
||||||
|
|
||||||
_ = http_delete(
|
api_url = ctx.obj.get("api_url", "http://localhost:8000")
|
||||||
ctx,
|
res = requests.delete(f"{api_url}/requisitions/{requisition_id}")
|
||||||
f"/requisitions/{requisition_id}",
|
res.raise_for_status()
|
||||||
)
|
|
||||||
|
|
||||||
success(f"Bank Requisition {requisition_id} deleted")
|
success(f"Bank Requisition {requisition_id} deleted")
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ def sync(ctx: click.Context, wait: bool, force: bool):
|
|||||||
result = api_client.sync_now(force=force)
|
result = api_client.sync_now(force=force)
|
||||||
|
|
||||||
if result.get("success"):
|
if result.get("success"):
|
||||||
success(f"Sync completed successfully!")
|
success("Sync completed successfully!")
|
||||||
info(f"Accounts processed: {result.get('accounts_processed', 0)}")
|
info(f"Accounts processed: {result.get('accounts_processed', 0)}")
|
||||||
info(f"Transactions added: {result.get('transactions_added', 0)}")
|
info(f"Transactions added: {result.get('transactions_added', 0)}")
|
||||||
info(f"Balances updated: {result.get('balances_updated', 0)}")
|
info(f"Balances updated: {result.get('balances_updated', 0)}")
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ from pathlib import Path
|
|||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
from leggen.utils.auth import get_token
|
|
||||||
from leggen.utils.config import load_config
|
from leggen.utils.config import load_config
|
||||||
from leggen.utils.text import error
|
from leggen.utils.text import error
|
||||||
|
|
||||||
@@ -111,14 +110,4 @@ def cli(ctx: click.Context, api_url: str):
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Store API URL in context for commands to use
|
# Store API URL in context for commands to use
|
||||||
if api_url:
|
ctx.obj["api_url"] = api_url
|
||||||
ctx.obj["api_url"] = api_url
|
|
||||||
|
|
||||||
# For backwards compatibility, still support direct GoCardless calls
|
|
||||||
# This will be used as fallback if leggend service is not available
|
|
||||||
try:
|
|
||||||
token = get_token(ctx)
|
|
||||||
ctx.obj["headers"] = {"Authorization": f"Bearer {token}"}
|
|
||||||
except Exception:
|
|
||||||
# If we can't get token, commands will rely on API service
|
|
||||||
pass
|
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import click
|
|
||||||
import requests
|
|
||||||
|
|
||||||
from leggen.utils.text import warning
|
|
||||||
|
|
||||||
|
|
||||||
def create_token(ctx: click.Context) -> str:
|
|
||||||
"""
|
|
||||||
Create a new token
|
|
||||||
"""
|
|
||||||
res = requests.post(
|
|
||||||
f"{ctx.obj['gocardless']['url']}/token/new/",
|
|
||||||
json={
|
|
||||||
"secret_id": ctx.obj["gocardless"]["key"],
|
|
||||||
"secret_key": ctx.obj["gocardless"]["secret"],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
res.raise_for_status()
|
|
||||||
auth = res.json()
|
|
||||||
save_auth(auth)
|
|
||||||
return auth["access"]
|
|
||||||
|
|
||||||
|
|
||||||
def get_token(ctx: click.Context) -> str:
|
|
||||||
"""
|
|
||||||
Get the token from the auth file or request a new one
|
|
||||||
"""
|
|
||||||
auth_file = Path.home() / ".config" / "leggen" / "auth.json"
|
|
||||||
if auth_file.exists():
|
|
||||||
with click.open_file(str(auth_file), "r") as f:
|
|
||||||
auth = json.load(f)
|
|
||||||
if not auth.get("access"):
|
|
||||||
return create_token(ctx)
|
|
||||||
|
|
||||||
res = requests.post(
|
|
||||||
f"{ctx.obj['gocardless']['url']}/token/refresh/",
|
|
||||||
json={"refresh": auth["refresh"]},
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
res.raise_for_status()
|
|
||||||
auth.update(res.json())
|
|
||||||
save_auth(auth)
|
|
||||||
return auth["access"]
|
|
||||||
except requests.exceptions.HTTPError:
|
|
||||||
warning(
|
|
||||||
f"Token probably expired, requesting a new one.\nResponse: {res.status_code}\n{res.text}"
|
|
||||||
)
|
|
||||||
return create_token(ctx)
|
|
||||||
else:
|
|
||||||
return create_token(ctx)
|
|
||||||
|
|
||||||
|
|
||||||
def save_auth(d: dict):
|
|
||||||
auth_dir = Path.home() / ".config" / "leggen"
|
|
||||||
auth_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
auth_file = auth_dir / "auth.json"
|
|
||||||
|
|
||||||
with click.open_file(str(auth_file), "w") as f:
|
|
||||||
json.dump(d, f)
|
|
||||||
@@ -3,7 +3,6 @@ from datetime import datetime
|
|||||||
import click
|
import click
|
||||||
|
|
||||||
import leggen.database.sqlite as sqlite_engine
|
import leggen.database.sqlite as sqlite_engine
|
||||||
from leggen.utils.network import get
|
|
||||||
from leggen.utils.text import info, warning
|
from leggen.utils.text import info, warning
|
||||||
|
|
||||||
|
|
||||||
@@ -32,15 +31,21 @@ def persist_transactions(ctx: click.Context, account: str, transactions: list) -
|
|||||||
|
|
||||||
|
|
||||||
def save_transactions(ctx: click.Context, account: str) -> list:
|
def save_transactions(ctx: click.Context, account: str) -> list:
|
||||||
|
import requests
|
||||||
|
|
||||||
|
api_url = ctx.obj.get("api_url", "http://localhost:8000")
|
||||||
|
|
||||||
info(f"[{account}] Getting account details")
|
info(f"[{account}] Getting account details")
|
||||||
account_info = get(ctx, f"/accounts/{account}")
|
res = requests.get(f"{api_url}/accounts/{account}")
|
||||||
|
res.raise_for_status()
|
||||||
|
account_info = res.json()
|
||||||
|
|
||||||
info(f"[{account}] Getting transactions")
|
info(f"[{account}] Getting transactions")
|
||||||
transactions = []
|
transactions = []
|
||||||
|
|
||||||
account_transactions = get(ctx, f"/accounts/{account}/transactions/").get(
|
res = requests.get(f"{api_url}/accounts/{account}/transactions/")
|
||||||
"transactions", []
|
res.raise_for_status()
|
||||||
)
|
account_transactions = res.json().get("transactions", [])
|
||||||
|
|
||||||
for transaction in account_transactions.get("booked", []):
|
for transaction in account_transactions.get("booked", []):
|
||||||
booked_date = transaction.get("bookingDateTime") or transaction.get(
|
booked_date = transaction.get("bookingDateTime") or transaction.get(
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
import click
|
|
||||||
import requests
|
|
||||||
|
|
||||||
from leggen.utils.text import error
|
|
||||||
|
|
||||||
|
|
||||||
def get(ctx: click.Context, path: str, params: dict = {}):
|
|
||||||
"""
|
|
||||||
GET request to the GoCardless API
|
|
||||||
"""
|
|
||||||
|
|
||||||
url = f"{ctx.obj['gocardless']['url']}{path}"
|
|
||||||
res = requests.get(url, headers=ctx.obj["headers"], params=params)
|
|
||||||
try:
|
|
||||||
res.raise_for_status()
|
|
||||||
except Exception as e:
|
|
||||||
error(f"Error: {e}\n{res.text}")
|
|
||||||
ctx.abort()
|
|
||||||
return res.json()
|
|
||||||
|
|
||||||
|
|
||||||
def post(ctx: click.Context, path: str, data: dict = {}):
|
|
||||||
"""
|
|
||||||
POST request to the GoCardless API
|
|
||||||
"""
|
|
||||||
|
|
||||||
url = f"{ctx.obj['gocardless']['url']}{path}"
|
|
||||||
res = requests.post(url, headers=ctx.obj["headers"], json=data)
|
|
||||||
try:
|
|
||||||
res.raise_for_status()
|
|
||||||
except Exception as e:
|
|
||||||
error(f"Error: {e}\n{res.text}")
|
|
||||||
ctx.abort()
|
|
||||||
return res.json()
|
|
||||||
|
|
||||||
|
|
||||||
def put(ctx: click.Context, path: str, data: dict = {}):
|
|
||||||
"""
|
|
||||||
PUT request to the GoCardless API
|
|
||||||
"""
|
|
||||||
|
|
||||||
url = f"{ctx.obj['gocardless']['url']}{path}"
|
|
||||||
res = requests.put(url, headers=ctx.obj["headers"], json=data)
|
|
||||||
try:
|
|
||||||
res.raise_for_status()
|
|
||||||
except Exception as e:
|
|
||||||
error(f"Error: {e}\n{res.text}")
|
|
||||||
ctx.abort()
|
|
||||||
return res.json()
|
|
||||||
|
|
||||||
|
|
||||||
def delete(ctx: click.Context, path: str):
|
|
||||||
"""
|
|
||||||
DELETE request to the GoCardless API
|
|
||||||
"""
|
|
||||||
|
|
||||||
url = f"{ctx.obj['gocardless']['url']}{path}"
|
|
||||||
res = requests.delete(url, headers=ctx.obj["headers"])
|
|
||||||
try:
|
|
||||||
res.raise_for_status()
|
|
||||||
except Exception as e:
|
|
||||||
error(f"Error: {e}\n{res.text}")
|
|
||||||
ctx.abort()
|
|
||||||
return res.json()
|
|
||||||
0
leggend/__init__.py
Normal file
0
leggend/__init__.py
Normal file
@@ -1,4 +1,3 @@
|
|||||||
from datetime import datetime
|
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from typing import Dict, Any, Optional, List
|
from typing import Dict, Optional, List
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from typing import List, Optional
|
from typing import Optional, List, Union
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
@@ -74,7 +74,9 @@ async def get_all_accounts() -> APIResponse:
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to get accounts: {e}")
|
logger.error(f"Failed to get accounts: {e}")
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to get accounts: {str(e)}")
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Failed to get accounts: {str(e)}"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
|
||||||
@router.get("/accounts/{account_id}", response_model=APIResponse)
|
@router.get("/accounts/{account_id}", response_model=APIResponse)
|
||||||
@@ -117,7 +119,9 @@ async def get_account_details(account_id: str) -> APIResponse:
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to get account details for {account_id}: {e}")
|
logger.error(f"Failed to get account details for {account_id}: {e}")
|
||||||
raise HTTPException(status_code=404, detail=f"Account not found: {str(e)}")
|
raise HTTPException(
|
||||||
|
status_code=404, detail=f"Account not found: {str(e)}"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
|
||||||
@router.get("/accounts/{account_id}/balances", response_model=APIResponse)
|
@router.get("/accounts/{account_id}/balances", response_model=APIResponse)
|
||||||
@@ -146,7 +150,9 @@ async def get_account_balances(account_id: str) -> APIResponse:
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to get balances for account {account_id}: {e}")
|
logger.error(f"Failed to get balances for account {account_id}: {e}")
|
||||||
raise HTTPException(status_code=404, detail=f"Failed to get balances: {str(e)}")
|
raise HTTPException(
|
||||||
|
status_code=404, detail=f"Failed to get balances: {str(e)}"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
|
||||||
@router.get("/accounts/{account_id}/transactions", response_model=APIResponse)
|
@router.get("/accounts/{account_id}/transactions", response_model=APIResponse)
|
||||||
@@ -172,11 +178,17 @@ async def get_account_transactions(
|
|||||||
|
|
||||||
# Apply pagination
|
# Apply pagination
|
||||||
total_transactions = len(processed_transactions)
|
total_transactions = len(processed_transactions)
|
||||||
paginated_transactions = processed_transactions[offset : offset + limit]
|
actual_offset = offset or 0
|
||||||
|
actual_limit = limit or 100
|
||||||
|
paginated_transactions = processed_transactions[
|
||||||
|
actual_offset : actual_offset + actual_limit
|
||||||
|
]
|
||||||
|
|
||||||
|
data: Union[List[TransactionSummary], List[Transaction]]
|
||||||
|
|
||||||
if summary_only:
|
if summary_only:
|
||||||
# Return simplified transaction summaries
|
# Return simplified transaction summaries
|
||||||
summaries = [
|
data = [
|
||||||
TransactionSummary(
|
TransactionSummary(
|
||||||
internal_transaction_id=txn["internalTransactionId"],
|
internal_transaction_id=txn["internalTransactionId"],
|
||||||
date=txn["transactionDate"],
|
date=txn["transactionDate"],
|
||||||
@@ -188,10 +200,9 @@ async def get_account_transactions(
|
|||||||
)
|
)
|
||||||
for txn in paginated_transactions
|
for txn in paginated_transactions
|
||||||
]
|
]
|
||||||
data = summaries
|
|
||||||
else:
|
else:
|
||||||
# Return full transaction details
|
# Return full transaction details
|
||||||
transactions = [
|
data = [
|
||||||
Transaction(
|
Transaction(
|
||||||
internal_transaction_id=txn["internalTransactionId"],
|
internal_transaction_id=txn["internalTransactionId"],
|
||||||
institution_id=txn["institutionId"],
|
institution_id=txn["institutionId"],
|
||||||
@@ -206,16 +217,15 @@ async def get_account_transactions(
|
|||||||
)
|
)
|
||||||
for txn in paginated_transactions
|
for txn in paginated_transactions
|
||||||
]
|
]
|
||||||
data = transactions
|
|
||||||
|
|
||||||
return APIResponse(
|
return APIResponse(
|
||||||
success=True,
|
success=True,
|
||||||
data=data,
|
data=data,
|
||||||
message=f"Retrieved {len(data)} transactions (showing {offset + 1}-{offset + len(data)} of {total_transactions})",
|
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(f"Failed to get transactions for account {account_id}: {e}")
|
logger.error(f"Failed to get transactions for account {account_id}: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=404, detail=f"Failed to get transactions: {str(e)}"
|
status_code=404, detail=f"Failed to get transactions: {str(e)}"
|
||||||
)
|
) from e
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
from typing import List, Optional
|
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from leggend.api.models.common import APIResponse, ErrorResponse
|
from leggend.api.models.common import APIResponse
|
||||||
from leggend.api.models.banks import (
|
from leggend.api.models.banks import (
|
||||||
BankInstitution,
|
BankInstitution,
|
||||||
BankConnectionRequest,
|
BankConnectionRequest,
|
||||||
@@ -46,15 +45,16 @@ async def get_bank_institutions(
|
|||||||
logger.error(f"Failed to get institutions for {country}: {e}")
|
logger.error(f"Failed to get institutions for {country}: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500, detail=f"Failed to get institutions: {str(e)}"
|
status_code=500, detail=f"Failed to get institutions: {str(e)}"
|
||||||
)
|
) from e
|
||||||
|
|
||||||
|
|
||||||
@router.post("/banks/connect", response_model=APIResponse)
|
@router.post("/banks/connect", response_model=APIResponse)
|
||||||
async def connect_to_bank(request: BankConnectionRequest) -> APIResponse:
|
async def connect_to_bank(request: BankConnectionRequest) -> APIResponse:
|
||||||
"""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/"
|
||||||
requisition_data = await gocardless_service.create_requisition(
|
requisition_data = await gocardless_service.create_requisition(
|
||||||
request.institution_id, request.redirect_url
|
request.institution_id, redirect_url
|
||||||
)
|
)
|
||||||
|
|
||||||
requisition = BankRequisition(
|
requisition = BankRequisition(
|
||||||
@@ -69,14 +69,14 @@ async def connect_to_bank(request: BankConnectionRequest) -> APIResponse:
|
|||||||
return APIResponse(
|
return APIResponse(
|
||||||
success=True,
|
success=True,
|
||||||
data=requisition,
|
data=requisition,
|
||||||
message=f"Bank connection created. Please visit the link to authorize.",
|
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}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500, detail=f"Failed to connect to bank: {str(e)}"
|
status_code=500, detail=f"Failed to connect to bank: {str(e)}"
|
||||||
)
|
) from e
|
||||||
|
|
||||||
|
|
||||||
@router.get("/banks/status", response_model=APIResponse)
|
@router.get("/banks/status", response_model=APIResponse)
|
||||||
@@ -114,7 +114,7 @@ async def get_bank_connections_status() -> APIResponse:
|
|||||||
logger.error(f"Failed to get bank connection status: {e}")
|
logger.error(f"Failed to get bank connection status: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500, detail=f"Failed to get bank status: {str(e)}"
|
status_code=500, detail=f"Failed to get bank status: {str(e)}"
|
||||||
)
|
) from e
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/banks/connections/{requisition_id}", response_model=APIResponse)
|
@router.delete("/banks/connections/{requisition_id}", response_model=APIResponse)
|
||||||
@@ -132,7 +132,7 @@ async def delete_bank_connection(requisition_id: str) -> APIResponse:
|
|||||||
logger.error(f"Failed to delete bank connection {requisition_id}: {e}")
|
logger.error(f"Failed to delete bank connection {requisition_id}: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500, detail=f"Failed to delete connection: {str(e)}"
|
status_code=500, detail=f"Failed to delete connection: {str(e)}"
|
||||||
)
|
) from e
|
||||||
|
|
||||||
|
|
||||||
@router.get("/banks/countries", response_model=APIResponse)
|
@router.get("/banks/countries", response_model=APIResponse)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from typing import Optional
|
from typing import Dict, Any
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ async def get_notification_settings() -> APIResponse:
|
|||||||
logger.error(f"Failed to get notification settings: {e}")
|
logger.error(f"Failed to get notification settings: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500, detail=f"Failed to get notification settings: {str(e)}"
|
status_code=500, detail=f"Failed to get notification settings: {str(e)}"
|
||||||
)
|
) from e
|
||||||
|
|
||||||
|
|
||||||
@router.put("/notifications/settings", response_model=APIResponse)
|
@router.put("/notifications/settings", response_model=APIResponse)
|
||||||
@@ -84,7 +84,7 @@ async def update_notification_settings(settings: NotificationSettings) -> APIRes
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Update filters config
|
# Update filters config
|
||||||
filters_config = {}
|
filters_config: Dict[str, Any] = {}
|
||||||
if settings.filters.case_insensitive:
|
if settings.filters.case_insensitive:
|
||||||
filters_config["case-insensitive"] = settings.filters.case_insensitive
|
filters_config["case-insensitive"] = settings.filters.case_insensitive
|
||||||
if settings.filters.case_sensitive:
|
if settings.filters.case_sensitive:
|
||||||
@@ -110,7 +110,7 @@ async def update_notification_settings(settings: NotificationSettings) -> APIRes
|
|||||||
logger.error(f"Failed to update notification settings: {e}")
|
logger.error(f"Failed to update notification settings: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500, detail=f"Failed to update notification settings: {str(e)}"
|
status_code=500, detail=f"Failed to update notification settings: {str(e)}"
|
||||||
)
|
) from e
|
||||||
|
|
||||||
|
|
||||||
@router.post("/notifications/test", response_model=APIResponse)
|
@router.post("/notifications/test", response_model=APIResponse)
|
||||||
@@ -137,7 +137,7 @@ async def test_notification(test_request: NotificationTest) -> APIResponse:
|
|||||||
logger.error(f"Failed to send test notification: {e}")
|
logger.error(f"Failed to send test notification: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500, detail=f"Failed to send test notification: {str(e)}"
|
status_code=500, detail=f"Failed to send test notification: {str(e)}"
|
||||||
)
|
) from e
|
||||||
|
|
||||||
|
|
||||||
@router.get("/notifications/services", response_model=APIResponse)
|
@router.get("/notifications/services", response_model=APIResponse)
|
||||||
@@ -179,7 +179,7 @@ async def get_notification_services() -> APIResponse:
|
|||||||
logger.error(f"Failed to get notification services: {e}")
|
logger.error(f"Failed to get notification services: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500, detail=f"Failed to get notification services: {str(e)}"
|
status_code=500, detail=f"Failed to get notification services: {str(e)}"
|
||||||
)
|
) from e
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/notifications/settings/{service}", response_model=APIResponse)
|
@router.delete("/notifications/settings/{service}", response_model=APIResponse)
|
||||||
@@ -206,4 +206,4 @@ async def delete_notification_service(service: str) -> APIResponse:
|
|||||||
logger.error(f"Failed to delete notification service {service}: {e}")
|
logger.error(f"Failed to delete notification service {service}: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500, detail=f"Failed to delete notification service: {str(e)}"
|
status_code=500, detail=f"Failed to delete notification service: {str(e)}"
|
||||||
)
|
) from e
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from fastapi import APIRouter, HTTPException, BackgroundTasks
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from leggend.api.models.common import APIResponse
|
from leggend.api.models.common import APIResponse
|
||||||
from leggend.api.models.sync import SyncRequest, SyncStatus, SyncResult, SchedulerConfig
|
from leggend.api.models.sync import SyncRequest, SchedulerConfig
|
||||||
from leggend.services.sync_service import SyncService
|
from leggend.services.sync_service import SyncService
|
||||||
from leggend.background.scheduler import scheduler
|
from leggend.background.scheduler import scheduler
|
||||||
from leggend.config import config
|
from leggend.config import config
|
||||||
@@ -31,7 +31,7 @@ async def get_sync_status() -> APIResponse:
|
|||||||
logger.error(f"Failed to get sync status: {e}")
|
logger.error(f"Failed to get sync status: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500, detail=f"Failed to get sync status: {str(e)}"
|
status_code=500, detail=f"Failed to get sync status: {str(e)}"
|
||||||
)
|
) from e
|
||||||
|
|
||||||
|
|
||||||
@router.post("/sync", response_model=APIResponse)
|
@router.post("/sync", response_model=APIResponse)
|
||||||
@@ -78,7 +78,9 @@ async def trigger_sync(
|
|||||||
|
|
||||||
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(status_code=500, detail=f"Failed to trigger sync: {str(e)}")
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Failed to trigger sync: {str(e)}"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
|
||||||
@router.post("/sync/now", response_model=APIResponse)
|
@router.post("/sync/now", response_model=APIResponse)
|
||||||
@@ -104,7 +106,9 @@ async def sync_now(sync_request: Optional[SyncRequest] = None) -> APIResponse:
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to run sync: {e}")
|
logger.error(f"Failed to run sync: {e}")
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to run sync: {str(e)}")
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Failed to run sync: {str(e)}"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
|
||||||
@router.get("/sync/scheduler", response_model=APIResponse)
|
@router.get("/sync/scheduler", response_model=APIResponse)
|
||||||
@@ -134,7 +138,7 @@ async def get_scheduler_config() -> APIResponse:
|
|||||||
logger.error(f"Failed to get scheduler config: {e}")
|
logger.error(f"Failed to get scheduler config: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500, detail=f"Failed to get scheduler config: {str(e)}"
|
status_code=500, detail=f"Failed to get scheduler config: {str(e)}"
|
||||||
)
|
) from e
|
||||||
|
|
||||||
|
|
||||||
@router.put("/sync/scheduler", response_model=APIResponse)
|
@router.put("/sync/scheduler", response_model=APIResponse)
|
||||||
@@ -152,7 +156,7 @@ async def update_scheduler_config(scheduler_config: SchedulerConfig) -> APIRespo
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400, detail=f"Invalid cron expression: {str(e)}"
|
status_code=400, detail=f"Invalid cron expression: {str(e)}"
|
||||||
)
|
) from e
|
||||||
|
|
||||||
# Update configuration
|
# Update configuration
|
||||||
schedule_data = scheduler_config.dict(exclude_none=True)
|
schedule_data = scheduler_config.dict(exclude_none=True)
|
||||||
@@ -171,7 +175,7 @@ async def update_scheduler_config(scheduler_config: SchedulerConfig) -> APIRespo
|
|||||||
logger.error(f"Failed to update scheduler config: {e}")
|
logger.error(f"Failed to update scheduler config: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500, detail=f"Failed to update scheduler config: {str(e)}"
|
status_code=500, detail=f"Failed to update scheduler config: {str(e)}"
|
||||||
)
|
) from e
|
||||||
|
|
||||||
|
|
||||||
@router.post("/sync/scheduler/start", response_model=APIResponse)
|
@router.post("/sync/scheduler/start", response_model=APIResponse)
|
||||||
@@ -188,7 +192,7 @@ async def start_scheduler() -> APIResponse:
|
|||||||
logger.error(f"Failed to start scheduler: {e}")
|
logger.error(f"Failed to start scheduler: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500, detail=f"Failed to start scheduler: {str(e)}"
|
status_code=500, detail=f"Failed to start scheduler: {str(e)}"
|
||||||
)
|
) from e
|
||||||
|
|
||||||
|
|
||||||
@router.post("/sync/scheduler/stop", response_model=APIResponse)
|
@router.post("/sync/scheduler/stop", response_model=APIResponse)
|
||||||
@@ -205,4 +209,4 @@ async def stop_scheduler() -> APIResponse:
|
|||||||
logger.error(f"Failed to stop scheduler: {e}")
|
logger.error(f"Failed to stop scheduler: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500, detail=f"Failed to stop scheduler: {str(e)}"
|
status_code=500, detail=f"Failed to stop scheduler: {str(e)}"
|
||||||
)
|
) from e
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from typing import List, Optional
|
from typing import Optional, List, Union
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -120,7 +120,13 @@ async def get_all_transactions(
|
|||||||
|
|
||||||
# Apply pagination
|
# Apply pagination
|
||||||
total_transactions = len(filtered_transactions)
|
total_transactions = len(filtered_transactions)
|
||||||
paginated_transactions = filtered_transactions[offset : offset + limit]
|
actual_offset = offset or 0
|
||||||
|
actual_limit = limit or 100
|
||||||
|
paginated_transactions = filtered_transactions[
|
||||||
|
actual_offset : actual_offset + actual_limit
|
||||||
|
]
|
||||||
|
|
||||||
|
data: Union[List[TransactionSummary], List[Transaction]]
|
||||||
|
|
||||||
if summary_only:
|
if summary_only:
|
||||||
# Return simplified transaction summaries
|
# Return simplified transaction summaries
|
||||||
@@ -157,14 +163,14 @@ async def get_all_transactions(
|
|||||||
return APIResponse(
|
return APIResponse(
|
||||||
success=True,
|
success=True,
|
||||||
data=data,
|
data=data,
|
||||||
message=f"Retrieved {len(data)} transactions (showing {offset + 1}-{offset + len(data)} of {total_transactions})",
|
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(f"Failed to get transactions: {e}")
|
logger.error(f"Failed to get transactions: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500, detail=f"Failed to get transactions: {str(e)}"
|
status_code=500, detail=f"Failed to get transactions: {str(e)}"
|
||||||
)
|
) from e
|
||||||
|
|
||||||
|
|
||||||
@router.get("/transactions/stats", response_model=APIResponse)
|
@router.get("/transactions/stats", response_model=APIResponse)
|
||||||
@@ -270,4 +276,4 @@ async def get_transaction_stats(
|
|||||||
logger.error(f"Failed to get transaction stats: {e}")
|
logger.error(f"Failed to get transaction stats: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500, detail=f"Failed to get transaction stats: {str(e)}"
|
status_code=500, detail=f"Failed to get transaction stats: {str(e)}"
|
||||||
)
|
) from e
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class Config:
|
|||||||
cls._instance = super().__new__(cls)
|
cls._instance = super().__new__(cls)
|
||||||
return cls._instance
|
return cls._instance
|
||||||
|
|
||||||
def load_config(self, config_path: str = None) -> Dict[str, Any]:
|
def load_config(self, config_path: Optional[str] = None) -> Dict[str, Any]:
|
||||||
if self._config is not None:
|
if self._config is not None:
|
||||||
return self._config
|
return self._config
|
||||||
|
|
||||||
@@ -43,7 +43,9 @@ class Config:
|
|||||||
return self._config
|
return self._config
|
||||||
|
|
||||||
def save_config(
|
def save_config(
|
||||||
self, config_data: Dict[str, Any] = None, config_path: str = None
|
self,
|
||||||
|
config_data: Optional[Dict[str, Any]] = None,
|
||||||
|
config_path: Optional[str] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Save configuration to TOML file"""
|
"""Save configuration to TOML file"""
|
||||||
if config_data is None:
|
if config_data is None:
|
||||||
@@ -55,6 +57,11 @@ class Config:
|
|||||||
str(Path.home() / ".config" / "leggen" / "config.toml"),
|
str(Path.home() / ".config" / "leggen" / "config.toml"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if config_path is None:
|
||||||
|
raise ValueError("No config path specified")
|
||||||
|
if config_data is None:
|
||||||
|
raise ValueError("No config data to save")
|
||||||
|
|
||||||
# Ensure directory exists
|
# Ensure directory exists
|
||||||
Path(config_path).parent.mkdir(parents=True, exist_ok=True)
|
Path(config_path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
@@ -75,6 +82,9 @@ class Config:
|
|||||||
if self._config is None:
|
if self._config is None:
|
||||||
self.load_config()
|
self.load_config()
|
||||||
|
|
||||||
|
if self._config is None:
|
||||||
|
raise RuntimeError("Failed to load config")
|
||||||
|
|
||||||
if section not in self._config:
|
if section not in self._config:
|
||||||
self._config[section] = {}
|
self._config[section] = {}
|
||||||
|
|
||||||
@@ -86,6 +96,9 @@ class Config:
|
|||||||
if self._config is None:
|
if self._config is None:
|
||||||
self.load_config()
|
self.load_config()
|
||||||
|
|
||||||
|
if self._config is None:
|
||||||
|
raise RuntimeError("Failed to load config")
|
||||||
|
|
||||||
self._config[section] = data
|
self._config[section] = data
|
||||||
self.save_config()
|
self.save_config()
|
||||||
|
|
||||||
@@ -93,6 +106,8 @@ class Config:
|
|||||||
def config(self) -> Dict[str, Any]:
|
def config(self) -> Dict[str, Any]:
|
||||||
if self._config is None:
|
if self._config is None:
|
||||||
self.load_config()
|
self.load_config()
|
||||||
|
if self._config is None:
|
||||||
|
raise RuntimeError("Failed to load config")
|
||||||
return self._config
|
return self._config
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import asyncio
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from importlib import metadata
|
from importlib import metadata
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
@@ -75,7 +75,10 @@ class DatabaseService:
|
|||||||
datetime.fromisoformat(booked_date), datetime.fromisoformat(value_date)
|
datetime.fromisoformat(booked_date), datetime.fromisoformat(value_date)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
min_date = datetime.fromisoformat(booked_date or value_date)
|
date_str = booked_date or value_date
|
||||||
|
if not date_str:
|
||||||
|
raise ValueError("No valid date found in transaction")
|
||||||
|
min_date = datetime.fromisoformat(date_str)
|
||||||
|
|
||||||
# Extract amount and currency
|
# Extract amount and currency
|
||||||
transaction_amount = transaction.get("transactionAmount", {})
|
transaction_amount = transaction.get("transactionAmount", {})
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import asyncio
|
|
||||||
import json
|
import json
|
||||||
import httpx
|
import httpx
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Any, List, Optional
|
from typing import Dict, Any, List
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ class NotificationService:
|
|||||||
description = transaction.get("description", "").lower()
|
description = transaction.get("description", "").lower()
|
||||||
|
|
||||||
# Check case-insensitive filters
|
# Check case-insensitive filters
|
||||||
for filter_name, filter_value in filters_case_insensitive.items():
|
for _filter_name, filter_value in filters_case_insensitive.items():
|
||||||
if filter_value.lower() in description:
|
if filter_value.lower() in description:
|
||||||
matching.append(
|
matching.append(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import asyncio
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import List, Dict, Any
|
from typing import List
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from leggend.config import config
|
|
||||||
from leggend.api.models.sync import SyncResult, SyncStatus
|
from leggend.api.models.sync import SyncResult, SyncStatus
|
||||||
from leggend.services.gocardless_service import GoCardlessService
|
from leggend.services.gocardless_service import GoCardlessService
|
||||||
from leggend.services.database_service import DatabaseService
|
from leggend.services.database_service import DatabaseService
|
||||||
|
|||||||
@@ -49,9 +49,12 @@ dev = [
|
|||||||
"pre-commit>=3.6.0",
|
"pre-commit>=3.6.0",
|
||||||
"pytest>=8.0.0",
|
"pytest>=8.0.0",
|
||||||
"pytest-asyncio>=0.23.0",
|
"pytest-asyncio>=0.23.0",
|
||||||
"pytest-mock>=3.12.0",
|
"pytest-mock>=3.12.0",
|
||||||
"respx>=0.21.0",
|
"respx>=0.21.0",
|
||||||
"requests-mock>=1.12.0",
|
"requests-mock>=1.12.0",
|
||||||
|
"mypy>=1.17.1",
|
||||||
|
"types-tabulate>=0.9.0.20241207",
|
||||||
|
"types-requests>=2.32.4.20250809",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.hatch.build.targets.sdist]
|
[tool.hatch.build.targets.sdist]
|
||||||
@@ -71,20 +74,19 @@ lint.extend-select = ["B", "C4", "PIE", "T20", "SIM", "TCH"]
|
|||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
python_files = "test_*.py"
|
python_files = "test_*.py"
|
||||||
python_classes = "Test*"
|
python_classes = "Test*"
|
||||||
python_functions = "test_*"
|
python_functions = "test_*"
|
||||||
addopts = [
|
addopts = ["-v", "--tb=short", "--strict-markers", "--disable-warnings"]
|
||||||
"-v",
|
|
||||||
"--tb=short",
|
|
||||||
"--strict-markers",
|
|
||||||
"--disable-warnings"
|
|
||||||
]
|
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
asyncio_default_fixture_loop_scope = "function"
|
asyncio_default_fixture_loop_scope = "function"
|
||||||
markers = [
|
markers = [
|
||||||
"unit: Unit tests",
|
"unit: Unit tests",
|
||||||
"integration: Integration tests",
|
"integration: Integration tests",
|
||||||
"slow: Slow running tests",
|
"slow: Slow running tests",
|
||||||
"api: API endpoint tests",
|
"api: API endpoint tests",
|
||||||
"cli: CLI command tests"
|
"cli: CLI command tests",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[tool.mypy.overrides]]
|
||||||
|
module = ["apscheduler.*"]
|
||||||
|
ignore_missing_imports = true
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import respx
|
|||||||
import httpx
|
import httpx
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from leggend.services.gocardless_service import GoCardlessService
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.api
|
@pytest.mark.api
|
||||||
class TestBanksAPI:
|
class TestBanksAPI:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Tests for CLI API client."""
|
"""Tests for CLI API client."""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
import requests
|
||||||
import requests_mock
|
import requests_mock
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
@@ -85,7 +86,7 @@ class TestLeggendAPIClient:
|
|||||||
"""Test handling of connection errors."""
|
"""Test handling of connection errors."""
|
||||||
client = LeggendAPIClient("http://localhost:9999") # Non-existent service
|
client = LeggendAPIClient("http://localhost:9999") # Non-existent service
|
||||||
|
|
||||||
with pytest.raises(Exception):
|
with pytest.raises((requests.ConnectionError, requests.RequestException)):
|
||||||
client.get_accounts()
|
client.get_accounts()
|
||||||
|
|
||||||
def test_http_error_handling(self):
|
def test_http_error_handling(self):
|
||||||
@@ -99,7 +100,7 @@ class TestLeggendAPIClient:
|
|||||||
json={"detail": "Internal server error"},
|
json={"detail": "Internal server error"},
|
||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(Exception):
|
with pytest.raises((requests.HTTPError, requests.RequestException)):
|
||||||
client.get_accounts()
|
client.get_accounts()
|
||||||
|
|
||||||
def test_custom_api_url(self):
|
def test_custom_api_url(self):
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
"""Tests for configuration management."""
|
"""Tests for configuration management."""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from leggend.config import Config
|
from leggend.config import Config
|
||||||
@@ -164,9 +162,11 @@ class TestConfig:
|
|||||||
config = Config()
|
config = Config()
|
||||||
config._config = None
|
config._config = None
|
||||||
|
|
||||||
with patch("builtins.open", side_effect=FileNotFoundError):
|
with (
|
||||||
with pytest.raises(FileNotFoundError):
|
patch("builtins.open", side_effect=FileNotFoundError),
|
||||||
config.load_config()
|
pytest.raises(FileNotFoundError),
|
||||||
|
):
|
||||||
|
config.load_config()
|
||||||
|
|
||||||
def test_notifications_config(self):
|
def test_notifications_config(self):
|
||||||
"""Test notifications configuration access."""
|
"""Test notifications configuration access."""
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
"""Tests for background scheduler."""
|
"""Tests for background scheduler."""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import asyncio
|
from unittest.mock import patch, AsyncMock, MagicMock
|
||||||
from unittest.mock import Mock, patch, AsyncMock, MagicMock
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from apscheduler.schedulers.blocking import BlockingScheduler
|
|
||||||
|
|
||||||
from leggend.background.scheduler import BackgroundScheduler
|
from leggend.background.scheduler import BackgroundScheduler
|
||||||
from leggend.services.sync_service import SyncService
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
|
|||||||
65
uv.lock
generated
65
uv.lock
generated
@@ -240,6 +240,7 @@ dependencies = [
|
|||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
|
{ name = "mypy" },
|
||||||
{ name = "pre-commit" },
|
{ name = "pre-commit" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "pytest-asyncio" },
|
{ name = "pytest-asyncio" },
|
||||||
@@ -247,6 +248,8 @@ dev = [
|
|||||||
{ name = "requests-mock" },
|
{ name = "requests-mock" },
|
||||||
{ name = "respx" },
|
{ name = "respx" },
|
||||||
{ name = "ruff" },
|
{ name = "ruff" },
|
||||||
|
{ name = "types-requests" },
|
||||||
|
{ name = "types-tabulate" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
@@ -265,6 +268,7 @@ requires-dist = [
|
|||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [
|
dev = [
|
||||||
|
{ name = "mypy", specifier = ">=1.17.1" },
|
||||||
{ name = "pre-commit", specifier = ">=3.6.0" },
|
{ name = "pre-commit", specifier = ">=3.6.0" },
|
||||||
{ name = "pytest", specifier = ">=8.0.0" },
|
{ name = "pytest", specifier = ">=8.0.0" },
|
||||||
{ name = "pytest-asyncio", specifier = ">=0.23.0" },
|
{ name = "pytest-asyncio", specifier = ">=0.23.0" },
|
||||||
@@ -272,6 +276,8 @@ dev = [
|
|||||||
{ name = "requests-mock", specifier = ">=1.12.0" },
|
{ name = "requests-mock", specifier = ">=1.12.0" },
|
||||||
{ name = "respx", specifier = ">=0.21.0" },
|
{ name = "respx", specifier = ">=0.21.0" },
|
||||||
{ name = "ruff", specifier = ">=0.6.1" },
|
{ name = "ruff", specifier = ">=0.6.1" },
|
||||||
|
{ name = "types-requests", specifier = ">=2.32.4.20250809" },
|
||||||
|
{ name = "types-tabulate", specifier = ">=0.9.0.20241207" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -287,6 +293,35 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" },
|
{ url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mypy"
|
||||||
|
version = "1.17.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "mypy-extensions" },
|
||||||
|
{ name = "pathspec" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/8e/22/ea637422dedf0bf36f3ef238eab4e455e2a0dcc3082b5cc067615347ab8e/mypy-1.17.1.tar.gz", hash = "sha256:25e01ec741ab5bb3eec8ba9cdb0f769230368a22c959c4937360efb89b7e9f01", size = 3352570, upload-time = "2025-07-31T07:54:19.204Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/17/a2/7034d0d61af8098ec47902108553122baa0f438df8a713be860f7407c9e6/mypy-1.17.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:69e83ea6553a3ba79c08c6e15dbd9bfa912ec1e493bf75489ef93beb65209aeb", size = 11086295, upload-time = "2025-07-31T07:53:28.124Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/1f/19e7e44b594d4b12f6ba8064dbe136505cec813549ca3e5191e40b1d3cc2/mypy-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1b16708a66d38abb1e6b5702f5c2c87e133289da36f6a1d15f6a5221085c6403", size = 10112355, upload-time = "2025-07-31T07:53:21.121Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/69/baa33927e29e6b4c55d798a9d44db5d394072eef2bdc18c3e2048c9ed1e9/mypy-1.17.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:89e972c0035e9e05823907ad5398c5a73b9f47a002b22359b177d40bdaee7056", size = 11875285, upload-time = "2025-07-31T07:53:55.293Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/13/f3a89c76b0a41e19490b01e7069713a30949d9a6c147289ee1521bcea245/mypy-1.17.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03b6d0ed2b188e35ee6d5c36b5580cffd6da23319991c49ab5556c023ccf1341", size = 12737895, upload-time = "2025-07-31T07:53:43.623Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/a1/c4ee79ac484241301564072e6476c5a5be2590bc2e7bfd28220033d2ef8f/mypy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c837b896b37cd103570d776bda106eabb8737aa6dd4f248451aecf53030cdbeb", size = 12931025, upload-time = "2025-07-31T07:54:17.125Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/b8/7409477be7919a0608900e6320b155c72caab4fef46427c5cc75f85edadd/mypy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:665afab0963a4b39dff7c1fa563cc8b11ecff7910206db4b2e64dd1ba25aed19", size = 9584664, upload-time = "2025-07-31T07:54:12.842Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1d/f3/8fcd2af0f5b806f6cf463efaffd3c9548a28f84220493ecd38d127b6b66d/mypy-1.17.1-py3-none-any.whl", hash = "sha256:a9f52c0351c21fe24c21d8c0eb1f62967b262d6729393397b6f443c3b773c3b9", size = 2283411, upload-time = "2025-07-31T07:53:24.664Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mypy-extensions"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nodeenv"
|
name = "nodeenv"
|
||||||
version = "1.9.1"
|
version = "1.9.1"
|
||||||
@@ -305,6 +340,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
|
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pathspec"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "platformdirs"
|
name = "platformdirs"
|
||||||
version = "4.3.6"
|
version = "4.3.6"
|
||||||
@@ -558,6 +602,27 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" },
|
{ url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "types-requests"
|
||||||
|
version = "2.32.4.20250809"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "urllib3" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ed/b0/9355adb86ec84d057fea765e4c49cce592aaf3d5117ce5609a95a7fc3dac/types_requests-2.32.4.20250809.tar.gz", hash = "sha256:d8060de1c8ee599311f56ff58010fb4902f462a1470802cf9f6ed27bc46c4df3", size = 23027, upload-time = "2025-08-09T03:17:10.664Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/6f/ec0012be842b1d888d46884ac5558fd62aeae1f0ec4f7a581433d890d4b5/types_requests-2.32.4.20250809-py3-none-any.whl", hash = "sha256:f73d1832fb519ece02c85b1f09d5f0dd3108938e7d47e7f94bbfa18a6782b163", size = 20644, upload-time = "2025-08-09T03:17:09.716Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "types-tabulate"
|
||||||
|
version = "0.9.0.20241207"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/3f/43/16030404a327e4ff8c692f2273854019ed36718667b2993609dc37d14dd4/types_tabulate-0.9.0.20241207.tar.gz", hash = "sha256:ac1ac174750c0a385dfd248edc6279fa328aaf4ea317915ab879a2ec47833230", size = 8195, upload-time = "2024-12-07T02:54:42.554Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5e/86/a9ebfd509cbe74471106dffed320e208c72537f9aeb0a55eaa6b1b5e4d17/types_tabulate-0.9.0.20241207-py3-none-any.whl", hash = "sha256:b8dad1343c2a8ba5861c5441370c3e35908edd234ff036d4298708a1d4cf8a85", size = 8307, upload-time = "2024-12-07T02:54:41.031Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typing-extensions"
|
name = "typing-extensions"
|
||||||
version = "4.15.0"
|
version = "4.15.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user