feat: Initialize FastAPI application with Azure authentication and transaction management
- Added .env.example for environment variable configuration. - Created app initialization files and core settings management. - Implemented API routers for reporting and transaction endpoints. - Developed transaction management service with CRUD operations. - Integrated Azure OAuth for user authentication. - Designed dashboard view with transaction filtering and display. - Added Swagger UI documentation with custom dark theme. - Created static and template files for frontend styling and layout.
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
from app.api.reporting import router as reporting_router
|
||||
from app.api.transactions import router as transactions_router
|
||||
|
||||
__all__ = ["reporting_router", "transactions_router"]
|
||||
@@ -0,0 +1,11 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.service.models import ReconSummary
|
||||
from app.service.recon_service import get_summary
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["Reporting"])
|
||||
|
||||
|
||||
@router.get("/summary", response_model=ReconSummary, summary="Reconciliation summary")
|
||||
async def get_summary_endpoint() -> ReconSummary:
|
||||
return get_summary()
|
||||
@@ -0,0 +1,44 @@
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from app.service.models import Transaction
|
||||
from app.service.recon_service import (
|
||||
FlagType,
|
||||
StatusType,
|
||||
add_transaction,
|
||||
delete_transaction_by_id,
|
||||
get_transaction_by_id,
|
||||
list_transactions,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["Transactions"])
|
||||
|
||||
|
||||
@router.get("/transactions", response_model=list[Transaction], summary="List transactions")
|
||||
async def list_transactions_endpoint(
|
||||
status: StatusType | None = Query(None, description="Filter by status"),
|
||||
flag: FlagType | None = Query(None, description="Filter by flag"),
|
||||
) -> list[Transaction]:
|
||||
return list_transactions(status=status, flag=flag)
|
||||
|
||||
|
||||
@router.get("/transactions/{transaction_id}", response_model=Transaction, summary="Get a transaction")
|
||||
async def get_transaction_endpoint(transaction_id: str) -> Transaction:
|
||||
transaction = get_transaction_by_id(transaction_id)
|
||||
if transaction is None:
|
||||
raise HTTPException(status_code=404, detail=f"{transaction_id} not found")
|
||||
return transaction
|
||||
|
||||
|
||||
@router.post("/transactions", response_model=Transaction, status_code=201, summary="Submit a transaction")
|
||||
async def create_transaction_endpoint(transaction: Transaction) -> Transaction:
|
||||
created = add_transaction(transaction)
|
||||
if not created:
|
||||
raise HTTPException(status_code=409, detail=f"{transaction.transaction_id} already exists")
|
||||
return transaction
|
||||
|
||||
|
||||
@router.delete("/transactions/{transaction_id}", status_code=204, summary="Delete a transaction")
|
||||
async def delete_transaction_endpoint(transaction_id: str) -> None:
|
||||
removed = delete_transaction_by_id(transaction_id)
|
||||
if not removed:
|
||||
raise HTTPException(status_code=404, detail=f"{transaction_id} not found")
|
||||
@@ -0,0 +1,3 @@
|
||||
from app.core.app_factory import create_app
|
||||
|
||||
__all__ = ["create_app"]
|
||||
@@ -0,0 +1,40 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app.api.reporting import router as reporting_router
|
||||
from app.api.transactions import router as transactions_router
|
||||
from app.core.settings import get_settings
|
||||
from app.views.auth import router as auth_router
|
||||
from app.views.dashboard import router as dashboard_router
|
||||
from app.views.docs import router as docs_router
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
settings = get_settings()
|
||||
|
||||
app = FastAPI(
|
||||
title="Recon Ranger",
|
||||
description="Financial crime reconciliation API — patrol your data landscape for inconsistencies.",
|
||||
version="0.1.0",
|
||||
docs_url=None,
|
||||
)
|
||||
app.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=settings.session_secret_key,
|
||||
same_site="lax",
|
||||
https_only=False,
|
||||
)
|
||||
|
||||
project_root = Path(__file__).resolve().parents[2]
|
||||
static_dir = project_root / "data" / "static"
|
||||
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
||||
|
||||
app.include_router(transactions_router)
|
||||
app.include_router(reporting_router)
|
||||
app.include_router(auth_router)
|
||||
app.include_router(dashboard_router)
|
||||
app.include_router(docs_router)
|
||||
return app
|
||||
@@ -0,0 +1,22 @@
|
||||
from functools import lru_cache
|
||||
|
||||
from authlib.integrations.starlette_client import OAuth
|
||||
|
||||
from app.core.settings import get_settings
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_oauth() -> OAuth:
|
||||
settings = get_settings()
|
||||
oauth = OAuth()
|
||||
oauth.register(
|
||||
name="azure",
|
||||
client_id=settings.azure_client_id,
|
||||
client_secret=settings.azure_client_secret,
|
||||
server_metadata_url=(
|
||||
f"https://login.microsoftonline.com/{settings.azure_tenant_id}"
|
||||
"/v2.0/.well-known/openid-configuration"
|
||||
),
|
||||
client_kwargs={"scope": "openid profile email"},
|
||||
)
|
||||
return oauth
|
||||
@@ -0,0 +1,26 @@
|
||||
from functools import lru_cache
|
||||
from os import getenv
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
load_dotenv(PROJECT_ROOT / ".env")
|
||||
|
||||
|
||||
class Settings:
|
||||
def __init__(self) -> None:
|
||||
self.session_secret_key = getenv("SESSION_SECRET_KEY", "change-me-in-production")
|
||||
self.azure_tenant_id = getenv("AZURE_TENANT_ID")
|
||||
self.azure_client_id = getenv("AZURE_CLIENT_ID")
|
||||
self.azure_client_secret = getenv("AZURE_CLIENT_SECRET")
|
||||
|
||||
@property
|
||||
def azure_configured(self) -> bool:
|
||||
return bool(self.azure_tenant_id and self.azure_client_id and self.azure_client_secret)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import uvicorn
|
||||
|
||||
from app.core.app_factory import create_app
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
from datetime import date
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class Transaction(BaseModel):
|
||||
transaction_id: str
|
||||
date: date
|
||||
account: str
|
||||
amount: float
|
||||
status: Literal["Matched", "Unmatched", "Pending"]
|
||||
flag: Literal["None", "Duplicate", "Threshold Breach", "Manual Review"]
|
||||
|
||||
|
||||
class ReconSummary(BaseModel):
|
||||
total: int
|
||||
matched: int
|
||||
unmatched: int
|
||||
pending: int
|
||||
flagged: int
|
||||
@@ -0,0 +1,92 @@
|
||||
from datetime import date
|
||||
from typing import Literal
|
||||
|
||||
from app.service.models import ReconSummary, Transaction
|
||||
|
||||
StatusType = Literal["Matched", "Unmatched", "Pending"]
|
||||
FlagType = Literal["None", "Duplicate", "Threshold Breach", "Manual Review"]
|
||||
|
||||
|
||||
TRANSACTIONS: list[Transaction] = [
|
||||
Transaction(
|
||||
transaction_id="TXN-001",
|
||||
date=date(2026, 4, 10),
|
||||
account="ACC-9821",
|
||||
amount=12400.00,
|
||||
status="Matched",
|
||||
flag="None",
|
||||
),
|
||||
Transaction(
|
||||
transaction_id="TXN-002",
|
||||
date=date(2026, 4, 11),
|
||||
account="ACC-4473",
|
||||
amount=3750.50,
|
||||
status="Unmatched",
|
||||
flag="Duplicate",
|
||||
),
|
||||
Transaction(
|
||||
transaction_id="TXN-003",
|
||||
date=date(2026, 4, 11),
|
||||
account="ACC-1190",
|
||||
amount=88200.00,
|
||||
status="Unmatched",
|
||||
flag="Threshold Breach",
|
||||
),
|
||||
Transaction(
|
||||
transaction_id="TXN-004",
|
||||
date=date(2026, 4, 12),
|
||||
account="ACC-6654",
|
||||
amount=540.00,
|
||||
status="Matched",
|
||||
flag="None",
|
||||
),
|
||||
Transaction(
|
||||
transaction_id="TXN-005",
|
||||
date=date(2026, 4, 13),
|
||||
account="ACC-3312",
|
||||
amount=21000.00,
|
||||
status="Pending",
|
||||
flag="Manual Review",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def list_transactions(status: StatusType | None = None, flag: FlagType | None = None) -> list[Transaction]:
|
||||
results = TRANSACTIONS
|
||||
if status:
|
||||
results = [item for item in results if item.status == status]
|
||||
if flag:
|
||||
results = [item for item in results if item.flag == flag]
|
||||
return results
|
||||
|
||||
|
||||
def get_transaction_by_id(transaction_id: str) -> Transaction | None:
|
||||
for item in TRANSACTIONS:
|
||||
if item.transaction_id == transaction_id:
|
||||
return item
|
||||
return None
|
||||
|
||||
|
||||
def add_transaction(transaction: Transaction) -> bool:
|
||||
if get_transaction_by_id(transaction.transaction_id) is not None:
|
||||
return False
|
||||
TRANSACTIONS.append(transaction)
|
||||
return True
|
||||
|
||||
|
||||
def delete_transaction_by_id(transaction_id: str) -> bool:
|
||||
for index, item in enumerate(TRANSACTIONS):
|
||||
if item.transaction_id == transaction_id:
|
||||
TRANSACTIONS.pop(index)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_summary() -> ReconSummary:
|
||||
return ReconSummary(
|
||||
total=len(TRANSACTIONS),
|
||||
matched=sum(1 for item in TRANSACTIONS if item.status == "Matched"),
|
||||
unmatched=sum(1 for item in TRANSACTIONS if item.status == "Unmatched"),
|
||||
pending=sum(1 for item in TRANSACTIONS if item.status == "Pending"),
|
||||
flagged=sum(1 for item in TRANSACTIONS if item.flag != "None"),
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
from app.views.auth import router as auth_router
|
||||
from app.views.dashboard import router as dashboard_router
|
||||
from app.views.docs import router as docs_router
|
||||
|
||||
__all__ = ["auth_router", "dashboard_router", "docs_router"]
|
||||
@@ -0,0 +1,46 @@
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from app.core.auth import get_oauth
|
||||
from app.core.settings import get_settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/login", include_in_schema=False)
|
||||
async def login(request: Request):
|
||||
settings = get_settings()
|
||||
if not settings.azure_configured:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=(
|
||||
"Azure Entra ID auth is not configured. Set AZURE_TENANT_ID, "
|
||||
"AZURE_CLIENT_ID, and AZURE_CLIENT_SECRET."
|
||||
),
|
||||
)
|
||||
|
||||
oauth = get_oauth()
|
||||
redirect_uri = request.url_for("auth_callback")
|
||||
return await oauth.azure.authorize_redirect(request, redirect_uri)
|
||||
|
||||
|
||||
@router.get("/auth/callback", include_in_schema=False, name="auth_callback")
|
||||
async def auth_callback(request: Request):
|
||||
oauth = get_oauth()
|
||||
token = await oauth.azure.authorize_access_token(request)
|
||||
userinfo = token.get("userinfo")
|
||||
if not userinfo:
|
||||
userinfo = await oauth.azure.parse_id_token(request, token)
|
||||
|
||||
request.session["user"] = {
|
||||
"sub": userinfo.get("sub"),
|
||||
"name": userinfo.get("name") or userinfo.get("preferred_username"),
|
||||
"email": userinfo.get("email") or userinfo.get("preferred_username"),
|
||||
}
|
||||
return RedirectResponse(url="/", status_code=302)
|
||||
|
||||
|
||||
@router.get("/logout", include_in_schema=False)
|
||||
async def logout(request: Request):
|
||||
request.session.clear()
|
||||
return RedirectResponse(url="/", status_code=302)
|
||||
@@ -0,0 +1,55 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Query, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app.service.recon_service import list_transactions
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
project_root = Path(__file__).resolve().parents[2]
|
||||
templates = Jinja2Templates(directory=str(project_root / "data" / "templates"))
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def dashboard(
|
||||
request: Request,
|
||||
recon_job_name: str | None = Query(default=None),
|
||||
as_at_date: str | None = Query(default=None),
|
||||
) -> HTMLResponse:
|
||||
user = request.session.get("user")
|
||||
if not user:
|
||||
return RedirectResponse(url="/login", status_code=302)
|
||||
|
||||
transactions = list_transactions()
|
||||
|
||||
if as_at_date:
|
||||
transactions = [item for item in transactions if item.date.isoformat() == as_at_date]
|
||||
|
||||
results = [
|
||||
{
|
||||
"Transaction ID": item.transaction_id,
|
||||
"Date": item.date.isoformat(),
|
||||
"Account": item.account,
|
||||
"Amount": f"{item.amount:,.2f}",
|
||||
"Status": item.status,
|
||||
"Flag": item.flag,
|
||||
}
|
||||
for item in transactions
|
||||
]
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="dashboard.html",
|
||||
context={
|
||||
"request": request,
|
||||
"title": "Recon Ranger Dashboard",
|
||||
"results": results,
|
||||
"user": user,
|
||||
"recon_job_name": recon_job_name,
|
||||
"as_at_date": as_at_date,
|
||||
"prev_cursor": None,
|
||||
"next_cursor": None,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.openapi.docs import get_swagger_ui_html
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/api/docs", include_in_schema=False)
|
||||
async def swagger_ui_dark(request: Request) -> HTMLResponse:
|
||||
base = get_swagger_ui_html(
|
||||
openapi_url=request.app.openapi_url,
|
||||
title="Recon Ranger — API Docs",
|
||||
swagger_css_url="/static/api/swagger-ui.css",
|
||||
)
|
||||
html = base.body.decode()
|
||||
|
||||
html = html.replace(
|
||||
"</head>",
|
||||
(
|
||||
'<link rel="stylesheet" href="/static/css/styles.css">\n'
|
||||
'<link rel="stylesheet" href="/static/api/swagger-dark.css">\n'
|
||||
"<style>.swagger-ui .topbar { display: none; }</style>\n"
|
||||
"</head>"
|
||||
),
|
||||
)
|
||||
|
||||
nav_html = """
|
||||
<header>
|
||||
<nav>
|
||||
<div class=\"menu\">
|
||||
<div class=\"logo\"><a href=\"/\">Recon Ranger</a></div>
|
||||
<ul>
|
||||
<li><a href=\"/\">Home</a></li>
|
||||
<li><a href=\"/api/docs\">API</a></li>
|
||||
<li><a href=\"mailto:someone@example.com\">Contact</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
<div style=\"padding-top:70px; position:relative; z-index:1\">
|
||||
"""
|
||||
html = html.replace("<body>", "<body>" + nav_html)
|
||||
html = html.replace("</body>", "</div></body>")
|
||||
|
||||
return HTMLResponse(html)
|
||||
Reference in New Issue
Block a user