mirror of
https://github.com/sortedcord/alemno-payments.git
synced 2026-07-22 04:02:49 +05:30
feat: Setup app lifecycle, job service and basic csv parser
This commit is contained in:
23
app/main.py
23
app/main.py
@@ -1,6 +1,21 @@
|
||||
def main():
|
||||
print("Hello from alemno-payments!")
|
||||
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.core.config import settings
|
||||
from app.db.base import Base
|
||||
from app.db.session import engine
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield
|
||||
# Shutdown: Clean up pool
|
||||
await engine.dispose()
|
||||
app = FastAPI(
|
||||
title=settings.PROJECT_NAME,
|
||||
openapi_url=f"{settings.API_V1_STR}/openapi.json",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
35
app/services/job_service.py
Normal file
35
app/services/job_service.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from typing import List, Optional
|
||||
from app.repositories.job_repository import JobRepository
|
||||
from app.core.exceptions import JobNotFoundException
|
||||
from app.db.models.job import Job
|
||||
from app.utils.csv_parser import parse_and_validate_csv
|
||||
from app.worker import process_transaction_job
|
||||
|
||||
|
||||
class JobService:
|
||||
def __init__(self, repo: JobRepository):
|
||||
self.repo = repo
|
||||
|
||||
async def upload_csv(self, filename: str, content: bytes) -> Job:
|
||||
parsed_transactions = parse_and_validate_csv(content)
|
||||
|
||||
job = await self.repo.create(filename=filename)
|
||||
|
||||
process_transaction_job.delay(job.id, parsed_transactions)
|
||||
|
||||
return job
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
async def list_jobs(self, status: Optional[str] = None) -> List[Job]:
|
||||
return await self.repo.list_all(status=status)
|
||||
70
app/utils/csv_parser.py
Normal file
70
app/utils/csv_parser.py
Normal file
@@ -0,0 +1,70 @@
|
||||
import csv
|
||||
import io
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List
|
||||
from app.core.exceptions import InvalidCSVException
|
||||
|
||||
REQUIRED_COLUMNS = {"transaction_id", "date", "amount", "category", "description"}
|
||||
|
||||
|
||||
def parse_and_validate_csv(content: bytes) -> List[Dict[str, Any]]:
|
||||
csv_file = io.StringIO(decoded)
|
||||
reader = csv.DictReader(csv_file)
|
||||
|
||||
# Map headers to predefined required cols
|
||||
headers = {h.strip().lower() for h in reader.fieldnames if h}
|
||||
header_mapping = {}
|
||||
for actual_header in reader.fieldnames:
|
||||
cleaned = actual_header.strip().lower()
|
||||
if cleaned in REQUIRED_COLUMNS:
|
||||
header_mapping[cleaned] = actual_header
|
||||
elif cleaned == "id" and "transaction_id" not in header_mapping:
|
||||
header_mapping["transaction_id"] = actual_header
|
||||
|
||||
missing = REQUIRED_COLUMNS - set(header_mapping.keys())
|
||||
if missing:
|
||||
raise InvalidCSVException(f"Missing required CSV columns: {', '.join(missing)}")
|
||||
|
||||
parsed_rows = []
|
||||
for line_num, row in enumerate(reader, start=2):
|
||||
t_id = row.get(header_mapping["transaction_id"])
|
||||
date_str = row.get(header_mapping["date"])
|
||||
amount_str = row.get(header_mapping["amount"])
|
||||
category = row.get(header_mapping["category"])
|
||||
description = row.get(header_mapping["description"])
|
||||
|
||||
if not all([t_id, date_str, amount_str, category, description]):
|
||||
raise InvalidCSVException(f"Row {line_num}: Empty values are not allowed in required fields")
|
||||
|
||||
try:
|
||||
amount = float(amount_str.strip())
|
||||
except ValueError as e:
|
||||
raise InvalidCSVException(f"Row {line_num}: Invalid amount '{amount_str}'. Must be a number.") from e
|
||||
|
||||
parsed_date = None
|
||||
for fmt in ("%Y-%m-%d", "%m/%d/%Y", "%d-%m-%Y", "%Y/%m/%d"):
|
||||
try:
|
||||
parsed_date = datetime.strptime(date_str.strip(), fmt).date()
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if not parsed_date:
|
||||
raise InvalidCSVException(
|
||||
f"Row {line_num}: Invalid date format '{date_str}'. Expected YYYY-MM-DD or MM/DD/YYYY"
|
||||
)
|
||||
|
||||
parsed_rows.append(
|
||||
{
|
||||
"transaction_id": t_id.strip(),
|
||||
"date": parsed_date.isoformat(),
|
||||
"amount": amount,
|
||||
"category": category.strip(),
|
||||
"description": description.strip(),
|
||||
}
|
||||
)
|
||||
|
||||
if not parsed_rows:
|
||||
raise InvalidCSVException("CSV file contains no data rows")
|
||||
|
||||
return parsed_rows
|
||||
Reference in New Issue
Block a user