diff --git a/app/main.py b/app/main.py index 5d6df3f..dca94d8 100644 --- a/app/main.py +++ b/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, +) diff --git a/app/services/job_service.py b/app/services/job_service.py new file mode 100644 index 0000000..e94d3a4 --- /dev/null +++ b/app/services/job_service.py @@ -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) diff --git a/app/utils/csv_parser.py b/app/utils/csv_parser.py new file mode 100644 index 0000000..a2fc75f --- /dev/null +++ b/app/utils/csv_parser.py @@ -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