mirror of
https://github.com/sortedcord/alemno-payments.git
synced 2026-07-22 04:02:49 +05:30
Compare commits
12 Commits
be6c95c497
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| a55eccf8e1 | |||
| 573d8d0d86 | |||
| 8100323af5 | |||
| 1902cd2d03 | |||
| 61d78af5c1 | |||
| c9ec297a5f | |||
| 9713a12591 | |||
| a3f2db0760 | |||
| 9c7c7b286e | |||
| 28ea349d51 | |||
| 9e1a5f83d4 | |||
| 8488e3bdd0 |
76
.github/workflows/docker.yml
vendored
Normal file
76
.github/workflows/docker.yml
vendored
Normal file
@@ -0,0 +1,76 @@
|
||||
name: Docker CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master ]
|
||||
pull_request:
|
||||
branches: [ main, master ]
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME_WEB: ghcr.io/${{ github.repository }}/web
|
||||
IMAGE_NAME_WORKER: ghcr.io/${{ github.repository }}/worker
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
if: github.event_name == 'push'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Web
|
||||
id: meta-web
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.IMAGE_NAME_WEB }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=sha,format=short
|
||||
latest
|
||||
|
||||
- name: Build and Push Web Image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name == 'push' }}
|
||||
tags: ${{ steps.meta-web.outputs.tags }}
|
||||
labels: ${{ steps.meta-web.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Extract metadata (tags, labels) for Worker
|
||||
id: meta-worker
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.IMAGE_NAME_WORKER }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=sha,format=short
|
||||
latest
|
||||
|
||||
- name: Build and Push Worker Image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name == 'push' }}
|
||||
tags: ${{ steps.meta-worker.outputs.tags }}
|
||||
labels: ${{ steps.meta-worker.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
57
.github/workflows/security.yml
vendored
Normal file
57
.github/workflows/security.yml
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
name: Security Scanning
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master ]
|
||||
pull_request:
|
||||
branches: [ main, master ]
|
||||
|
||||
jobs:
|
||||
bandit:
|
||||
name: Run Bandit (SAST)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Run Bandit
|
||||
run: uvx bandit -r app/ -ll -ii
|
||||
|
||||
pip-audit:
|
||||
name: Dependency Audit
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Compile requirements
|
||||
run: uv pip compile pyproject.toml -o requirements.txt
|
||||
|
||||
- name: Run pip-audit
|
||||
run: uvx pip-audit -r requirements.txt
|
||||
|
||||
gitleaks:
|
||||
name: Secret Scanning
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Run Gitleaks
|
||||
uses: gitleaks/gitleaks-action@v2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
60
.github/workflows/tests.yml
vendored
Normal file
60
.github/workflows/tests.yml
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
name: Run Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master ]
|
||||
pull_request:
|
||||
branches: [ main, master ]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: alemno_test_db
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Run Tests with Coverage
|
||||
env:
|
||||
DATABASE_URL: postgresql+asyncpg://postgres:postgres@localhost:5432/alemno_test_db
|
||||
REDIS_URL: redis://localhost:6379/0
|
||||
TESTING: "True"
|
||||
run: |
|
||||
uv run pytest --cov=app --cov-report=xml --cov-report=term-missing -v
|
||||
|
||||
- name: Upload Coverage Report
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: code-coverage-report
|
||||
path: coverage.xml
|
||||
195
README.md
195
README.md
@@ -1 +1,194 @@
|
||||
# Alemno Payments
|
||||
# Alemeno Payments
|
||||
|
||||
[](file:///home/sortedcord/Projects/alemno_payments/pyproject.toml)
|
||||
[](file:///home/sortedcord/Projects/alemno_payments/app/app.py)
|
||||
[](https://github.com/sortedcord/alemno-payments/actions/workflows/tests.yml)
|
||||
[](https://github.com/sortedcord/alemno-payments/actions/workflows/lint.yml)
|
||||
[](https://github.com/sortedcord/alemno-payments/actions/workflows/docker.yml)
|
||||
[](https://github.com/sortedcord/alemno-payments/actions/workflows/security.yml)
|
||||
|
||||
An asynchronous transactional data cleaning, validation, and LLM powered categorization pipeline.
|
||||
|
||||
## Features
|
||||
|
||||
- Reads multi format dates and handles missing transaction IDs by auto generating unique fallbacks, and parses messy amounts with currency signs and commas cleanly.
|
||||
- Offloads validation, spend conversion, and analysis to a Celery background worker backed by Redis.
|
||||
- Integrates with the Google GenAI SDK (`gemini-2.5-flash-lite` 75% cheaper than `gemini-3.5-flash-lite with no tangible decrease in performance) using structured output schemas to categorize transaction records into database- defined custom categories.
|
||||
- Uses Redis caching with a 24-hour TTL to store live exchange rates, falling back to a hardcoded rate of `93.0` if offline.
|
||||
- Statistical Anomaly Detection:
|
||||
- Flags transactions exceeding 3 times the account's median spend as statistical outliers.
|
||||
- Flags domestic transactions in USD currency.
|
||||
- Flags high value transactions and transactions with fraud indicative keywords.
|
||||
|
||||
[](https://drive.google.com/file/d/1yk0-pXClrOp0ccTiaO7tbXPHZg4EH1eE/view?usp=sharing)
|
||||
|
||||
[Technical Video](https://www.youtube.com/watch?v=Xjfyf1ct-0U)
|
||||
|
||||
_Click on the image to view the [Draw.io](https://drive.google.com/file/d/1yk0-pXClrOp0ccTiaO7tbXPHZg4EH1eE/view?usp=sharing) file_
|
||||
|
||||
## Self Hosting & Deployment
|
||||
|
||||
We publish production-ready images to **GitHub Container Registry (GHCR)**. You can pull them directly or run them via Docker Compose.
|
||||
|
||||
Create a `docker-compose.yml` file to host the entire stack (or just clone the repo and directly run `docker compose up -d` :
|
||||
|
||||
```yaml
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: alemno_db
|
||||
ports:
|
||||
- "5432:5432"
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: alemno_payments
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d alemno_payments"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: alemno_redis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
|
||||
web:
|
||||
image: ghcr.io/sortedcord/alemno-payments/web:latest
|
||||
container_name: alemno_web
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- POSTGRES_HOST=db
|
||||
- POSTGRES_PORT=5432
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- POSTGRES_DB=alemno_payments
|
||||
- REDIS_HOST=redis
|
||||
- REDIS_PORT=6379
|
||||
- REDIS_DB=0
|
||||
- GEMINI_API_KEY=your-gemini-api-key
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
|
||||
worker:
|
||||
image: ghcr.io/sortedcord/alemno-payments/worker:latest
|
||||
container_name: alemno_worker
|
||||
environment:
|
||||
- POSTGRES_HOST=db
|
||||
- POSTGRES_PORT=5432
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- POSTGRES_DB=alemno_payments
|
||||
- REDIS_HOST=redis
|
||||
- REDIS_PORT=6379
|
||||
- REDIS_DB=0
|
||||
- GEMINI_API_KEY=your-gemini-api-key
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
```
|
||||
|
||||
Launch the stack:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Testing the API with curl
|
||||
|
||||
All API endpoints are prefixed with `/api/v1`. Below are copy-pasteable `curl` commands using the sample [transactions.csv](file:///home/sortedcord/Projects/alemno_payments/transactions.csv) dataset.
|
||||
|
||||
### 1. Upload CSV Transaction Data
|
||||
|
||||
Submit a CSV file for asynchronous processing.
|
||||
|
||||
```bash
|
||||
curl -X POST -F "file=@transactions.csv" http://localhost:3000/api/v1/jobs/upload
|
||||
```
|
||||
|
||||
_Response Example:_
|
||||
|
||||
```json
|
||||
{
|
||||
"filename": "transactions.csv",
|
||||
"id": "c0249af1-c52d-4d49-9305-3d1a10f29e03",
|
||||
"status": "pending",
|
||||
"row_count_raw": null,
|
||||
"row_count_clean": null,
|
||||
"created_at": "2026-07-03T06:29:13.123456Z",
|
||||
"completed_at": null,
|
||||
"error_message": null
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Check Job Status
|
||||
|
||||
Check the status of a job using the job ID returned by the upload endpoint.
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/api/v1/jobs/c0249af1-c52d-4d49-9305-3d1a10f29e03/status
|
||||
```
|
||||
|
||||
_Response Example (while processing):_
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "c0249af1-c52d-4d49-9305-3d1a10f29e03",
|
||||
"status": "processing",
|
||||
"summary": null
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Get Full Job Results
|
||||
|
||||
Retrieve the high-level summary along with all categorized and parsed transactions once the job is completed.
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/api/v1/jobs/c0249af1-c52d-4d49-9305-3d1a10f29e03/results
|
||||
```
|
||||
|
||||
### 4. List All Jobs
|
||||
|
||||
List all historical processing jobs. You can optionally filter by status (e.g. `pending`, `processing`, `completed`, `failed`).
|
||||
|
||||
```bash
|
||||
# List all jobs
|
||||
curl http://localhost:3000/api/v1/jobs
|
||||
|
||||
# List completed jobs only
|
||||
curl "http://localhost:3000/api/v1/jobs?status=completed"
|
||||
```
|
||||
|
||||
### 5. List Categories
|
||||
|
||||
List all available transaction categories sorted alphabetically.
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/api/v1/categories
|
||||
```
|
||||
|
||||
### 6. Create Custom Category
|
||||
|
||||
Add a new custom category. Newly uploaded transactions will dynamically map to this category during classification if identified by the LLM.
|
||||
|
||||
```bash
|
||||
curl -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "Subscription"}' \
|
||||
http://localhost:3000/api/v1/categories
|
||||
```
|
||||
|
||||
35
app/api/v1/categories.py
Normal file
35
app/api/v1/categories.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from typing import List
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from app.core.dependencies import get_category_service
|
||||
from app.core.exceptions import CategoryAlreadyExistsException
|
||||
from app.schemas.category import CategoryCreate, CategoryResponse
|
||||
from app.services.category_service import CategoryService
|
||||
|
||||
router = APIRouter(prefix="/categories", tags=["categories"])
|
||||
|
||||
|
||||
@router.get("", response_model=List[CategoryResponse])
|
||||
async def list_categories(
|
||||
service: CategoryService = Depends(get_category_service),
|
||||
):
|
||||
"""
|
||||
Returns the list of all available categories ordered alphabetically.
|
||||
"""
|
||||
return await service.list_categories()
|
||||
|
||||
|
||||
@router.post("", response_model=CategoryResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_category(
|
||||
category_in: CategoryCreate,
|
||||
service: CategoryService = Depends(get_category_service),
|
||||
):
|
||||
"""
|
||||
Creates a new custom category. The name must be unique.
|
||||
"""
|
||||
try:
|
||||
return await service.create_category(name=category_in.name)
|
||||
except CategoryAlreadyExistsException as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
)
|
||||
@@ -4,6 +4,9 @@ from app.core.config import settings
|
||||
from app.core.logging import logger
|
||||
|
||||
|
||||
from app.core.observability import time_it
|
||||
|
||||
|
||||
class ExchangeRateClient:
|
||||
def __init__(self):
|
||||
self.default_rate = 93.0
|
||||
@@ -20,6 +23,7 @@ class ExchangeRateClient:
|
||||
logger.warning(f"Could not connect to Redis for exchange rate caching: {e}")
|
||||
self.redis_client = None
|
||||
|
||||
@time_it("Get USD to INR Exchange Rate")
|
||||
async def get_usd_to_inr_rate(self) -> float:
|
||||
"""
|
||||
Retrieves the USD to INR conversion rate.
|
||||
|
||||
@@ -3,6 +3,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.db.session import get_db
|
||||
from app.repositories.job_repository import JobRepository
|
||||
from app.services.job_service import JobService
|
||||
from app.services.category_service import CategoryService
|
||||
|
||||
|
||||
def get_job_repository(db: AsyncSession = Depends(get_db)) -> JobRepository:
|
||||
@@ -13,3 +14,8 @@ def get_job_repository(db: AsyncSession = Depends(get_db)) -> JobRepository:
|
||||
def get_job_service(repo: JobRepository = Depends(get_job_repository)) -> JobService:
|
||||
"""Dependency to retrieve JobService"""
|
||||
return JobService(repo)
|
||||
|
||||
|
||||
def get_category_service(db: AsyncSession = Depends(get_db)) -> CategoryService:
|
||||
"""Dependency to retrieve CategoryService"""
|
||||
return CategoryService(db)
|
||||
|
||||
@@ -18,3 +18,11 @@ class InvalidCSVException(AlemnoException):
|
||||
def __init__(self, detail: str):
|
||||
self.detail = detail
|
||||
super().__init__(detail)
|
||||
|
||||
|
||||
class CategoryAlreadyExistsException(AlemnoException):
|
||||
"""Raised when a category already exists"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
super().__init__(f"Category '{name}' already exists.")
|
||||
|
||||
59
app/core/observability.py
Normal file
59
app/core/observability.py
Normal file
@@ -0,0 +1,59 @@
|
||||
import functools
|
||||
import inspect
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from typing import Optional
|
||||
from app.core.logging import logger
|
||||
|
||||
|
||||
@contextmanager
|
||||
def benchmark(name: str):
|
||||
"""
|
||||
Context manager to benchmark a block of code.
|
||||
Example:
|
||||
with benchmark("Database save"):
|
||||
await repo.save(data)
|
||||
"""
|
||||
start_time = time.perf_counter()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
elapsed = time.perf_counter() - start_time
|
||||
logger.info(f"[BENCHMARK] {name} completed in {elapsed:.4f} seconds")
|
||||
|
||||
|
||||
def time_it(name: Optional[str] = None):
|
||||
"""
|
||||
Decorator to benchmark synchronous or asynchronous functions.
|
||||
Example:
|
||||
@time_it("Retrieve exchange rate")
|
||||
async def get_rate():
|
||||
...
|
||||
"""
|
||||
|
||||
def decorator(func):
|
||||
label = name or func.__name__
|
||||
|
||||
@functools.wraps(func)
|
||||
async def async_wrapper(*args, **kwargs):
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
finally:
|
||||
elapsed = time.perf_counter() - start
|
||||
logger.info(f"[BENCHMARK] {label} completed in {elapsed:.4f} seconds")
|
||||
|
||||
@functools.wraps(func)
|
||||
def sync_wrapper(*args, **kwargs):
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
finally:
|
||||
elapsed = time.perf_counter() - start
|
||||
logger.info(f"[BENCHMARK] {label} completed in {elapsed:.4f} seconds")
|
||||
|
||||
if inspect.iscoroutinefunction(func):
|
||||
return async_wrapper
|
||||
return sync_wrapper
|
||||
|
||||
return decorator
|
||||
@@ -2,5 +2,6 @@ from app.db.base import Base
|
||||
from app.db.models.job import Job
|
||||
from app.db.models.transaction import Transaction
|
||||
from app.db.models.job_summary import JobSummary
|
||||
from app.db.models.category import Category
|
||||
|
||||
__all__ = ["Base", "Job", "Transaction", "JobSummary"]
|
||||
__all__ = ["Base", "Job", "Transaction", "JobSummary", "Category"]
|
||||
|
||||
10
app/db/models/category.py
Normal file
10
app/db/models/category.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from sqlalchemy import Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class Category(Base):
|
||||
__tablename__ = "categories"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
|
||||
26
app/main.py
26
app/main.py
@@ -2,6 +2,7 @@ from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from app.api.v1.jobs import router as jobs_router
|
||||
from app.api.v1.categories import router as categories_router
|
||||
from app.core.config import settings
|
||||
from app.db.base import Base
|
||||
from app.db.session import engine
|
||||
@@ -12,6 +13,28 @@ async def lifespan(app: FastAPI):
|
||||
# Startup: Create tables if they don't exist
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
# Seed default categories if empty
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.db.models.category import Category
|
||||
from sqlalchemy import select
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(select(Category))
|
||||
if not result.scalars().first():
|
||||
defaults = [
|
||||
Category(name="Food"),
|
||||
Category(name="Shopping"),
|
||||
Category(name="Travel"),
|
||||
Category(name="Transport"),
|
||||
Category(name="Utilities"),
|
||||
Category(name="Cash Withdrawal"),
|
||||
Category(name="Entertainment"),
|
||||
Category(name="Other"),
|
||||
]
|
||||
session.add_all(defaults)
|
||||
await session.commit()
|
||||
|
||||
yield
|
||||
# Shutdown: Clean up pool
|
||||
await engine.dispose()
|
||||
@@ -32,8 +55,9 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Include API Router
|
||||
# Include API Routers
|
||||
app.include_router(jobs_router, prefix=settings.API_V1_STR)
|
||||
app.include_router(categories_router, prefix=settings.API_V1_STR)
|
||||
|
||||
|
||||
# Root healthcheck endpoint
|
||||
|
||||
28
app/repositories/category_repository.py
Normal file
28
app/repositories/category_repository.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from typing import List, Optional
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.db.models.category import Category
|
||||
|
||||
|
||||
class CategoryRepository:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def list_all(self) -> List[Category]:
|
||||
"""List all category records ordered alphabetically by name"""
|
||||
query = select(Category).order_by(Category.name.asc())
|
||||
result = await self.db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def create(self, name: str) -> Category:
|
||||
"""Create a new category record"""
|
||||
category = Category(name=name)
|
||||
self.db.add(category)
|
||||
await self.db.flush()
|
||||
return category
|
||||
|
||||
async def get_by_name(self, name: str) -> Optional[Category]:
|
||||
"""Fetch a category record by name (case-insensitive)"""
|
||||
query = select(Category).where(Category.name.ilike(name))
|
||||
result = await self.db.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
17
app/schemas/category.py
Normal file
17
app/schemas/category.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
|
||||
|
||||
class CategoryBase(BaseModel):
|
||||
name: str = Field(
|
||||
..., description="Unique category name", min_length=1, max_length=100
|
||||
)
|
||||
|
||||
|
||||
class CategoryCreate(CategoryBase):
|
||||
pass
|
||||
|
||||
|
||||
class CategoryResponse(CategoryBase):
|
||||
id: int
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
26
app/services/category_service.py
Normal file
26
app/services/category_service.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from typing import List
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.repositories.category_repository import CategoryRepository
|
||||
from app.db.models.category import Category
|
||||
from app.core.exceptions import CategoryAlreadyExistsException
|
||||
|
||||
|
||||
class CategoryService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
self.repo = CategoryRepository(db)
|
||||
|
||||
async def list_categories(self) -> List[Category]:
|
||||
"""Returns all categories alphabetical list"""
|
||||
return await self.repo.list_all()
|
||||
|
||||
async def create_category(self, name: str) -> Category:
|
||||
"""Creates a new category, validating uniqueness first"""
|
||||
name_clean = name.strip()
|
||||
existing = await self.repo.get_by_name(name_clean)
|
||||
if existing:
|
||||
raise CategoryAlreadyExistsException(name_clean)
|
||||
|
||||
category = await self.repo.create(name_clean)
|
||||
await self.db.commit()
|
||||
return category
|
||||
@@ -6,10 +6,14 @@ from app.utils.csv_parser import parse_and_validate_csv
|
||||
from app.worker import process_transaction_job
|
||||
|
||||
|
||||
from app.core.observability import time_it
|
||||
|
||||
|
||||
class JobService:
|
||||
def __init__(self, repo: JobRepository):
|
||||
self.repo = repo
|
||||
|
||||
@time_it("Upload CSV Service")
|
||||
async def upload_csv(self, filename: str, content: bytes) -> Job:
|
||||
parsed_transactions = parse_and_validate_csv(content)
|
||||
|
||||
@@ -19,17 +23,20 @@ class JobService:
|
||||
|
||||
return job
|
||||
|
||||
@time_it("Get Job Status Service")
|
||||
async def get_job_status(self, job_id: str) -> Job:
|
||||
job = await self.repo.get_by_id(job_id)
|
||||
if not job:
|
||||
raise JobNotFoundException(job_id)
|
||||
return job
|
||||
|
||||
@time_it("Get Job Results Service")
|
||||
async def get_job_results(self, job_id: str) -> Job:
|
||||
job = await self.repo.get_by_id(job_id)
|
||||
if not job:
|
||||
raise JobNotFoundException(job_id)
|
||||
return job
|
||||
|
||||
@time_it("List Jobs Service")
|
||||
async def list_jobs(self, status: Optional[str] = None) -> List[Job]:
|
||||
return await self.repo.list_all(status=status)
|
||||
|
||||
@@ -4,9 +4,12 @@ from datetime import datetime
|
||||
from typing import Any, Dict, List
|
||||
from app.core.exceptions import InvalidCSVException
|
||||
|
||||
from app.core.observability import time_it
|
||||
|
||||
REQUIRED_COLUMNS = {"txn_id", "date", "merchant", "amount"}
|
||||
|
||||
|
||||
@time_it("Parse and Validate CSV")
|
||||
def parse_and_validate_csv(content: bytes) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Parses CSV content from bytes, validates headers, and checks field types.
|
||||
@@ -83,6 +86,7 @@ def parse_and_validate_csv(content: bytes) -> List[Dict[str, Any]]:
|
||||
# Auto-generate txn_id if missing or empty
|
||||
if not txn_id or not txn_id.strip():
|
||||
import uuid
|
||||
|
||||
txn_id = f"GEN_{uuid.uuid4().hex[:8].upper()}"
|
||||
else:
|
||||
txn_id = txn_id.strip()
|
||||
|
||||
504
app/worker.py
504
app/worker.py
@@ -3,10 +3,10 @@ import json
|
||||
import os
|
||||
import statistics
|
||||
from datetime import datetime, date
|
||||
from typing import Any, Dict, List
|
||||
from typing import Any, Dict, List, Optional
|
||||
from sqlalchemy import select
|
||||
from celery import Celery
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, AliasChoices
|
||||
from google import genai
|
||||
from app.core.config import settings
|
||||
from app.core.logging import logger
|
||||
@@ -15,16 +15,38 @@ from app.repositories.job_repository import JobRepository
|
||||
from app.db.models.transaction import Transaction
|
||||
from app.db.models.job_summary import JobSummary
|
||||
from app.clients.exchange_rate_client import ExchangeRateClient
|
||||
from app.core.observability import time_it, benchmark
|
||||
|
||||
exchange_rate_client = ExchangeRateClient()
|
||||
|
||||
|
||||
def clean_json_response(text: str) -> str:
|
||||
"""Extracts the first JSON object or array from the text, handling markdown fences and conversational prefix/suffix."""
|
||||
text = text.strip()
|
||||
start_idx = -1
|
||||
for i, char in enumerate(text):
|
||||
if char in ("{", "["):
|
||||
start_idx = i
|
||||
break
|
||||
|
||||
end_idx = -1
|
||||
for i in range(len(text) - 1, -1, -1):
|
||||
if text[i] in ("}", "]"):
|
||||
end_idx = i
|
||||
break
|
||||
|
||||
if start_idx != -1 and end_idx != -1 and end_idx > start_idx:
|
||||
return text[start_idx : end_idx + 1]
|
||||
|
||||
return text
|
||||
|
||||
|
||||
class TransactionClassification(BaseModel):
|
||||
txn_id: str = Field(
|
||||
description="The unique transaction ID (txn_id) from the input."
|
||||
)
|
||||
category: str = Field(
|
||||
description="Assigned category, must be one of: Food, Shopping, Travel, Transport, Utilities, Cash Withdrawal, Entertainment, or Other."
|
||||
description="Assigned category matching one of the allowed categories list."
|
||||
)
|
||||
|
||||
|
||||
@@ -32,9 +54,11 @@ class BatchClassificationResponse(BaseModel):
|
||||
classifications: List[TransactionClassification]
|
||||
|
||||
|
||||
@time_it("LLM Batch Classification Request")
|
||||
async def classify_transactions_batch(
|
||||
client: genai.Client,
|
||||
batch_txns: List[Dict[str, Any]],
|
||||
allowed_categories: List[str],
|
||||
) -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
Calls the Gemini model to classify a batch of transactions.
|
||||
@@ -55,8 +79,7 @@ async def classify_transactions_batch(
|
||||
|
||||
input_prompt = (
|
||||
"Classify the following financial transactions. For each transaction, match its txn_id "
|
||||
"and assign one of the allowed categories: Food, Shopping, Travel, Transport, Utilities, "
|
||||
"Cash Withdrawal, Entertainment, or Other based on the merchant name and details:\n\n"
|
||||
f"and assign one of the allowed categories: {', '.join(allowed_categories)} based on the merchant name and details:\n\n"
|
||||
f"{json.dumps(prompt_data, indent=2)}"
|
||||
)
|
||||
|
||||
@@ -70,7 +93,7 @@ async def classify_transactions_batch(
|
||||
interaction = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: client.interactions.create(
|
||||
model="gemini-3.5-flash",
|
||||
model="gemini-2.5-flash-lite",
|
||||
input=input_prompt,
|
||||
response_format={
|
||||
"type": "text",
|
||||
@@ -80,9 +103,8 @@ async def classify_transactions_batch(
|
||||
),
|
||||
)
|
||||
|
||||
resp_obj = BatchClassificationResponse.model_validate_json(
|
||||
interaction.output_text
|
||||
)
|
||||
clean_text = clean_json_response(interaction.output_text)
|
||||
resp_obj = BatchClassificationResponse.model_validate_json(clean_text)
|
||||
|
||||
results = {}
|
||||
for item in resp_obj.classifications:
|
||||
@@ -113,6 +135,95 @@ async def classify_transactions_batch(
|
||||
return results
|
||||
|
||||
|
||||
class JobNarrativeSummary(BaseModel):
|
||||
total_spend_by_currency: Dict[str, float] = Field(
|
||||
validation_alias=AliasChoices(
|
||||
"total_spend_by_currency", "spend_by_currency", "total_spend"
|
||||
),
|
||||
description="Total spend grouped by original currency",
|
||||
)
|
||||
top_3_merchants: Dict[str, float] = Field(
|
||||
validation_alias=AliasChoices("top_3_merchants", "top_merchants", "top_3"),
|
||||
description="Top 3 merchants by spend volume (value in USD equivalent)",
|
||||
)
|
||||
anomaly_count: int = Field(
|
||||
validation_alias=AliasChoices(
|
||||
"anomaly_count", "total_anomalies", "anomalies", "anomaly_transactions"
|
||||
),
|
||||
description="Total number of anomaly transactions flagged in the dataset",
|
||||
)
|
||||
spending_narrative: str = Field(
|
||||
validation_alias=AliasChoices("spending_narrative", "narrative", "summary"),
|
||||
description="A cohesive 2-3 sentence spending narrative",
|
||||
)
|
||||
risk_level: str = Field(
|
||||
validation_alias=AliasChoices("risk_level", "overall_risk_level", "risk"),
|
||||
description="Overall risk level: Low, Medium, or High",
|
||||
)
|
||||
|
||||
|
||||
@time_it("LLM Job Narrative Summary Request")
|
||||
async def generate_job_summary_llm(
|
||||
client: genai.Client,
|
||||
db_transactions: List[Transaction],
|
||||
) -> Optional[JobNarrativeSummary]:
|
||||
"""
|
||||
Calls the Gemini model to produce a structured JSON summary:
|
||||
total spend by currency, top 3 merchants, anomaly count, a 2-3 sentence spending narrative, and a risk level.
|
||||
"""
|
||||
# Build a simplified list of transactions for context
|
||||
tx_data = [
|
||||
{
|
||||
"merchant": t.merchant,
|
||||
"amount": t.amount,
|
||||
"currency": t.currency,
|
||||
"category": t.category,
|
||||
"is_anomaly": t.is_anomaly,
|
||||
"anomaly_reason": t.anomaly_reason,
|
||||
}
|
||||
for t in db_transactions
|
||||
]
|
||||
|
||||
input_prompt = (
|
||||
"Analyze the following list of processed financial transactions and generate a structured JSON summary. "
|
||||
"The returned JSON MUST contain exactly the following keys:\n"
|
||||
' - "total_spend_by_currency": dictionary mapping currency symbol/code to total amount spent (e.g. {"USD": 150.0, "INR": 25000.0})\n'
|
||||
' - "top_3_merchants": dictionary mapping the top 3 merchants by spend to their total spend value in USD equivalent (assuming 83 INR per USD for conversion)\n'
|
||||
' - "anomaly_count": integer showing the total count of flagged anomaly transactions (is_anomaly=True)\n'
|
||||
' - "spending_narrative": string containing a cohesive 2-3 sentence narrative summarizing key spending insights and largest merchants\n'
|
||||
' - "risk_level": string showing the overall risk level of the job: "Low", "Medium", or "High" (pick one based on the nature of anomalies)\n\n'
|
||||
f"{json.dumps(tx_data, indent=2)}"
|
||||
)
|
||||
|
||||
for attempt in range(1, 4):
|
||||
try:
|
||||
logger.info(
|
||||
f"Calling LLM to generate job narrative summary (Attempt {attempt})..."
|
||||
)
|
||||
loop = asyncio.get_running_loop()
|
||||
interaction = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: client.interactions.create(
|
||||
model="gemini-2.5-flash-lite",
|
||||
input=input_prompt,
|
||||
response_format={
|
||||
"type": "text",
|
||||
"mime_type": "application/json",
|
||||
"schema": JobNarrativeSummary.model_json_schema(),
|
||||
},
|
||||
),
|
||||
)
|
||||
clean_text = clean_json_response(interaction.output_text)
|
||||
resp_obj = JobNarrativeSummary.model_validate_json(clean_text)
|
||||
return resp_obj
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to generate LLM summary (Attempt {attempt}): {e}")
|
||||
if attempt == 3:
|
||||
break
|
||||
await asyncio.sleep(2)
|
||||
return None
|
||||
|
||||
|
||||
# Define Celery app
|
||||
celery_app = Celery(
|
||||
"alemno_worker",
|
||||
@@ -145,19 +256,26 @@ def process_transaction_job(job_id: str, transactions: List[Dict[str, Any]]):
|
||||
return run_async(_process_job_async(job_id, transactions))
|
||||
|
||||
|
||||
@time_it("Process Job Background Task")
|
||||
async def _process_job_async(job_id: str, transactions: List[Dict[str, Any]]):
|
||||
async with AsyncSessionLocal() as db:
|
||||
repo = JobRepository(db)
|
||||
job = await repo.get_by_id(job_id)
|
||||
if not job:
|
||||
logger.error(f"Job with ID {job_id} not found in database.")
|
||||
return
|
||||
from app.db.session import engine
|
||||
|
||||
with benchmark("Dispose Engine"):
|
||||
await engine.dispose()
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
with benchmark("Initial Job Setup"):
|
||||
repo = JobRepository(db)
|
||||
job = await repo.get_by_id(job_id)
|
||||
if not job:
|
||||
logger.error(f"Job with ID {job_id} not found in database.")
|
||||
return
|
||||
|
||||
try:
|
||||
# Update status to processing
|
||||
await repo.update(job, status="processing")
|
||||
await db.commit()
|
||||
|
||||
try:
|
||||
db_transactions = []
|
||||
total_spend_inr = 0.0
|
||||
total_spend_usd = 0.0
|
||||
@@ -166,43 +284,44 @@ async def _process_job_async(job_id: str, transactions: List[Dict[str, Any]]):
|
||||
|
||||
USD_TO_INR = await exchange_rate_client.get_usd_to_inr_rate()
|
||||
|
||||
# 1. Fetch existing transaction amounts and currencies for all accounts in this job to calculate median
|
||||
account_ids = {
|
||||
t.get("account_id") for t in transactions if t.get("account_id")
|
||||
}
|
||||
existing_amounts_inr = {}
|
||||
if account_ids:
|
||||
stmt = select(
|
||||
Transaction.account_id, Transaction.amount, Transaction.currency
|
||||
).where(Transaction.account_id.in_(account_ids))
|
||||
res = await db.execute(stmt)
|
||||
for acc_id, amt, curr in res.all():
|
||||
if acc_id not in existing_amounts_inr:
|
||||
existing_amounts_inr[acc_id] = []
|
||||
# Standardize to INR for median calculation
|
||||
amt_inr = amt * USD_TO_INR if curr.upper() == "USD" else amt
|
||||
existing_amounts_inr[acc_id].append(amt_inr)
|
||||
with benchmark("Compute Account History Medians"):
|
||||
# 1. Fetch existing transaction amounts and currencies for all accounts in this job to calculate median
|
||||
account_ids = {
|
||||
t.get("account_id") for t in transactions if t.get("account_id")
|
||||
}
|
||||
existing_amounts_inr = {}
|
||||
if account_ids:
|
||||
stmt = select(
|
||||
Transaction.account_id, Transaction.amount, Transaction.currency
|
||||
).where(Transaction.account_id.in_(account_ids))
|
||||
res = await db.execute(stmt)
|
||||
for acc_id, amt, curr in res.all():
|
||||
if acc_id not in existing_amounts_inr:
|
||||
existing_amounts_inr[acc_id] = []
|
||||
# Standardize to INR for median calculation
|
||||
amt_inr = amt * USD_TO_INR if curr.upper() == "USD" else amt
|
||||
existing_amounts_inr[acc_id].append(amt_inr)
|
||||
|
||||
# Group incoming transaction amounts standardized to INR
|
||||
incoming_amounts_inr = {}
|
||||
for t in transactions:
|
||||
acc_id = t.get("account_id")
|
||||
if acc_id:
|
||||
if acc_id not in incoming_amounts_inr:
|
||||
incoming_amounts_inr[acc_id] = []
|
||||
amt = float(t["amount"])
|
||||
curr = t.get("currency", "INR").strip().upper()
|
||||
amt_inr = amt * USD_TO_INR if curr == "USD" else amt
|
||||
incoming_amounts_inr[acc_id].append(amt_inr)
|
||||
# Group incoming transaction amounts standardized to INR
|
||||
incoming_amounts_inr = {}
|
||||
for t in transactions:
|
||||
acc_id = t.get("account_id")
|
||||
if acc_id:
|
||||
if acc_id not in incoming_amounts_inr:
|
||||
incoming_amounts_inr[acc_id] = []
|
||||
amt = float(t["amount"])
|
||||
curr = t.get("currency", "INR").strip().upper()
|
||||
amt_inr = amt * USD_TO_INR if curr == "USD" else amt
|
||||
incoming_amounts_inr[acc_id].append(amt_inr)
|
||||
|
||||
# Precalculate median of (existing + incoming) transactions in INR
|
||||
account_medians_inr = {}
|
||||
for acc_id in account_ids:
|
||||
all_amts = existing_amounts_inr.get(
|
||||
acc_id, []
|
||||
) + incoming_amounts_inr.get(acc_id, [])
|
||||
if all_amts:
|
||||
account_medians_inr[acc_id] = statistics.median(all_amts)
|
||||
# Precalculate median of (existing + incoming) transactions in INR
|
||||
account_medians_inr = {}
|
||||
for acc_id in account_ids:
|
||||
all_amts = existing_amounts_inr.get(
|
||||
acc_id, []
|
||||
) + incoming_amounts_inr.get(acc_id, [])
|
||||
if all_amts:
|
||||
account_medians_inr[acc_id] = statistics.median(all_amts)
|
||||
|
||||
# 2. Call LLM to classify transactions without a category
|
||||
# Find all uncategorized incoming transactions
|
||||
@@ -215,20 +334,62 @@ async def _process_job_async(job_id: str, transactions: List[Dict[str, Any]]):
|
||||
llm_results = {}
|
||||
api_key = settings.GEMINI_API_KEY or os.environ.get("GEMINI_API_KEY")
|
||||
if uncategorized_txns:
|
||||
# Load allowed categories from the database categories table
|
||||
from app.db.models.category import Category
|
||||
|
||||
try:
|
||||
res_cats = await db.execute(select(Category.name))
|
||||
allowed_categories = [r[0] for r in res_cats.all()]
|
||||
except Exception as db_err:
|
||||
logger.warning(f"Could not load categories from database: {db_err}")
|
||||
allowed_categories = []
|
||||
|
||||
if not allowed_categories:
|
||||
allowed_categories = [
|
||||
"Food",
|
||||
"Shopping",
|
||||
"Travel",
|
||||
"Transport",
|
||||
"Utilities",
|
||||
"Cash Withdrawal",
|
||||
"Entertainment",
|
||||
"Other",
|
||||
]
|
||||
|
||||
if api_key:
|
||||
logger.info(
|
||||
f"Found {len(uncategorized_txns)} uncategorized transactions. Initializing GenAI client..."
|
||||
)
|
||||
try:
|
||||
client = genai.Client(api_key=api_key)
|
||||
# Batch transactions in chunks of 50
|
||||
# Split into batches of 50 and fire all concurrently
|
||||
batch_size = 50
|
||||
for i in range(0, len(uncategorized_txns), batch_size):
|
||||
batch = uncategorized_txns[i : i + batch_size]
|
||||
batch_results = await classify_transactions_batch(
|
||||
client, batch
|
||||
batches = [
|
||||
uncategorized_txns[i : i + batch_size]
|
||||
for i in range(0, len(uncategorized_txns), batch_size)
|
||||
]
|
||||
logger.info(
|
||||
f"Dispatching {len(batches)} classification batch(es) concurrently..."
|
||||
)
|
||||
with benchmark(
|
||||
"LLM Concurrent Batch Classification (all batches)"
|
||||
):
|
||||
batch_results_list = await asyncio.gather(
|
||||
*[
|
||||
classify_transactions_batch(
|
||||
client, batch, allowed_categories
|
||||
)
|
||||
for batch in batches
|
||||
],
|
||||
return_exceptions=True,
|
||||
)
|
||||
llm_results.update(batch_results)
|
||||
for batch_result in batch_results_list:
|
||||
if isinstance(batch_result, Exception):
|
||||
logger.error(
|
||||
f"A classification batch failed: {batch_result}"
|
||||
)
|
||||
else:
|
||||
llm_results.update(batch_result)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to initialize GenAI Client or classify: {e}"
|
||||
@@ -247,58 +408,92 @@ async def _process_job_async(job_id: str, transactions: List[Dict[str, Any]]):
|
||||
# Mock categorization mapping based on merchant name keywords
|
||||
for t in uncategorized_txns:
|
||||
merchant_lower = t["merchant"].lower()
|
||||
cat = "Other"
|
||||
if any(
|
||||
kw in merchant_lower
|
||||
for kw in ["swiggy", "zomato", "restaurant", "cafe", "food"]
|
||||
cat = (
|
||||
"Other"
|
||||
if "Other" in allowed_categories
|
||||
else (
|
||||
allowed_categories[0] if allowed_categories else "Other"
|
||||
)
|
||||
)
|
||||
if (
|
||||
any(
|
||||
kw in merchant_lower
|
||||
for kw in [
|
||||
"swiggy",
|
||||
"zomato",
|
||||
"restaurant",
|
||||
"cafe",
|
||||
"food",
|
||||
]
|
||||
)
|
||||
and "Food" in allowed_categories
|
||||
):
|
||||
cat = "Food"
|
||||
elif any(
|
||||
kw in merchant_lower
|
||||
for kw in ["ola", "uber", "taxi", "metro", "train"]
|
||||
elif (
|
||||
any(
|
||||
kw in merchant_lower
|
||||
for kw in ["ola", "uber", "taxi", "metro", "train"]
|
||||
)
|
||||
and "Transport" in allowed_categories
|
||||
):
|
||||
cat = "Transport"
|
||||
elif any(
|
||||
kw in merchant_lower
|
||||
for kw in [
|
||||
"amazon",
|
||||
"flipkart",
|
||||
"shopping",
|
||||
"store",
|
||||
"supermarket",
|
||||
]
|
||||
elif (
|
||||
any(
|
||||
kw in merchant_lower
|
||||
for kw in [
|
||||
"amazon",
|
||||
"flipkart",
|
||||
"shopping",
|
||||
"store",
|
||||
"supermarket",
|
||||
]
|
||||
)
|
||||
and "Shopping" in allowed_categories
|
||||
):
|
||||
cat = "Shopping"
|
||||
elif any(
|
||||
kw in merchant_lower
|
||||
for kw in ["air", "flight", "hotel", "travel", "irctc"]
|
||||
elif (
|
||||
any(
|
||||
kw in merchant_lower
|
||||
for kw in ["air", "flight", "hotel", "travel", "irctc"]
|
||||
)
|
||||
and "Travel" in allowed_categories
|
||||
):
|
||||
cat = "Travel"
|
||||
elif any(
|
||||
kw in merchant_lower
|
||||
for kw in [
|
||||
"electric",
|
||||
"power",
|
||||
"water",
|
||||
"bill",
|
||||
"utility",
|
||||
"utilities",
|
||||
]
|
||||
elif (
|
||||
any(
|
||||
kw in merchant_lower
|
||||
for kw in [
|
||||
"electric",
|
||||
"power",
|
||||
"water",
|
||||
"bill",
|
||||
"utility",
|
||||
"utilities",
|
||||
]
|
||||
)
|
||||
and "Utilities" in allowed_categories
|
||||
):
|
||||
cat = "Utilities"
|
||||
elif any(
|
||||
kw in merchant_lower for kw in ["atm", "cash", "withdrawal"]
|
||||
elif (
|
||||
any(
|
||||
kw in merchant_lower
|
||||
for kw in ["atm", "cash", "withdrawal"]
|
||||
)
|
||||
and "Cash Withdrawal" in allowed_categories
|
||||
):
|
||||
cat = "Cash Withdrawal"
|
||||
elif any(
|
||||
kw in merchant_lower
|
||||
for kw in [
|
||||
"netflix",
|
||||
"spotify",
|
||||
"movie",
|
||||
"show",
|
||||
"entertainment",
|
||||
]
|
||||
elif (
|
||||
any(
|
||||
kw in merchant_lower
|
||||
for kw in [
|
||||
"netflix",
|
||||
"spotify",
|
||||
"movie",
|
||||
"show",
|
||||
"entertainment",
|
||||
]
|
||||
)
|
||||
and "Entertainment" in allowed_categories
|
||||
):
|
||||
cat = "Entertainment"
|
||||
|
||||
@@ -412,55 +607,84 @@ async def _process_job_async(job_id: str, transactions: List[Dict[str, Any]]):
|
||||
)
|
||||
db_transactions.append(db_txn)
|
||||
|
||||
await repo.add_transactions(db_transactions)
|
||||
with benchmark("Save Cleaned Transactions to DB"):
|
||||
await repo.add_transactions(db_transactions)
|
||||
|
||||
top_merchant = (
|
||||
max(merchant_spends, key=merchant_spends.get)
|
||||
if merchant_spends
|
||||
else "None"
|
||||
)
|
||||
risk_level = "Low"
|
||||
if anomaly_count > 2:
|
||||
risk_level = "High"
|
||||
elif anomaly_count > 0:
|
||||
risk_level = "Medium"
|
||||
# Generate narrative and risk summary using LLM if api_key is available
|
||||
llm_summary = None
|
||||
if api_key:
|
||||
try:
|
||||
client = genai.Client(api_key=api_key)
|
||||
llm_summary = await generate_job_summary_llm(
|
||||
client, db_transactions
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in LLM summary generation: {e}")
|
||||
|
||||
narrative = (
|
||||
f"Successfully parsed and clean-processed {len(transactions)} transaction records. "
|
||||
f"Total spend is INR {total_spend_inr:,.2f} (${total_spend_usd:,.2f} USD). "
|
||||
f"Top merchant by spend volume is '{top_merchant}' with total of ${merchant_spends.get(top_merchant, 0):,.2f} USD. "
|
||||
f"Scan flagged {anomaly_count} potential anomalies, resulting in a overall risk level of '{risk_level}'."
|
||||
)
|
||||
if llm_summary:
|
||||
# Normalize risk level value to match capitalized Low/Medium/High in our DB
|
||||
risk_level_str = llm_summary.risk_level.strip().lower()
|
||||
if risk_level_str == "high":
|
||||
risk_level = "High"
|
||||
elif risk_level_str == "medium":
|
||||
risk_level = "Medium"
|
||||
else:
|
||||
risk_level = "Low"
|
||||
|
||||
# Build top merchants dict sorted by spend limit (keep top 5)
|
||||
sorted_merchants = sorted(
|
||||
merchant_spends.items(), key=lambda x: x[1], reverse=True
|
||||
)[:5]
|
||||
top_merchants_json = {
|
||||
merchant: round(spend, 2) for merchant, spend in sorted_merchants
|
||||
}
|
||||
narrative = llm_summary.spending_narrative.strip()
|
||||
top_merchants_json = {
|
||||
merchant: round(spend, 2)
|
||||
for merchant, spend in llm_summary.top_3_merchants.items()
|
||||
}
|
||||
anomaly_count = llm_summary.anomaly_count
|
||||
else:
|
||||
top_merchant = (
|
||||
max(merchant_spends, key=merchant_spends.get)
|
||||
if merchant_spends
|
||||
else "None"
|
||||
)
|
||||
risk_level = "Low"
|
||||
if anomaly_count > 2:
|
||||
risk_level = "High"
|
||||
elif anomaly_count > 0:
|
||||
risk_level = "Medium"
|
||||
|
||||
narrative = (
|
||||
f"Successfully parsed and clean-processed {len(transactions)} transaction records. "
|
||||
f"Total spend is INR {total_spend_inr:,.2f} (${total_spend_usd:,.2f} USD). "
|
||||
f"Top merchant by spend volume is '{top_merchant}' with total of ${merchant_spends.get(top_merchant, 0):,.2f} USD. "
|
||||
f"Scan flagged {anomaly_count} potential anomalies, resulting in a overall risk level of '{risk_level}'."
|
||||
)
|
||||
|
||||
sorted_merchants = sorted(
|
||||
merchant_spends.items(), key=lambda x: x[1], reverse=True
|
||||
)[:3]
|
||||
top_merchants_json = {
|
||||
merchant: round(spend, 2) for merchant, spend in sorted_merchants
|
||||
}
|
||||
|
||||
# Create job summary
|
||||
summary = JobSummary(
|
||||
job_id=job_id,
|
||||
total_spend_inr=round(total_spend_inr, 2),
|
||||
total_spend_usd=round(total_spend_usd, 2),
|
||||
top_merchants=top_merchants_json,
|
||||
anomaly_count=anomaly_count,
|
||||
narrative=narrative,
|
||||
risk_level=risk_level,
|
||||
)
|
||||
await repo.add_summary(summary)
|
||||
with benchmark("Save Summary and Complete Job"):
|
||||
summary = JobSummary(
|
||||
job_id=job_id,
|
||||
total_spend_inr=round(total_spend_inr, 2),
|
||||
total_spend_usd=round(total_spend_usd, 2),
|
||||
top_merchants=top_merchants_json,
|
||||
anomaly_count=anomaly_count,
|
||||
narrative=narrative,
|
||||
risk_level=risk_level,
|
||||
)
|
||||
await repo.add_summary(summary)
|
||||
|
||||
# Update job state
|
||||
await repo.update(
|
||||
job,
|
||||
status="completed",
|
||||
row_count_raw=len(transactions),
|
||||
row_count_clean=len(db_transactions),
|
||||
completed_at=datetime.utcnow(),
|
||||
)
|
||||
await db.commit()
|
||||
# Update job state
|
||||
await repo.update(
|
||||
job,
|
||||
status="completed",
|
||||
row_count_raw=len(transactions),
|
||||
row_count_clean=len(db_transactions),
|
||||
completed_at=datetime.utcnow(),
|
||||
)
|
||||
await db.commit()
|
||||
logger.info(f"Job {job_id} processing completed successfully.")
|
||||
|
||||
except Exception as e:
|
||||
|
||||
896
coverage.xml
Normal file
896
coverage.xml
Normal file
@@ -0,0 +1,896 @@
|
||||
<?xml version="1.0" ?>
|
||||
<coverage version="7.15.0" timestamp="1783047937028" lines-valid="711" lines-covered="402" line-rate="0.5654" branches-covered="0" branches-valid="0" branch-rate="0" complexity="0">
|
||||
<!-- Generated by coverage.py: https://coverage.readthedocs.io/en/7.15.0 -->
|
||||
<!-- Based on https://raw.githubusercontent.com/cobertura/web/master/htdocs/xml/coverage-04.dtd -->
|
||||
<sources>
|
||||
<source>/home/sortedcord/Projects/alemno_payments/app</source>
|
||||
</sources>
|
||||
<packages>
|
||||
<package name="." line-rate="0.2851" branch-rate="0" complexity="0">
|
||||
<classes>
|
||||
<class name="app.py" filename="app.py" complexity="0" line-rate="0" branch-rate="0">
|
||||
<methods/>
|
||||
<lines>
|
||||
<line number="1" hits="0"/>
|
||||
<line number="3" hits="0"/>
|
||||
</lines>
|
||||
</class>
|
||||
<class name="main.py" filename="main.py" complexity="0" line-rate="0.5333" branch-rate="0">
|
||||
<methods/>
|
||||
<lines>
|
||||
<line number="1" hits="1"/>
|
||||
<line number="2" hits="1"/>
|
||||
<line number="3" hits="1"/>
|
||||
<line number="4" hits="1"/>
|
||||
<line number="5" hits="1"/>
|
||||
<line number="6" hits="1"/>
|
||||
<line number="7" hits="1"/>
|
||||
<line number="8" hits="1"/>
|
||||
<line number="11" hits="1"/>
|
||||
<line number="12" hits="1"/>
|
||||
<line number="14" hits="0"/>
|
||||
<line number="15" hits="0"/>
|
||||
<line number="18" hits="0"/>
|
||||
<line number="19" hits="0"/>
|
||||
<line number="20" hits="0"/>
|
||||
<line number="22" hits="0"/>
|
||||
<line number="23" hits="0"/>
|
||||
<line number="24" hits="0"/>
|
||||
<line number="25" hits="0"/>
|
||||
<line number="35" hits="0"/>
|
||||
<line number="36" hits="0"/>
|
||||
<line number="38" hits="0"/>
|
||||
<line number="40" hits="0"/>
|
||||
<line number="43" hits="1"/>
|
||||
<line number="50" hits="1"/>
|
||||
<line number="59" hits="1"/>
|
||||
<line number="60" hits="1"/>
|
||||
<line number="64" hits="1"/>
|
||||
<line number="65" hits="1"/>
|
||||
<line number="66" hits="0"/>
|
||||
</lines>
|
||||
</class>
|
||||
<class name="worker.py" filename="worker.py" complexity="0" line-rate="0.2535" branch-rate="0">
|
||||
<methods/>
|
||||
<lines>
|
||||
<line number="1" hits="1"/>
|
||||
<line number="2" hits="1"/>
|
||||
<line number="3" hits="1"/>
|
||||
<line number="4" hits="1"/>
|
||||
<line number="5" hits="1"/>
|
||||
<line number="6" hits="1"/>
|
||||
<line number="7" hits="1"/>
|
||||
<line number="8" hits="1"/>
|
||||
<line number="9" hits="1"/>
|
||||
<line number="10" hits="1"/>
|
||||
<line number="11" hits="1"/>
|
||||
<line number="12" hits="1"/>
|
||||
<line number="13" hits="1"/>
|
||||
<line number="14" hits="1"/>
|
||||
<line number="15" hits="1"/>
|
||||
<line number="16" hits="1"/>
|
||||
<line number="17" hits="1"/>
|
||||
<line number="19" hits="1"/>
|
||||
<line number="22" hits="1"/>
|
||||
<line number="23" hits="1"/>
|
||||
<line number="26" hits="1"/>
|
||||
<line number="31" hits="1"/>
|
||||
<line number="35" hits="1"/>
|
||||
<line number="46" hits="1"/>
|
||||
<line number="47" hits="1"/>
|
||||
<line number="48" hits="1"/>
|
||||
<line number="57" hits="1"/>
|
||||
<line number="63" hits="1"/>
|
||||
<line number="64" hits="1"/>
|
||||
<line number="65" hits="1"/>
|
||||
<line number="68" hits="1"/>
|
||||
<line number="70" hits="1"/>
|
||||
<line number="83" hits="1"/>
|
||||
<line number="87" hits="1"/>
|
||||
<line number="88" hits="1"/>
|
||||
<line number="89" hits="1"/>
|
||||
<line number="90" hits="1"/>
|
||||
<line number="95" hits="1"/>
|
||||
<line number="96" hits="1"/>
|
||||
<line number="97" hits="1"/>
|
||||
<line number="98" hits="1"/>
|
||||
<line number="99" hits="1"/>
|
||||
<line number="100" hits="1"/>
|
||||
<line number="101" hits="1"/>
|
||||
<line number="103" hits="1"/>
|
||||
<line number="106" hits="1"/>
|
||||
<line number="107" hits="1"/>
|
||||
<line number="108" hits="1"/>
|
||||
<line number="113" hits="1"/>
|
||||
<line number="117" hits="1"/>
|
||||
<line number="124" hits="1"/>
|
||||
<line number="133" hits="1"/>
|
||||
<line number="135" hits="0"/>
|
||||
<line number="138" hits="1"/>
|
||||
<line number="139" hits="1"/>
|
||||
<line number="144" hits="0"/>
|
||||
<line number="145" hits="0"/>
|
||||
<line number="148" hits="1"/>
|
||||
<line number="149" hits="0"/>
|
||||
<line number="151" hits="0"/>
|
||||
<line number="152" hits="0"/>
|
||||
<line number="153" hits="0"/>
|
||||
<line number="154" hits="0"/>
|
||||
<line number="155" hits="0"/>
|
||||
<line number="156" hits="0"/>
|
||||
<line number="157" hits="0"/>
|
||||
<line number="159" hits="0"/>
|
||||
<line number="161" hits="0"/>
|
||||
<line number="162" hits="0"/>
|
||||
<line number="164" hits="0"/>
|
||||
<line number="165" hits="0"/>
|
||||
<line number="166" hits="0"/>
|
||||
<line number="167" hits="0"/>
|
||||
<line number="168" hits="0"/>
|
||||
<line number="170" hits="0"/>
|
||||
<line number="173" hits="0"/>
|
||||
<line number="176" hits="0"/>
|
||||
<line number="177" hits="0"/>
|
||||
<line number="178" hits="0"/>
|
||||
<line number="181" hits="0"/>
|
||||
<line number="182" hits="0"/>
|
||||
<line number="183" hits="0"/>
|
||||
<line number="184" hits="0"/>
|
||||
<line number="186" hits="0"/>
|
||||
<line number="187" hits="0"/>
|
||||
<line number="190" hits="0"/>
|
||||
<line number="191" hits="0"/>
|
||||
<line number="192" hits="0"/>
|
||||
<line number="193" hits="0"/>
|
||||
<line number="194" hits="0"/>
|
||||
<line number="195" hits="0"/>
|
||||
<line number="196" hits="0"/>
|
||||
<line number="197" hits="0"/>
|
||||
<line number="198" hits="0"/>
|
||||
<line number="199" hits="0"/>
|
||||
<line number="202" hits="0"/>
|
||||
<line number="203" hits="0"/>
|
||||
<line number="204" hits="0"/>
|
||||
<line number="207" hits="0"/>
|
||||
<line number="208" hits="0"/>
|
||||
<line number="212" hits="0"/>
|
||||
<line number="213" hits="0"/>
|
||||
<line number="214" hits="0"/>
|
||||
<line number="215" hits="0"/>
|
||||
<line number="216" hits="0"/>
|
||||
<line number="218" hits="0"/>
|
||||
<line number="219" hits="0"/>
|
||||
<line number="220" hits="0"/>
|
||||
<line number="222" hits="0"/>
|
||||
<line number="224" hits="0"/>
|
||||
<line number="225" hits="0"/>
|
||||
<line number="226" hits="0"/>
|
||||
<line number="227" hits="0"/>
|
||||
<line number="228" hits="0"/>
|
||||
<line number="229" hits="0"/>
|
||||
<line number="231" hits="0"/>
|
||||
<line number="232" hits="0"/>
|
||||
<line number="243" hits="0"/>
|
||||
<line number="244" hits="0"/>
|
||||
<line number="247" hits="0"/>
|
||||
<line number="248" hits="0"/>
|
||||
<line number="250" hits="0"/>
|
||||
<line number="251" hits="0"/>
|
||||
<line number="252" hits="0"/>
|
||||
<line number="253" hits="0"/>
|
||||
<line number="256" hits="0"/>
|
||||
<line number="257" hits="0"/>
|
||||
<line number="258" hits="0"/>
|
||||
<line number="262" hits="0"/>
|
||||
<line number="263" hits="0"/>
|
||||
<line number="269" hits="0"/>
|
||||
<line number="273" hits="0"/>
|
||||
<line number="274" hits="0"/>
|
||||
<line number="275" hits="0"/>
|
||||
<line number="282" hits="0"/>
|
||||
<line number="295" hits="0"/>
|
||||
<line number="296" hits="0"/>
|
||||
<line number="303" hits="0"/>
|
||||
<line number="304" hits="0"/>
|
||||
<line number="317" hits="0"/>
|
||||
<line number="318" hits="0"/>
|
||||
<line number="325" hits="0"/>
|
||||
<line number="326" hits="0"/>
|
||||
<line number="340" hits="0"/>
|
||||
<line number="341" hits="0"/>
|
||||
<line number="348" hits="0"/>
|
||||
<line number="349" hits="0"/>
|
||||
<line number="362" hits="0"/>
|
||||
<line number="364" hits="0"/>
|
||||
<line number="370" hits="0"/>
|
||||
<line number="371" hits="0"/>
|
||||
<line number="372" hits="0"/>
|
||||
<line number="374" hits="0"/>
|
||||
<line number="379" hits="0"/>
|
||||
<line number="380" hits="0"/>
|
||||
<line number="381" hits="0"/>
|
||||
<line number="382" hits="0"/>
|
||||
<line number="383" hits="0"/>
|
||||
<line number="386" hits="0"/>
|
||||
<line number="387" hits="0"/>
|
||||
<line number="388" hits="0"/>
|
||||
<line number="390" hits="0"/>
|
||||
<line number="391" hits="0"/>
|
||||
<line number="393" hits="0"/>
|
||||
<line number="394" hits="0"/>
|
||||
<line number="397" hits="0"/>
|
||||
<line number="400" hits="0"/>
|
||||
<line number="403" hits="0"/>
|
||||
<line number="404" hits="0"/>
|
||||
<line number="405" hits="0"/>
|
||||
<line number="406" hits="0"/>
|
||||
<line number="411" hits="0"/>
|
||||
<line number="412" hits="0"/>
|
||||
<line number="413" hits="0"/>
|
||||
<line number="416" hits="0"/>
|
||||
<line number="419" hits="0"/>
|
||||
<line number="420" hits="0"/>
|
||||
<line number="423" hits="0"/>
|
||||
<line number="424" hits="0"/>
|
||||
<line number="428" hits="0"/>
|
||||
<line number="430" hits="0"/>
|
||||
<line number="431" hits="0"/>
|
||||
<line number="433" hits="0"/>
|
||||
<line number="434" hits="0"/>
|
||||
<line number="437" hits="0"/>
|
||||
<line number="438" hits="0"/>
|
||||
<line number="445" hits="0"/>
|
||||
<line number="446" hits="0"/>
|
||||
<line number="447" hits="0"/>
|
||||
<line number="448" hits="0"/>
|
||||
<line number="449" hits="0"/>
|
||||
<line number="450" hits="0"/>
|
||||
<line number="452" hits="0"/>
|
||||
<line number="453" hits="0"/>
|
||||
<line number="454" hits="0"/>
|
||||
<line number="456" hits="0"/>
|
||||
<line number="472" hits="0"/>
|
||||
<line number="474" hits="0"/>
|
||||
<line number="476" hits="0"/>
|
||||
<line number="481" hits="0"/>
|
||||
<line number="482" hits="0"/>
|
||||
<line number="483" hits="0"/>
|
||||
<line number="484" hits="0"/>
|
||||
<line number="485" hits="0"/>
|
||||
<line number="487" hits="0"/>
|
||||
<line number="495" hits="0"/>
|
||||
<line number="498" hits="0"/>
|
||||
<line number="503" hits="0"/>
|
||||
<line number="512" hits="0"/>
|
||||
<line number="515" hits="0"/>
|
||||
<line number="522" hits="0"/>
|
||||
<line number="523" hits="0"/>
|
||||
<line number="525" hits="0"/>
|
||||
<line number="526" hits="0"/>
|
||||
<line number="527" hits="0"/>
|
||||
<line number="533" hits="0"/>
|
||||
<line number="534" hits="0"/>
|
||||
</lines>
|
||||
</class>
|
||||
</classes>
|
||||
</package>
|
||||
<package name="api.v1" line-rate="0.52" branch-rate="0" complexity="0">
|
||||
<classes>
|
||||
<class name="categories.py" filename="api/v1/categories.py" complexity="0" line-rate="0.6875" branch-rate="0">
|
||||
<methods/>
|
||||
<lines>
|
||||
<line number="1" hits="1"/>
|
||||
<line number="2" hits="1"/>
|
||||
<line number="3" hits="1"/>
|
||||
<line number="4" hits="1"/>
|
||||
<line number="5" hits="1"/>
|
||||
<line number="6" hits="1"/>
|
||||
<line number="8" hits="1"/>
|
||||
<line number="11" hits="1"/>
|
||||
<line number="12" hits="1"/>
|
||||
<line number="18" hits="0"/>
|
||||
<line number="21" hits="1"/>
|
||||
<line number="22" hits="1"/>
|
||||
<line number="29" hits="0"/>
|
||||
<line number="30" hits="0"/>
|
||||
<line number="31" hits="0"/>
|
||||
<line number="32" hits="0"/>
|
||||
</lines>
|
||||
</class>
|
||||
<class name="jobs.py" filename="api/v1/jobs.py" complexity="0" line-rate="0.4412" branch-rate="0">
|
||||
<methods/>
|
||||
<lines>
|
||||
<line number="1" hits="1"/>
|
||||
<line number="2" hits="1"/>
|
||||
<line number="3" hits="1"/>
|
||||
<line number="4" hits="1"/>
|
||||
<line number="5" hits="1"/>
|
||||
<line number="6" hits="1"/>
|
||||
<line number="8" hits="1"/>
|
||||
<line number="11" hits="1"/>
|
||||
<line number="12" hits="1"/>
|
||||
<line number="20" hits="0"/>
|
||||
<line number="21" hits="0"/>
|
||||
<line number="26" hits="0"/>
|
||||
<line number="27" hits="0"/>
|
||||
<line number="28" hits="0"/>
|
||||
<line number="29" hits="0"/>
|
||||
<line number="30" hits="0"/>
|
||||
<line number="31" hits="0"/>
|
||||
<line number="37" hits="1"/>
|
||||
<line number="38" hits="1"/>
|
||||
<line number="46" hits="0"/>
|
||||
<line number="47" hits="0"/>
|
||||
<line number="48" hits="0"/>
|
||||
<line number="49" hits="0"/>
|
||||
<line number="50" hits="0"/>
|
||||
<line number="56" hits="1"/>
|
||||
<line number="57" hits="1"/>
|
||||
<line number="65" hits="0"/>
|
||||
<line number="66" hits="0"/>
|
||||
<line number="67" hits="0"/>
|
||||
<line number="68" hits="0"/>
|
||||
<line number="69" hits="0"/>
|
||||
<line number="75" hits="1"/>
|
||||
<line number="76" hits="1"/>
|
||||
<line number="84" hits="0"/>
|
||||
</lines>
|
||||
</class>
|
||||
</classes>
|
||||
</package>
|
||||
<package name="clients" line-rate="0.4186" branch-rate="0" complexity="0">
|
||||
<classes>
|
||||
<class name="exchange_rate_client.py" filename="clients/exchange_rate_client.py" complexity="0" line-rate="0.4186" branch-rate="0">
|
||||
<methods/>
|
||||
<lines>
|
||||
<line number="1" hits="1"/>
|
||||
<line number="2" hits="1"/>
|
||||
<line number="3" hits="1"/>
|
||||
<line number="4" hits="1"/>
|
||||
<line number="7" hits="1"/>
|
||||
<line number="8" hits="1"/>
|
||||
<line number="9" hits="1"/>
|
||||
<line number="10" hits="1"/>
|
||||
<line number="11" hits="1"/>
|
||||
<line number="12" hits="1"/>
|
||||
<line number="13" hits="1"/>
|
||||
<line number="19" hits="0"/>
|
||||
<line number="20" hits="0"/>
|
||||
<line number="21" hits="0"/>
|
||||
<line number="23" hits="1"/>
|
||||
<line number="30" hits="1"/>
|
||||
<line number="31" hits="1"/>
|
||||
<line number="32" hits="1"/>
|
||||
<line number="33" hits="1"/>
|
||||
<line number="34" hits="1"/>
|
||||
<line number="35" hits="1"/>
|
||||
<line number="36" hits="0"/>
|
||||
<line number="37" hits="0"/>
|
||||
<line number="40" hits="0"/>
|
||||
<line number="41" hits="0"/>
|
||||
<line number="42" hits="0"/>
|
||||
<line number="43" hits="0"/>
|
||||
<line number="44" hits="0"/>
|
||||
<line number="45" hits="0"/>
|
||||
<line number="46" hits="0"/>
|
||||
<line number="47" hits="0"/>
|
||||
<line number="48" hits="0"/>
|
||||
<line number="49" hits="0"/>
|
||||
<line number="50" hits="0"/>
|
||||
<line number="54" hits="0"/>
|
||||
<line number="55" hits="0"/>
|
||||
<line number="56" hits="0"/>
|
||||
<line number="59" hits="0"/>
|
||||
<line number="60" hits="0"/>
|
||||
<line number="63" hits="0"/>
|
||||
<line number="64" hits="0"/>
|
||||
<line number="65" hits="0"/>
|
||||
<line number="70" hits="0"/>
|
||||
</lines>
|
||||
</class>
|
||||
</classes>
|
||||
</package>
|
||||
<package name="core" line-rate="0.8814" branch-rate="0" complexity="0">
|
||||
<classes>
|
||||
<class name="config.py" filename="core/config.py" complexity="0" line-rate="1" branch-rate="0">
|
||||
<methods/>
|
||||
<lines>
|
||||
<line number="1" hits="1"/>
|
||||
<line number="2" hits="1"/>
|
||||
<line number="3" hits="1"/>
|
||||
<line number="6" hits="1"/>
|
||||
<line number="7" hits="1"/>
|
||||
<line number="12" hits="1"/>
|
||||
<line number="13" hits="1"/>
|
||||
<line number="16" hits="1"/>
|
||||
<line number="17" hits="1"/>
|
||||
<line number="18" hits="1"/>
|
||||
<line number="19" hits="1"/>
|
||||
<line number="20" hits="1"/>
|
||||
<line number="23" hits="1"/>
|
||||
<line number="24" hits="1"/>
|
||||
<line number="25" hits="1"/>
|
||||
<line number="28" hits="1"/>
|
||||
<line number="30" hits="1"/>
|
||||
<line number="31" hits="1"/>
|
||||
<line number="32" hits="1"/>
|
||||
<line number="33" hits="1"/>
|
||||
<line number="40" hits="1"/>
|
||||
<line number="41" hits="1"/>
|
||||
<line number="42" hits="1"/>
|
||||
<line number="43" hits="1"/>
|
||||
<line number="45" hits="1"/>
|
||||
<line number="46" hits="1"/>
|
||||
<line number="47" hits="1"/>
|
||||
<line number="48" hits="1"/>
|
||||
<line number="51" hits="1"/>
|
||||
</lines>
|
||||
</class>
|
||||
<class name="dependencies.py" filename="core/dependencies.py" complexity="0" line-rate="0.75" branch-rate="0">
|
||||
<methods/>
|
||||
<lines>
|
||||
<line number="1" hits="1"/>
|
||||
<line number="2" hits="1"/>
|
||||
<line number="3" hits="1"/>
|
||||
<line number="4" hits="1"/>
|
||||
<line number="5" hits="1"/>
|
||||
<line number="6" hits="1"/>
|
||||
<line number="9" hits="1"/>
|
||||
<line number="11" hits="0"/>
|
||||
<line number="14" hits="1"/>
|
||||
<line number="16" hits="0"/>
|
||||
<line number="19" hits="1"/>
|
||||
<line number="21" hits="0"/>
|
||||
</lines>
|
||||
</class>
|
||||
<class name="exceptions.py" filename="core/exceptions.py" complexity="0" line-rate="0.7143" branch-rate="0">
|
||||
<methods/>
|
||||
<lines>
|
||||
<line number="1" hits="1"/>
|
||||
<line number="4" hits="1"/>
|
||||
<line number="7" hits="1"/>
|
||||
<line number="10" hits="1"/>
|
||||
<line number="11" hits="0"/>
|
||||
<line number="12" hits="0"/>
|
||||
<line number="15" hits="1"/>
|
||||
<line number="18" hits="1"/>
|
||||
<line number="19" hits="1"/>
|
||||
<line number="20" hits="1"/>
|
||||
<line number="23" hits="1"/>
|
||||
<line number="26" hits="1"/>
|
||||
<line number="27" hits="0"/>
|
||||
<line number="28" hits="0"/>
|
||||
</lines>
|
||||
</class>
|
||||
<class name="logging.py" filename="core/logging.py" complexity="0" line-rate="1" branch-rate="0">
|
||||
<methods/>
|
||||
<lines>
|
||||
<line number="1" hits="1"/>
|
||||
<line number="2" hits="1"/>
|
||||
<line number="5" hits="1"/>
|
||||
<line number="11" hits="1"/>
|
||||
</lines>
|
||||
</class>
|
||||
</classes>
|
||||
</package>
|
||||
<package name="db" line-rate="0.5294" branch-rate="0" complexity="0">
|
||||
<classes>
|
||||
<class name="base.py" filename="db/base.py" complexity="0" line-rate="1" branch-rate="0">
|
||||
<methods/>
|
||||
<lines>
|
||||
<line number="1" hits="1"/>
|
||||
<line number="4" hits="1"/>
|
||||
<line number="5" hits="1"/>
|
||||
</lines>
|
||||
</class>
|
||||
<class name="session.py" filename="db/session.py" complexity="0" line-rate="0.4286" branch-rate="0">
|
||||
<methods/>
|
||||
<lines>
|
||||
<line number="1" hits="1"/>
|
||||
<line number="2" hits="1"/>
|
||||
<line number="3" hits="1"/>
|
||||
<line number="6" hits="1"/>
|
||||
<line number="15" hits="1"/>
|
||||
<line number="22" hits="1"/>
|
||||
<line number="24" hits="0"/>
|
||||
<line number="25" hits="0"/>
|
||||
<line number="26" hits="0"/>
|
||||
<line number="27" hits="0"/>
|
||||
<line number="28" hits="0"/>
|
||||
<line number="29" hits="0"/>
|
||||
<line number="30" hits="0"/>
|
||||
<line number="32" hits="0"/>
|
||||
</lines>
|
||||
</class>
|
||||
</classes>
|
||||
</package>
|
||||
<package name="db.models" line-rate="1" branch-rate="0" complexity="0">
|
||||
<classes>
|
||||
<class name="__init__.py" filename="db/models/__init__.py" complexity="0" line-rate="1" branch-rate="0">
|
||||
<methods/>
|
||||
<lines>
|
||||
<line number="1" hits="1"/>
|
||||
<line number="2" hits="1"/>
|
||||
<line number="3" hits="1"/>
|
||||
<line number="4" hits="1"/>
|
||||
<line number="5" hits="1"/>
|
||||
<line number="7" hits="1"/>
|
||||
</lines>
|
||||
</class>
|
||||
<class name="category.py" filename="db/models/category.py" complexity="0" line-rate="1" branch-rate="0">
|
||||
<methods/>
|
||||
<lines>
|
||||
<line number="1" hits="1"/>
|
||||
<line number="2" hits="1"/>
|
||||
<line number="3" hits="1"/>
|
||||
<line number="6" hits="1"/>
|
||||
<line number="7" hits="1"/>
|
||||
<line number="9" hits="1"/>
|
||||
<line number="10" hits="1"/>
|
||||
</lines>
|
||||
</class>
|
||||
<class name="job.py" filename="db/models/job.py" complexity="0" line-rate="1" branch-rate="0">
|
||||
<methods/>
|
||||
<lines>
|
||||
<line number="1" hits="1"/>
|
||||
<line number="2" hits="1"/>
|
||||
<line number="3" hits="1"/>
|
||||
<line number="4" hits="1"/>
|
||||
<line number="5" hits="1"/>
|
||||
<line number="6" hits="1"/>
|
||||
<line number="7" hits="1"/>
|
||||
<line number="14" hits="1"/>
|
||||
<line number="15" hits="1"/>
|
||||
<line number="17" hits="1"/>
|
||||
<line number="23" hits="1"/>
|
||||
<line number="24" hits="1"/>
|
||||
<line number="29" hits="1"/>
|
||||
<line number="30" hits="1"/>
|
||||
<line number="31" hits="1"/>
|
||||
<line number="36" hits="1"/>
|
||||
<line number="40" hits="1"/>
|
||||
<line number="46" hits="1"/>
|
||||
<line number="51" hits="1"/>
|
||||
</lines>
|
||||
</class>
|
||||
<class name="job_summary.py" filename="db/models/job_summary.py" complexity="0" line-rate="1" branch-rate="0">
|
||||
<methods/>
|
||||
<lines>
|
||||
<line number="1" hits="1"/>
|
||||
<line number="2" hits="1"/>
|
||||
<line number="3" hits="1"/>
|
||||
<line number="4" hits="1"/>
|
||||
<line number="5" hits="1"/>
|
||||
<line number="6" hits="1"/>
|
||||
<line number="12" hits="1"/>
|
||||
<line number="13" hits="1"/>
|
||||
<line number="15" hits="1"/>
|
||||
<line number="21" hits="1"/>
|
||||
<line number="28" hits="1"/>
|
||||
<line number="29" hits="1"/>
|
||||
<line number="30" hits="1"/>
|
||||
<line number="33" hits="1"/>
|
||||
<line number="34" hits="1"/>
|
||||
<line number="35" hits="1"/>
|
||||
<line number="38" hits="1"/>
|
||||
</lines>
|
||||
</class>
|
||||
<class name="transaction.py" filename="db/models/transaction.py" complexity="0" line-rate="1" branch-rate="0">
|
||||
<methods/>
|
||||
<lines>
|
||||
<line number="1" hits="1"/>
|
||||
<line number="2" hits="1"/>
|
||||
<line number="3" hits="1"/>
|
||||
<line number="4" hits="1"/>
|
||||
<line number="5" hits="1"/>
|
||||
<line number="6" hits="1"/>
|
||||
<line number="7" hits="1"/>
|
||||
<line number="13" hits="1"/>
|
||||
<line number="14" hits="1"/>
|
||||
<line number="16" hits="1"/>
|
||||
<line number="22" hits="1"/>
|
||||
<line number="28" hits="1"/>
|
||||
<line number="29" hits="1"/>
|
||||
<line number="30" hits="1"/>
|
||||
<line number="31" hits="1"/>
|
||||
<line number="32" hits="1"/>
|
||||
<line number="33" hits="1"/>
|
||||
<line number="34" hits="1"/>
|
||||
<line number="35" hits="1"/>
|
||||
<line number="36" hits="1"/>
|
||||
<line number="37" hits="1"/>
|
||||
<line number="38" hits="1"/>
|
||||
<line number="39" hits="1"/>
|
||||
<line number="40" hits="1"/>
|
||||
<line number="43" hits="1"/>
|
||||
</lines>
|
||||
</class>
|
||||
</classes>
|
||||
</package>
|
||||
<package name="repositories" line-rate="0.4262" branch-rate="0" complexity="0">
|
||||
<classes>
|
||||
<class name="__init__.py" filename="repositories/__init__.py" complexity="0" line-rate="1" branch-rate="0">
|
||||
<methods/>
|
||||
<lines>
|
||||
<line number="1" hits="1"/>
|
||||
<line number="3" hits="1"/>
|
||||
</lines>
|
||||
</class>
|
||||
<class name="category_repository.py" filename="repositories/category_repository.py" complexity="0" line-rate="0.45" branch-rate="0">
|
||||
<methods/>
|
||||
<lines>
|
||||
<line number="1" hits="1"/>
|
||||
<line number="2" hits="1"/>
|
||||
<line number="3" hits="1"/>
|
||||
<line number="4" hits="1"/>
|
||||
<line number="7" hits="1"/>
|
||||
<line number="8" hits="1"/>
|
||||
<line number="9" hits="0"/>
|
||||
<line number="11" hits="1"/>
|
||||
<line number="13" hits="0"/>
|
||||
<line number="14" hits="0"/>
|
||||
<line number="15" hits="0"/>
|
||||
<line number="17" hits="1"/>
|
||||
<line number="19" hits="0"/>
|
||||
<line number="20" hits="0"/>
|
||||
<line number="21" hits="0"/>
|
||||
<line number="22" hits="0"/>
|
||||
<line number="24" hits="1"/>
|
||||
<line number="26" hits="0"/>
|
||||
<line number="27" hits="0"/>
|
||||
<line number="28" hits="0"/>
|
||||
</lines>
|
||||
</class>
|
||||
<class name="job_repository.py" filename="repositories/job_repository.py" complexity="0" line-rate="0.3846" branch-rate="0">
|
||||
<methods/>
|
||||
<lines>
|
||||
<line number="1" hits="1"/>
|
||||
<line number="2" hits="1"/>
|
||||
<line number="3" hits="1"/>
|
||||
<line number="4" hits="1"/>
|
||||
<line number="5" hits="1"/>
|
||||
<line number="6" hits="1"/>
|
||||
<line number="7" hits="1"/>
|
||||
<line number="10" hits="1"/>
|
||||
<line number="11" hits="1"/>
|
||||
<line number="12" hits="0"/>
|
||||
<line number="14" hits="1"/>
|
||||
<line number="16" hits="0"/>
|
||||
<line number="17" hits="0"/>
|
||||
<line number="18" hits="0"/>
|
||||
<line number="19" hits="0"/>
|
||||
<line number="21" hits="1"/>
|
||||
<line number="23" hits="0"/>
|
||||
<line number="28" hits="0"/>
|
||||
<line number="29" hits="0"/>
|
||||
<line number="31" hits="1"/>
|
||||
<line number="33" hits="0"/>
|
||||
<line number="34" hits="0"/>
|
||||
<line number="35" hits="0"/>
|
||||
<line number="36" hits="0"/>
|
||||
<line number="37" hits="0"/>
|
||||
<line number="38" hits="0"/>
|
||||
<line number="40" hits="1"/>
|
||||
<line number="42" hits="0"/>
|
||||
<line number="43" hits="0"/>
|
||||
<line number="44" hits="0"/>
|
||||
<line number="45" hits="0"/>
|
||||
<line number="46" hits="0"/>
|
||||
<line number="48" hits="1"/>
|
||||
<line number="50" hits="0"/>
|
||||
<line number="51" hits="0"/>
|
||||
<line number="53" hits="1"/>
|
||||
<line number="55" hits="0"/>
|
||||
<line number="56" hits="0"/>
|
||||
<line number="57" hits="0"/>
|
||||
</lines>
|
||||
</class>
|
||||
</classes>
|
||||
</package>
|
||||
<package name="schemas" line-rate="1" branch-rate="0" complexity="0">
|
||||
<classes>
|
||||
<class name="__init__.py" filename="schemas/__init__.py" complexity="0" line-rate="1" branch-rate="0">
|
||||
<methods/>
|
||||
<lines>
|
||||
<line number="1" hits="1"/>
|
||||
<line number="10" hits="1"/>
|
||||
</lines>
|
||||
</class>
|
||||
<class name="category.py" filename="schemas/category.py" complexity="0" line-rate="1" branch-rate="0">
|
||||
<methods/>
|
||||
<lines>
|
||||
<line number="1" hits="1"/>
|
||||
<line number="4" hits="1"/>
|
||||
<line number="5" hits="1"/>
|
||||
<line number="10" hits="1"/>
|
||||
<line number="11" hits="1"/>
|
||||
<line number="14" hits="1"/>
|
||||
<line number="17" hits="1"/>
|
||||
</lines>
|
||||
</class>
|
||||
<class name="job.py" filename="schemas/job.py" complexity="0" line-rate="1" branch-rate="0">
|
||||
<methods/>
|
||||
<lines>
|
||||
<line number="1" hits="1"/>
|
||||
<line number="2" hits="1"/>
|
||||
<line number="3" hits="1"/>
|
||||
<line number="6" hits="1"/>
|
||||
<line number="7" hits="1"/>
|
||||
<line number="16" hits="1"/>
|
||||
<line number="17" hits="1"/>
|
||||
<line number="18" hits="1"/>
|
||||
<line number="20" hits="1"/>
|
||||
<line number="21" hits="1"/>
|
||||
<line number="22" hits="1"/>
|
||||
<line number="23" hits="1"/>
|
||||
<line number="26" hits="1"/>
|
||||
<line number="27" hits="1"/>
|
||||
<line number="35" hits="1"/>
|
||||
<line number="36" hits="1"/>
|
||||
<line number="39" hits="1"/>
|
||||
<line number="43" hits="1"/>
|
||||
<line number="44" hits="1"/>
|
||||
<line number="47" hits="1"/>
|
||||
<line number="48" hits="1"/>
|
||||
<line number="52" hits="1"/>
|
||||
<line number="53" hits="1"/>
|
||||
<line number="55" hits="1"/>
|
||||
<line number="56" hits="1"/>
|
||||
<line number="59" hits="1"/>
|
||||
<line number="60" hits="1"/>
|
||||
<line number="64" hits="1"/>
|
||||
<line number="67" hits="1"/>
|
||||
<line number="68" hits="1"/>
|
||||
<line number="72" hits="1"/>
|
||||
<line number="73" hits="1"/>
|
||||
</lines>
|
||||
</class>
|
||||
</classes>
|
||||
</package>
|
||||
<package name="services" line-rate="0.4894" branch-rate="0" complexity="0">
|
||||
<classes>
|
||||
<class name="__init__.py" filename="services/__init__.py" complexity="0" line-rate="1" branch-rate="0">
|
||||
<methods/>
|
||||
<lines>
|
||||
<line number="1" hits="1"/>
|
||||
<line number="3" hits="1"/>
|
||||
</lines>
|
||||
</class>
|
||||
<class name="category_service.py" filename="services/category_service.py" complexity="0" line-rate="0.4737" branch-rate="0">
|
||||
<methods/>
|
||||
<lines>
|
||||
<line number="1" hits="1"/>
|
||||
<line number="2" hits="1"/>
|
||||
<line number="3" hits="1"/>
|
||||
<line number="4" hits="1"/>
|
||||
<line number="5" hits="1"/>
|
||||
<line number="8" hits="1"/>
|
||||
<line number="9" hits="1"/>
|
||||
<line number="10" hits="0"/>
|
||||
<line number="11" hits="0"/>
|
||||
<line number="13" hits="1"/>
|
||||
<line number="15" hits="0"/>
|
||||
<line number="17" hits="1"/>
|
||||
<line number="19" hits="0"/>
|
||||
<line number="20" hits="0"/>
|
||||
<line number="21" hits="0"/>
|
||||
<line number="22" hits="0"/>
|
||||
<line number="24" hits="0"/>
|
||||
<line number="25" hits="0"/>
|
||||
<line number="26" hits="0"/>
|
||||
</lines>
|
||||
</class>
|
||||
<class name="job_service.py" filename="services/job_service.py" complexity="0" line-rate="0.4615" branch-rate="0">
|
||||
<methods/>
|
||||
<lines>
|
||||
<line number="1" hits="1"/>
|
||||
<line number="2" hits="1"/>
|
||||
<line number="3" hits="1"/>
|
||||
<line number="4" hits="1"/>
|
||||
<line number="5" hits="1"/>
|
||||
<line number="6" hits="1"/>
|
||||
<line number="9" hits="1"/>
|
||||
<line number="10" hits="1"/>
|
||||
<line number="11" hits="0"/>
|
||||
<line number="13" hits="1"/>
|
||||
<line number="14" hits="0"/>
|
||||
<line number="16" hits="0"/>
|
||||
<line number="18" hits="0"/>
|
||||
<line number="20" hits="0"/>
|
||||
<line number="22" hits="1"/>
|
||||
<line number="23" hits="0"/>
|
||||
<line number="24" hits="0"/>
|
||||
<line number="25" hits="0"/>
|
||||
<line number="26" hits="0"/>
|
||||
<line number="28" hits="1"/>
|
||||
<line number="29" hits="0"/>
|
||||
<line number="30" hits="0"/>
|
||||
<line number="31" hits="0"/>
|
||||
<line number="32" hits="0"/>
|
||||
<line number="34" hits="1"/>
|
||||
<line number="35" hits="0"/>
|
||||
</lines>
|
||||
</class>
|
||||
</classes>
|
||||
</package>
|
||||
<package name="utils" line-rate="0.8857" branch-rate="0" complexity="0">
|
||||
<classes>
|
||||
<class name="csv_parser.py" filename="utils/csv_parser.py" complexity="0" line-rate="0.8857" branch-rate="0">
|
||||
<methods/>
|
||||
<lines>
|
||||
<line number="1" hits="1"/>
|
||||
<line number="2" hits="1"/>
|
||||
<line number="3" hits="1"/>
|
||||
<line number="4" hits="1"/>
|
||||
<line number="5" hits="1"/>
|
||||
<line number="7" hits="1"/>
|
||||
<line number="10" hits="1"/>
|
||||
<line number="16" hits="1"/>
|
||||
<line number="17" hits="1"/>
|
||||
<line number="18" hits="0"/>
|
||||
<line number="19" hits="0"/>
|
||||
<line number="21" hits="1"/>
|
||||
<line number="22" hits="1"/>
|
||||
<line number="24" hits="1"/>
|
||||
<line number="25" hits="0"/>
|
||||
<line number="27" hits="1"/>
|
||||
<line number="28" hits="1"/>
|
||||
<line number="29" hits="1"/>
|
||||
<line number="30" hits="0"/>
|
||||
<line number="31" hits="1"/>
|
||||
<line number="34" hits="1"/>
|
||||
<line number="35" hits="1"/>
|
||||
<line number="36" hits="1"/>
|
||||
<line number="37" hits="1"/>
|
||||
<line number="38" hits="1"/>
|
||||
<line number="39" hits="1"/>
|
||||
<line number="40" hits="1"/>
|
||||
<line number="41" hits="1"/>
|
||||
<line number="42" hits="1"/>
|
||||
<line number="43" hits="1"/>
|
||||
<line number="44" hits="1"/>
|
||||
<line number="45" hits="1"/>
|
||||
<line number="46" hits="1"/>
|
||||
<line number="47" hits="1"/>
|
||||
<line number="50" hits="1"/>
|
||||
<line number="51" hits="1"/>
|
||||
<line number="52" hits="1"/>
|
||||
<line number="54" hits="1"/>
|
||||
<line number="55" hits="1"/>
|
||||
<line number="56" hits="1"/>
|
||||
<line number="57" hits="1"/>
|
||||
<line number="58" hits="1"/>
|
||||
<line number="59" hits="1"/>
|
||||
<line number="62" hits="1"/>
|
||||
<line number="67" hits="1"/>
|
||||
<line number="72" hits="1"/>
|
||||
<line number="78" hits="1"/>
|
||||
<line number="79" hits="0"/>
|
||||
<line number="84" hits="1"/>
|
||||
<line number="85" hits="0"/>
|
||||
<line number="87" hits="0"/>
|
||||
<line number="89" hits="1"/>
|
||||
<line number="92" hits="1"/>
|
||||
<line number="95" hits="1"/>
|
||||
<line number="96" hits="1"/>
|
||||
<line number="97" hits="1"/>
|
||||
<line number="98" hits="1"/>
|
||||
<line number="103" hits="1"/>
|
||||
<line number="104" hits="1"/>
|
||||
<line number="105" hits="1"/>
|
||||
<line number="106" hits="1"/>
|
||||
<line number="107" hits="1"/>
|
||||
<line number="108" hits="1"/>
|
||||
<line number="109" hits="1"/>
|
||||
<line number="111" hits="1"/>
|
||||
<line number="112" hits="1"/>
|
||||
<line number="116" hits="1"/>
|
||||
<line number="128" hits="1"/>
|
||||
<line number="129" hits="0"/>
|
||||
<line number="131" hits="1"/>
|
||||
</lines>
|
||||
</class>
|
||||
</classes>
|
||||
</package>
|
||||
</packages>
|
||||
</coverage>
|
||||
4
docs/assets/Overview.svg
Normal file
4
docs/assets/Overview.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 1.8 MiB |
0
docs/assets/overview_diagram.svg
Normal file
0
docs/assets/overview_diagram.svg
Normal file
@@ -27,4 +27,5 @@ asyncio_mode = "auto"
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"ruff>=0.15.20",
|
||||
"pytest-cov>=6.0.0",
|
||||
]
|
||||
|
||||
32
tests/api/test_categories_api.py
Normal file
32
tests/api/test_categories_api.py
Normal file
@@ -0,0 +1,32 @@
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
async def test_get_categories_seeded_defaults(client: AsyncClient):
|
||||
# Retrieve categories and verify the default list is present (since lifespan seeds them)
|
||||
response = await client.get("/api/v1/categories")
|
||||
assert response.status_code == 200
|
||||
categories = response.json()
|
||||
assert len(categories) >= 8
|
||||
|
||||
names = [c["name"] for c in categories]
|
||||
assert "Food" in names
|
||||
assert "Shopping" in names
|
||||
assert "Travel" in names
|
||||
assert "Other" in names
|
||||
|
||||
|
||||
async def test_create_and_duplicate_category(client: AsyncClient):
|
||||
# 1. Create a new category
|
||||
response = await client.post("/api/v1/categories", json={"name": "Medical"})
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert "id" in data
|
||||
assert data["name"] == "Medical"
|
||||
|
||||
# 2. Try creating the same category (duplicate, case-insensitive check)
|
||||
dup_response = await client.post("/api/v1/categories", json={"name": "medical"})
|
||||
assert dup_response.status_code == 400
|
||||
assert "already exists" in dup_response.json()["detail"]
|
||||
@@ -46,6 +46,24 @@ def initialize_test_db():
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
# Seed categories in test DB
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.db.models.category import Category
|
||||
|
||||
async with AsyncSession(test_engine) as session:
|
||||
defaults = [
|
||||
Category(name="Food"),
|
||||
Category(name="Shopping"),
|
||||
Category(name="Travel"),
|
||||
Category(name="Transport"),
|
||||
Category(name="Utilities"),
|
||||
Category(name="Cash Withdrawal"),
|
||||
Category(name="Entertainment"),
|
||||
Category(name="Other"),
|
||||
]
|
||||
session.add_all(defaults)
|
||||
await session.commit()
|
||||
|
||||
async def teardown_db():
|
||||
async with test_engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
|
||||
53
tests/services/test_benchmarker.py
Normal file
53
tests/services/test_benchmarker.py
Normal file
@@ -0,0 +1,53 @@
|
||||
import pytest
|
||||
import asyncio
|
||||
from app.core.observability import benchmark, time_it
|
||||
|
||||
|
||||
def test_benchmark_context_manager(caplog):
|
||||
import logging
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
with benchmark("Test Task"):
|
||||
# simulate work
|
||||
pass
|
||||
|
||||
assert any(
|
||||
"[BENCHMARK] Test Task completed in" in record.message
|
||||
for record in caplog.records
|
||||
)
|
||||
|
||||
|
||||
def test_time_it_decorator_sync(caplog):
|
||||
import logging
|
||||
|
||||
@time_it("Sync Helper")
|
||||
def my_sync_fn(x, y):
|
||||
return x + y
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
res = my_sync_fn(10, 20)
|
||||
|
||||
assert res == 30
|
||||
assert any(
|
||||
"[BENCHMARK] Sync Helper completed in" in record.message
|
||||
for record in caplog.records
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_time_it_decorator_async(caplog):
|
||||
import logging
|
||||
|
||||
@time_it("Async Helper")
|
||||
async def my_async_fn():
|
||||
await asyncio.sleep(0.01)
|
||||
return "done"
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
res = await my_async_fn()
|
||||
|
||||
assert res == "done"
|
||||
assert any(
|
||||
"[BENCHMARK] Async Helper completed in" in record.message
|
||||
for record in caplog.records
|
||||
)
|
||||
@@ -18,7 +18,9 @@ async def test_classify_transactions_batch_retry_success():
|
||||
{"txn_id": "TX2", "merchant": "Amazon", "amount": 1200.0},
|
||||
]
|
||||
|
||||
results = await classify_transactions_batch(mock_client, batch_txns)
|
||||
results = await classify_transactions_batch(
|
||||
mock_client, batch_txns, ["Food", "Shopping", "Other"]
|
||||
)
|
||||
assert len(results) == 2
|
||||
assert results["TX1"]["category"] == "Food"
|
||||
assert results["TX1"]["failed"] is False
|
||||
@@ -37,7 +39,9 @@ async def test_classify_transactions_batch_retry_failure():
|
||||
|
||||
# Patch sleep to make retry test run instantly
|
||||
with patch("asyncio.sleep", return_value=None):
|
||||
results = await classify_transactions_batch(mock_client, batch_txns)
|
||||
results = await classify_transactions_batch(
|
||||
mock_client, batch_txns, ["Food", "Shopping", "Other"]
|
||||
)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results["TX1"]["category"] == "Other"
|
||||
|
||||
77
tests/services/test_llm_summary.py
Normal file
77
tests/services/test_llm_summary.py
Normal file
@@ -0,0 +1,77 @@
|
||||
import pytest
|
||||
from app.worker import generate_job_summary_llm, clean_json_response
|
||||
from app.db.models.transaction import Transaction
|
||||
from google import genai
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
def test_clean_json_response_markdown():
|
||||
# Test stripping markdown code fences
|
||||
dirty_json = '```json\n{"key": "value"}\n```'
|
||||
assert clean_json_response(dirty_json) == '{"key": "value"}'
|
||||
|
||||
# Test stripping markdown code fences with conversational prefix
|
||||
dirty_conversational = (
|
||||
'Here is the data:\n```\n{"key": "value"}\n```\nHope it helps.'
|
||||
)
|
||||
assert clean_json_response(dirty_conversational) == '{"key": "value"}'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_job_summary_llm_success():
|
||||
# 1. Setup mock transactions
|
||||
t1 = Transaction(
|
||||
merchant="Zomato",
|
||||
amount=100.0,
|
||||
currency="USD",
|
||||
category="Food",
|
||||
is_anomaly=False,
|
||||
)
|
||||
t2 = Transaction(
|
||||
merchant="Ola",
|
||||
amount=2500.0,
|
||||
currency="INR",
|
||||
category="Transport",
|
||||
is_anomaly=True,
|
||||
anomaly_reason="Outlier",
|
||||
)
|
||||
tx_list = [t1, t2]
|
||||
|
||||
# 2. Mock client and return json
|
||||
mock_client = MagicMock(spec=genai.Client)
|
||||
mock_interaction = MagicMock()
|
||||
mock_interaction.output_text = """
|
||||
```json
|
||||
{
|
||||
"total_spend_by_currency": {"USD": 100.0, "INR": 2500.0},
|
||||
"top_3_merchants": {"Ola": 30.12, "Zomato": 100.0},
|
||||
"anomaly_count": 1,
|
||||
"spending_narrative": "Cohesive two sentence summary of spendings. Mostly food and transit.",
|
||||
"risk_level": "Medium"
|
||||
}
|
||||
```
|
||||
"""
|
||||
mock_client.interactions.create.return_value = mock_interaction
|
||||
|
||||
# 3. Call and assert
|
||||
result = await generate_job_summary_llm(mock_client, tx_list)
|
||||
assert result is not None
|
||||
assert result.anomaly_count == 1
|
||||
assert result.risk_level == "Medium"
|
||||
assert result.total_spend_by_currency["USD"] == 100.0
|
||||
assert result.total_spend_by_currency["INR"] == 2500.0
|
||||
assert "Ola" in result.top_3_merchants
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_job_summary_llm_retry_failure():
|
||||
mock_client = MagicMock(spec=genai.Client)
|
||||
mock_client.interactions.create.side_effect = Exception("API Key Expired")
|
||||
|
||||
tx_list = [Transaction(merchant="Test", amount=1.0, currency="USD")]
|
||||
|
||||
# Patch sleep to execute instantly
|
||||
with patch("asyncio.sleep", return_value=None):
|
||||
result = await generate_job_summary_llm(mock_client, tx_list)
|
||||
|
||||
assert result is None
|
||||
96
transactions.csv
Normal file
96
transactions.csv
Normal file
@@ -0,0 +1,96 @@
|
||||
txn_id,date,merchant,amount,currency,status,category,account_id,notes
|
||||
TXN1065,04-09-2024,Flipkart,10882.55,INR,SUCCESS,Shopping,ACC003,Refund expected
|
||||
TXN1054,2024/02/05,Swiggy,$11325.79,INR,success,Food,ACC004,
|
||||
TXN1021,17-02-2024,Zomato,2536.35,USD,SUCCESS,Food,ACC001,Verified
|
||||
TXN1045,07-05-2024,Amazon,6874.1,INR,failed,Shopping,ACC004,SUSPICIOUS
|
||||
TXN1076,14-08-2024,Flipkart,2763.26,INR,SUCCESS,Shopping,ACC004,Duplicate?
|
||||
TXN1029,19-05-2024,Flipkart,9092.21,INR,FAILED,Shopping,ACC003,
|
||||
TXN1004,04-07-2024,Jio Recharge,2924.71,INR,SUCCESS,Utilities,ACC001,Verified
|
||||
TXN1002,2024/01/04,HDFC ATM,10487.18,INR,FAILED,Cash Withdrawal,ACC004,
|
||||
TXN1000,23-11-2024,Dentist Appointment Fee,423.91,INR,failed,,ACC004,
|
||||
TXN1073,16-05-2024,HDFC ATM,1117.58,INR,failed,Cash Withdrawal,ACC004,
|
||||
TXN1019,26-11-2024,Amazon,956.16,INR,success,Shopping,ACC004,Refund expected
|
||||
TXN1006,24-03-2024,IRCTC,5722.86,INR,FAILED,Travel,ACC001,Refund expected
|
||||
TXN1014,2024/09/05,Amazon,14670.87,INR,failed,Shopping,ACC005,
|
||||
TXN1009,11-03-2024,MakeMyTrip,7428.06,USD,success,Travel,ACC004,SUSPICIOUS
|
||||
TXN1066,2024/06/03,Swiggy,10634.88,INR,FAILED,Food,ACC002,
|
||||
TXN1071,09-10-2024,Amazon,2390.16,INR,SUCCESS,Shopping,ACC003,
|
||||
TXN1044,16-03-2024,Swiggy,13395.26,INR,success,Food,ACC005,Verified
|
||||
TXN1047,2024/09/23,IRCTC,9419.89,INR,FAILED,Travel,ACC005,
|
||||
TXN1068,03-02-2024,Ola,7401.61,inr,failed,Transport,ACC001,Refund expected
|
||||
TXN1077,23-02-2024,HDFC ATM,4980.56,INR,failed,,ACC001,Duplicate?
|
||||
TXN1060,22-05-2024,IRCTC,3695.76,INR,SUCCESS,Travel,ACC003,Refund expected
|
||||
TXN1009,11-03-2024,MakeMyTrip,7428.06,USD,success,Travel,ACC004,SUSPICIOUS
|
||||
TXN1033,13-10-2024,Ola,14608.86,inr,success,Transport,ACC002,Verified
|
||||
TXN1059,29-11-2024,Flipkart,9257.89,INR,failed,Shopping,ACC002,
|
||||
TXN1034,19-06-2024,Ola,12043.42,inr,SUCCESS,Transport,ACC001,
|
||||
TXN1062,27-07-2024,IRCTC,8349.88,INR,PENDING,Travel,ACC001,Duplicate?
|
||||
TXN1042,20-09-2024,Jio Recharge,5061.06,INR,success,Utilities,ACC002,
|
||||
TXN1023,2024/10/23,MakeMyTrip,961.32,USD,failed,Travel,ACC005,
|
||||
TXN1011,16-05-2024,BookMyShow,1717.7,INR,FAILED,Entertainment,ACC002,SUSPICIOUS
|
||||
,25-06-2024,Jio Recharge,4004.59,INR,PENDING,,ACC003,
|
||||
TXN1020,22-05-2024,IRCTC,3784.61,INR,failed,,ACC005,
|
||||
TXN1064,20-08-2024,Flipkart,4634.01,INR,failed,Shopping,ACC002,Refund expected
|
||||
TXN1025,02-08-2024,Jio Recharge,8500.14,INR,FAILED,Utilities,ACC004,Refund expected
|
||||
TXN1069,01-08-2024,Jio Recharge,8962.1,INR,PENDING,Utilities,ACC005,Verified
|
||||
TXN1024,05-02-2024,Jio Recharge,1066.01,INR,failed,Utilities,ACC005,
|
||||
TXN1005,21-11-2024,BookMyShow,2481.68,INR,SUCCESS,Entertainment,ACC002,SUSPICIOUS
|
||||
TXN1018,30-08-2024,Ola,2896.63,inr,PENDING,Transport,ACC001,
|
||||
TXN1032,06-03-2024,Swiggy,4658.46,INR,FAILED,Food,ACC002,
|
||||
TXN1028,30-11-2024,MakeMyTrip,166.96,USD,success,Travel,ACC001,
|
||||
TXN1070,13-11-2024,Swiggy,9163.98,INR,success,Food,ACC001,Refund expected
|
||||
TXN1043,31-08-2024,Jio Recharge,12753.58,INR,success,Utilities,ACC004,Verified
|
||||
TXN1079,12-09-2024,IRCTC,11411.86,INR,PENDING,Travel,ACC004,
|
||||
TXN1008,25-11-2024,IRCTC,2185.93,INR,failed,Travel,ACC005,Verified
|
||||
TXN1035,12-04-2024,IRCTC,5277.39,INR,success,Travel,ACC003,
|
||||
TXN1072,06-02-2024,Zomato,13862.47,USD,success,Food,ACC005,Refund expected
|
||||
TXN1039,15-11-2024,BookMyShow,9967.64,INR,PENDING,Entertainment,ACC003,SUSPICIOUS
|
||||
TXN1079,12-09-2024,IRCTC,11411.86,INR,PENDING,Travel,ACC004,
|
||||
TXN1038,07-04-2024,Flipkart,713.58,INR,success,Shopping,ACC001,
|
||||
TXN1026,2024/06/02,IRCTC,4776.85,INR,failed,Travel,ACC001,SUSPICIOUS
|
||||
TXN1040,03-08-2024,Ola,10175.9,inr,failed,Transport,ACC003,Duplicate?
|
||||
TXN1058,2024/06/23,Swiggy,12751.16,INR,PENDING,Food,ACC004,Refund expected
|
||||
,2024/04/21,Zomato,7605.06,USD,failed,Food,ACC004,SUSPICIOUS
|
||||
TXN1022,20-08-2024,Flipkart,6373.96,INR,PENDING,Shopping,ACC003,Refund expected
|
||||
TXN1075,16-02-2024,Zomato,14430.57,USD,FAILED,Food,ACC002,Verified
|
||||
TXN1012,2024/11/16,Flipkart,12632.96,INR,success,Shopping,ACC005,
|
||||
TXN1074,04-06-2024,Ola,14143.01,inr,SUCCESS,Transport,ACC003,SUSPICIOUS
|
||||
TXN1063,20-12-2024,Zomato,4627.78,USD,PENDING,Food,ACC005,Duplicate?
|
||||
TXN1013,07-09-2024,Swiggy,1722.42,INR,success,,ACC005,
|
||||
TXN1057,03-06-2024,Jio Recharge,$12092.64,INR,failed,Utilities,ACC003,
|
||||
TXN1010,14-07-2024,Jio Recharge,14942.01,INR,SUCCESS,Utilities,ACC001,
|
||||
TXN1046,15-09-2024,Ola,12467.01,inr,PENDING,Transport,ACC002,
|
||||
TXN1017,17-01-2024,BookMyShow,1109.32,INR,success,Entertainment,ACC005,SUSPICIOUS
|
||||
TXN1050,02-08-2024,MakeMyTrip,11225.36,USD,success,Travel,ACC004,
|
||||
TXN1078,2024/02/29,Ola,12980.41,inr,PENDING,Transport,ACC002,SUSPICIOUS
|
||||
TXN1041,23-10-2024,Jio Recharge,9837.85,INR,PENDING,Utilities,ACC002,
|
||||
TXN1035,12-04-2024,IRCTC,5277.39,INR,success,Travel,ACC003,
|
||||
TXN1037,15-05-2024,Swiggy,1670.62,INR,failed,Food,ACC001,Verified
|
||||
TXN2003,2024-07-15,IRCTC,193647.29,INR,SUCCESS,,ACC002,
|
||||
TXN1048,2024/04/28,Ola,10424.55,inr,failed,Transport,ACC001,Refund expected
|
||||
TXN1016,25-04-2024,Amazon,5104.38,INR,SUCCESS,Shopping,ACC001,Refund expected
|
||||
TXN1053,13-07-2024,Flipkart,11292.55,INR,SUCCESS,Shopping,ACC001,Duplicate?
|
||||
TXN1033,13-10-2024,Ola,14608.86,inr,success,Transport,ACC002,Verified
|
||||
TXN1031,14-10-2024,Swiggy,1722.51,INR,SUCCESS,Food,ACC005,SUSPICIOUS
|
||||
TXN1000_DUP,23-11-2024,Amazon,423.91,INR,failed,,ACC004,
|
||||
TXN2002,2024-07-15,Ola,91185.1,INR,SUCCESS,,ACC001,
|
||||
TXN1055,2024/04/21,IRCTC,10507.0,INR,SUCCESS,Travel,ACC002,Duplicate?
|
||||
TXN1036,22-05-2024,BookMyShow,9640.15,INR,SUCCESS,Entertainment,ACC003,Refund expected
|
||||
TXN2001,2024-07-15,Flipkart,146100.68,INR,SUCCESS,,ACC005,
|
||||
,20-11-2024,Ola,12448.75,inr,FAILED,Transport,ACC004,SUSPICIOUS
|
||||
TXN1007,10-06-2024,Ola,4052.73,inr,FAILED,Transport,ACC004,Verified
|
||||
TXN1015,18-04-2024,MakeMyTrip,11341.21,USD,FAILED,Travel,ACC005,Verified
|
||||
TXN2004,2024-07-15,IRCTC,191918.37,INR,SUCCESS,,ACC003,
|
||||
TXN1067,08-09-2024,Amazon,13097.16,INR,success,Shopping,ACC003,
|
||||
TXN1020,22-05-2024,IRCTC,3784.61,INR,failed,,ACC005,
|
||||
TXN1042,20-09-2024,Jio Recharge,5061.06,INR,success,Utilities,ACC002,
|
||||
TXN1016,25-04-2024,Amazon,5104.38,INR,SUCCESS,Shopping,ACC001,Refund expected
|
||||
TXN1061,30-06-2024,Flipkart,5138.07,INR,SUCCESS,Shopping,ACC002,
|
||||
,18-07-2024,Jio Recharge,14193.63,INR,PENDING,,ACC003,Duplicate?
|
||||
TXN1027,15-05-2024,HDFC ATM,14002.23,INR,success,Cash Withdrawal,ACC005,
|
||||
TXN1051,08-12-2024,Ola,10876.51,inr,failed,,ACC004,SUSPICIOUS
|
||||
TXN1052,14-01-2024,Amazon,9659.11,INR,PENDING,Shopping,ACC004,Duplicate?
|
||||
TXN1044,16-03-2024,Swiggy,13395.26,INR,success,Food,ACC005,Verified
|
||||
TXN1029,19-05-2024,Flipkart,9092.21,INR,FAILED,Shopping,ACC003,
|
||||
TXN2000,2024-07-15,Jio Recharge,175917.65,INR,SUCCESS,,ACC002,
|
||||
TXN1056,14-01-2024,Flipkart,8658.16,INR,success,,ACC002,
|
||||
|
59
uv.lock
generated
59
uv.lock
generated
@@ -24,6 +24,7 @@ dependencies = [
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
@@ -45,7 +46,10 @@ requires-dist = [
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [{ name = "ruff", specifier = ">=0.15.20" }]
|
||||
dev = [
|
||||
{ name = "pytest-cov", specifier = ">=6.0.0" },
|
||||
{ name = "ruff", specifier = ">=0.15.20" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "amqp"
|
||||
@@ -283,6 +287,45 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cc/8b/adeb62ea8951f13c4c7fef2e7a85e1a06b499c8d8237ea589d496029e53f/coverage-7.15.0.tar.gz", hash = "sha256:9ac3fe7a1435986463eaa8ee253ae2f2a268709ba4ae5c7dd1f52a05391ad78f", size = 925362, upload-time = "2026-07-02T13:10:50.535Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/9c/1e3ca54f72a3185ece06c58d871099898c48f0ed6430d17b6ab75f0d180a/coverage-7.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:41cb79af843222e11da87127ad0ecbfa878abadd0f770a4a99391a27d3887324", size = 220906, upload-time = "2026-07-02T13:09:51.339Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/37/f718613d83b274880382f6b67e78f3802549ae39b0b3e65ae5b5974df56e/coverage-7.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7d2008989ef8fe54188d3f3bfa2e3099b025af11e90a6a1b9e7dc433d04263d8", size = 221239, upload-time = "2026-07-02T13:09:53.138Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/ce/22bae91e0b75445f68d365c7643ed0aa4880bbf77450ee74ca65bdae53a7/coverage-7.15.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:769e8ece11a596315ebf5aa7ec383aeeed016c091d2bf6363ffb996d41529092", size = 252286, upload-time = "2026-07-02T13:09:54.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/1e/bec5e32aa508615d9d7a2790effb25fb4dc28606e995816afe400b25ece3/coverage-7.15.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:65a6b6164ee5c39e2f3803f314292d6c61a607ba7fee253d1e03c42dc3903502", size = 254789, upload-time = "2026-07-02T13:09:56.678Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/29/0e865435b4354e4a7c03b1b7920046d31d0a273d55decefea27e011cb9bf/coverage-7.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75128817f95a5c45bb01d65fd2d8b9cb54bbe03d81608fb70e3e14b437ad56c2", size = 256135, upload-time = "2026-07-02T13:09:58.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/ff/33a870b58a13325d62fc0a6c8f01fa0ff667cef60c7498e2382a147dfa18/coverage-7.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9887bb428fe2d4cd4bee89bac1a6c9932f484afd5b36fbd4ff6ea5f825bb1f5e", size = 258449, upload-time = "2026-07-02T13:10:00.057Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/7b/6fffe596bf3ddba8462758d02c5dad730fd91055a6634aa2e4226229181a/coverage-7.15.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0bfc0be1f702042207a93a00523b1065ee1fe951e96edf311581c0bbc2e34888", size = 252313, upload-time = "2026-07-02T13:10:01.946Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/1b/11468dd6c1676ab831a70cb9a8d4e198e8607fa0b7220ab918b73fe9bfbd/coverage-7.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f64627d55def5a43282d70e08396672692f77e4da610a5bb8bb4060b432b6859", size = 254142, upload-time = "2026-07-02T13:10:04.065Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/41/29328e21d16b1b95092c30dd700e08cf915bd3734f836df8f3bdb0e8fa9f/coverage-7.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:2c6f0fa473003905c6d5bac328ee4eba9fbea654f15bc24b8a3274b23363fa99", size = 252108, upload-time = "2026-07-02T13:10:06.11Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/de/05ccfb990439655b35afbfd8e0d13fe66677565a7d4eb38c3f5ef2635e1c/coverage-7.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2bcf9afaf064172c6ec3c58a325a9957ad1178c05dd934e25f253321776e0676", size = 256385, upload-time = "2026-07-02T13:10:08.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/0e/486828a3d2695ea7a2609f17ff572f6b01905e608379440a11da4b8dffbe/coverage-7.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:baf06bc987115d6fb938d403f7eab684a057766c490367999a2b71a6883110c6", size = 251923, upload-time = "2026-07-02T13:10:10.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/c7/03582b6715f078e5e558354c87616d945b9894cda2dace8e4009b17035e4/coverage-7.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f0405f2ff97b1c4c0e782cb32e02f32369bcf2e6b618b591d67e1ea754575dfe", size = 253580, upload-time = "2026-07-02T13:10:12.052Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/dc/9e578bbaf2ecb4959a81b7e7601ad8cca772cba2892e8d144cb749b4a71a/coverage-7.15.0-cp314-cp314-win32.whl", hash = "sha256:ab282853ed5fbd64bbb162f19cb8fcb7087187508a6374b4f9c34ec1577c4e8f", size = 223107, upload-time = "2026-07-02T13:10:13.994Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/3e/c8c3b75d8dbe0e35f7b0cc3ff5e949fc59500f70b21d0398813f66740664/coverage-7.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bb3040e9f4bbe26fcb0cd7cc85ac63e630d3f3a9c74f027abf4caa27e706663", size = 223597, upload-time = "2026-07-02T13:10:15.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/bc/3cbc9fb036eb388519bccd521f783499c39b64256013fbc362782f196fe1/coverage-7.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:346771144d34f7fa84ec28386f78e0f31653f33cf35e19d253d5b35f9e8201da", size = 223020, upload-time = "2026-07-02T13:10:17.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/00/199c4a8d656dff63102577a056c0fce2ff6a79e40adac092fc986c49cbf1/coverage-7.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d34a010905fb6401324ba016b5da03d574967f7b21ce48ea41e66f0f1f95f641", size = 221638, upload-time = "2026-07-02T13:10:19.703Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/8e/9d0092c96a3d3a26951ecc7020826aa57bcb1b119ca81acbba996884ab13/coverage-7.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bb25d825d885ca8036795dacfc3924d33091fc76d71ebc99420c6b79e77d96fa", size = 221903, upload-time = "2026-07-02T13:10:21.514Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/b4/c0ca3028f42c9a08e51feb4561ef1192e5de99797cd1db5b04590c215bda/coverage-7.15.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:94c9686bfe8a9a6810297aecbd99beaa3445f9e8dc2f80b1382cca0d86b64461", size = 263267, upload-time = "2026-07-02T13:10:23.261Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/aa/a375e3846e5d3c013dc600b2a3231089055c73d77f5393dd2192a8d64da6/coverage-7.15.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9bd671c25f9d85f09d7ec481d0e43d5139f486c06a37139847a7ce569788af72", size = 265390, upload-time = "2026-07-02T13:10:25.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/e1/5783cdabb797305e1c9e4809fea496d31834c51fa772514f73dc148bcfc9/coverage-7.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:110cbdf8d2e216577312cf06ccf85539c0e5a5420ef747e4a4719b5e483c88cd", size = 267811, upload-time = "2026-07-02T13:10:27.249Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/31/96d8bbf58b8e9193bc8389574a91a0db48355ee98feb66aa6bf8d1b32eea/coverage-7.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c5d4619214f1d9993e7b00a8600d14614b7e9d84e89507460b126aa5e6559e5", size = 268928, upload-time = "2026-07-02T13:10:29.242Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/7a/5294567e811a1cb7eda93140c628fa050d66189da28da320f93d1d815c73/coverage-7.15.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:781a704516e2d8346fbbd5be6c6f3412dd824785146528b3a01816f26c081007", size = 262378, upload-time = "2026-07-02T13:10:31.107Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/3f/3f48538421f899f28946f90a3d272136a4686e1abf461cc9249a783ee0f3/coverage-7.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd4a1b44bcb65ee29e947ac92bbee04956df3a6bfc6143641bb6cae7ede00fc9", size = 265263, upload-time = "2026-07-02T13:10:32.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/d3/092df15efcab8a9c1467ee960eb8019bbad3f9300d115d89ea6195f369ff/coverage-7.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0e4950c9d6d3e39c64c991814ff315e2d0b9cb8152363594212c9e55208c0a8f", size = 262866, upload-time = "2026-07-02T13:10:35.104Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/ab/0254d2b88665efb2c57ad368cc77ab5de3435bd8d5add4729c1b0e79431e/coverage-7.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:fe9c87ff42e5472d80d21704972e1f96e104a0a599d77c5e35db5a3c562e2571", size = 266599, upload-time = "2026-07-02T13:10:37.05Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/79/1cfa4023e489ce6fbc7be4a5d442dbc375edb4f4fda39a352cedb53263c2/coverage-7.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f00d5ae1dd2fe13fb8186e3e7d37bcbd8b25c0d764ff7d1b32cef9be058510a8", size = 261714, upload-time = "2026-07-02T13:10:38.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/eb/fee5c8665656be63f497418d410484637c438172568688e8ac92e06574e7/coverage-7.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:363ab38cc78b615f11c9cac3cf1d7eef950c18b9fdedfb9066f59461dcf84d68", size = 264025, upload-time = "2026-07-02T13:10:40.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/99/63005db722f91edc81abc16302f9cc2f6228c1679e46e15be9ae144b14d0/coverage-7.15.0-cp314-cp314t-win32.whl", hash = "sha256:54fd9c53a5fafff509195f1b6a3f9be615d8e8362a3629ff1de23d270c03c86b", size = 223413, upload-time = "2026-07-02T13:10:42.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/e8/2bc6181c4fb06f1a6b981eb85330cc57bfad7e3f710fc9c9d350013ba228/coverage-7.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:87b47553097ba185ed964866078e7e63adea9f5f51b5f39691c34f30afd21080", size = 224245, upload-time = "2026-07-02T13:10:44.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/b8/4d959bf9cc45d0cfed2f4d35cafcab978cdb6ea02eb5100009cd740632a3/coverage-7.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aeefb2dd178fe7eee79f0ad25d75855cb35ee9ed472db2c5ea06f5b4fd00cec5", size = 223558, upload-time = "2026-07-02T13:10:46.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/30/21b2ad45959cd50e909e02ebac1e30b4ceb7162e91c11d4c570223a458b7/coverage-7.15.0-py3-none-any.whl", hash = "sha256:56da6a4cbe8f7e9e80bd072ca9cefe67d7106a440a7ec06519ec6507ac94ad19", size = 212632, upload-time = "2026-07-02T13:10:48.641Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "49.0.0"
|
||||
@@ -673,6 +716,20 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-cov"
|
||||
version = "7.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "coverage" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dateutil"
|
||||
version = "2.9.0.post0"
|
||||
|
||||
Reference in New Issue
Block a user