mirror of
https://github.com/sortedcord/alemno-payments.git
synced 2026-07-22 04:02:49 +05:30
Compare commits
6 Commits
9713a12591
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| a55eccf8e1 | |||
| 573d8d0d86 | |||
| 8100323af5 | |||
| 1902cd2d03 | |||
| 61d78af5c1 | |||
| c9ec297a5f |
119
README.md
119
README.md
@@ -1,4 +1,4 @@
|
||||
# Alemno Payments
|
||||
# Alemeno Payments
|
||||
|
||||
[](file:///home/sortedcord/Projects/alemno_payments/pyproject.toml)
|
||||
[](file:///home/sortedcord/Projects/alemno_payments/app/app.py)
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
An asynchronous transactional data cleaning, validation, and LLM powered categorization pipeline.
|
||||
|
||||
## 🚀 Features
|
||||
## 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.
|
||||
@@ -20,13 +20,17 @@ An asynchronous transactional data cleaning, validation, and LLM powered categor
|
||||
- Flags domestic transactions in USD currency.
|
||||
- Flags high value transactions and transactions with fraud indicative keywords.
|
||||
|
||||
## 🐳 Self Hosting & Deployment
|
||||
[](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.
|
||||
|
||||
### Option A: Docker Compose (Using Pre-built Images)
|
||||
|
||||
Create a `docker-compose.yml` file to self-host the entire stack:
|
||||
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"
|
||||
@@ -105,35 +109,86 @@ Launch the stack:
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Option B: Direct Pull (Manual Run)
|
||||
## Testing the API with curl
|
||||
|
||||
You can pull and run individual containers manually from GHCR:
|
||||
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. **Pull the images**:
|
||||
### 1. Upload CSV Transaction Data
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/sortedcord/alemno-payments/web:latest
|
||||
docker pull ghcr.io/sortedcord/alemno-payments/worker:latest
|
||||
```
|
||||
Submit a CSV file for asynchronous processing.
|
||||
|
||||
2. **Run Web (FastAPI API Server)**:
|
||||
```bash
|
||||
curl -X POST -F "file=@transactions.csv" http://localhost:3000/api/v1/jobs/upload
|
||||
```
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name alemno_web \
|
||||
-p 3000:3000 \
|
||||
-e POSTGRES_HOST=your-db-host \
|
||||
-e REDIS_HOST=your-redis-host \
|
||||
-e GEMINI_API_KEY=your-gemini-api-key \
|
||||
ghcr.io/sortedcord/alemno-payments/web:latest
|
||||
```
|
||||
_Response Example:_
|
||||
|
||||
3. **Run Worker (Celery Processing Worker)**:
|
||||
```bash
|
||||
docker run -d \
|
||||
--name alemno_worker \
|
||||
-e POSTGRES_HOST=your-db-host \
|
||||
-e REDIS_HOST=your-redis-host \
|
||||
-e GEMINI_API_KEY=your-gemini-api-key \
|
||||
ghcr.io/sortedcord/alemno-payments/worker:latest celery -A app.worker.celery_app worker --loglevel=info
|
||||
```
|
||||
```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
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
@@ -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.
|
||||
|
||||
168
app/worker.py
168
app/worker.py
@@ -15,6 +15,7 @@ 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()
|
||||
|
||||
@@ -53,6 +54,7 @@ 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]],
|
||||
@@ -160,6 +162,7 @@ class JobNarrativeSummary(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
@time_it("LLM Job Narrative Summary Request")
|
||||
async def generate_job_summary_llm(
|
||||
client: genai.Client,
|
||||
db_transactions: List[Transaction],
|
||||
@@ -253,22 +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]]):
|
||||
from app.db.session import engine
|
||||
|
||||
await engine.dispose()
|
||||
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
|
||||
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
|
||||
@@ -277,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
|
||||
@@ -354,14 +362,34 @@ async def _process_job_async(job_id: str, transactions: List[Dict[str, Any]]):
|
||||
)
|
||||
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, allowed_categories
|
||||
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}"
|
||||
@@ -579,7 +607,8 @@ 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)
|
||||
|
||||
# Generate narrative and risk summary using LLM if api_key is available
|
||||
llm_summary = None
|
||||
@@ -635,26 +664,27 @@ async def _process_job_async(job_id: str, transactions: List[Dict[str, Any]]):
|
||||
}
|
||||
|
||||
# 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:
|
||||
|
||||
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
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
|
||||
)
|
||||
Reference in New Issue
Block a user