mirror of
https://github.com/elisiariocouto/leggen.git
synced 2025-12-28 21:59:14 +00:00
Compare commits
29 Commits
1bc259bd3e
...
2025.9.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e0b8eb2a4 | ||
|
|
f2e05484dc | ||
|
|
37949a4e1f | ||
|
|
abf39abe74 | ||
|
|
957099786c | ||
|
|
2191fe9066 | ||
|
|
bc947183e3 | ||
|
|
16afa1ed8a | ||
|
|
541cb262ee | ||
|
|
eaaea6e459 | ||
|
|
34501f5f0d | ||
|
|
dcac53d181 | ||
|
|
cb2e70e42d | ||
|
|
417b77539f | ||
|
|
947342e196 | ||
|
|
c5fd26cb3e | ||
|
|
6c8b8ed3cc | ||
|
|
abacfd78c8 | ||
|
|
26487cff89 | ||
|
|
46f3f5c498 | ||
|
|
6bce7eb6be | ||
|
|
155c30559f | ||
|
|
ec8ef8346a | ||
|
|
de3da84dff | ||
|
|
47164e8546 | ||
|
|
34e793c75c | ||
|
|
4018b263f2 | ||
|
|
f0fee4fd82 | ||
|
|
91f53b35b1 |
@@ -7,7 +7,14 @@
|
||||
"Bash(git commit:*)",
|
||||
"Bash(ruff check:*)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(mypy:*)"
|
||||
"Bash(mypy:*)",
|
||||
"WebFetch(domain:localhost)",
|
||||
"Bash(npm create:*)",
|
||||
"Bash(npm install)",
|
||||
"Bash(npm install:*)",
|
||||
"Bash(npx tailwindcss init:*)",
|
||||
"Bash(./node_modules/.bin/tailwindcss:*)",
|
||||
"Bash(npm run build:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
.git/
|
||||
data/
|
||||
docker-compose.dev.yml
|
||||
frontend/node_modules/
|
||||
.venv/
|
||||
|
||||
124
.github/workflows/release.yml
vendored
124
.github/workflows/release.yml
vendored
@@ -42,44 +42,90 @@ jobs:
|
||||
- name: Publish package
|
||||
run: uv publish
|
||||
|
||||
push-docker:
|
||||
push-docker-backend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: elisiariocouto
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
# list of Docker images to use as base name for tags
|
||||
images: |
|
||||
elisiariocouto/leggen
|
||||
ghcr.io/elisiariocouto/leggen
|
||||
# generate Docker tags based on the following events/attributes
|
||||
tags: |
|
||||
type=ref,event=tag
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: elisiariocouto
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Docker meta backend
|
||||
id: meta-backend
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
# list of Docker images to use as base name for tags
|
||||
images: |
|
||||
elisiariocouto/leggen
|
||||
ghcr.io/elisiariocouto/leggen
|
||||
# generate Docker tags based on the following events/attributes
|
||||
tags: |
|
||||
type=ref,event=tag
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest
|
||||
- name: Build and push backend
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta-backend.outputs.tags }}
|
||||
labels: ${{ steps.meta-backend.outputs.labels }}
|
||||
|
||||
push-docker-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: elisiariocouto
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Docker meta frontend
|
||||
id: meta-frontend
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
# list of Docker images to use as base name for tags
|
||||
images: |
|
||||
elisiariocouto/leggen
|
||||
ghcr.io/elisiariocouto/leggen
|
||||
# generate Docker tags based on the following events/attributes
|
||||
tags: |
|
||||
type=ref,event=tag,suffix=-frontend
|
||||
type=semver,pattern={{version}},suffix=-frontend
|
||||
type=semver,pattern={{major}}.{{minor}},suffix=-frontend
|
||||
type=raw,value=latest-frontend
|
||||
- name: Build and push frontend
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./frontend
|
||||
file: ./frontend/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta-frontend.outputs.tags }}
|
||||
labels: ${{ steps.meta-frontend.outputs.labels }}
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -14,7 +14,6 @@ dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
|
||||
41
AGENTS.md
Normal file
41
AGENTS.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Agent Guidelines for Leggen
|
||||
|
||||
## Build/Lint/Test Commands
|
||||
|
||||
### Frontend (React/TypeScript)
|
||||
- **Dev server**: `cd frontend && npm run dev`
|
||||
- **Build**: `cd frontend && npm run build`
|
||||
- **Lint**: `cd frontend && npm run lint`
|
||||
|
||||
### Backend (Python)
|
||||
- **Lint**: `uv run ruff check .`
|
||||
- **Format**: `uv run ruff format .`
|
||||
- **Type check**: `uv run mypy leggen leggend --check-untyped-defs`
|
||||
- **All checks**: `uv run pre-commit run --all-files`
|
||||
- **Run all tests**: `uv run pytest`
|
||||
- **Run single test**: `uv run pytest tests/unit/test_api_accounts.py::TestAccountsAPI::test_get_all_accounts_success -v`
|
||||
- **Run tests by marker**: `uv run pytest -m "api"` or `uv run pytest -m "unit"`
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
### Python
|
||||
- **Imports**: Standard library → Third-party → Local (blank lines between groups)
|
||||
- **Naming**: snake_case for variables/functions, PascalCase for classes
|
||||
- **Types**: Use type hints for all function parameters and return values
|
||||
- **Error handling**: Use specific exceptions, loguru for logging
|
||||
- **Path handling**: Use `pathlib.Path` instead of `os.path`
|
||||
- **CLI**: Use Click framework with proper option decorators
|
||||
|
||||
### TypeScript/React
|
||||
- **Imports**: React hooks first, then third-party, then local components/types
|
||||
- **Naming**: PascalCase for components, camelCase for variables/functions
|
||||
- **Types**: Use `import type` for type-only imports, define interfaces/types
|
||||
- **Styling**: Tailwind CSS with `clsx` utility for conditional classes
|
||||
- **Icons**: lucide-react with consistent naming
|
||||
- **Data fetching**: @tanstack/react-query with proper error handling
|
||||
- **Components**: Functional components with hooks, proper TypeScript typing
|
||||
|
||||
### General
|
||||
- **Formatting**: ruff for Python, ESLint for TypeScript
|
||||
- **Commits**: Use conventional commits, run pre-commit hooks before pushing
|
||||
- **Security**: Never log sensitive data, use environment variables for secrets
|
||||
88
CHANGELOG.md
88
CHANGELOG.md
@@ -1,4 +1,92 @@
|
||||
|
||||
## 2025.9.0 (2025/09/09)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **cli:** Show transactions without internal ID when using --full. ([46f3f5c4](https://github.com/elisiariocouto/leggen/commit/46f3f5c4984224c3f4b421e1a06dcf44d4f211e0))
|
||||
- Do not install development dependencies. ([73d6bd32](https://github.com/elisiariocouto/leggen/commit/73d6bd32dbc59608ef1472dc65d9e18450f00896))
|
||||
- Implement proper GoCardless authentication and add dev features ([f0fee4fd](https://github.com/elisiariocouto/leggen/commit/f0fee4fd82e1c788614d73fcd0075f5e16976650))
|
||||
- Make internal transcation ID optional. ([6bce7eb6](https://github.com/elisiariocouto/leggen/commit/6bce7eb6be5f9a5286eb27e777fbf83a6b1c5f8d))
|
||||
- Resolve 404 balances endpoint and currency formatting errors ([417b7753](https://github.com/elisiariocouto/leggen/commit/417b77539fc275493d55efb29f92abcea666b210))
|
||||
- Merge account details into balance data to prevent unknown/N/A values ([eaaea6e4](https://github.com/elisiariocouto/leggen/commit/eaaea6e4598e9c81997573e19f4ef1c58ebe320f))
|
||||
- Use account status for balance records instead of hardcoded 'active' ([541cb262](https://github.com/elisiariocouto/leggen/commit/541cb262ee5783eedf2b154c148c28ec89845da5))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
- Update README for new web architecture ([4018b263](https://github.com/elisiariocouto/leggen/commit/4018b263f27c2b59af31428d7a0878280a291c85))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
- Transform to web architecture with FastAPI backend ([91f53b35](https://github.com/elisiariocouto/leggen/commit/91f53b35b18740869ee9cebfac394db2e12db099))
|
||||
- Add comprehensive test suite with 46 passing tests ([34e793c7](https://github.com/elisiariocouto/leggen/commit/34e793c75c8df1e57ea240b92ccf0843a80c2a14))
|
||||
- Add mypy to pre-commit. ([ec8ef834](https://github.com/elisiariocouto/leggen/commit/ec8ef8346add878f3ff4e8ed928b952d9b5dd584))
|
||||
- Implement database-first architecture to minimize GoCardless API calls ([155c3055](https://github.com/elisiariocouto/leggen/commit/155c30559f4cacd76ef01e50ec29ee436d3f9d56))
|
||||
- Implement dynamic API connection status ([cb2e70e4](https://github.com/elisiariocouto/leggen/commit/cb2e70e42d1122e9c2e5420b095aeb1e55454c24))
|
||||
- Add automatic balance timestamp migration mechanism ([34501f5f](https://github.com/elisiariocouto/leggen/commit/34501f5f0d3b3dff68364b60be77bfb99071b269))
|
||||
- Improve notification filters configuration format ([2191fe90](https://github.com/elisiariocouto/leggen/commit/2191fe906659f4fd22c25b6cb9fbb95c03472f00))
|
||||
- Add notifications view and update branding ([abf39abe](https://github.com/elisiariocouto/leggen/commit/abf39abe74b75d8cb980109fbcbdd940066cc90b))
|
||||
- Make API URL configurable and improve code quality ([37949a4e](https://github.com/elisiariocouto/leggen/commit/37949a4e1f25a2656f6abef75ba942f7b205c130))
|
||||
- Change versioning scheme to calver. ([f2e05484](https://github.com/elisiariocouto/leggen/commit/f2e05484dc688409b6db6bd16858b066d3a16976))
|
||||
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- Implement code review suggestions and format code. ([de3da84d](https://github.com/elisiariocouto/leggen/commit/de3da84dffd83e0b232cf76836935a66eb704aee))
|
||||
|
||||
|
||||
### Refactor
|
||||
|
||||
- Remove MongoDB support, simplify to SQLite-only architecture ([47164e85](https://github.com/elisiariocouto/leggen/commit/47164e854600dfcac482449769b1d2e55c842570))
|
||||
- Remove unused amount_threshold and keywords from notification filters ([95709978](https://github.com/elisiariocouto/leggen/commit/957099786cb0e48c9ffbda11b3172ec9fae9ac37))
|
||||
|
||||
|
||||
|
||||
## 2025.9.0 (2025/09/09)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **cli:** Show transactions without internal ID when using --full. ([46f3f5c4](https://github.com/elisiariocouto/leggen/commit/46f3f5c4984224c3f4b421e1a06dcf44d4f211e0))
|
||||
- Do not install development dependencies. ([73d6bd32](https://github.com/elisiariocouto/leggen/commit/73d6bd32dbc59608ef1472dc65d9e18450f00896))
|
||||
- Implement proper GoCardless authentication and add dev features ([f0fee4fd](https://github.com/elisiariocouto/leggen/commit/f0fee4fd82e1c788614d73fcd0075f5e16976650))
|
||||
- Make internal transcation ID optional. ([6bce7eb6](https://github.com/elisiariocouto/leggen/commit/6bce7eb6be5f9a5286eb27e777fbf83a6b1c5f8d))
|
||||
- Resolve 404 balances endpoint and currency formatting errors ([417b7753](https://github.com/elisiariocouto/leggen/commit/417b77539fc275493d55efb29f92abcea666b210))
|
||||
- Merge account details into balance data to prevent unknown/N/A values ([eaaea6e4](https://github.com/elisiariocouto/leggen/commit/eaaea6e4598e9c81997573e19f4ef1c58ebe320f))
|
||||
- Use account status for balance records instead of hardcoded 'active' ([541cb262](https://github.com/elisiariocouto/leggen/commit/541cb262ee5783eedf2b154c148c28ec89845da5))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
- Update README for new web architecture ([4018b263](https://github.com/elisiariocouto/leggen/commit/4018b263f27c2b59af31428d7a0878280a291c85))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
- Transform to web architecture with FastAPI backend ([91f53b35](https://github.com/elisiariocouto/leggen/commit/91f53b35b18740869ee9cebfac394db2e12db099))
|
||||
- Add comprehensive test suite with 46 passing tests ([34e793c7](https://github.com/elisiariocouto/leggen/commit/34e793c75c8df1e57ea240b92ccf0843a80c2a14))
|
||||
- Add mypy to pre-commit. ([ec8ef834](https://github.com/elisiariocouto/leggen/commit/ec8ef8346add878f3ff4e8ed928b952d9b5dd584))
|
||||
- Implement database-first architecture to minimize GoCardless API calls ([155c3055](https://github.com/elisiariocouto/leggen/commit/155c30559f4cacd76ef01e50ec29ee436d3f9d56))
|
||||
- Implement dynamic API connection status ([cb2e70e4](https://github.com/elisiariocouto/leggen/commit/cb2e70e42d1122e9c2e5420b095aeb1e55454c24))
|
||||
- Add automatic balance timestamp migration mechanism ([34501f5f](https://github.com/elisiariocouto/leggen/commit/34501f5f0d3b3dff68364b60be77bfb99071b269))
|
||||
- Improve notification filters configuration format ([2191fe90](https://github.com/elisiariocouto/leggen/commit/2191fe906659f4fd22c25b6cb9fbb95c03472f00))
|
||||
- Add notifications view and update branding ([abf39abe](https://github.com/elisiariocouto/leggen/commit/abf39abe74b75d8cb980109fbcbdd940066cc90b))
|
||||
- Make API URL configurable and improve code quality ([37949a4e](https://github.com/elisiariocouto/leggen/commit/37949a4e1f25a2656f6abef75ba942f7b205c130))
|
||||
- Change versioning scheme to calver. ([f2e05484](https://github.com/elisiariocouto/leggen/commit/f2e05484dc688409b6db6bd16858b066d3a16976))
|
||||
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- Implement code review suggestions and format code. ([de3da84d](https://github.com/elisiariocouto/leggen/commit/de3da84dffd83e0b232cf76836935a66eb704aee))
|
||||
|
||||
|
||||
### Refactor
|
||||
|
||||
- Remove MongoDB support, simplify to SQLite-only architecture ([47164e85](https://github.com/elisiariocouto/leggen/commit/47164e854600dfcac482449769b1d2e55c842570))
|
||||
- Remove unused amount_threshold and keywords from notification filters ([95709978](https://github.com/elisiariocouto/leggen/commit/957099786cb0e48c9ffbda11b3172ec9fae9ac37))
|
||||
|
||||
|
||||
|
||||
## 0.6.11 (2025/02/23)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
69
CLAUDE.md
69
CLAUDE.md
@@ -1,69 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Leggen is an Open Banking CLI tool built in Python that connects to banks using the GoCardless Open Banking API. It allows users to sync bank transactions to SQLite/MongoDB databases, visualize data with NocoDB, and send notifications based on transaction filters.
|
||||
|
||||
## Development Commands
|
||||
|
||||
- **Install dependencies**: `uv sync` (uses uv package manager)
|
||||
- **Run locally**: `uv run leggen --help`
|
||||
- **Lint code**: `ruff check` and `ruff format` (configured in pyproject.toml)
|
||||
- **Build Docker image**: `docker build -t leggen .`
|
||||
- **Run with Docker Compose**: `docker compose up -d`
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Structure
|
||||
- `leggen/main.py` - Main CLI entry point using Click framework with custom command loading
|
||||
- `leggen/commands/` - CLI command implementations (balances, sync, transactions, etc.)
|
||||
- `leggen/utils/` - Core utilities for authentication, database operations, network requests, and notifications
|
||||
- `leggen/database/` - Database adapters for SQLite and MongoDB
|
||||
- `leggen/notifications/` - Discord and Telegram notification handlers
|
||||
|
||||
### Key Components
|
||||
|
||||
**Configuration System**:
|
||||
- Uses TOML configuration files (default: `~/.config/leggen/config.toml`)
|
||||
- Configuration loaded via `leggen/utils/config.py`
|
||||
- Supports GoCardless API credentials, database settings, and notification configurations
|
||||
|
||||
**Authentication & API**:
|
||||
- GoCardless Open Banking API integration in `leggen/utils/gocardless.py`
|
||||
- Token-based authentication via `leggen/utils/auth.py`
|
||||
- Network utilities in `leggen/utils/network.py`
|
||||
|
||||
**Database Operations**:
|
||||
- Dual database support: SQLite (`database/sqlite.py`) and MongoDB (`database/mongo.py`)
|
||||
- Transaction persistence and balance tracking via `utils/database.py`
|
||||
- Data storage patterns follow bank account and transaction models
|
||||
|
||||
**Command Architecture**:
|
||||
- Dynamic command loading system in `main.py` with support for command groups
|
||||
- Commands organized as modules with individual click decorators
|
||||
- Bank management commands grouped under `commands/bank/`
|
||||
|
||||
### Data Flow
|
||||
1. Configuration loaded from TOML file
|
||||
2. GoCardless API authentication and bank requisition management
|
||||
3. Account and transaction data retrieval from banks
|
||||
4. Data persistence to configured databases (SQLite/MongoDB)
|
||||
5. Optional notifications sent via Discord/Telegram based on filters
|
||||
6. Data visualization available through NocoDB integration
|
||||
|
||||
## Docker & Deployment
|
||||
|
||||
The project uses multi-stage Docker builds with uv for dependency management. The compose.yml includes:
|
||||
- Main leggen service with sync scheduling via Ofelia
|
||||
- NocoDB for data visualization
|
||||
- Optional MongoDB with mongo-express admin interface
|
||||
|
||||
## Configuration Requirements
|
||||
|
||||
All operations require a valid `config.toml` file with GoCardless API credentials. The configuration structure includes sections for:
|
||||
- `[gocardless]` - API credentials and endpoint
|
||||
- `[database]` - Storage backend selection
|
||||
- `[notifications]` - Discord/Telegram webhook settings
|
||||
- `[filters]` - Transaction matching patterns for notifications
|
||||
17
Dockerfile
17
Dockerfile
@@ -6,25 +6,28 @@ WORKDIR /app
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||
uv sync --frozen --no-install-project --no-editable
|
||||
uv sync --locked --no-install-project --no-editable
|
||||
|
||||
COPY . /app
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --frozen --no-editable --no-group dev
|
||||
uv sync --locked --no-editable --no-group dev
|
||||
|
||||
FROM python:3.13-alpine
|
||||
|
||||
LABEL org.opencontainers.image.source="https://github.com/elisiariocouto/leggen"
|
||||
LABEL org.opencontainers.image.authors="Elisiário Couto <elisiario@couto.io>"
|
||||
LABEL org.opencontainers.image.licenses="MIT"
|
||||
LABEL org.opencontainers.image.title="leggen"
|
||||
LABEL org.opencontainers.image.description="An Open Banking CLI"
|
||||
LABEL org.opencontainers.image.title="Leggend API"
|
||||
LABEL org.opencontainers.image.description="Open Banking API for Leggen"
|
||||
LABEL org.opencontainers.image.url="https://github.com/elisiariocouto/leggen"
|
||||
|
||||
WORKDIR /app
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
COPY --from=builder --chown=app:app /app/.venv /app/.venv
|
||||
COPY --from=builder /app/.venv /app/.venv
|
||||
|
||||
ENTRYPOINT ["/app/.venv/bin/leggen"]
|
||||
EXPOSE 8000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s CMD wget -q --spider http://127.0.0.1:8000/api/v1/health || exit 1
|
||||
|
||||
CMD ["/app/.venv/bin/leggend"]
|
||||
|
||||
91
PROJECT.md
91
PROJECT.md
@@ -1,91 +0,0 @@
|
||||
# Leggen Web Transformation Project
|
||||
|
||||
## Overview
|
||||
Transform leggen from CLI-only to web application with FastAPI backend (`leggend`) and SvelteKit frontend (`leggen-web`).
|
||||
|
||||
## Progress Tracking
|
||||
|
||||
### ✅ Phase 1: FastAPI Backend (`leggend`)
|
||||
|
||||
#### 1.1 Core Structure
|
||||
- [x] Create directory structure (`leggend/`, `api/`, `services/`, etc.)
|
||||
- [x] Add FastAPI dependencies to pyproject.toml
|
||||
- [x] Create configuration management system
|
||||
- [x] Set up FastAPI main application
|
||||
- [x] Create Pydantic models for API responses
|
||||
|
||||
#### 1.2 API Endpoints
|
||||
- [x] Banks API (`/api/v1/banks/`)
|
||||
- [x] `GET /institutions` - List available banks
|
||||
- [x] `POST /connect` - Connect to bank
|
||||
- [x] `GET /status` - Bank connection status
|
||||
- [x] Accounts API (`/api/v1/accounts/`)
|
||||
- [x] `GET /` - List all accounts
|
||||
- [x] `GET /{id}/balances` - Account balances
|
||||
- [x] `GET /{id}/transactions` - Account transactions
|
||||
- [x] Sync API (`/api/v1/sync/`)
|
||||
- [x] `POST /` - Trigger manual sync
|
||||
- [x] `GET /status` - Sync status
|
||||
- [x] Notifications API (`/api/v1/notifications/`)
|
||||
- [x] `GET/POST/PUT /settings` - Manage notification settings
|
||||
|
||||
#### 1.3 Background Jobs
|
||||
- [x] Implement APScheduler for sync scheduling
|
||||
- [x] Replace Ofelia with internal Python scheduler
|
||||
- [x] Migrate existing sync logic from CLI
|
||||
|
||||
### ⏳ Phase 2: SvelteKit Frontend (`leggen-web`)
|
||||
|
||||
#### 2.1 Project Setup
|
||||
- [ ] Create SvelteKit project structure
|
||||
- [ ] Set up API client for backend communication
|
||||
- [ ] Design component architecture
|
||||
|
||||
#### 2.2 UI Components
|
||||
- [ ] Dashboard with account overview
|
||||
- [ ] Bank connection wizard
|
||||
- [ ] Transaction history and filtering
|
||||
- [ ] Settings management
|
||||
- [ ] Real-time sync status
|
||||
|
||||
### ✅ Phase 3: CLI Refactoring
|
||||
|
||||
#### 3.1 API Client Integration
|
||||
- [x] Create HTTP client for FastAPI calls
|
||||
- [x] Refactor existing commands to use APIs
|
||||
- [x] Maintain CLI user experience
|
||||
- [x] Add API URL configuration option
|
||||
|
||||
### ✅ Phase 4: Docker & Deployment
|
||||
|
||||
#### 4.1 Container Setup
|
||||
- [x] Create Dockerfile for `leggend` service
|
||||
- [x] Update docker-compose.yml with `leggend` service
|
||||
- [x] Remove Ofelia dependency (scheduler now internal)
|
||||
- [ ] Create Dockerfile for `leggen-web` (deferred - not implementing web UI yet)
|
||||
|
||||
## Current Status
|
||||
**Active Phase**: Phase 2 - CLI Integration Complete
|
||||
**Last Updated**: 2025-09-01
|
||||
**Completion**: ~80% (FastAPI backend and CLI refactoring complete)
|
||||
|
||||
## Next Steps (Future Enhancements)
|
||||
1. Implement SvelteKit web frontend
|
||||
2. Add real-time WebSocket support for sync status
|
||||
3. Implement user authentication and multi-user support
|
||||
4. Add more comprehensive error handling and logging
|
||||
5. Implement database migrations for schema changes
|
||||
|
||||
## Recent Achievements
|
||||
- ✅ Complete FastAPI backend with all major endpoints
|
||||
- ✅ Configurable background job scheduler (replaces Ofelia)
|
||||
- ✅ CLI successfully refactored to use API endpoints
|
||||
- ✅ Docker configuration updated for new architecture
|
||||
- ✅ Maintained backward compatibility and user experience
|
||||
|
||||
## Architecture Decisions
|
||||
- **FastAPI**: For high-performance async API backend
|
||||
- **APScheduler**: For internal job scheduling (replacing Ofelia)
|
||||
- **SvelteKit**: For modern, reactive frontend
|
||||
- **Existing Logic**: Reuse all business logic from current CLI commands
|
||||
- **Configuration**: Centralize in `leggend` service, maintain TOML compatibility
|
||||
56
README.md
56
README.md
@@ -2,9 +2,7 @@
|
||||
|
||||
An Open Banking CLI and API service for managing bank connections and transactions.
|
||||
|
||||
This tool provides both a **FastAPI backend service** (`leggend`) and a **command-line interface** (`leggen`) to connect to banks using the GoCardless Open Banking API.
|
||||
|
||||
**New in v0.6.11**: Web-ready architecture with FastAPI backend, enhanced CLI, and background job scheduling.
|
||||
This tool provides **FastAPI backend service** (`leggend`), a **React Web Interface** and a **command-line interface** (`leggen`) to connect to banks using the GoCardless Open Banking API.
|
||||
|
||||
Having your bank data accessible through both CLI and REST API gives you the power to backup, analyze, create reports, and integrate with other applications.
|
||||
|
||||
@@ -18,8 +16,11 @@ Having your bank data accessible through both CLI and REST API gives you the pow
|
||||
### 📦 Storage
|
||||
- [SQLite](https://www.sqlite.org): for storing transactions, simple and easy to use
|
||||
|
||||
### 📊 Visualization
|
||||
- [NocoDB](https://github.com/nocodb/nocodb): for visualizing and querying transactions, a simple and easy to use interface for SQLite
|
||||
### Frontend
|
||||
- [React](https://reactjs.org/): Modern web interface with TypeScript
|
||||
- [Vite](https://vitejs.dev/): Fast build tool and development server
|
||||
- [Tailwind CSS](https://tailwindcss.com/): Utility-first CSS framework
|
||||
- [TanStack Query](https://tanstack.com/query): Powerful data synchronization for React
|
||||
|
||||
## ✨ Features
|
||||
|
||||
@@ -39,8 +40,6 @@ Having your bank data accessible through both CLI and REST API gives you the pow
|
||||
### 📡 API & Integration
|
||||
- **REST API**: Complete FastAPI backend with comprehensive endpoints
|
||||
- **CLI Interface**: Enhanced command-line tools with new options
|
||||
- **Health Checks**: Service monitoring and dependency management
|
||||
- **Auto-reload**: Development mode with file watching
|
||||
|
||||
### 🔔 Notifications & Monitoring
|
||||
- Discord and Telegram notifications for filtered transactions
|
||||
@@ -48,12 +47,6 @@ Having your bank data accessible through both CLI and REST API gives you the pow
|
||||
- Account expiry notifications and status alerts
|
||||
- Comprehensive logging and error handling
|
||||
|
||||
### 📊 Visualization & Analysis
|
||||
- NocoDB integration for visual data exploration
|
||||
- Transaction statistics and reporting
|
||||
- Account balance tracking over time
|
||||
- Export capabilities for further analysis
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
@@ -63,7 +56,7 @@ Having your bank data accessible through both CLI and REST API gives you the pow
|
||||
### Installation Options
|
||||
|
||||
#### Option 1: Docker Compose (Recommended)
|
||||
The easiest way to get started is with Docker Compose:
|
||||
The easiest way to get started is with Docker Compose, which includes both the React frontend and FastAPI backend:
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
@@ -74,8 +67,11 @@ cd leggen
|
||||
mkdir -p leggen && cp config.example.toml leggen/config.toml
|
||||
# Edit leggen/config.toml with your GoCardless credentials
|
||||
|
||||
# Start all services
|
||||
# Start all services (frontend + backend)
|
||||
docker compose up -d
|
||||
|
||||
# Access the web interface at http://localhost:3000
|
||||
# API is available at http://localhost:8000
|
||||
```
|
||||
|
||||
#### Option 2: Local Development
|
||||
@@ -124,9 +120,9 @@ chat_id = 12345
|
||||
enabled = true
|
||||
|
||||
# Optional: Transaction filters for notifications
|
||||
[filters.case-insensitive]
|
||||
salary = "salary"
|
||||
bills = "utility"
|
||||
[filters]
|
||||
case-insensitive = ["salary", "utility"]
|
||||
case-sensitive = ["SpecificStore"]
|
||||
```
|
||||
|
||||
## 📖 Usage
|
||||
@@ -193,17 +189,21 @@ leggen status
|
||||
### Docker Usage
|
||||
|
||||
```bash
|
||||
# Start all services
|
||||
# Start all services (frontend + backend)
|
||||
docker compose up -d
|
||||
|
||||
# Connect to a bank
|
||||
docker compose run leggen bank add
|
||||
|
||||
# Run a sync
|
||||
docker compose run leggen sync --wait
|
||||
# View service status
|
||||
docker compose ps
|
||||
|
||||
# Check logs
|
||||
docker compose logs frontend
|
||||
docker compose logs leggend
|
||||
|
||||
# Access the web interface at http://localhost:3000
|
||||
# API documentation at http://localhost:8000/docs
|
||||
|
||||
# Stop all services
|
||||
docker compose down
|
||||
```
|
||||
|
||||
## 🔌 API Endpoints
|
||||
@@ -216,7 +216,7 @@ The FastAPI backend provides comprehensive REST endpoints:
|
||||
- `GET /api/v1/banks/status` - Connection status
|
||||
- `GET /api/v1/banks/countries` - Supported countries
|
||||
|
||||
### Accounts & Balances
|
||||
### Accounts & Balances
|
||||
- `GET /api/v1/accounts` - List all accounts
|
||||
- `GET /api/v1/accounts/{id}` - Account details
|
||||
- `GET /api/v1/accounts/{id}/balances` - Account balances
|
||||
@@ -262,7 +262,7 @@ Run the comprehensive test suite with:
|
||||
# Run all tests
|
||||
uv run pytest
|
||||
|
||||
# Run unit tests only
|
||||
# Run unit tests only
|
||||
uv run pytest tests/unit/
|
||||
|
||||
# Run with verbose output
|
||||
@@ -293,7 +293,7 @@ leggen/ # CLI application
|
||||
├── utils/ # Shared utilities
|
||||
└── api_client.py # API client for leggend service
|
||||
|
||||
leggend/ # FastAPI backend service
|
||||
leggend/ # FastAPI backend service
|
||||
├── api/ # API routes and models
|
||||
├── services/ # Business logic
|
||||
├── background/ # Background job scheduler
|
||||
@@ -317,6 +317,6 @@ tests/ # Test suite
|
||||
|
||||
## ⚠️ Notes
|
||||
- This project is in active development
|
||||
- Web frontend planned for future releases
|
||||
- GoCardless API rate limits apply
|
||||
- Some banks may require additional authorization steps
|
||||
- Docker images are automatically built and published on releases
|
||||
|
||||
63
compose.yml
63
compose.yml
@@ -1,56 +1,25 @@
|
||||
services:
|
||||
# FastAPI backend service
|
||||
leggend:
|
||||
# React frontend service
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
restart: "unless-stopped"
|
||||
ports:
|
||||
- "127.0.0.1:8000:8000"
|
||||
volumes:
|
||||
- "./leggen:/root/.config/leggen" # Configuration file directory
|
||||
- "./db:/app" # Database storage
|
||||
- "127.0.0.1:3000:80"
|
||||
environment:
|
||||
- LEGGEN_CONFIG_FILE=/root/.config/leggen/config.toml
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
nocodb:
|
||||
image: nocodb/nocodb:latest
|
||||
restart: "unless-stopped"
|
||||
volumes:
|
||||
- "./nocodb:/usr/app/data/"
|
||||
- "./db:/usr/leggen:ro"
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
- API_BACKEND_URL=${API_BACKEND_URL:-http://leggend:8000}
|
||||
depends_on:
|
||||
leggend:
|
||||
condition: service_healthy
|
||||
|
||||
# Optional: If you want to have a mongodb, uncomment the following lines
|
||||
# mongo:
|
||||
# image: mongo:7
|
||||
# restart: "unless-stopped"
|
||||
# # If you want to expose the mongodb port to the host, uncomment the following lines
|
||||
# # ports:
|
||||
# # - 127.0.0.1:27017:27017
|
||||
# volumes:
|
||||
# - "./data:/data/db"
|
||||
# environment:
|
||||
# MONGO_INITDB_ROOT_USERNAME: "leggen"
|
||||
# MONGO_INITDB_ROOT_PASSWORD: "changeme"
|
||||
|
||||
# Optional: If you want to have an admin interface for your mongodb, uncomment the following lines
|
||||
# mongo-express:
|
||||
# image: mongo-express
|
||||
# restart: "unless-stopped"
|
||||
# # By default, we are exposing the mongo-express port to the host
|
||||
# ports:
|
||||
# - 127.0.0.1:8081:8081
|
||||
# environment:
|
||||
# ME_CONFIG_MONGODB_URL: "mongodb://leggen:changeme@mongo:27017/"
|
||||
# ME_CONFIG_BASICAUTH_USERNAME: ""
|
||||
# depends_on:
|
||||
# - mongo
|
||||
# FastAPI backend service
|
||||
leggend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
restart: "unless-stopped"
|
||||
ports:
|
||||
- "127.0.0.1:8000:8000"
|
||||
volumes:
|
||||
- "./data:/root/.config/leggen"
|
||||
|
||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
34
frontend/Dockerfile
Normal file
34
frontend/Dockerfile
Normal file
@@ -0,0 +1,34 @@
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm i
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy built application from builder stage
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy server configuration template
|
||||
COPY default.conf.template /etc/nginx/templates/default.conf.template
|
||||
|
||||
# Set default API backend URL (can be overridden at runtime)
|
||||
ENV API_BACKEND_URL=http://leggend:8000
|
||||
|
||||
# Expose port 80
|
||||
EXPOSE 80
|
||||
|
||||
# Start nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
124
frontend/README.md
Normal file
124
frontend/README.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# Leggen Frontend
|
||||
|
||||
A modern React dashboard for the Leggen Open Banking CLI tool. This frontend provides a user-friendly interface to view bank accounts, transactions, and balances.
|
||||
|
||||
## Features
|
||||
|
||||
- **Modern Dashboard**: Clean, responsive interface built with React and TypeScript
|
||||
- **Bank Accounts Overview**: View all connected bank accounts with real-time balances
|
||||
- **Transaction Management**: Browse, search, and filter transactions across all accounts
|
||||
- **Responsive Design**: Works seamlessly on desktop, tablet, and mobile devices
|
||||
- **Real-time Data**: Powered by React Query for efficient data fetching and caching
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 18+ and npm
|
||||
- Leggen API server running (configurable via environment variables)
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. **Install dependencies:**
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. **Start the development server:**
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
3. **Open your browser to:**
|
||||
```
|
||||
http://localhost:5173
|
||||
```
|
||||
|
||||
## Available Scripts
|
||||
|
||||
- `npm run dev` - Start development server
|
||||
- `npm run build` - Build for production
|
||||
- `npm run preview` - Preview production build
|
||||
- `npm run lint` - Run ESLint
|
||||
|
||||
## Architecture
|
||||
|
||||
### Key Technologies
|
||||
|
||||
- **React 18** - Modern React with hooks and concurrent features
|
||||
- **TypeScript** - Type-safe JavaScript development
|
||||
- **Vite** - Fast build tool and development server
|
||||
- **Tailwind CSS** - Utility-first CSS framework
|
||||
- **React Query** - Data fetching and caching
|
||||
- **Axios** - HTTP client for API calls
|
||||
- **Lucide React** - Modern icon library
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── components/ # React components
|
||||
│ ├── Dashboard.tsx # Main dashboard layout
|
||||
│ ├── AccountsOverview.tsx
|
||||
│ └── TransactionsList.tsx
|
||||
├── lib/ # Utilities and API client
|
||||
│ ├── api.ts # API client and endpoints
|
||||
│ └── utils.ts # Helper functions
|
||||
├── types/ # TypeScript type definitions
|
||||
│ └── api.ts # API response types
|
||||
└── App.tsx # Main application component
|
||||
```
|
||||
|
||||
## API Integration
|
||||
|
||||
The frontend connects to the Leggen API server (configurable via environment variables). The API client handles:
|
||||
|
||||
- Account retrieval and management
|
||||
- Transaction fetching with filtering
|
||||
- Balance information
|
||||
- Error handling and loading states
|
||||
|
||||
## Configuration
|
||||
|
||||
### API URL Configuration
|
||||
|
||||
The frontend supports configurable API URLs through environment variables:
|
||||
|
||||
**Development:**
|
||||
|
||||
- Set `VITE_API_URL` to call external APIs during development
|
||||
- Example: `VITE_API_URL=https://staging-api.example.com npm run dev`
|
||||
|
||||
**Production:**
|
||||
|
||||
- Uses relative URLs (`/api/v1`) that nginx proxies to the backend
|
||||
- Configure nginx proxy target via `API_BACKEND_URL` environment variable
|
||||
- Default: `http://leggend:8000`
|
||||
|
||||
**Docker Compose:**
|
||||
|
||||
```bash
|
||||
# Override API backend URL
|
||||
API_BACKEND_URL=https://prod-api.example.com docker-compose up
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
The dashboard is designed to work with the Leggen CLI tool's API endpoints. Make sure your Leggen server is running before starting the frontend development server.
|
||||
|
||||
### Adding New Features
|
||||
|
||||
1. Define TypeScript types in `src/types/api.ts`
|
||||
2. Add API methods to `src/lib/api.ts`
|
||||
3. Create React components in `src/components/`
|
||||
4. Use React Query for data fetching and state management
|
||||
|
||||
## Deployment
|
||||
|
||||
Build the application for production:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
The built files will be in the `dist/` directory, ready to be served by any static web server.
|
||||
33
frontend/default.conf.template
Normal file
33
frontend/default.conf.template
Normal file
@@ -0,0 +1,33 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Enable gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_proxied expired no-cache no-store private auth;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
|
||||
|
||||
# Handle client-side routing
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# API proxy to backend (configurable via API_BACKEND_URL env var)
|
||||
location /api/ {
|
||||
proxy_pass ${API_BACKEND_URL};
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
23
frontend/eslint.config.js
Normal file
23
frontend/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import tseslint from "typescript-eslint";
|
||||
import { globalIgnores } from "eslint/config";
|
||||
|
||||
export default tseslint.config([
|
||||
globalIgnores(["dist"]),
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs["recommended-latest"],
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
]);
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Leggen</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4629
frontend/package-lock.json
generated
Normal file
4629
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
frontend/package.json
Normal file
37
frontend/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tanstack/react-query": "^5.87.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"axios": "^1.11.0",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.542.0",
|
||||
"postcss": "^8.5.6",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"tailwindcss": "^3.4.17"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.33.0",
|
||||
"@types/react": "^19.1.10",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"eslint": "^9.33.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.39.1",
|
||||
"vite": "^7.1.2"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
4
frontend/public/favicon.svg
Normal file
4
frontend/public/favicon.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="6" fill="#3B82F6"/>
|
||||
<path d="M8 24V8h6c2.2 0 4 1.8 4 4v4c0 2.2-1.8 4-4 4H12v4H8zm4-8h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-2v4z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 257 B |
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
1
frontend/src/App.css
Normal file
1
frontend/src/App.css
Normal file
@@ -0,0 +1 @@
|
||||
/* Additional styles if needed */
|
||||
23
frontend/src/App.tsx
Normal file
23
frontend/src/App.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import Dashboard from "./components/Dashboard";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Dashboard />
|
||||
</div>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
1
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
214
frontend/src/components/AccountsOverview.tsx
Normal file
214
frontend/src/components/AccountsOverview.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
CreditCard,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Building2,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { apiClient } from "../lib/api";
|
||||
import { formatCurrency, formatDate } from "../lib/utils";
|
||||
import LoadingSpinner from "./LoadingSpinner";
|
||||
import type { Account, Balance } from "../types/api";
|
||||
|
||||
export default function AccountsOverview() {
|
||||
const {
|
||||
data: accounts,
|
||||
isLoading: accountsLoading,
|
||||
error: accountsError,
|
||||
refetch: refetchAccounts,
|
||||
} = useQuery<Account[]>({
|
||||
queryKey: ["accounts"],
|
||||
queryFn: apiClient.getAccounts,
|
||||
});
|
||||
|
||||
const { data: balances } = useQuery<Balance[]>({
|
||||
queryKey: ["balances"],
|
||||
queryFn: () => apiClient.getBalances(),
|
||||
});
|
||||
|
||||
if (accountsLoading) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<LoadingSpinner message="Loading accounts..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (accountsError) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-center text-center">
|
||||
<div>
|
||||
<AlertCircle className="h-12 w-12 text-red-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
Failed to load accounts
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Unable to connect to the Leggen API. Please check your
|
||||
configuration and ensure the API server is running.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => refetchAccounts()}
|
||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const totalBalance =
|
||||
accounts?.reduce((sum, account) => {
|
||||
// Get the first available balance from the balances array
|
||||
const primaryBalance = account.balances?.[0]?.amount || 0;
|
||||
return sum + primaryBalance;
|
||||
}, 0) || 0;
|
||||
const totalAccounts = accounts?.length || 0;
|
||||
const uniqueBanks = new Set(accounts?.map((acc) => acc.institution_id) || [])
|
||||
.size;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Total Balance</p>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{formatCurrency(totalBalance)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-green-100 rounded-full">
|
||||
<TrendingUp className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">
|
||||
Total Accounts
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{totalAccounts}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-blue-100 rounded-full">
|
||||
<CreditCard className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">
|
||||
Connected Banks
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{uniqueBanks}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-purple-100 rounded-full">
|
||||
<Building2 className="h-6 w-6 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Accounts List */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-medium text-gray-900">Bank Accounts</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Manage your connected bank accounts
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!accounts || accounts.length === 0 ? (
|
||||
<div className="p-6 text-center">
|
||||
<CreditCard className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
No accounts found
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Connect your first bank account to get started with Leggen.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-200">
|
||||
{accounts.map((account) => {
|
||||
// Get balance from account's balances array or fallback to balances query
|
||||
const accountBalance = account.balances?.[0];
|
||||
const fallbackBalance = balances?.find(
|
||||
(b) => b.account_id === account.id,
|
||||
);
|
||||
const balance =
|
||||
accountBalance?.amount || fallbackBalance?.balance_amount || 0;
|
||||
const currency =
|
||||
accountBalance?.currency ||
|
||||
fallbackBalance?.currency ||
|
||||
account.currency ||
|
||||
"EUR";
|
||||
const isPositive = balance >= 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={account.id}
|
||||
className="p-6 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="p-3 bg-gray-100 rounded-full">
|
||||
<Building2 className="h-6 w-6 text-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-lg font-medium text-gray-900">
|
||||
{account.name || "Unnamed Account"}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
{account.institution_id} • {account.status}
|
||||
</p>
|
||||
{account.iban && (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
IBAN: {account.iban}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<div className="flex items-center space-x-2">
|
||||
{isPositive ? (
|
||||
<TrendingUp className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<TrendingDown className="h-4 w-4 text-red-500" />
|
||||
)}
|
||||
<p
|
||||
className={`text-lg font-semibold ${
|
||||
isPositive ? "text-green-600" : "text-red-600"
|
||||
}`}
|
||||
>
|
||||
{formatCurrency(balance, currency)}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">
|
||||
Updated{" "}
|
||||
{formatDate(account.last_accessed || account.created)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
197
frontend/src/components/Dashboard.tsx
Normal file
197
frontend/src/components/Dashboard.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
CreditCard,
|
||||
TrendingUp,
|
||||
Activity,
|
||||
Menu,
|
||||
X,
|
||||
Home,
|
||||
List,
|
||||
BarChart3,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Bell,
|
||||
} from "lucide-react";
|
||||
import { apiClient } from "../lib/api";
|
||||
import AccountsOverview from "./AccountsOverview";
|
||||
import TransactionsList from "./TransactionsList";
|
||||
import Notifications from "./Notifications";
|
||||
import ErrorBoundary from "./ErrorBoundary";
|
||||
import { cn } from "../lib/utils";
|
||||
import type { Account } from "../types/api";
|
||||
|
||||
type TabType = "overview" | "transactions" | "analytics" | "notifications";
|
||||
|
||||
export default function Dashboard() {
|
||||
const [activeTab, setActiveTab] = useState<TabType>("overview");
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
const { data: accounts } = useQuery<Account[]>({
|
||||
queryKey: ["accounts"],
|
||||
queryFn: apiClient.getAccounts,
|
||||
});
|
||||
|
||||
const {
|
||||
data: healthStatus,
|
||||
isLoading: healthLoading,
|
||||
isError: healthError,
|
||||
} = useQuery({
|
||||
queryKey: ["health"],
|
||||
queryFn: async () => {
|
||||
return await apiClient.getHealth();
|
||||
},
|
||||
refetchInterval: 30000, // Check every 30 seconds
|
||||
retry: 3,
|
||||
});
|
||||
|
||||
const navigation = [
|
||||
{ name: "Overview", icon: Home, id: "overview" as TabType },
|
||||
{ name: "Transactions", icon: List, id: "transactions" as TabType },
|
||||
{ name: "Analytics", icon: BarChart3, id: "analytics" as TabType },
|
||||
{ name: "Notifications", icon: Bell, id: "notifications" as TabType },
|
||||
];
|
||||
|
||||
const totalBalance =
|
||||
accounts?.reduce((sum, account) => {
|
||||
// Get the first available balance from the balances array
|
||||
const primaryBalance = account.balances?.[0]?.amount || 0;
|
||||
return sum + primaryBalance;
|
||||
}, 0) || 0;
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-100">
|
||||
{/* Sidebar */}
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-lg transform transition-transform duration-300 ease-in-out lg:translate-x-0 lg:static lg:inset-0",
|
||||
sidebarOpen ? "translate-x-0" : "-translate-x-full",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between h-16 px-6 border-b border-gray-200">
|
||||
<div className="flex items-center space-x-2">
|
||||
<CreditCard className="h-8 w-8 text-blue-600" />
|
||||
<h1 className="text-xl font-bold text-gray-900">Leggen</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className="lg:hidden p-1 rounded-md text-gray-400 hover:text-gray-500"
|
||||
>
|
||||
<X className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="px-6 py-4">
|
||||
<div className="space-y-1">
|
||||
{navigation.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
setActiveTab(item.id);
|
||||
setSidebarOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center w-full px-3 py-2 text-sm font-medium rounded-md transition-colors",
|
||||
activeTab === item.id
|
||||
? "bg-blue-100 text-blue-700"
|
||||
: "text-gray-700 hover:text-gray-900 hover:bg-gray-100",
|
||||
)}
|
||||
>
|
||||
<item.icon className="mr-3 h-5 w-5" />
|
||||
{item.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Account Summary in Sidebar */}
|
||||
<div className="px-6 py-4 border-t border-gray-200 mt-auto">
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-600">
|
||||
Total Balance
|
||||
</span>
|
||||
<TrendingUp className="h-4 w-4 text-green-500" />
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-gray-900 mt-1">
|
||||
{new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
}).format(totalBalance)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{accounts?.length || 0} accounts
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overlay for mobile */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-gray-600 bg-opacity-75 lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex flex-col flex-1 overflow-hidden">
|
||||
{/* Header */}
|
||||
<header className="bg-white shadow-sm border-b border-gray-200">
|
||||
<div className="flex items-center justify-between h-16 px-6">
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
className="lg:hidden p-1 rounded-md text-gray-400 hover:text-gray-500"
|
||||
>
|
||||
<Menu className="h-6 w-6" />
|
||||
</button>
|
||||
<h2 className="text-lg font-semibold text-gray-900 lg:ml-0 ml-4">
|
||||
{navigation.find((item) => item.id === activeTab)?.name}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center space-x-1">
|
||||
{healthLoading ? (
|
||||
<>
|
||||
<Activity className="h-4 w-4 text-yellow-500 animate-pulse" />
|
||||
<span className="text-sm text-gray-600">Checking...</span>
|
||||
</>
|
||||
) : healthError || healthStatus?.status !== "healthy" ? (
|
||||
<>
|
||||
<WifiOff className="h-4 w-4 text-red-500" />
|
||||
<span className="text-sm text-red-500">Disconnected</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Wifi className="h-4 w-4 text-green-500" />
|
||||
<span className="text-sm text-gray-600">Connected</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main content area */}
|
||||
<main className="flex-1 overflow-y-auto p-6">
|
||||
<ErrorBoundary>
|
||||
{activeTab === "overview" && <AccountsOverview />}
|
||||
{activeTab === "transactions" && <TransactionsList />}
|
||||
{activeTab === "analytics" && (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
||||
Analytics
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Analytics dashboard coming soon...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === "notifications" && <Notifications />}
|
||||
</ErrorBoundary>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
frontend/src/components/ErrorBoundary.tsx
Normal file
89
frontend/src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { Component } from "react";
|
||||
import type { ErrorInfo, ReactNode } from "react";
|
||||
import { AlertTriangle, RefreshCw } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
errorInfo?: ErrorInfo;
|
||||
}
|
||||
|
||||
class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error("ErrorBoundary caught an error:", error, errorInfo);
|
||||
this.setState({ error, errorInfo });
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({ hasError: false, error: undefined, errorInfo: undefined });
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-center text-center">
|
||||
<div>
|
||||
<AlertTriangle className="h-12 w-12 text-red-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
Something went wrong
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
An error occurred while rendering this component. Please try
|
||||
refreshing or check the console for more details.
|
||||
</p>
|
||||
|
||||
{this.state.error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-md p-3 mb-4 text-left">
|
||||
<p className="text-sm font-mono text-red-800">
|
||||
<strong>Error:</strong> {this.state.error.message}
|
||||
</p>
|
||||
{this.state.error.stack && (
|
||||
<details className="mt-2">
|
||||
<summary className="text-sm text-red-600 cursor-pointer">
|
||||
Stack trace
|
||||
</summary>
|
||||
<pre className="text-xs text-red-700 mt-1 whitespace-pre-wrap">
|
||||
{this.state.error.stack}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={this.handleReset}
|
||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
18
frontend/src/components/LoadingSpinner.tsx
Normal file
18
frontend/src/components/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { RefreshCw } from "lucide-react";
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export default function LoadingSpinner({
|
||||
message = "Loading...",
|
||||
}: LoadingSpinnerProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="text-center">
|
||||
<RefreshCw className="h-8 w-8 animate-spin text-blue-600 mx-auto mb-2" />
|
||||
<p className="text-gray-600 text-sm">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
320
frontend/src/components/Notifications.tsx
Normal file
320
frontend/src/components/Notifications.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Bell,
|
||||
MessageSquare,
|
||||
Send,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Settings,
|
||||
TestTube,
|
||||
} from "lucide-react";
|
||||
import { apiClient } from "../lib/api";
|
||||
import LoadingSpinner from "./LoadingSpinner";
|
||||
import type { NotificationSettings, NotificationService } from "../types/api";
|
||||
|
||||
export default function Notifications() {
|
||||
const [testService, setTestService] = useState("");
|
||||
const [testMessage, setTestMessage] = useState(
|
||||
"Test notification from Leggen",
|
||||
);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const {
|
||||
data: settings,
|
||||
isLoading: settingsLoading,
|
||||
error: settingsError,
|
||||
refetch: refetchSettings,
|
||||
} = useQuery<NotificationSettings>({
|
||||
queryKey: ["notificationSettings"],
|
||||
queryFn: apiClient.getNotificationSettings,
|
||||
});
|
||||
|
||||
const {
|
||||
data: services,
|
||||
isLoading: servicesLoading,
|
||||
error: servicesError,
|
||||
refetch: refetchServices,
|
||||
} = useQuery<NotificationService[]>({
|
||||
queryKey: ["notificationServices"],
|
||||
queryFn: apiClient.getNotificationServices,
|
||||
});
|
||||
|
||||
const testMutation = useMutation({
|
||||
mutationFn: apiClient.testNotification,
|
||||
onSuccess: () => {
|
||||
// Could show a success toast here
|
||||
console.log("Test notification sent successfully");
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to send test notification:", error);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteServiceMutation = useMutation({
|
||||
mutationFn: apiClient.deleteNotificationService,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["notificationSettings"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["notificationServices"] });
|
||||
},
|
||||
});
|
||||
|
||||
if (settingsLoading || servicesLoading) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<LoadingSpinner message="Loading notifications..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (settingsError || servicesError) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-center text-center">
|
||||
<div>
|
||||
<AlertCircle className="h-12 w-12 text-red-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
Failed to load notifications
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Unable to connect to the Leggen API. Please check your
|
||||
configuration and ensure the API server is running.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
refetchSettings();
|
||||
refetchServices();
|
||||
}}
|
||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleTestNotification = () => {
|
||||
if (!testService) return;
|
||||
|
||||
testMutation.mutate({
|
||||
service: testService,
|
||||
message: testMessage,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteService = (serviceName: string) => {
|
||||
if (
|
||||
confirm(
|
||||
`Are you sure you want to delete the ${serviceName} notification service?`,
|
||||
)
|
||||
) {
|
||||
deleteServiceMutation.mutate(serviceName);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Test Notification Section */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center space-x-2 mb-4">
|
||||
<TestTube className="h-5 w-5 text-blue-600" />
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
Test Notifications
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Service
|
||||
</label>
|
||||
<select
|
||||
value={testService}
|
||||
onChange={(e) => setTestService(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Select a service...</option>
|
||||
{services?.map((service) => (
|
||||
<option key={service.name} value={service.name}>
|
||||
{service.name} {service.enabled ? "(Enabled)" : "(Disabled)"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Message
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={testMessage}
|
||||
onChange={(e) => setTestMessage(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Test message..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={handleTestNotification}
|
||||
disabled={!testService || testMutation.isPending}
|
||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Send className="h-4 w-4 mr-2" />
|
||||
{testMutation.isPending ? "Sending..." : "Send Test Notification"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notification Services */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Bell className="h-5 w-5 text-blue-600" />
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
Notification Services
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Manage your notification services
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!services || services.length === 0 ? (
|
||||
<div className="p-6 text-center">
|
||||
<Bell className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
No notification services configured
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Configure notification services in your backend to receive alerts.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-200">
|
||||
{services.map((service) => (
|
||||
<div
|
||||
key={service.name}
|
||||
className="p-6 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="p-3 bg-gray-100 rounded-full">
|
||||
{service.name.toLowerCase().includes("discord") ? (
|
||||
<MessageSquare className="h-6 w-6 text-gray-600" />
|
||||
) : service.name.toLowerCase().includes("telegram") ? (
|
||||
<Send className="h-6 w-6 text-gray-600" />
|
||||
) : (
|
||||
<Bell className="h-6 w-6 text-gray-600" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-lg font-medium text-gray-900 capitalize">
|
||||
{service.name}
|
||||
</h4>
|
||||
<div className="flex items-center space-x-2 mt-1">
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
service.enabled
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-red-100 text-red-800"
|
||||
}`}
|
||||
>
|
||||
{service.enabled ? (
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
) : (
|
||||
<AlertCircle className="h-3 w-3 mr-1" />
|
||||
)}
|
||||
{service.enabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
service.configured
|
||||
? "bg-blue-100 text-blue-800"
|
||||
: "bg-yellow-100 text-yellow-800"
|
||||
}`}
|
||||
>
|
||||
{service.configured ? "Configured" : "Not Configured"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => handleDeleteService(service.name)}
|
||||
disabled={deleteServiceMutation.isPending}
|
||||
className="p-2 text-red-600 hover:text-red-800 hover:bg-red-50 rounded-md transition-colors"
|
||||
title={`Delete ${service.name} service`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notification Settings */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center space-x-2 mb-4">
|
||||
<Settings className="h-5 w-5 text-blue-600" />
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
Notification Settings
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{settings && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">
|
||||
Filters
|
||||
</h4>
|
||||
<div className="bg-gray-50 rounded-md p-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">
|
||||
Case Insensitive Filters
|
||||
</label>
|
||||
<p className="text-sm text-gray-900">
|
||||
{settings.filters.case_insensitive.length > 0
|
||||
? settings.filters.case_insensitive.join(", ")
|
||||
: "None"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">
|
||||
Case Sensitive Filters
|
||||
</label>
|
||||
<p className="text-sm text-gray-900">
|
||||
{settings.filters.case_sensitive &&
|
||||
settings.filters.case_sensitive.length > 0
|
||||
? settings.filters.case_sensitive.join(", ")
|
||||
: "None"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-600">
|
||||
<p>
|
||||
Configure notification settings through your backend API to
|
||||
customize filters and service configurations.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
339
frontend/src/components/TransactionsList.tsx
Normal file
339
frontend/src/components/TransactionsList.tsx
Normal file
@@ -0,0 +1,339 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Filter,
|
||||
Search,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Calendar,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { apiClient } from "../lib/api";
|
||||
import { formatCurrency, formatDate } from "../lib/utils";
|
||||
import LoadingSpinner from "./LoadingSpinner";
|
||||
import type { Account, Transaction } from "../types/api";
|
||||
|
||||
export default function TransactionsList() {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [selectedAccount, setSelectedAccount] = useState<string>("");
|
||||
const [startDate, setStartDate] = useState("");
|
||||
const [endDate, setEndDate] = useState("");
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
const { data: accounts } = useQuery<Account[]>({
|
||||
queryKey: ["accounts"],
|
||||
queryFn: apiClient.getAccounts,
|
||||
});
|
||||
|
||||
const {
|
||||
data: transactions,
|
||||
isLoading: transactionsLoading,
|
||||
error: transactionsError,
|
||||
refetch: refetchTransactions,
|
||||
} = useQuery<Transaction[]>({
|
||||
queryKey: ["transactions", selectedAccount, startDate, endDate],
|
||||
queryFn: () =>
|
||||
apiClient.getTransactions({
|
||||
accountId: selectedAccount || undefined,
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
const filteredTransactions = (transactions || []).filter((transaction) => {
|
||||
// Additional validation (API client should have already filtered out invalid ones)
|
||||
if (!transaction || !transaction.account_id) {
|
||||
console.warn(
|
||||
"Invalid transaction found after API filtering:",
|
||||
transaction,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const description = transaction.description || "";
|
||||
const creditorName = transaction.creditor_name || "";
|
||||
const debtorName = transaction.debtor_name || "";
|
||||
const reference = transaction.reference || "";
|
||||
|
||||
const matchesSearch =
|
||||
searchTerm === "" ||
|
||||
description.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
creditorName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
debtorName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
reference.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
return matchesSearch;
|
||||
});
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearchTerm("");
|
||||
setSelectedAccount("");
|
||||
setStartDate("");
|
||||
setEndDate("");
|
||||
};
|
||||
|
||||
const hasActiveFilters =
|
||||
searchTerm || selectedAccount || startDate || endDate;
|
||||
|
||||
if (transactionsLoading) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<LoadingSpinner message="Loading transactions..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (transactionsError) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-center text-center">
|
||||
<div>
|
||||
<AlertCircle className="h-12 w-12 text-red-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
Failed to load transactions
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Unable to fetch transactions from the Leggen API.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => refetchTransactions()}
|
||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium text-gray-900">Transactions</h3>
|
||||
<div className="flex items-center space-x-2">
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="inline-flex items-center px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded-full hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
<X className="h-3 w-3 mr-1" />
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className="inline-flex items-center px-3 py-2 bg-blue-100 text-blue-700 rounded-md hover:bg-blue-200 transition-colors"
|
||||
>
|
||||
<Filter className="h-4 w-4 mr-2" />
|
||||
Filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showFilters && (
|
||||
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{/* Search */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Search
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Description, name, reference..."
|
||||
className="pl-10 pr-3 py-2 w-full border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Account Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Account
|
||||
</label>
|
||||
<select
|
||||
value={selectedAccount}
|
||||
onChange={(e) => setSelectedAccount(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="">All accounts</option>
|
||||
{accounts?.map((account) => (
|
||||
<option key={account.id} value={account.id}>
|
||||
{account.name || "Unnamed Account"} (
|
||||
{account.institution_id})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Start Date */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Start Date
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Calendar className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="pl-10 pr-3 py-2 w-full border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* End Date */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
End Date
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Calendar className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="pl-10 pr-3 py-2 w-full border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results Summary */}
|
||||
<div className="px-6 py-3 bg-gray-50 border-b border-gray-200">
|
||||
<p className="text-sm text-gray-600">
|
||||
Showing {filteredTransactions.length} transaction
|
||||
{filteredTransactions.length !== 1 ? "s" : ""}
|
||||
{selectedAccount && accounts && (
|
||||
<span className="ml-1">
|
||||
for {accounts.find((acc) => acc.id === selectedAccount)?.name}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transactions List */}
|
||||
{filteredTransactions.length === 0 ? (
|
||||
<div className="bg-white rounded-lg shadow p-6 text-center">
|
||||
<div className="text-gray-400 mb-4">
|
||||
<TrendingUp className="h-12 w-12 mx-auto" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
No transactions found
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
{hasActiveFilters
|
||||
? "Try adjusting your filters to see more results."
|
||||
: "No transactions are available for the selected criteria."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow divide-y divide-gray-200">
|
||||
{filteredTransactions.map((transaction) => {
|
||||
const account = accounts?.find(
|
||||
(acc) => acc.id === transaction.account_id,
|
||||
);
|
||||
const isPositive = transaction.amount > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={
|
||||
transaction.internal_transaction_id ||
|
||||
`${transaction.account_id}-${transaction.date}-${transaction.amount}`
|
||||
}
|
||||
className="p-6 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-start space-x-3">
|
||||
<div
|
||||
className={`p-2 rounded-full ${
|
||||
isPositive ? "bg-green-100" : "bg-red-100"
|
||||
}`}
|
||||
>
|
||||
{isPositive ? (
|
||||
<TrendingUp className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<TrendingDown className="h-4 w-4 text-red-600" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-1">
|
||||
{transaction.description}
|
||||
</h4>
|
||||
|
||||
<div className="text-xs text-gray-500 space-y-1">
|
||||
{account && (
|
||||
<p>
|
||||
{account.name || "Unnamed Account"} •{" "}
|
||||
{account.institution_id}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(transaction.creditor_name ||
|
||||
transaction.debtor_name) && (
|
||||
<p>
|
||||
{isPositive ? "From: " : "To: "}
|
||||
{transaction.creditor_name ||
|
||||
transaction.debtor_name}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{transaction.reference && (
|
||||
<p>Ref: {transaction.reference}</p>
|
||||
)}
|
||||
|
||||
{transaction.internal_transaction_id && (
|
||||
<p>ID: {transaction.internal_transaction_id}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right ml-4">
|
||||
<p
|
||||
className={`text-lg font-semibold ${
|
||||
isPositive ? "text-green-600" : "text-red-600"
|
||||
}`}
|
||||
>
|
||||
{isPositive ? "+" : ""}
|
||||
{formatCurrency(transaction.amount, transaction.currency)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{transaction.date
|
||||
? formatDate(transaction.date)
|
||||
: "No date"}
|
||||
</p>
|
||||
{transaction.booking_date &&
|
||||
transaction.booking_date !== transaction.date && (
|
||||
<p className="text-xs text-gray-400">
|
||||
Booked: {formatDate(transaction.booking_date)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
3
frontend/src/index.css
Normal file
3
frontend/src/index.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
130
frontend/src/lib/api.ts
Normal file
130
frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import axios from "axios";
|
||||
import type {
|
||||
Account,
|
||||
Transaction,
|
||||
Balance,
|
||||
ApiResponse,
|
||||
NotificationSettings,
|
||||
NotificationTest,
|
||||
NotificationService,
|
||||
NotificationServicesResponse,
|
||||
HealthData,
|
||||
} from "../types/api";
|
||||
|
||||
// Use VITE_API_URL for development, relative URLs for production
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || "/api/v1";
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
export const apiClient = {
|
||||
// Get all accounts
|
||||
getAccounts: async (): Promise<Account[]> => {
|
||||
const response = await api.get<ApiResponse<Account[]>>("/accounts");
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// Get account by ID
|
||||
getAccount: async (id: string): Promise<Account> => {
|
||||
const response = await api.get<ApiResponse<Account>>(`/accounts/${id}`);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// Get all balances
|
||||
getBalances: async (): Promise<Balance[]> => {
|
||||
const response = await api.get<ApiResponse<Balance[]>>("/balances");
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// Get balances for specific account
|
||||
getAccountBalances: async (accountId: string): Promise<Balance[]> => {
|
||||
const response = await api.get<ApiResponse<Balance[]>>(
|
||||
`/accounts/${accountId}/balances`,
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// Get transactions with optional filters
|
||||
getTransactions: async (params?: {
|
||||
accountId?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
search?: string;
|
||||
}): Promise<Transaction[]> => {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (params?.accountId) queryParams.append("account_id", params.accountId);
|
||||
if (params?.startDate) queryParams.append("start_date", params.startDate);
|
||||
if (params?.endDate) queryParams.append("end_date", params.endDate);
|
||||
if (params?.page) queryParams.append("page", params.page.toString());
|
||||
if (params?.perPage)
|
||||
queryParams.append("per_page", params.perPage.toString());
|
||||
if (params?.search) queryParams.append("search", params.search);
|
||||
|
||||
const response = await api.get<ApiResponse<Transaction[]>>(
|
||||
`/transactions?${queryParams.toString()}`,
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// Get transaction by ID
|
||||
getTransaction: async (id: string): Promise<Transaction> => {
|
||||
const response = await api.get<ApiResponse<Transaction>>(
|
||||
`/transactions/${id}`,
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// Get notification settings
|
||||
getNotificationSettings: async (): Promise<NotificationSettings> => {
|
||||
const response = await api.get<ApiResponse<NotificationSettings>>(
|
||||
"/notifications/settings",
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// Update notification settings
|
||||
updateNotificationSettings: async (
|
||||
settings: NotificationSettings,
|
||||
): Promise<NotificationSettings> => {
|
||||
const response = await api.put<ApiResponse<NotificationSettings>>(
|
||||
"/notifications/settings",
|
||||
settings,
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// Test notification
|
||||
testNotification: async (test: NotificationTest): Promise<void> => {
|
||||
await api.post("/notifications/test", test);
|
||||
},
|
||||
|
||||
// Get notification services
|
||||
getNotificationServices: async (): Promise<NotificationService[]> => {
|
||||
const response = await api.get<ApiResponse<NotificationServicesResponse>>(
|
||||
"/notifications/services",
|
||||
);
|
||||
// Convert object to array format
|
||||
const servicesData = response.data.data;
|
||||
return Object.values(servicesData);
|
||||
},
|
||||
|
||||
// Delete notification service
|
||||
deleteNotificationService: async (service: string): Promise<void> => {
|
||||
await api.delete(`/notifications/settings/${service}`);
|
||||
},
|
||||
|
||||
// Health check
|
||||
getHealth: async (): Promise<HealthData> => {
|
||||
const response = await api.get<ApiResponse<HealthData>>("/health");
|
||||
return response.data.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default apiClient;
|
||||
62
frontend/src/lib/utils.ts
Normal file
62
frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return clsx(inputs);
|
||||
}
|
||||
|
||||
export function formatCurrency(
|
||||
amount: number,
|
||||
currency: string = "EUR",
|
||||
): string {
|
||||
// Validate currency code - must be 3 letters and a valid ISO 4217 code
|
||||
const validCurrency =
|
||||
currency && /^[A-Z]{3}$/.test(currency) ? currency : "EUR";
|
||||
|
||||
try {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: validCurrency,
|
||||
}).format(amount);
|
||||
} catch {
|
||||
// Fallback if currency is still invalid
|
||||
console.warn(`Invalid currency code: ${currency}, falling back to EUR`);
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
}).format(amount);
|
||||
}
|
||||
}
|
||||
|
||||
export function formatDate(date: string): string {
|
||||
if (!date) return "No date";
|
||||
|
||||
const parsedDate = new Date(date);
|
||||
if (isNaN(parsedDate.getTime())) {
|
||||
console.warn("Invalid date string:", date);
|
||||
return "Invalid date";
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(parsedDate);
|
||||
}
|
||||
|
||||
export function formatDateTime(date: string): string {
|
||||
if (!date) return "No date";
|
||||
|
||||
const parsedDate = new Date(date);
|
||||
if (isNaN(parsedDate.getTime())) {
|
||||
console.warn("Invalid date string:", date);
|
||||
return "Invalid date";
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(parsedDate);
|
||||
}
|
||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import App from "./App.tsx";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
135
frontend/src/types/api.ts
Normal file
135
frontend/src/types/api.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
export interface AccountBalance {
|
||||
amount: number;
|
||||
currency: string;
|
||||
balance_type: string;
|
||||
last_change_date?: string;
|
||||
}
|
||||
|
||||
export interface Account {
|
||||
id: string;
|
||||
institution_id: string;
|
||||
status: string;
|
||||
iban?: string;
|
||||
name?: string;
|
||||
currency?: string;
|
||||
created: string;
|
||||
last_accessed?: string;
|
||||
balances: AccountBalance[];
|
||||
}
|
||||
|
||||
export interface Transaction {
|
||||
internal_transaction_id: string | null;
|
||||
account_id: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
description: string;
|
||||
date: string;
|
||||
status: string;
|
||||
// Optional fields that may be present in some transactions
|
||||
booking_date?: string;
|
||||
value_date?: string;
|
||||
creditor_name?: string;
|
||||
debtor_name?: string;
|
||||
reference?: string;
|
||||
category?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
// Type for raw transaction data from API (before sanitization)
|
||||
export interface RawTransaction {
|
||||
id?: string;
|
||||
internal_id?: string;
|
||||
account_id?: string;
|
||||
amount?: number;
|
||||
currency?: string;
|
||||
description?: string;
|
||||
transaction_date?: string;
|
||||
booking_date?: string;
|
||||
value_date?: string;
|
||||
creditor_name?: string;
|
||||
debtor_name?: string;
|
||||
reference?: string;
|
||||
category?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface Balance {
|
||||
id: string;
|
||||
account_id: string;
|
||||
balance_amount: number;
|
||||
balance_type: string;
|
||||
currency: string;
|
||||
reference_date: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Bank {
|
||||
id: string;
|
||||
name: string;
|
||||
country_code: string;
|
||||
logo_url?: string;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
data: T;
|
||||
message?: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
// Notification types
|
||||
export interface DiscordConfig {
|
||||
webhook: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface TelegramConfig {
|
||||
token: string;
|
||||
chat_id: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface NotificationFilters {
|
||||
case_insensitive: string[];
|
||||
case_sensitive?: string[];
|
||||
}
|
||||
|
||||
export interface NotificationSettings {
|
||||
discord?: DiscordConfig;
|
||||
telegram?: TelegramConfig;
|
||||
filters: NotificationFilters;
|
||||
}
|
||||
|
||||
export interface NotificationTest {
|
||||
service: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface NotificationService {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
configured: boolean;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export interface NotificationServicesResponse {
|
||||
[serviceName: string]: NotificationService;
|
||||
}
|
||||
|
||||
// Health check response data
|
||||
export interface HealthData {
|
||||
status: string;
|
||||
config_loaded?: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
8
frontend/tailwind.config.js
Normal file
8
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,8 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [require("@tailwindcss/forms")],
|
||||
};
|
||||
27
frontend/tsconfig.app.json
Normal file
27
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
frontend/tsconfig.json
Normal file
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
25
frontend/tsconfig.node.json
Normal file
25
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
7
frontend/vite.config.ts
Normal file
7
frontend/vite.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
});
|
||||
@@ -16,6 +16,31 @@ def persist_balances(ctx: click.Context, balance: dict):
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Create the accounts table if it doesn't exist
|
||||
cursor.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS accounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
institution_id TEXT,
|
||||
status TEXT,
|
||||
iban TEXT,
|
||||
name TEXT,
|
||||
currency TEXT,
|
||||
created DATETIME,
|
||||
last_accessed DATETIME,
|
||||
last_updated DATETIME
|
||||
)"""
|
||||
)
|
||||
|
||||
# Create indexes for accounts table
|
||||
cursor.execute(
|
||||
"""CREATE INDEX IF NOT EXISTS idx_accounts_institution_id
|
||||
ON accounts(institution_id)"""
|
||||
)
|
||||
cursor.execute(
|
||||
"""CREATE INDEX IF NOT EXISTS idx_accounts_status
|
||||
ON accounts(status)"""
|
||||
)
|
||||
|
||||
# Create the balances table if it doesn't exist
|
||||
cursor.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS balances (
|
||||
@@ -185,6 +210,7 @@ def get_transactions(
|
||||
min_amount=None,
|
||||
max_amount=None,
|
||||
search=None,
|
||||
hide_missing_ids=True,
|
||||
):
|
||||
"""Get transactions from SQLite database with optional filtering"""
|
||||
from pathlib import Path
|
||||
@@ -224,6 +250,11 @@ def get_transactions(
|
||||
query += " AND description LIKE ?"
|
||||
params.append(f"%{search}%")
|
||||
|
||||
if hide_missing_ids:
|
||||
query += (
|
||||
" AND internalTransactionId IS NOT NULL AND internalTransactionId != ''"
|
||||
)
|
||||
|
||||
# Add ordering and pagination
|
||||
query += " ORDER BY transactionDate DESC"
|
||||
|
||||
@@ -372,6 +403,11 @@ def get_transaction_count(account_id=None, **filters):
|
||||
query += " AND description LIKE ?"
|
||||
params.append(f"%{filters['search']}%")
|
||||
|
||||
if filters.get("hide_missing_ids", True):
|
||||
query += (
|
||||
" AND internalTransactionId IS NOT NULL AND internalTransactionId != ''"
|
||||
)
|
||||
|
||||
try:
|
||||
cursor.execute(query, params)
|
||||
count = cursor.fetchone()[0]
|
||||
@@ -381,3 +417,133 @@ def get_transaction_count(account_id=None, **filters):
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
raise e
|
||||
|
||||
|
||||
def persist_account(account_data: dict):
|
||||
"""Persist account details to SQLite database"""
|
||||
from pathlib import Path
|
||||
|
||||
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Create the accounts table if it doesn't exist
|
||||
cursor.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS accounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
institution_id TEXT,
|
||||
status TEXT,
|
||||
iban TEXT,
|
||||
name TEXT,
|
||||
currency TEXT,
|
||||
created DATETIME,
|
||||
last_accessed DATETIME,
|
||||
last_updated DATETIME
|
||||
)"""
|
||||
)
|
||||
|
||||
# Create indexes for accounts table
|
||||
cursor.execute(
|
||||
"""CREATE INDEX IF NOT EXISTS idx_accounts_institution_id
|
||||
ON accounts(institution_id)"""
|
||||
)
|
||||
cursor.execute(
|
||||
"""CREATE INDEX IF NOT EXISTS idx_accounts_status
|
||||
ON accounts(status)"""
|
||||
)
|
||||
|
||||
try:
|
||||
# Insert or replace account data
|
||||
cursor.execute(
|
||||
"""INSERT OR REPLACE INTO accounts (
|
||||
id,
|
||||
institution_id,
|
||||
status,
|
||||
iban,
|
||||
name,
|
||||
currency,
|
||||
created,
|
||||
last_accessed,
|
||||
last_updated
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
account_data["id"],
|
||||
account_data["institution_id"],
|
||||
account_data["status"],
|
||||
account_data.get("iban"),
|
||||
account_data.get("name"),
|
||||
account_data.get("currency"),
|
||||
account_data["created"],
|
||||
account_data.get("last_accessed"),
|
||||
account_data.get("last_updated", account_data["created"]),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
success(f"[{account_data['id']}] Account details persisted to database")
|
||||
return account_data
|
||||
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
raise e
|
||||
|
||||
|
||||
def get_accounts(account_ids=None):
|
||||
"""Get account details from SQLite database"""
|
||||
from pathlib import Path
|
||||
|
||||
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
|
||||
if not db_path.exists():
|
||||
return []
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
|
||||
query = "SELECT * FROM accounts"
|
||||
params = []
|
||||
|
||||
if account_ids:
|
||||
placeholders = ",".join("?" * len(account_ids))
|
||||
query += f" WHERE id IN ({placeholders})"
|
||||
params.extend(account_ids)
|
||||
|
||||
query += " ORDER BY created DESC"
|
||||
|
||||
try:
|
||||
cursor.execute(query, params)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
accounts = [dict(row) for row in rows]
|
||||
conn.close()
|
||||
return accounts
|
||||
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
raise e
|
||||
|
||||
|
||||
def get_account(account_id: str):
|
||||
"""Get specific account details from SQLite database"""
|
||||
from pathlib import Path
|
||||
|
||||
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
|
||||
if not db_path.exists():
|
||||
return None
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute("SELECT * FROM accounts WHERE id = ?", (account_id,))
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
if row:
|
||||
return dict(row)
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
raise e
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Dict, Optional, List
|
||||
from typing import Optional, List
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -21,10 +21,8 @@ class TelegramConfig(BaseModel):
|
||||
class NotificationFilters(BaseModel):
|
||||
"""Notification filters configuration"""
|
||||
|
||||
case_insensitive: Dict[str, str] = {}
|
||||
case_sensitive: Optional[Dict[str, str]] = None
|
||||
amount_threshold: Optional[float] = None
|
||||
keywords: List[str] = []
|
||||
case_insensitive: List[str] = []
|
||||
case_sensitive: Optional[List[str]] = None
|
||||
|
||||
|
||||
class NotificationSettings(BaseModel):
|
||||
|
||||
@@ -9,67 +9,65 @@ from leggend.api.models.accounts import (
|
||||
Transaction,
|
||||
TransactionSummary,
|
||||
)
|
||||
from leggend.services.gocardless_service import GoCardlessService
|
||||
from leggend.services.database_service import DatabaseService
|
||||
|
||||
router = APIRouter()
|
||||
gocardless_service = GoCardlessService()
|
||||
database_service = DatabaseService()
|
||||
|
||||
|
||||
@router.get("/accounts", response_model=APIResponse)
|
||||
async def get_all_accounts() -> APIResponse:
|
||||
"""Get all connected accounts"""
|
||||
"""Get all connected accounts from database"""
|
||||
try:
|
||||
requisitions_data = await gocardless_service.get_requisitions()
|
||||
|
||||
all_accounts = set()
|
||||
for req in requisitions_data.get("results", []):
|
||||
all_accounts.update(req.get("accounts", []))
|
||||
|
||||
accounts = []
|
||||
for account_id in all_accounts:
|
||||
|
||||
# Get all account details from database
|
||||
db_accounts = await database_service.get_accounts_from_db()
|
||||
|
||||
# Process accounts found in database
|
||||
for db_account in db_accounts:
|
||||
try:
|
||||
account_details = await gocardless_service.get_account_details(
|
||||
account_id
|
||||
)
|
||||
balances_data = await gocardless_service.get_account_balances(
|
||||
account_id
|
||||
# Get latest balances from database for this account
|
||||
balances_data = await database_service.get_balances_from_db(
|
||||
db_account["id"]
|
||||
)
|
||||
|
||||
# Process balances
|
||||
balances = []
|
||||
for balance in balances_data.get("balances", []):
|
||||
balance_amount = balance["balanceAmount"]
|
||||
for balance in balances_data:
|
||||
balances.append(
|
||||
AccountBalance(
|
||||
amount=float(balance_amount["amount"]),
|
||||
currency=balance_amount["currency"],
|
||||
balance_type=balance["balanceType"],
|
||||
last_change_date=balance.get("lastChangeDateTime"),
|
||||
amount=balance["amount"],
|
||||
currency=balance["currency"],
|
||||
balance_type=balance["type"],
|
||||
last_change_date=balance.get("timestamp"),
|
||||
)
|
||||
)
|
||||
|
||||
accounts.append(
|
||||
AccountDetails(
|
||||
id=account_details["id"],
|
||||
institution_id=account_details["institution_id"],
|
||||
status=account_details["status"],
|
||||
iban=account_details.get("iban"),
|
||||
name=account_details.get("name"),
|
||||
currency=account_details.get("currency"),
|
||||
created=account_details["created"],
|
||||
last_accessed=account_details.get("last_accessed"),
|
||||
id=db_account["id"],
|
||||
institution_id=db_account["institution_id"],
|
||||
status=db_account["status"],
|
||||
iban=db_account.get("iban"),
|
||||
name=db_account.get("name"),
|
||||
currency=db_account.get("currency"),
|
||||
created=db_account["created"],
|
||||
last_accessed=db_account.get("last_accessed"),
|
||||
balances=balances,
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get details for account {account_id}: {e}")
|
||||
logger.error(
|
||||
f"Failed to process database account {db_account['id']}: {e}"
|
||||
)
|
||||
continue
|
||||
|
||||
return APIResponse(
|
||||
success=True, data=accounts, message=f"Retrieved {len(accounts)} accounts"
|
||||
success=True,
|
||||
data=accounts,
|
||||
message=f"Retrieved {len(accounts)} accounts from database",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
@@ -81,46 +79,55 @@ async def get_all_accounts() -> APIResponse:
|
||||
|
||||
@router.get("/accounts/{account_id}", response_model=APIResponse)
|
||||
async def get_account_details(account_id: str) -> APIResponse:
|
||||
"""Get details for a specific account"""
|
||||
"""Get details for a specific account from database"""
|
||||
try:
|
||||
account_details = await gocardless_service.get_account_details(account_id)
|
||||
balances_data = await gocardless_service.get_account_balances(account_id)
|
||||
# Get account details from database
|
||||
db_account = await database_service.get_account_details_from_db(account_id)
|
||||
|
||||
if not db_account:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Account {account_id} not found in database"
|
||||
)
|
||||
|
||||
# Get latest balances from database for this account
|
||||
balances_data = await database_service.get_balances_from_db(account_id)
|
||||
|
||||
# Process balances
|
||||
balances = []
|
||||
for balance in balances_data.get("balances", []):
|
||||
balance_amount = balance["balanceAmount"]
|
||||
for balance in balances_data:
|
||||
balances.append(
|
||||
AccountBalance(
|
||||
amount=float(balance_amount["amount"]),
|
||||
currency=balance_amount["currency"],
|
||||
balance_type=balance["balanceType"],
|
||||
last_change_date=balance.get("lastChangeDateTime"),
|
||||
amount=balance["amount"],
|
||||
currency=balance["currency"],
|
||||
balance_type=balance["type"],
|
||||
last_change_date=balance.get("timestamp"),
|
||||
)
|
||||
)
|
||||
|
||||
account = AccountDetails(
|
||||
id=account_details["id"],
|
||||
institution_id=account_details["institution_id"],
|
||||
status=account_details["status"],
|
||||
iban=account_details.get("iban"),
|
||||
name=account_details.get("name"),
|
||||
currency=account_details.get("currency"),
|
||||
created=account_details["created"],
|
||||
last_accessed=account_details.get("last_accessed"),
|
||||
id=db_account["id"],
|
||||
institution_id=db_account["institution_id"],
|
||||
status=db_account["status"],
|
||||
iban=db_account.get("iban"),
|
||||
name=db_account.get("name"),
|
||||
currency=db_account.get("currency"),
|
||||
created=db_account["created"],
|
||||
last_accessed=db_account.get("last_accessed"),
|
||||
balances=balances,
|
||||
)
|
||||
|
||||
return APIResponse(
|
||||
success=True,
|
||||
data=account,
|
||||
message=f"Account details retrieved for {account_id}",
|
||||
message=f"Account details retrieved from database for {account_id}",
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as 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)}"
|
||||
status_code=500, detail=f"Failed to get account details: {str(e)}"
|
||||
) from e
|
||||
|
||||
|
||||
@@ -157,6 +164,56 @@ async def get_account_balances(account_id: str) -> APIResponse:
|
||||
) from e
|
||||
|
||||
|
||||
@router.get("/balances", response_model=APIResponse)
|
||||
async def get_all_balances() -> APIResponse:
|
||||
"""Get all balances from all accounts in database"""
|
||||
try:
|
||||
# Get all accounts first to iterate through them
|
||||
db_accounts = await database_service.get_accounts_from_db()
|
||||
|
||||
all_balances = []
|
||||
for db_account in db_accounts:
|
||||
try:
|
||||
# Get balances for this account
|
||||
db_balances = await database_service.get_balances_from_db(
|
||||
account_id=db_account["id"]
|
||||
)
|
||||
|
||||
# Process balances and add account info
|
||||
for balance in db_balances:
|
||||
balance_data = {
|
||||
"id": f"{db_account['id']}_{balance['type']}", # Create unique ID
|
||||
"account_id": db_account["id"],
|
||||
"balance_amount": balance["amount"],
|
||||
"balance_type": balance["type"],
|
||||
"currency": balance["currency"],
|
||||
"reference_date": balance.get(
|
||||
"timestamp", db_account.get("last_accessed")
|
||||
),
|
||||
"created_at": db_account.get("created"),
|
||||
"updated_at": db_account.get("last_accessed"),
|
||||
}
|
||||
all_balances.append(balance_data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to get balances for account {db_account['id']}: {e}"
|
||||
)
|
||||
continue
|
||||
|
||||
return APIResponse(
|
||||
success=True,
|
||||
data=all_balances,
|
||||
message=f"Retrieved {len(all_balances)} balances from {len(db_accounts)} accounts",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get all balances: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to get balances: {str(e)}"
|
||||
) from e
|
||||
|
||||
|
||||
@router.get("/accounts/{account_id}/transactions", response_model=APIResponse)
|
||||
async def get_account_transactions(
|
||||
account_id: str,
|
||||
|
||||
@@ -36,17 +36,18 @@ async def get_notification_settings() -> APIResponse:
|
||||
if discord_config.get("webhook")
|
||||
else None,
|
||||
telegram=TelegramConfig(
|
||||
token="***" if telegram_config.get("token") else "",
|
||||
chat_id=telegram_config.get("chat_id", 0),
|
||||
token="***"
|
||||
if (telegram_config.get("token") or telegram_config.get("api-key"))
|
||||
else "",
|
||||
chat_id=telegram_config.get("chat_id")
|
||||
or telegram_config.get("chat-id", 0),
|
||||
enabled=telegram_config.get("enabled", True),
|
||||
)
|
||||
if telegram_config.get("token")
|
||||
if (telegram_config.get("token") or telegram_config.get("api-key"))
|
||||
else None,
|
||||
filters=NotificationFilters(
|
||||
case_insensitive=filters_config.get("case-insensitive", {}),
|
||||
case_insensitive=filters_config.get("case-insensitive", []),
|
||||
case_sensitive=filters_config.get("case-sensitive"),
|
||||
amount_threshold=filters_config.get("amount_threshold"),
|
||||
keywords=filters_config.get("keywords", []),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -89,10 +90,6 @@ async def update_notification_settings(settings: NotificationSettings) -> APIRes
|
||||
filters_config["case-insensitive"] = settings.filters.case_insensitive
|
||||
if settings.filters.case_sensitive:
|
||||
filters_config["case-sensitive"] = settings.filters.case_sensitive
|
||||
if settings.filters.amount_threshold:
|
||||
filters_config["amount_threshold"] = settings.filters.amount_threshold
|
||||
if settings.filters.keywords:
|
||||
filters_config["keywords"] = settings.filters.keywords
|
||||
|
||||
# Save to config
|
||||
if notifications_config:
|
||||
@@ -158,12 +155,24 @@ async def get_notification_services() -> APIResponse:
|
||||
"telegram": {
|
||||
"name": "Telegram",
|
||||
"enabled": bool(
|
||||
notifications_config.get("telegram", {}).get("token")
|
||||
and notifications_config.get("telegram", {}).get("chat_id")
|
||||
(
|
||||
notifications_config.get("telegram", {}).get("token")
|
||||
or notifications_config.get("telegram", {}).get("api-key")
|
||||
)
|
||||
and (
|
||||
notifications_config.get("telegram", {}).get("chat_id")
|
||||
or notifications_config.get("telegram", {}).get("chat-id")
|
||||
)
|
||||
),
|
||||
"configured": bool(
|
||||
notifications_config.get("telegram", {}).get("token")
|
||||
and notifications_config.get("telegram", {}).get("chat_id")
|
||||
(
|
||||
notifications_config.get("telegram", {}).get("token")
|
||||
or notifications_config.get("telegram", {}).get("api-key")
|
||||
)
|
||||
and (
|
||||
notifications_config.get("telegram", {}).get("chat_id")
|
||||
or notifications_config.get("telegram", {}).get("chat-id")
|
||||
)
|
||||
),
|
||||
"active": notifications_config.get("telegram", {}).get("enabled", True),
|
||||
},
|
||||
|
||||
@@ -5,11 +5,9 @@ from loguru import logger
|
||||
|
||||
from leggend.api.models.common import APIResponse
|
||||
from leggend.api.models.accounts import Transaction, TransactionSummary
|
||||
from leggend.services.gocardless_service import GoCardlessService
|
||||
from leggend.services.database_service import DatabaseService
|
||||
|
||||
router = APIRouter()
|
||||
gocardless_service = GoCardlessService()
|
||||
database_service = DatabaseService()
|
||||
|
||||
|
||||
@@ -20,6 +18,9 @@ async def get_all_transactions(
|
||||
summary_only: bool = Query(
|
||||
default=True, description="Return transaction summaries only"
|
||||
),
|
||||
hide_missing_ids: bool = Query(
|
||||
default=True, description="Hide transactions without internalTransactionId"
|
||||
),
|
||||
date_from: Optional[str] = Query(
|
||||
default=None, description="Filter from date (YYYY-MM-DD)"
|
||||
),
|
||||
@@ -49,6 +50,18 @@ async def get_all_transactions(
|
||||
min_amount=min_amount,
|
||||
max_amount=max_amount,
|
||||
search=search,
|
||||
hide_missing_ids=hide_missing_ids,
|
||||
)
|
||||
|
||||
# Get total count for pagination info (respecting the same filters)
|
||||
total_transactions = await database_service.get_transaction_count_from_db(
|
||||
account_id=account_id,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
min_amount=min_amount,
|
||||
max_amount=max_amount,
|
||||
search=search,
|
||||
hide_missing_ids=hide_missing_ids,
|
||||
)
|
||||
|
||||
# Get total count for pagination info
|
||||
@@ -113,6 +126,9 @@ async def get_all_transactions(
|
||||
async def get_transaction_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"),
|
||||
hide_missing_ids: bool = Query(
|
||||
default=True, description="Hide transactions without internalTransactionId"
|
||||
),
|
||||
) -> APIResponse:
|
||||
"""Get transaction statistics for the last N days from database"""
|
||||
try:
|
||||
@@ -130,6 +146,7 @@ async def get_transaction_stats(
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
limit=None, # Get all matching transactions for stats
|
||||
hide_missing_ids=hide_missing_ids,
|
||||
)
|
||||
|
||||
# Calculate stats
|
||||
|
||||
@@ -24,6 +24,17 @@ async def lifespan(app: FastAPI):
|
||||
logger.error(f"Failed to load configuration: {e}")
|
||||
raise
|
||||
|
||||
# Run database migrations
|
||||
try:
|
||||
from leggend.services.database_service import DatabaseService
|
||||
|
||||
db_service = DatabaseService()
|
||||
await db_service.run_migrations_if_needed()
|
||||
logger.info("Database migrations completed")
|
||||
except Exception as e:
|
||||
logger.error(f"Database migration failed: {e}")
|
||||
raise
|
||||
|
||||
# Start background scheduler
|
||||
scheduler.start()
|
||||
logger.info("Background scheduler started")
|
||||
@@ -55,7 +66,8 @@ def create_app() -> FastAPI:
|
||||
allow_origins=[
|
||||
"http://localhost:3000",
|
||||
"http://localhost:5173",
|
||||
], # SvelteKit dev servers
|
||||
"http://frontend:80",
|
||||
], # Frontend container and dev servers
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
@@ -77,9 +89,32 @@ def create_app() -> FastAPI:
|
||||
version = "unknown"
|
||||
return {"message": "Leggend API is running", "version": version}
|
||||
|
||||
@app.get("/health")
|
||||
@app.get("/api/v1/health")
|
||||
async def health():
|
||||
return {"status": "healthy", "config_loaded": config._config is not None}
|
||||
"""Health check endpoint for API connectivity"""
|
||||
try:
|
||||
from leggend.api.models.common import APIResponse
|
||||
|
||||
config_loaded = config._config is not None
|
||||
|
||||
return APIResponse(
|
||||
success=True,
|
||||
data={
|
||||
"status": "healthy",
|
||||
"config_loaded": config_loaded,
|
||||
"message": "API is running and responsive",
|
||||
},
|
||||
message="Health check successful",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Health check failed: {e}")
|
||||
from leggend.api.models.common import APIResponse
|
||||
|
||||
return APIResponse(
|
||||
success=False,
|
||||
data={"status": "unhealthy", "error": str(e)},
|
||||
message="Health check failed",
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Any, Optional
|
||||
import sqlite3
|
||||
|
||||
from loguru import logger
|
||||
|
||||
@@ -115,6 +116,7 @@ class DatabaseService:
|
||||
min_amount: Optional[float] = None,
|
||||
max_amount: Optional[float] = None,
|
||||
search: Optional[str] = None,
|
||||
hide_missing_ids: bool = True,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Get transactions from SQLite database"""
|
||||
if not self.sqlite_enabled:
|
||||
@@ -124,13 +126,14 @@ class DatabaseService:
|
||||
try:
|
||||
transactions = sqlite_db.get_transactions(
|
||||
account_id=account_id,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
limit=limit or 100,
|
||||
offset=offset or 0,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
min_amount=min_amount,
|
||||
max_amount=max_amount,
|
||||
search=search,
|
||||
hide_missing_ids=hide_missing_ids,
|
||||
)
|
||||
logger.debug(f"Retrieved {len(transactions)} transactions from database")
|
||||
return transactions
|
||||
@@ -146,6 +149,7 @@ class DatabaseService:
|
||||
min_amount: Optional[float] = None,
|
||||
max_amount: Optional[float] = None,
|
||||
search: Optional[str] = None,
|
||||
hide_missing_ids: bool = True,
|
||||
) -> int:
|
||||
"""Get total count of transactions from SQLite database"""
|
||||
if not self.sqlite_enabled:
|
||||
@@ -162,7 +166,9 @@ class DatabaseService:
|
||||
# Remove None values
|
||||
filters = {k: v for k, v in filters.items() if v is not None}
|
||||
|
||||
count = sqlite_db.get_transaction_count(account_id=account_id, **filters)
|
||||
count = sqlite_db.get_transaction_count(
|
||||
account_id=account_id, hide_missing_ids=hide_missing_ids, **filters
|
||||
)
|
||||
logger.debug(f"Total transaction count: {count}")
|
||||
return count
|
||||
except Exception as e:
|
||||
@@ -203,6 +209,181 @@ class DatabaseService:
|
||||
logger.error(f"Failed to get account summary from database: {e}")
|
||||
return None
|
||||
|
||||
async def persist_account_details(self, account_data: Dict[str, Any]) -> None:
|
||||
"""Persist account details to database"""
|
||||
if not self.sqlite_enabled:
|
||||
logger.warning("SQLite database disabled, skipping account persistence")
|
||||
return
|
||||
|
||||
await self._persist_account_details_sqlite(account_data)
|
||||
|
||||
async def get_accounts_from_db(
|
||||
self, account_ids: Optional[List[str]] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Get account details from database"""
|
||||
if not self.sqlite_enabled:
|
||||
logger.warning("SQLite database disabled, cannot read accounts")
|
||||
return []
|
||||
|
||||
try:
|
||||
accounts = sqlite_db.get_accounts(account_ids=account_ids)
|
||||
logger.debug(f"Retrieved {len(accounts)} accounts from database")
|
||||
return accounts
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get accounts from database: {e}")
|
||||
return []
|
||||
|
||||
async def get_account_details_from_db(
|
||||
self, account_id: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Get specific account details from database"""
|
||||
if not self.sqlite_enabled:
|
||||
logger.warning("SQLite database disabled, cannot read account")
|
||||
return None
|
||||
|
||||
try:
|
||||
account = sqlite_db.get_account(account_id)
|
||||
if account:
|
||||
logger.debug(
|
||||
f"Retrieved account details from database for {account_id}"
|
||||
)
|
||||
return account
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get account details from database: {e}")
|
||||
return None
|
||||
|
||||
async def run_migrations_if_needed(self):
|
||||
"""Run all necessary database migrations"""
|
||||
if not self.sqlite_enabled:
|
||||
logger.info("SQLite database disabled, skipping migrations")
|
||||
return
|
||||
|
||||
await self._migrate_balance_timestamps_if_needed()
|
||||
|
||||
async def _migrate_balance_timestamps_if_needed(self):
|
||||
"""Check and migrate balance timestamps if needed"""
|
||||
try:
|
||||
if await self._check_balance_timestamp_migration_needed():
|
||||
logger.info("Balance timestamp migration needed, starting...")
|
||||
await self._migrate_balance_timestamps()
|
||||
logger.info("Balance timestamp migration completed")
|
||||
else:
|
||||
logger.info("Balance timestamps are already consistent")
|
||||
except Exception as e:
|
||||
logger.error(f"Balance timestamp migration failed: {e}")
|
||||
raise
|
||||
|
||||
async def _check_balance_timestamp_migration_needed(self) -> bool:
|
||||
"""Check if balance timestamps need migration"""
|
||||
from pathlib import Path
|
||||
|
||||
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
|
||||
if not db_path.exists():
|
||||
return False
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check for mixed timestamp types
|
||||
cursor.execute("""
|
||||
SELECT typeof(timestamp) as type, COUNT(*) as count
|
||||
FROM balances
|
||||
GROUP BY typeof(timestamp)
|
||||
""")
|
||||
|
||||
types = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
# If we have both 'real' and 'text' types, migration is needed
|
||||
type_names = [row[0] for row in types]
|
||||
return "real" in type_names and "text" in type_names
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check migration status: {e}")
|
||||
return False
|
||||
|
||||
async def _migrate_balance_timestamps(self):
|
||||
"""Convert all Unix timestamps to datetime strings"""
|
||||
from pathlib import Path
|
||||
|
||||
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
|
||||
if not db_path.exists():
|
||||
logger.warning("Database file not found, skipping migration")
|
||||
return
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Get all balances with REAL timestamps
|
||||
cursor.execute("""
|
||||
SELECT id, timestamp
|
||||
FROM balances
|
||||
WHERE typeof(timestamp) = 'real'
|
||||
ORDER BY id
|
||||
""")
|
||||
|
||||
unix_records = cursor.fetchall()
|
||||
total_records = len(unix_records)
|
||||
|
||||
if total_records == 0:
|
||||
logger.info("No Unix timestamps found to migrate")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f"Migrating {total_records} balance records from Unix to datetime format"
|
||||
)
|
||||
|
||||
# Convert and update in batches
|
||||
batch_size = 100
|
||||
migrated_count = 0
|
||||
|
||||
for i in range(0, total_records, batch_size):
|
||||
batch = unix_records[i : i + batch_size]
|
||||
|
||||
for record_id, unix_timestamp in batch:
|
||||
try:
|
||||
# Convert Unix timestamp to datetime string
|
||||
dt_string = self._unix_to_datetime_string(float(unix_timestamp))
|
||||
|
||||
# Update the record
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE balances
|
||||
SET timestamp = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(dt_string, record_id),
|
||||
)
|
||||
|
||||
migrated_count += 1
|
||||
|
||||
if migrated_count % 100 == 0:
|
||||
logger.info(
|
||||
f"Migrated {migrated_count}/{total_records} balance records"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to migrate record {record_id}: {e}")
|
||||
continue
|
||||
|
||||
# Commit batch
|
||||
conn.commit()
|
||||
|
||||
conn.close()
|
||||
logger.info(f"Successfully migrated {migrated_count} balance records")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Balance timestamp migration failed: {e}")
|
||||
raise
|
||||
|
||||
def _unix_to_datetime_string(self, unix_timestamp: float) -> str:
|
||||
"""Convert Unix timestamp to datetime string"""
|
||||
dt = datetime.fromtimestamp(unix_timestamp)
|
||||
return dt.isoformat()
|
||||
|
||||
async def _persist_balance_sqlite(
|
||||
self, account_id: str, balance_data: Dict[str, Any]
|
||||
) -> None:
|
||||
@@ -265,12 +446,12 @@ class DatabaseService:
|
||||
(
|
||||
account_id,
|
||||
balance_data.get("institution_id", "unknown"),
|
||||
"active",
|
||||
balance_data.get("account_status"),
|
||||
balance_data.get("iban", "N/A"),
|
||||
float(balance_amount["amount"]),
|
||||
balance_amount["currency"],
|
||||
balance["balanceType"],
|
||||
datetime.now(),
|
||||
datetime.now().isoformat(),
|
||||
),
|
||||
)
|
||||
except sqlite3.IntegrityError:
|
||||
@@ -381,3 +562,23 @@ class DatabaseService:
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to persist transactions to SQLite: {e}")
|
||||
raise
|
||||
|
||||
async def _persist_account_details_sqlite(
|
||||
self, account_data: Dict[str, Any]
|
||||
) -> None:
|
||||
"""Persist account details to SQLite"""
|
||||
try:
|
||||
from pathlib import Path
|
||||
|
||||
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Use the sqlite_db module function
|
||||
sqlite_db.persist_account(account_data)
|
||||
|
||||
logger.info(
|
||||
f"Persisted account details to SQLite for account {account_data['id']}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to persist account details to SQLite: {e}")
|
||||
raise
|
||||
|
||||
@@ -63,14 +63,29 @@ class NotificationService:
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Filter transactions based on notification criteria"""
|
||||
matching = []
|
||||
filters_case_insensitive = self.filters_config.get("case-insensitive", {})
|
||||
filters_case_insensitive = self.filters_config.get("case-insensitive", [])
|
||||
filters_case_sensitive = self.filters_config.get("case-sensitive", [])
|
||||
|
||||
for transaction in transactions:
|
||||
description = transaction.get("description", "").lower()
|
||||
description = transaction.get("description", "")
|
||||
description_lower = description.lower()
|
||||
|
||||
# Check case-insensitive filters
|
||||
for _filter_name, filter_value in filters_case_insensitive.items():
|
||||
if filter_value.lower() in description:
|
||||
for filter_value in filters_case_insensitive:
|
||||
if filter_value.lower() in description_lower:
|
||||
matching.append(
|
||||
{
|
||||
"name": transaction["description"],
|
||||
"value": transaction["transactionValue"],
|
||||
"currency": transaction["transactionCurrency"],
|
||||
"date": transaction["transactionDate"],
|
||||
}
|
||||
)
|
||||
break
|
||||
|
||||
# Check case-sensitive filters
|
||||
for filter_value in filters_case_sensitive:
|
||||
if filter_value in description:
|
||||
matching.append(
|
||||
{
|
||||
"name": transaction["description"],
|
||||
@@ -95,7 +110,8 @@ class NotificationService:
|
||||
telegram_config = self.notifications_config.get("telegram", {})
|
||||
return bool(
|
||||
telegram_config.get("token")
|
||||
and telegram_config.get("chat_id")
|
||||
or telegram_config.get("api-key")
|
||||
and (telegram_config.get("chat_id") or telegram_config.get("chat-id"))
|
||||
and telegram_config.get("enabled", True)
|
||||
)
|
||||
|
||||
@@ -117,11 +133,67 @@ class NotificationService:
|
||||
|
||||
async def _send_discord_test(self, message: str) -> None:
|
||||
"""Send Discord test notification"""
|
||||
logger.info(f"Sending Discord test: {message}")
|
||||
try:
|
||||
from leggen.notifications.discord import send_expire_notification
|
||||
import click
|
||||
|
||||
# Create a mock context with the webhook
|
||||
ctx = click.Context(click.Command("test"))
|
||||
ctx.obj = {
|
||||
"notifications": {
|
||||
"discord": {
|
||||
"webhook": self.notifications_config.get("discord", {}).get(
|
||||
"webhook"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Send test notification using the actual implementation
|
||||
test_notification = {
|
||||
"bank": "Test",
|
||||
"requisition_id": "test-123",
|
||||
"status": "active",
|
||||
"days_left": 30,
|
||||
}
|
||||
send_expire_notification(ctx, test_notification)
|
||||
logger.info(f"Discord test notification sent: {message}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send Discord test notification: {e}")
|
||||
raise
|
||||
|
||||
async def _send_telegram_test(self, message: str) -> None:
|
||||
"""Send Telegram test notification"""
|
||||
logger.info(f"Sending Telegram test: {message}")
|
||||
try:
|
||||
from leggen.notifications.telegram import send_expire_notification
|
||||
import click
|
||||
|
||||
# Create a mock context with the telegram config
|
||||
ctx = click.Context(click.Command("test"))
|
||||
telegram_config = self.notifications_config.get("telegram", {})
|
||||
ctx.obj = {
|
||||
"notifications": {
|
||||
"telegram": {
|
||||
"api-key": telegram_config.get("token")
|
||||
or telegram_config.get("api-key"),
|
||||
"chat-id": telegram_config.get("chat_id")
|
||||
or telegram_config.get("chat-id"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Send test notification using the actual implementation
|
||||
test_notification = {
|
||||
"bank": "Test",
|
||||
"requisition_id": "test-123",
|
||||
"status": "active",
|
||||
"days_left": 30,
|
||||
}
|
||||
send_expire_notification(ctx, test_notification)
|
||||
logger.info(f"Telegram test notification sent: {message}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send Telegram test notification: {e}")
|
||||
raise
|
||||
|
||||
async def _send_discord_expiry(self, notification_data: Dict[str, Any]) -> None:
|
||||
"""Send Discord expiry notification"""
|
||||
|
||||
@@ -55,10 +55,25 @@ class SyncService:
|
||||
account_id
|
||||
)
|
||||
|
||||
# Persist account details to database
|
||||
if account_details:
|
||||
await self.database.persist_account_details(account_details)
|
||||
|
||||
# Get and save balances
|
||||
balances = await self.gocardless.get_account_balances(account_id)
|
||||
if balances:
|
||||
await self.database.persist_balance(account_id, balances)
|
||||
if balances and account_details:
|
||||
# Merge account details into balances data for proper persistence
|
||||
balances_with_account_info = balances.copy()
|
||||
balances_with_account_info["institution_id"] = (
|
||||
account_details.get("institution_id")
|
||||
)
|
||||
balances_with_account_info["iban"] = account_details.get("iban")
|
||||
balances_with_account_info["account_status"] = (
|
||||
account_details.get("status")
|
||||
)
|
||||
await self.database.persist_balance(
|
||||
account_id, balances_with_account_info
|
||||
)
|
||||
balances_updated += len(balances.get("balances", []))
|
||||
|
||||
# Get and save transactions
|
||||
|
||||
7
opencode.json
Normal file
7
opencode.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"permission": {
|
||||
"edit": "ask",
|
||||
"bash": "ask"
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
[project]
|
||||
name = "leggen"
|
||||
version = "0.6.11"
|
||||
version = "2025.9.0"
|
||||
description = "An Open Banking CLI"
|
||||
authors = [{ name = "Elisiário Couto", email = "elisiario@couto.io" }]
|
||||
requires-python = "~=3.12.0"
|
||||
requires-python = "~=3.13.0"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
keywords = [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -ef -o pipefail
|
||||
|
||||
@@ -13,23 +13,37 @@ check_command git
|
||||
check_command git-cliff
|
||||
check_command uv
|
||||
|
||||
if [ -z "$1" ]; then
|
||||
echo " > No semver verb specified, run release with <major|minor|patch> parameter."
|
||||
exit 1
|
||||
# Get current date components
|
||||
YEAR=$(date +%Y)
|
||||
MONTH=$(date +%-m) # %-m removes zero padding
|
||||
|
||||
# Get the latest version for current year and month
|
||||
LATEST_TAG=$(git tag -l "${YEAR}.${MONTH}.*" | sort -V | tail -n 1)
|
||||
|
||||
if [ -z "$LATEST_TAG" ]; then
|
||||
# No version for current year/month exists, start at 0
|
||||
MICRO=0
|
||||
else
|
||||
# Extract micro version and increment
|
||||
MICRO=$(echo "$LATEST_TAG" | cut -d. -f3)
|
||||
MICRO=$((MICRO + 1))
|
||||
fi
|
||||
|
||||
CURRENT_VERSION=$(uvx poetry version -s)
|
||||
NEXT_VERSION="${YEAR}.${MONTH}.${MICRO}"
|
||||
CURRENT_VERSION=$(uv version --short)
|
||||
|
||||
echo " > Current version is $CURRENT_VERSION"
|
||||
echo " > Setting new version to $NEXT_VERSION"
|
||||
|
||||
uvx poetry version "$1"
|
||||
NEXT_VERSION=$(uvx poetry version -s)
|
||||
# Manually update version in pyproject.toml
|
||||
sed -i '' "s/^version = .*/version = \"${NEXT_VERSION}\"/" pyproject.toml
|
||||
|
||||
echo " > leggen bumped to $NEXT_VERSION"
|
||||
echo " > Version bumped to $NEXT_VERSION"
|
||||
echo "Updating CHANGELOG.md"
|
||||
git-cliff --unreleased --tag "$NEXT_VERSION" --prepend CHANGELOG.md > /dev/null
|
||||
|
||||
echo " > Commiting changes and adding git tag"
|
||||
git add pyproject.toml CHANGELOG.md
|
||||
git add pyproject.toml CHANGELOG.md uv.lock
|
||||
git commit -m "chore(ci): Bump version to $NEXT_VERSION"
|
||||
git tag -a "$NEXT_VERSION" -m "$NEXT_VERSION"
|
||||
|
||||
|
||||
@@ -21,7 +21,18 @@ def temp_config_dir():
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config(temp_config_dir):
|
||||
def temp_db_path():
|
||||
"""Create a temporary database file for testing."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp_file:
|
||||
db_path = Path(tmp_file.name)
|
||||
yield db_path
|
||||
# Clean up the temporary database file after test
|
||||
if db_path.exists():
|
||||
db_path.unlink()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config(temp_config_dir, temp_db_path):
|
||||
"""Mock configuration for testing."""
|
||||
config_data = {
|
||||
"gocardless": {
|
||||
@@ -72,6 +83,28 @@ def api_client(fastapi_app):
|
||||
return TestClient(fastapi_app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db_path(temp_db_path):
|
||||
"""Mock the database path to use temporary database for testing."""
|
||||
from pathlib import Path
|
||||
|
||||
# Create the expected directory structure
|
||||
temp_home = temp_db_path.parent
|
||||
config_dir = temp_home / ".config" / "leggen"
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create the expected database path
|
||||
expected_db_path = config_dir / "leggen.db"
|
||||
|
||||
# Mock Path.home to return our temp directory
|
||||
def mock_home():
|
||||
return temp_home
|
||||
|
||||
# Patch Path.home in the main pathlib module
|
||||
with patch.object(Path, "home", staticmethod(mock_home)):
|
||||
yield expected_db_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_bank_data():
|
||||
"""Sample bank/institution data for testing."""
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"""Tests for accounts API endpoints."""
|
||||
|
||||
import pytest
|
||||
import respx
|
||||
import httpx
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
@@ -10,44 +8,51 @@ from unittest.mock import patch
|
||||
class TestAccountsAPI:
|
||||
"""Test account-related API endpoints."""
|
||||
|
||||
@respx.mock
|
||||
def test_get_all_accounts_success(
|
||||
self, api_client, mock_config, mock_auth_token, sample_account_data
|
||||
self,
|
||||
api_client,
|
||||
mock_config,
|
||||
mock_auth_token,
|
||||
sample_account_data,
|
||||
mock_db_path,
|
||||
):
|
||||
"""Test successful retrieval of all accounts."""
|
||||
requisitions_data = {
|
||||
"results": [{"id": "req-123", "accounts": ["test-account-123"]}]
|
||||
}
|
||||
"""Test successful retrieval of all accounts from database."""
|
||||
mock_accounts = [
|
||||
{
|
||||
"id": "test-account-123",
|
||||
"institution_id": "REVOLUT_REVOLT21",
|
||||
"status": "READY",
|
||||
"iban": "LT313250081177977789",
|
||||
"created": "2024-02-13T23:56:00Z",
|
||||
"last_accessed": "2025-09-01T09:30:00Z",
|
||||
}
|
||||
]
|
||||
|
||||
balances_data = {
|
||||
"balances": [
|
||||
{
|
||||
"balanceAmount": {"amount": "100.50", "currency": "EUR"},
|
||||
"balanceType": "interimAvailable",
|
||||
"lastChangeDateTime": "2025-09-01T09:30:00Z",
|
||||
}
|
||||
]
|
||||
}
|
||||
mock_balances = [
|
||||
{
|
||||
"id": 1,
|
||||
"account_id": "test-account-123",
|
||||
"bank": "REVOLUT_REVOLT21",
|
||||
"status": "active",
|
||||
"iban": "LT313250081177977789",
|
||||
"amount": 100.50,
|
||||
"currency": "EUR",
|
||||
"type": "interimAvailable",
|
||||
"timestamp": "2025-09-01T09:30:00Z",
|
||||
}
|
||||
]
|
||||
|
||||
# Mock GoCardless token creation
|
||||
respx.post("https://bankaccountdata.gocardless.com/api/v2/token/new/").mock(
|
||||
return_value=httpx.Response(
|
||||
200, json={"access": "test-token", "refresh": "test-refresh"}
|
||||
)
|
||||
)
|
||||
|
||||
# Mock GoCardless API calls
|
||||
respx.get("https://bankaccountdata.gocardless.com/api/v2/requisitions/").mock(
|
||||
return_value=httpx.Response(200, json=requisitions_data)
|
||||
)
|
||||
respx.get(
|
||||
"https://bankaccountdata.gocardless.com/api/v2/accounts/test-account-123/"
|
||||
).mock(return_value=httpx.Response(200, json=sample_account_data))
|
||||
respx.get(
|
||||
"https://bankaccountdata.gocardless.com/api/v2/accounts/test-account-123/balances/"
|
||||
).mock(return_value=httpx.Response(200, json=balances_data))
|
||||
|
||||
with patch("leggend.config.config", mock_config):
|
||||
with (
|
||||
patch("leggend.config.config", mock_config),
|
||||
patch(
|
||||
"leggend.api.routes.accounts.database_service.get_accounts_from_db",
|
||||
return_value=mock_accounts,
|
||||
),
|
||||
patch(
|
||||
"leggend.api.routes.accounts.database_service.get_balances_from_db",
|
||||
return_value=mock_balances,
|
||||
),
|
||||
):
|
||||
response = api_client.get("/api/v1/accounts")
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -60,36 +65,49 @@ class TestAccountsAPI:
|
||||
assert len(account["balances"]) == 1
|
||||
assert account["balances"][0]["amount"] == 100.50
|
||||
|
||||
@respx.mock
|
||||
def test_get_account_details_success(
|
||||
self, api_client, mock_config, mock_auth_token, sample_account_data
|
||||
self,
|
||||
api_client,
|
||||
mock_config,
|
||||
mock_auth_token,
|
||||
sample_account_data,
|
||||
mock_db_path,
|
||||
):
|
||||
"""Test successful retrieval of specific account details."""
|
||||
balances_data = {
|
||||
"balances": [
|
||||
{
|
||||
"balanceAmount": {"amount": "250.75", "currency": "EUR"},
|
||||
"balanceType": "interimAvailable",
|
||||
}
|
||||
]
|
||||
"""Test successful retrieval of specific account details from database."""
|
||||
mock_account = {
|
||||
"id": "test-account-123",
|
||||
"institution_id": "REVOLUT_REVOLT21",
|
||||
"status": "READY",
|
||||
"iban": "LT313250081177977789",
|
||||
"created": "2024-02-13T23:56:00Z",
|
||||
"last_accessed": "2025-09-01T09:30:00Z",
|
||||
}
|
||||
|
||||
# Mock GoCardless token creation
|
||||
respx.post("https://bankaccountdata.gocardless.com/api/v2/token/new/").mock(
|
||||
return_value=httpx.Response(
|
||||
200, json={"access": "test-token", "refresh": "test-refresh"}
|
||||
)
|
||||
)
|
||||
mock_balances = [
|
||||
{
|
||||
"id": 1,
|
||||
"account_id": "test-account-123",
|
||||
"bank": "REVOLUT_REVOLT21",
|
||||
"status": "active",
|
||||
"iban": "LT313250081177977789",
|
||||
"amount": 250.75,
|
||||
"currency": "EUR",
|
||||
"type": "interimAvailable",
|
||||
"timestamp": "2025-09-01T09:30:00Z",
|
||||
}
|
||||
]
|
||||
|
||||
# Mock GoCardless API calls
|
||||
respx.get(
|
||||
"https://bankaccountdata.gocardless.com/api/v2/accounts/test-account-123/"
|
||||
).mock(return_value=httpx.Response(200, json=sample_account_data))
|
||||
respx.get(
|
||||
"https://bankaccountdata.gocardless.com/api/v2/accounts/test-account-123/balances/"
|
||||
).mock(return_value=httpx.Response(200, json=balances_data))
|
||||
|
||||
with patch("leggend.config.config", mock_config):
|
||||
with (
|
||||
patch("leggend.config.config", mock_config),
|
||||
patch(
|
||||
"leggend.api.routes.accounts.database_service.get_account_details_from_db",
|
||||
return_value=mock_account,
|
||||
),
|
||||
patch(
|
||||
"leggend.api.routes.accounts.database_service.get_balances_from_db",
|
||||
return_value=mock_balances,
|
||||
),
|
||||
):
|
||||
response = api_client.get("/api/v1/accounts/test-account-123")
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -101,7 +119,7 @@ class TestAccountsAPI:
|
||||
assert len(account["balances"]) == 1
|
||||
|
||||
def test_get_account_balances_success(
|
||||
self, api_client, mock_config, mock_auth_token
|
||||
self, api_client, mock_config, mock_auth_token, mock_db_path
|
||||
):
|
||||
"""Test successful retrieval of account balances from database."""
|
||||
mock_balances = [
|
||||
@@ -153,6 +171,7 @@ class TestAccountsAPI:
|
||||
mock_auth_token,
|
||||
sample_account_data,
|
||||
sample_transaction_data,
|
||||
mock_db_path,
|
||||
):
|
||||
"""Test successful retrieval of account transactions from database."""
|
||||
mock_transactions = [
|
||||
@@ -203,6 +222,7 @@ class TestAccountsAPI:
|
||||
mock_auth_token,
|
||||
sample_account_data,
|
||||
sample_transaction_data,
|
||||
mock_db_path,
|
||||
):
|
||||
"""Test retrieval of full transaction details from database."""
|
||||
mock_transactions = [
|
||||
@@ -246,24 +266,17 @@ class TestAccountsAPI:
|
||||
assert transaction["iban"] == "LT313250081177977789"
|
||||
assert "raw_transaction" in transaction
|
||||
|
||||
def test_get_account_not_found(self, api_client, mock_config, mock_auth_token):
|
||||
def test_get_account_not_found(
|
||||
self, api_client, mock_config, mock_auth_token, mock_db_path
|
||||
):
|
||||
"""Test handling of non-existent account."""
|
||||
# Mock 404 response from GoCardless
|
||||
with respx.mock:
|
||||
# Mock GoCardless token creation
|
||||
respx.post("https://bankaccountdata.gocardless.com/api/v2/token/new/").mock(
|
||||
return_value=httpx.Response(
|
||||
200, json={"access": "test-token", "refresh": "test-refresh"}
|
||||
)
|
||||
)
|
||||
with (
|
||||
patch("leggend.config.config", mock_config),
|
||||
patch(
|
||||
"leggend.api.routes.accounts.database_service.get_account_details_from_db",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
response = api_client.get("/api/v1/accounts/nonexistent")
|
||||
|
||||
respx.get(
|
||||
"https://bankaccountdata.gocardless.com/api/v2/accounts/nonexistent/"
|
||||
).mock(
|
||||
return_value=httpx.Response(404, json={"detail": "Account not found"})
|
||||
)
|
||||
|
||||
with patch("leggend.config.config", mock_config):
|
||||
response = api_client.get("/api/v1/accounts/nonexistent")
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.status_code == 404
|
||||
|
||||
@@ -166,6 +166,7 @@ class TestTransactionsAPI:
|
||||
min_amount=-50.0,
|
||||
max_amount=0.0,
|
||||
search="Coffee",
|
||||
hide_missing_ids=True,
|
||||
)
|
||||
|
||||
def test_get_transactions_empty_result(
|
||||
|
||||
@@ -188,8 +188,8 @@ class TestConfig:
|
||||
"""Test filters configuration access."""
|
||||
custom_config = {
|
||||
"filters": {
|
||||
"case-insensitive": {"salary": "SALARY", "bills": "BILL"},
|
||||
"amount_threshold": 100.0,
|
||||
"case-insensitive": ["salary", "utility"],
|
||||
"case-sensitive": ["SpecificStore"],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,5 +197,6 @@ class TestConfig:
|
||||
config._config = custom_config
|
||||
|
||||
filters = config.filters_config
|
||||
assert filters["case-insensitive"]["salary"] == "SALARY"
|
||||
assert filters["amount_threshold"] == 100.0
|
||||
assert "salary" in filters["case-insensitive"]
|
||||
assert "utility" in filters["case-insensitive"]
|
||||
assert "SpecificStore" in filters["case-sensitive"]
|
||||
|
||||
@@ -99,6 +99,7 @@ class TestDatabaseService:
|
||||
min_amount=None,
|
||||
max_amount=None,
|
||||
search=None,
|
||||
hide_missing_ids=True,
|
||||
)
|
||||
|
||||
async def test_get_transactions_from_db_with_filters(
|
||||
@@ -129,6 +130,7 @@ class TestDatabaseService:
|
||||
min_amount=-50.0,
|
||||
max_amount=0.0,
|
||||
search="Coffee",
|
||||
hide_missing_ids=True,
|
||||
)
|
||||
|
||||
async def test_get_transactions_from_db_sqlite_disabled(self, database_service):
|
||||
@@ -158,7 +160,9 @@ class TestDatabaseService:
|
||||
)
|
||||
|
||||
assert result == 42
|
||||
mock_get_count.assert_called_once_with(account_id="test-account-123")
|
||||
mock_get_count.assert_called_once_with(
|
||||
account_id="test-account-123", hide_missing_ids=True
|
||||
)
|
||||
|
||||
async def test_get_transaction_count_from_db_with_filters(self, database_service):
|
||||
"""Test getting transaction count with filters."""
|
||||
@@ -178,6 +182,7 @@ class TestDatabaseService:
|
||||
date_from="2025-09-01",
|
||||
min_amount=-100.0,
|
||||
search="Coffee",
|
||||
hide_missing_ids=True,
|
||||
)
|
||||
|
||||
async def test_get_transaction_count_from_db_sqlite_disabled(
|
||||
|
||||
177
uv.lock
generated
177
uv.lock
generated
@@ -1,6 +1,6 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = "==3.12.*"
|
||||
requires-python = "==3.13.*"
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
@@ -18,7 +18,6 @@ source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "sniffio" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f1/b4/636b3b65173d3ce9a38ef5f0522789614e590dab6a8d505340a4efe4c567/anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6", size = 213252, upload-time = "2025-08-04T08:54:26.451Z" }
|
||||
wheels = [
|
||||
@@ -61,19 +60,19 @@ version = "3.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188, upload-time = "2024-12-24T18:12:35.43Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/9a/dd1e1cdceb841925b7798369a09279bd1cf183cef0f9ddf15a3a6502ee45/charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545", size = 196105, upload-time = "2024-12-24T18:10:38.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/8c/90bfabf8c4809ecb648f39794cf2a84ff2e7d2a6cf159fe68d9a26160467/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7", size = 140404, upload-time = "2024-12-24T18:10:44.272Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/8f/e410d57c721945ea3b4f1a04b74f70ce8fa800d393d72899f0a40526401f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757", size = 150423, upload-time = "2024-12-24T18:10:45.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/b8/e6825e25deb691ff98cf5c9072ee0605dc2acfca98af70c2d1b1bc75190d/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa", size = 143184, upload-time = "2024-12-24T18:10:47.898Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/a2/513f6cbe752421f16d969e32f3583762bfd583848b763913ddab8d9bfd4f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d", size = 145268, upload-time = "2024-12-24T18:10:50.589Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/94/8a5277664f27c3c438546f3eb53b33f5b19568eb7424736bdc440a88a31f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616", size = 147601, upload-time = "2024-12-24T18:10:52.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/5f/6d352c51ee763623a98e31194823518e09bfa48be2a7e8383cf691bbb3d0/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b", size = 141098, upload-time = "2024-12-24T18:10:53.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/d4/f5704cb629ba5ab16d1d3d741396aec6dc3ca2b67757c45b0599bb010478/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d", size = 149520, upload-time = "2024-12-24T18:10:55.048Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/96/64120b1d02b81785f222b976c0fb79a35875457fa9bb40827678e54d1bc8/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a", size = 152852, upload-time = "2024-12-24T18:10:57.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/c9/98e3732278a99f47d487fd3468bc60b882920cef29d1fa6ca460a1fdf4e6/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9", size = 150488, upload-time = "2024-12-24T18:10:59.43Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/0e/9c8d4cb99c98c1007cc11eda969ebfe837bbbd0acdb4736d228ccaabcd22/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1", size = 146192, upload-time = "2024-12-24T18:11:00.676Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/21/2b6b5b860781a0b49427309cb8670785aa543fb2178de875b87b9cc97746/charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35", size = 95550, upload-time = "2024-12-24T18:11:01.952Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/5b/1b390b03b1d16c7e382b561c5329f83cc06623916aab983e8ab9239c7d5c/charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f", size = 102785, upload-time = "2024-12-24T18:11:03.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698, upload-time = "2024-12-24T18:11:05.834Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162, upload-time = "2024-12-24T18:11:07.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263, upload-time = "2024-12-24T18:11:08.374Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966, upload-time = "2024-12-24T18:11:09.831Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992, upload-time = "2024-12-24T18:11:12.03Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162, upload-time = "2024-12-24T18:11:13.372Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972, upload-time = "2024-12-24T18:11:14.628Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095, upload-time = "2024-12-24T18:11:17.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668, upload-time = "2024-12-24T18:11:18.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073, upload-time = "2024-12-24T18:11:21.507Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732, upload-time = "2024-12-24T18:11:22.774Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391, upload-time = "2024-12-24T18:11:24.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702, upload-time = "2024-12-24T18:11:26.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767, upload-time = "2024-12-24T18:12:32.852Z" },
|
||||
]
|
||||
|
||||
@@ -170,13 +169,13 @@ version = "0.6.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a7/9a/ce5e1f7e131522e6d3426e8e7a490b3a01f39a6696602e1c4f33f9e94277/httptools-0.6.4.tar.gz", hash = "sha256:4e93eee4add6493b59a5c514da98c939b244fce4a0d8879cd3f466562f4b7d5c", size = 240639, upload-time = "2024-10-16T19:45:08.902Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/0e/d0b71465c66b9185f90a091ab36389a7352985fe857e352801c39d6127c8/httptools-0.6.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:df017d6c780287d5c80601dafa31f17bddb170232d85c066604d8558683711a2", size = 200683, upload-time = "2024-10-16T19:44:30.175Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/b8/412a9bb28d0a8988de3296e01efa0bd62068b33856cdda47fe1b5e890954/httptools-0.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:85071a1e8c2d051b507161f6c3e26155b5c790e4e28d7f236422dbacc2a9cc44", size = 104337, upload-time = "2024-10-16T19:44:31.786Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/01/6fb20be3196ffdc8eeec4e653bc2a275eca7f36634c86302242c4fbb2760/httptools-0.6.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69422b7f458c5af875922cdb5bd586cc1f1033295aa9ff63ee196a87519ac8e1", size = 508796, upload-time = "2024-10-16T19:44:32.825Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/d8/b644c44acc1368938317d76ac991c9bba1166311880bcc0ac297cb9d6bd7/httptools-0.6.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:16e603a3bff50db08cd578d54f07032ca1631450ceb972c2f834c2b860c28ea2", size = 510837, upload-time = "2024-10-16T19:44:33.974Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/d8/254d16a31d543073a0e57f1c329ca7378d8924e7e292eda72d0064987486/httptools-0.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec4f178901fa1834d4a060320d2f3abc5c9e39766953d038f1458cb885f47e81", size = 485289, upload-time = "2024-10-16T19:44:35.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/3c/4aee161b4b7a971660b8be71a92c24d6c64372c1ab3ae7f366b3680df20f/httptools-0.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f9eb89ecf8b290f2e293325c646a211ff1c2493222798bb80a530c5e7502494f", size = 489779, upload-time = "2024-10-16T19:44:36.253Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/b7/5cae71a8868e555f3f67a50ee7f673ce36eac970f029c0c5e9d584352961/httptools-0.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:db78cb9ca56b59b016e64b6031eda5653be0589dba2b1b43453f6e8b405a0970", size = 88634, upload-time = "2024-10-16T19:44:37.357Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/a3/9fe9ad23fd35f7de6b91eeb60848986058bd8b5a5c1e256f5860a160cc3e/httptools-0.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ade273d7e767d5fae13fa637f4d53b6e961fb7fd93c7797562663f0171c26660", size = 197214, upload-time = "2024-10-16T19:44:38.738Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/d9/82d5e68bab783b632023f2fa31db20bebb4e89dfc4d2293945fd68484ee4/httptools-0.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:856f4bc0478ae143bad54a4242fccb1f3f86a6e1be5548fecfd4102061b3a083", size = 102431, upload-time = "2024-10-16T19:44:39.818Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/c1/cb499655cbdbfb57b577734fde02f6fa0bbc3fe9fb4d87b742b512908dff/httptools-0.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:322d20ea9cdd1fa98bd6a74b77e2ec5b818abdc3d36695ab402a0de8ef2865a3", size = 473121, upload-time = "2024-10-16T19:44:41.189Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/71/ee32fd358f8a3bb199b03261f10921716990808a675d8160b5383487a317/httptools-0.6.4-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4d87b29bd4486c0093fc64dea80231f7c7f7eb4dc70ae394d70a495ab8436071", size = 473805, upload-time = "2024-10-16T19:44:42.384Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/0a/0d4df132bfca1507114198b766f1737d57580c9ad1cf93c1ff673e3387be/httptools-0.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:342dd6946aa6bda4b8f18c734576106b8a31f2fe31492881a9a160ec84ff4bd5", size = 448858, upload-time = "2024-10-16T19:44:43.959Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/6a/787004fdef2cabea27bad1073bf6a33f2437b4dbd3b6fb4a9d71172b1c7c/httptools-0.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b36913ba52008249223042dca46e69967985fb4051951f94357ea681e1f5dc0", size = 452042, upload-time = "2024-10-16T19:44:45.071Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/dc/7decab5c404d1d2cdc1bb330b1bf70e83d6af0396fd4fc76fc60c0d522bf/httptools-0.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:28908df1b9bb8187393d5b5db91435ccc9c8e891657f9cbb42a2541b44c82fc8", size = 87682, upload-time = "2024-10-16T19:44:46.46Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -223,7 +222,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "leggen"
|
||||
version = "0.6.11"
|
||||
version = "2025.9.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "apscheduler" },
|
||||
@@ -304,12 +303,12 @@ dependencies = [
|
||||
]
|
||||
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/5b/82/aec2fc9b9b149f372850291827537a508d6c4d3664b1750a324b91f71355/mypy-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93378d3203a5c0800c6b6d850ad2f19f7a3cdf1a3701d3416dbf128805c6a6a7", size = 11075338, upload-time = "2025-07-31T07:53:38.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/ac/ee93fbde9d2242657128af8c86f5d917cd2887584cf948a8e3663d0cd737/mypy-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:15d54056f7fe7a826d897789f53dd6377ec2ea8ba6f776dc83c2902b899fee81", size = 10113066, upload-time = "2025-07-31T07:54:14.707Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/68/946a1e0be93f17f7caa56c45844ec691ca153ee8b62f21eddda336a2d203/mypy-1.17.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:209a58fed9987eccc20f2ca94afe7257a8f46eb5df1fb69958650973230f91e6", size = 11875473, upload-time = "2025-07-31T07:53:14.504Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/0f/478b4dce1cb4f43cf0f0d00fba3030b21ca04a01b74d1cd272a528cf446f/mypy-1.17.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:099b9a5da47de9e2cb5165e581f158e854d9e19d2e96b6698c0d64de911dd849", size = 12744296, upload-time = "2025-07-31T07:53:03.896Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/70/afa5850176379d1b303f992a828de95fc14487429a7139a4e0bdd17a8279/mypy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa6ffadfbe6994d724c5a1bb6123a7d27dd68fc9c059561cd33b664a79578e14", size = 12914657, upload-time = "2025-07-31T07:54:08.576Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/f9/4a83e1c856a3d9c8f6edaa4749a4864ee98486e9b9dbfbc93842891029c2/mypy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:9a2b7d9180aed171f033c9f2fc6c204c1245cf60b0cb61cf2e7acc24eea78e0a", size = 9593320, upload-time = "2025-07-31T07:53:01.341Z" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
@@ -407,20 +406,23 @@ dependencies = [
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -487,15 +489,15 @@ version = "6.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -577,7 +579,6 @@ version = "0.47.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/15/b9/cc3017f9a9c9b6e27c5106cc10cc7904653c3eec0729793aec10479dd669/starlette-0.47.3.tar.gz", hash = "sha256:6bc94f839cc176c4858894f1f8908f0ab79dfec1a6b8402f6da9be26ebea52e9", size = 2584144, upload-time = "2025-08-24T13:36:42.122Z" }
|
||||
wheels = [
|
||||
@@ -704,12 +705,12 @@ version = "0.21.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/af/c0/854216d09d33c543f12a44b393c402e89a920b1a0a7dc634c42de91b9cf6/uvloop-0.21.0.tar.gz", hash = "sha256:3bf12b0fda68447806a7ad847bfa591613177275d35b6724b1ee573faa3704e3", size = 2492741, upload-time = "2024-10-14T23:38:35.489Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/4c/03f93178830dc7ce8b4cdee1d36770d2f5ebb6f3d37d354e061eefc73545/uvloop-0.21.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:359ec2c888397b9e592a889c4d72ba3d6befba8b2bb01743f72fffbde663b59c", size = 1471284, upload-time = "2024-10-14T23:37:47.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/3e/92c03f4d05e50f09251bd8b2b2b584a2a7f8fe600008bcc4523337abe676/uvloop-0.21.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f7089d2dc73179ce5ac255bdf37c236a9f914b264825fdaacaded6990a7fb4c2", size = 821349, upload-time = "2024-10-14T23:37:50.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/ef/a02ec5da49909dbbfb1fd205a9a1ac4e88ea92dcae885e7c961847cd51e2/uvloop-0.21.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:baa4dcdbd9ae0a372f2167a207cd98c9f9a1ea1188a8a526431eef2f8116cc8d", size = 4580089, upload-time = "2024-10-14T23:37:51.703Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/a7/b4e6a19925c900be9f98bec0a75e6e8f79bb53bdeb891916609ab3958967/uvloop-0.21.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86975dca1c773a2c9864f4c52c5a55631038e387b47eaf56210f873887b6c8dc", size = 4693770, upload-time = "2024-10-14T23:37:54.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/0c/f07435a18a4b94ce6bd0677d8319cd3de61f3a9eeb1e5f8ab4e8b5edfcb3/uvloop-0.21.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:461d9ae6660fbbafedd07559c6a2e57cd553b34b0065b6550685f6653a98c1cb", size = 4451321, upload-time = "2024-10-14T23:37:55.766Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/eb/f7032be105877bcf924709c97b1bf3b90255b4ec251f9340cef912559f28/uvloop-0.21.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:183aef7c8730e54c9a3ee3227464daed66e37ba13040bb3f350bc2ddc040f22f", size = 4659022, upload-time = "2024-10-14T23:37:58.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/8d/2cbef610ca21539f0f36e2b34da49302029e7c9f09acef0b1c3b5839412b/uvloop-0.21.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bfd55dfcc2a512316e65f16e503e9e450cab148ef11df4e4e679b5e8253a5281", size = 1468123, upload-time = "2024-10-14T23:38:00.688Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/0d/b0038d5a469f94ed8f2b2fce2434a18396d8fbfb5da85a0a9781ebbdec14/uvloop-0.21.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787ae31ad8a2856fc4e7c095341cccc7209bd657d0e71ad0dc2ea83c4a6fa8af", size = 819325, upload-time = "2024-10-14T23:38:02.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/94/0a687f39e78c4c1e02e3272c6b2ccdb4e0085fda3b8352fecd0410ccf915/uvloop-0.21.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ee4d4ef48036ff6e5cfffb09dd192c7a5027153948d85b8da7ff705065bacc6", size = 4582806, upload-time = "2024-10-14T23:38:04.711Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/19/f5b78616566ea68edd42aacaf645adbf71fbd83fc52281fba555dc27e3f1/uvloop-0.21.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3df876acd7ec037a3d005b3ab85a7e4110422e4d9c1571d4fc89b0fc41b6816", size = 4701068, upload-time = "2024-10-14T23:38:06.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/57/66f061ee118f413cd22a656de622925097170b9380b30091b78ea0c6ea75/uvloop-0.21.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd53ecc9a0f3d87ab847503c2e1552b690362e005ab54e8a48ba97da3924c0dc", size = 4454428, upload-time = "2024-10-14T23:38:08.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/9a/0962b05b308494e3202d3f794a6e85abe471fe3cafdbcf95c2e8c713aabd/uvloop-0.21.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5c39f217ab3c663dc699c04cbd50c13813e31d917642d459fdcec07555cc553", size = 4660018, upload-time = "2024-10-14T23:38:10.888Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -735,19 +736,29 @@ dependencies = [
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2a/9a/d451fcc97d029f5812e898fd30a53fd8c15c7bbd058fd75cfc6beb9bd761/watchfiles-1.1.0.tar.gz", hash = "sha256:693ed7ec72cbfcee399e92c895362b6e66d63dac6b91e2c11ae03d10d503e575", size = 94406, upload-time = "2025-06-15T19:06:59.42Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/b8/858957045a38a4079203a33aaa7d23ea9269ca7761c8a074af3524fbb240/watchfiles-1.1.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9dc001c3e10de4725c749d4c2f2bdc6ae24de5a88a339c4bce32300a31ede179", size = 402339, upload-time = "2025-06-15T19:05:24.516Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/28/98b222cca751ba68e88521fabd79a4fab64005fc5976ea49b53fa205d1fa/watchfiles-1.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d9ba68ec283153dead62cbe81872d28e053745f12335d037de9cbd14bd1877f5", size = 394409, upload-time = "2025-06-15T19:05:25.469Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/50/dee79968566c03190677c26f7f47960aff738d32087087bdf63a5473e7df/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:130fc497b8ee68dce163e4254d9b0356411d1490e868bd8790028bc46c5cc297", size = 450939, upload-time = "2025-06-15T19:05:26.494Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/45/a7b56fb129700f3cfe2594a01aa38d033b92a33dddce86c8dfdfc1247b72/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:50a51a90610d0845a5931a780d8e51d7bd7f309ebc25132ba975aca016b576a0", size = 457270, upload-time = "2025-06-15T19:05:27.466Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/c8/fa5ef9476b1d02dc6b5e258f515fcaaecf559037edf8b6feffcbc097c4b8/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dc44678a72ac0910bac46fa6a0de6af9ba1355669b3dfaf1ce5f05ca7a74364e", size = 483370, upload-time = "2025-06-15T19:05:28.548Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/68/42cfcdd6533ec94f0a7aab83f759ec11280f70b11bfba0b0f885e298f9bd/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a543492513a93b001975ae283a51f4b67973662a375a403ae82f420d2c7205ee", size = 598654, upload-time = "2025-06-15T19:05:29.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/74/b2a1544224118cc28df7e59008a929e711f9c68ce7d554e171b2dc531352/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ac164e20d17cc285f2b94dc31c384bc3aa3dd5e7490473b3db043dd70fbccfd", size = 478667, upload-time = "2025-06-15T19:05:31.172Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/77/e3362fe308358dc9f8588102481e599c83e1b91c2ae843780a7ded939a35/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7590d5a455321e53857892ab8879dce62d1f4b04748769f5adf2e707afb9d4f", size = 452213, upload-time = "2025-06-15T19:05:32.299Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/17/c8f1a36540c9a1558d4faf08e909399e8133599fa359bf52ec8fcee5be6f/watchfiles-1.1.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:37d3d3f7defb13f62ece99e9be912afe9dd8a0077b7c45ee5a57c74811d581a4", size = 626718, upload-time = "2025-06-15T19:05:33.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/45/fb599be38b4bd38032643783d7496a26a6f9ae05dea1a42e58229a20ac13/watchfiles-1.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:7080c4bb3efd70a07b1cc2df99a7aa51d98685be56be6038c3169199d0a1c69f", size = 623098, upload-time = "2025-06-15T19:05:34.534Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/e7/fdf40e038475498e160cd167333c946e45d8563ae4dd65caf757e9ffe6b4/watchfiles-1.1.0-cp312-cp312-win32.whl", hash = "sha256:cbcf8630ef4afb05dc30107bfa17f16c0896bb30ee48fc24bf64c1f970f3b1fd", size = 279209, upload-time = "2025-06-15T19:05:35.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/d3/3ae9d5124ec75143bdf088d436cba39812122edc47709cd2caafeac3266f/watchfiles-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:cbd949bdd87567b0ad183d7676feb98136cde5bb9025403794a4c0db28ed3a47", size = 292786, upload-time = "2025-06-15T19:05:36.559Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/2f/7dd4fc8b5f2b34b545e19629b4a018bfb1de23b3a496766a2c1165ca890d/watchfiles-1.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:0a7d40b77f07be87c6faa93d0951a0fcd8cbca1ddff60a1b65d741bac6f3a9f6", size = 284343, upload-time = "2025-06-15T19:05:37.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/42/fae874df96595556a9089ade83be34a2e04f0f11eb53a8dbf8a8a5e562b4/watchfiles-1.1.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5007f860c7f1f8df471e4e04aaa8c43673429047d63205d1630880f7637bca30", size = 402004, upload-time = "2025-06-15T19:05:38.499Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/55/a77e533e59c3003d9803c09c44c3651224067cbe7fb5d574ddbaa31e11ca/watchfiles-1.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:20ecc8abbd957046f1fe9562757903f5eaf57c3bce70929fda6c7711bb58074a", size = 393671, upload-time = "2025-06-15T19:05:39.52Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/68/b0afb3f79c8e832e6571022611adbdc36e35a44e14f129ba09709aa4bb7a/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2f0498b7d2a3c072766dba3274fe22a183dbea1f99d188f1c6c72209a1063dc", size = 449772, upload-time = "2025-06-15T19:05:40.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/05/46dd1f6879bc40e1e74c6c39a1b9ab9e790bf1f5a2fe6c08b463d9a807f4/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:239736577e848678e13b201bba14e89718f5c2133dfd6b1f7846fa1b58a8532b", size = 456789, upload-time = "2025-06-15T19:05:42.045Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/ca/0eeb2c06227ca7f12e50a47a3679df0cd1ba487ea19cf844a905920f8e95/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eff4b8d89f444f7e49136dc695599a591ff769300734446c0a86cba2eb2f9895", size = 482551, upload-time = "2025-06-15T19:05:43.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/47/2cecbd8694095647406645f822781008cc524320466ea393f55fe70eed3b/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12b0a02a91762c08f7264e2e79542f76870c3040bbc847fb67410ab81474932a", size = 597420, upload-time = "2025-06-15T19:05:45.244Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/7e/82abc4240e0806846548559d70f0b1a6dfdca75c1b4f9fa62b504ae9b083/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29e7bc2eee15cbb339c68445959108803dc14ee0c7b4eea556400131a8de462b", size = 477950, upload-time = "2025-06-15T19:05:46.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/0d/4d564798a49bf5482a4fa9416dea6b6c0733a3b5700cb8a5a503c4b15853/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d9481174d3ed982e269c090f780122fb59cee6c3796f74efe74e70f7780ed94c", size = 451706, upload-time = "2025-06-15T19:05:47.459Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/b5/5516cf46b033192d544102ea07c65b6f770f10ed1d0a6d388f5d3874f6e4/watchfiles-1.1.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:80f811146831c8c86ab17b640801c25dc0a88c630e855e2bef3568f30434d52b", size = 625814, upload-time = "2025-06-15T19:05:48.654Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/dd/7c1331f902f30669ac3e754680b6edb9a0dd06dea5438e61128111fadd2c/watchfiles-1.1.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:60022527e71d1d1fda67a33150ee42869042bce3d0fcc9cc49be009a9cded3fb", size = 622820, upload-time = "2025-06-15T19:05:50.088Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/14/36d7a8e27cd128d7b1009e7715a7c02f6c131be9d4ce1e5c3b73d0e342d8/watchfiles-1.1.0-cp313-cp313-win32.whl", hash = "sha256:32d6d4e583593cb8576e129879ea0991660b935177c0f93c6681359b3654bfa9", size = 279194, upload-time = "2025-06-15T19:05:51.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/41/2dd88054b849aa546dbeef5696019c58f8e0774f4d1c42123273304cdb2e/watchfiles-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:f21af781a4a6fbad54f03c598ab620e3a77032c5878f3d780448421a6e1818c7", size = 292349, upload-time = "2025-06-15T19:05:52.201Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/cf/421d659de88285eb13941cf11a81f875c176f76a6d99342599be88e08d03/watchfiles-1.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:5366164391873ed76bfdf618818c82084c9db7fac82b64a20c44d335eec9ced5", size = 283836, upload-time = "2025-06-15T19:05:53.265Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/10/6faf6858d527e3599cc50ec9fcae73590fbddc1420bd4fdccfebffeedbc6/watchfiles-1.1.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:17ab167cca6339c2b830b744eaf10803d2a5b6683be4d79d8475d88b4a8a4be1", size = 400343, upload-time = "2025-06-15T19:05:54.252Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/20/5cb7d3966f5e8c718006d0e97dfe379a82f16fecd3caa7810f634412047a/watchfiles-1.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:328dbc9bff7205c215a7807da7c18dce37da7da718e798356212d22696404339", size = 392916, upload-time = "2025-06-15T19:05:55.264Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/07/d8f1176328fa9e9581b6f120b017e286d2a2d22ae3f554efd9515c8e1b49/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7208ab6e009c627b7557ce55c465c98967e8caa8b11833531fdf95799372633", size = 449582, upload-time = "2025-06-15T19:05:56.317Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/e8/80a14a453cf6038e81d072a86c05276692a1826471fef91df7537dba8b46/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a8f6f72974a19efead54195bc9bed4d850fc047bb7aa971268fd9a8387c89011", size = 456752, upload-time = "2025-06-15T19:05:57.359Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/25/0853b3fe0e3c2f5af9ea60eb2e781eade939760239a72c2d38fc4cc335f6/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d181ef50923c29cf0450c3cd47e2f0557b62218c50b2ab8ce2ecaa02bd97e670", size = 481436, upload-time = "2025-06-15T19:05:58.447Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/9e/4af0056c258b861fbb29dcb36258de1e2b857be4a9509e6298abcf31e5c9/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adb4167043d3a78280d5d05ce0ba22055c266cf8655ce942f2fb881262ff3cdf", size = 596016, upload-time = "2025-06-15T19:05:59.59Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/fa/95d604b58aa375e781daf350897aaaa089cff59d84147e9ccff2447c8294/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5701dc474b041e2934a26d31d39f90fac8a3dee2322b39f7729867f932b1d4", size = 476727, upload-time = "2025-06-15T19:06:01.086Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/95/fe479b2664f19be4cf5ceeb21be05afd491d95f142e72d26a42f41b7c4f8/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b067915e3c3936966a8607f6fe5487df0c9c4afb85226613b520890049deea20", size = 451864, upload-time = "2025-06-15T19:06:02.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/8a/3c4af14b93a15ce55901cd7a92e1a4701910f1768c78fb30f61d2b79785b/watchfiles-1.1.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:9c733cda03b6d636b4219625a4acb5c6ffb10803338e437fb614fef9516825ef", size = 625626, upload-time = "2025-06-15T19:06:03.578Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/f5/cf6aa047d4d9e128f4b7cde615236a915673775ef171ff85971d698f3c2c/watchfiles-1.1.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:cc08ef8b90d78bfac66f0def80240b0197008e4852c9f285907377b2947ffdcb", size = 622744, upload-time = "2025-06-15T19:06:05.066Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -756,17 +767,17 @@ version = "15.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user