Commit
·
b8fc47f
1
Parent(s):
2f6b940
deployment ready
Browse files- .gitignore +30 -0
- Dockerfile +16 -0
- app/__init__.py +1 -0
- app/config.py +11 -0
- app/db.py +27 -0
- app/main.py +39 -0
- app/routes/__init__.py +1 -0
- app/routes/budget.py +41 -0
- app/scheduler.py +134 -0
- app/services/__init__.py +1 -0
- app/services/overspend.py +36 -0
- app/utils/__init__.py +1 -0
- app/utils/ai_rephrase.py +2 -0
- requirements.txt +6 -0
.gitignore
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Byte-compiled / cache
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
.mypy_cache/
|
| 6 |
+
.pytest_cache/
|
| 7 |
+
|
| 8 |
+
# Virtual environments
|
| 9 |
+
.env
|
| 10 |
+
.env.*
|
| 11 |
+
.venv/
|
| 12 |
+
venv/
|
| 13 |
+
env/
|
| 14 |
+
|
| 15 |
+
# Distribution / packaging
|
| 16 |
+
build/
|
| 17 |
+
dist/
|
| 18 |
+
*.egg-info/
|
| 19 |
+
.eggs/
|
| 20 |
+
|
| 21 |
+
# Logs and coverage reports
|
| 22 |
+
*.log
|
| 23 |
+
coverage.xml
|
| 24 |
+
.coverage*
|
| 25 |
+
htmlcov/
|
| 26 |
+
|
| 27 |
+
# Tooling
|
| 28 |
+
.vscode/
|
| 29 |
+
.idea/
|
| 30 |
+
.DS_Store
|
Dockerfile
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# syntax=docker/dockerfile:1
|
| 2 |
+
FROM python:3.10-slim
|
| 3 |
+
|
| 4 |
+
WORKDIR /app
|
| 5 |
+
|
| 6 |
+
ENV PYTHONDONTWRITEBYTECODE=1
|
| 7 |
+
ENV PYTHONUNBUFFERED=1
|
| 8 |
+
|
| 9 |
+
COPY requirements.txt .
|
| 10 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 11 |
+
|
| 12 |
+
COPY app ./app
|
| 13 |
+
|
| 14 |
+
EXPOSE 7860
|
| 15 |
+
|
| 16 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
app/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# FastAPI app package
|
app/config.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from urllib.parse import urlparse
|
| 3 |
+
|
| 4 |
+
from dotenv import load_dotenv
|
| 5 |
+
|
| 6 |
+
load_dotenv()
|
| 7 |
+
|
| 8 |
+
MONGO_URI = os.getenv("MONGO_URI", "mongodb://localhost:27017")
|
| 9 |
+
_uri_db = urlparse(MONGO_URI).path.lstrip("/").split("/")[0]
|
| 10 |
+
MONGO_DB = os.getenv("MONGO_DB", _uri_db or "early_overspending_alerts")
|
| 11 |
+
APP_PORT = int(os.getenv("PORT", "7860"))
|
app/db.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Optional
|
| 2 |
+
|
| 3 |
+
from motor.motor_asyncio import AsyncIOMotorClient
|
| 4 |
+
|
| 5 |
+
from .config import MONGO_DB, MONGO_URI
|
| 6 |
+
|
| 7 |
+
mongo_client: Optional[AsyncIOMotorClient] = None
|
| 8 |
+
db = None
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
async def connect_to_mongo():
|
| 12 |
+
"""Create a single shared Mongo client + DB handle."""
|
| 13 |
+
global mongo_client, db
|
| 14 |
+
|
| 15 |
+
if mongo_client:
|
| 16 |
+
return db
|
| 17 |
+
|
| 18 |
+
mongo_client = AsyncIOMotorClient(MONGO_URI)
|
| 19 |
+
db = mongo_client[MONGO_DB]
|
| 20 |
+
return db
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
async def close_mongo():
|
| 24 |
+
global mongo_client
|
| 25 |
+
if mongo_client:
|
| 26 |
+
mongo_client.close()
|
| 27 |
+
mongo_client = None
|
app/main.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uvicorn
|
| 2 |
+
from fastapi import FastAPI
|
| 3 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 4 |
+
|
| 5 |
+
from app.db import close_mongo, connect_to_mongo
|
| 6 |
+
from app.routes.budget import router as budget_router
|
| 7 |
+
from app.scheduler import BudgetScheduler
|
| 8 |
+
|
| 9 |
+
app = FastAPI(title="Early Overspending Alerts")
|
| 10 |
+
app.add_middleware(
|
| 11 |
+
CORSMiddleware,
|
| 12 |
+
allow_origins=["*"],
|
| 13 |
+
allow_credentials=True,
|
| 14 |
+
allow_methods=["*"],
|
| 15 |
+
allow_headers=["*"],
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
scheduler = BudgetScheduler()
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@app.on_event("startup")
|
| 22 |
+
async def startup_event():
|
| 23 |
+
db = await connect_to_mongo()
|
| 24 |
+
app.state.db = db
|
| 25 |
+
scheduler.start(db)
|
| 26 |
+
print("Budget scheduler running every 10 seconds")
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@app.on_event("shutdown")
|
| 30 |
+
async def shutdown_event():
|
| 31 |
+
scheduler.shutdown()
|
| 32 |
+
await close_mongo()
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
app.include_router(budget_router)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
if __name__ == "__main__":
|
| 39 |
+
uvicorn.run("app.main:app", host="0.0.0.0", port=7860, reload=False)
|
app/routes/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Routers package
|
app/routes/budget.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime
|
| 2 |
+
|
| 3 |
+
from bson import ObjectId
|
| 4 |
+
from bson.errors import InvalidId
|
| 5 |
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
| 6 |
+
from pymongo import ReturnDocument
|
| 7 |
+
|
| 8 |
+
from app.services.overspend import check_overspending, log_alert
|
| 9 |
+
|
| 10 |
+
router = APIRouter(prefix="/api/budget", tags=["Budget"])
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def get_db(request: Request):
|
| 14 |
+
db = getattr(request.app.state, "db", None)
|
| 15 |
+
if db is None:
|
| 16 |
+
raise HTTPException(status_code=503, detail="Database not ready")
|
| 17 |
+
return db
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@router.put("/update/{budget_id}")
|
| 21 |
+
async def update_budget(budget_id: str, payload: dict, db=Depends(get_db)):
|
| 22 |
+
try:
|
| 23 |
+
object_id = ObjectId(budget_id)
|
| 24 |
+
except (InvalidId, TypeError):
|
| 25 |
+
raise HTTPException(status_code=400, detail="Invalid budget id")
|
| 26 |
+
|
| 27 |
+
payload["updatedAt"] = datetime.utcnow()
|
| 28 |
+
|
| 29 |
+
updated_budget = await db["budgets"].find_one_and_update(
|
| 30 |
+
{"_id": object_id},
|
| 31 |
+
{"$set": payload},
|
| 32 |
+
return_document=ReturnDocument.AFTER,
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
if not updated_budget:
|
| 36 |
+
raise HTTPException(status_code=404, detail="Budget not found")
|
| 37 |
+
|
| 38 |
+
alerts = await check_overspending(updated_budget)
|
| 39 |
+
await log_alert(db, updated_budget, alerts)
|
| 40 |
+
|
| 41 |
+
return {"message": "Budget updated successfully", "alertsGenerated": alerts}
|
app/scheduler.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import math
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
from typing import Iterable
|
| 4 |
+
|
| 5 |
+
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
| 6 |
+
from apscheduler.triggers.interval import IntervalTrigger
|
| 7 |
+
|
| 8 |
+
from app.services.overspend import check_overspending
|
| 9 |
+
|
| 10 |
+
PERIOD_BUCKETS = {"YEARLY", "MONTHLY", "WEEKLY"}
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class BudgetScheduler:
|
| 14 |
+
def __init__(self):
|
| 15 |
+
self.scheduler = AsyncIOScheduler()
|
| 16 |
+
|
| 17 |
+
def start(self, db):
|
| 18 |
+
"""Start a 10s interval job for budget monitoring."""
|
| 19 |
+
self.scheduler.add_job(
|
| 20 |
+
run_budget_cron,
|
| 21 |
+
IntervalTrigger(seconds=10),
|
| 22 |
+
args=[db],
|
| 23 |
+
max_instances=1,
|
| 24 |
+
coalesce=True,
|
| 25 |
+
id="budget-cron",
|
| 26 |
+
replace_existing=True,
|
| 27 |
+
)
|
| 28 |
+
self.scheduler.start()
|
| 29 |
+
|
| 30 |
+
def shutdown(self):
|
| 31 |
+
if self.scheduler.running:
|
| 32 |
+
self.scheduler.shutdown(wait=False)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
async def run_budget_cron(db):
|
| 36 |
+
now = datetime.utcnow()
|
| 37 |
+
print(f"Budget cron tick at {now.isoformat()}Z")
|
| 38 |
+
|
| 39 |
+
budgets_cursor = db["budgets"].find({"status": "OPEN"})
|
| 40 |
+
budgets: Iterable[dict] = await budgets_cursor.to_list(length=None)
|
| 41 |
+
current_week = math.ceil(now.day / 7)
|
| 42 |
+
|
| 43 |
+
for budget in budgets:
|
| 44 |
+
current_spend = budget.get("spendAmount") or 0
|
| 45 |
+
alerts = await check_overspending(budget)
|
| 46 |
+
if not alerts:
|
| 47 |
+
continue
|
| 48 |
+
|
| 49 |
+
budget_id = str(budget.get("_id"))
|
| 50 |
+
last_log = await db["alert_logs"].find_one(
|
| 51 |
+
{"budgetId": budget_id}, sort=[("triggeredAt", -1)]
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
last_alert_date = last_log.get("triggeredAt") if last_log else None
|
| 55 |
+
budget_updated_at = budget.get("updatedAt")
|
| 56 |
+
should_send = False
|
| 57 |
+
|
| 58 |
+
if not last_alert_date:
|
| 59 |
+
should_send = True
|
| 60 |
+
else:
|
| 61 |
+
if budget.get("period") == "YEARLY" and last_alert_date.year != now.year:
|
| 62 |
+
should_send = True
|
| 63 |
+
|
| 64 |
+
if budget.get("period") == "MONTHLY" and (
|
| 65 |
+
last_alert_date.year != now.year or last_alert_date.month != now.month
|
| 66 |
+
):
|
| 67 |
+
should_send = True
|
| 68 |
+
|
| 69 |
+
if budget.get("period") == "WEEKLY":
|
| 70 |
+
diff_days = (now - last_alert_date).days
|
| 71 |
+
if diff_days >= 7:
|
| 72 |
+
should_send = True
|
| 73 |
+
|
| 74 |
+
period = budget.get("period")
|
| 75 |
+
uses_period_buckets = period in PERIOD_BUCKETS
|
| 76 |
+
month_bucket = (now.month - 1) if period == "MONTHLY" else None
|
| 77 |
+
week_bucket = current_week if period == "WEEKLY" else None
|
| 78 |
+
year_bucket = now.year if uses_period_buckets else None
|
| 79 |
+
|
| 80 |
+
existing_log = await db["alert_logs"].find_one(
|
| 81 |
+
{
|
| 82 |
+
"budgetId": budget_id,
|
| 83 |
+
"period": period,
|
| 84 |
+
"year": year_bucket,
|
| 85 |
+
"month": month_bucket,
|
| 86 |
+
"week": week_bucket,
|
| 87 |
+
}
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
if existing_log:
|
| 91 |
+
last_trigger_at = existing_log.get("triggeredAt")
|
| 92 |
+
updated_after_last_alert = (
|
| 93 |
+
budget_updated_at and last_trigger_at and budget_updated_at > last_trigger_at
|
| 94 |
+
)
|
| 95 |
+
last_spend_snapshot = existing_log.get("budgetSpendSnapshot")
|
| 96 |
+
if last_spend_snapshot is None:
|
| 97 |
+
last_spend_snapshot = float("-inf")
|
| 98 |
+
|
| 99 |
+
spend_spike_detected = current_spend > last_spend_snapshot
|
| 100 |
+
|
| 101 |
+
if updated_after_last_alert and spend_spike_detected:
|
| 102 |
+
await db["alert_logs"].update_one(
|
| 103 |
+
{"_id": existing_log["_id"]},
|
| 104 |
+
{
|
| 105 |
+
"$set": {
|
| 106 |
+
"alerts": alerts,
|
| 107 |
+
"triggeredAt": now,
|
| 108 |
+
"budgetName": budget.get("name"),
|
| 109 |
+
"budgetSpendSnapshot": current_spend,
|
| 110 |
+
}
|
| 111 |
+
},
|
| 112 |
+
)
|
| 113 |
+
print(f"Overspend alert updated for: {budget.get('name')}")
|
| 114 |
+
|
| 115 |
+
should_send = False
|
| 116 |
+
|
| 117 |
+
if not should_send:
|
| 118 |
+
continue
|
| 119 |
+
|
| 120 |
+
await db["alert_logs"].insert_one(
|
| 121 |
+
{
|
| 122 |
+
"budgetId": budget_id,
|
| 123 |
+
"budgetName": budget.get("name"),
|
| 124 |
+
"alerts": alerts,
|
| 125 |
+
"budgetSpendSnapshot": current_spend,
|
| 126 |
+
"period": period,
|
| 127 |
+
"year": year_bucket,
|
| 128 |
+
"month": month_bucket,
|
| 129 |
+
"week": week_bucket,
|
| 130 |
+
"triggeredAt": now,
|
| 131 |
+
}
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
print(f"Overspend alert logged for: {budget.get('name')}")
|
app/services/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Service layer package
|
app/services/overspend.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime
|
| 2 |
+
from typing import Dict, List
|
| 3 |
+
|
| 4 |
+
from app.utils.ai_rephrase import rephrase_alert
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
async def check_overspending(budget: Dict) -> List[str]:
|
| 8 |
+
alerts: List[str] = []
|
| 9 |
+
|
| 10 |
+
max_amount = budget.get("maxAmount") or 0
|
| 11 |
+
spend_amount = budget.get("spendAmount") or 0
|
| 12 |
+
if max_amount:
|
| 13 |
+
burn_rate = (spend_amount / max_amount) * 100
|
| 14 |
+
if burn_rate >= 60:
|
| 15 |
+
alerts.append(
|
| 16 |
+
await rephrase_alert(
|
| 17 |
+
f"You have already used {burn_rate:.1f}% of your total budget {budget.get('name', '')}"
|
| 18 |
+
)
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
return alerts
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
async def log_alert(db, budget: Dict, alerts: List[str]):
|
| 25 |
+
if not alerts:
|
| 26 |
+
return
|
| 27 |
+
|
| 28 |
+
await db["alert_logs"].insert_one(
|
| 29 |
+
{
|
| 30 |
+
"budgetId": str(budget.get("_id")),
|
| 31 |
+
"budgetName": budget.get("name"),
|
| 32 |
+
"alerts": alerts,
|
| 33 |
+
"budgetSpendSnapshot": budget.get("spendAmount") or 0,
|
| 34 |
+
"triggeredAt": datetime.utcnow(),
|
| 35 |
+
}
|
| 36 |
+
)
|
app/utils/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Utility package
|
app/utils/ai_rephrase.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
async def rephrase_alert(text: str) -> str:
|
| 2 |
+
return f"Heads up! {text}"
|
requirements.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.115.6
|
| 2 |
+
uvicorn[standard]==0.32.1
|
| 3 |
+
motor==3.6.0
|
| 4 |
+
apscheduler==3.10.4
|
| 5 |
+
python-dotenv==1.0.1
|
| 6 |
+
tzlocal==5.2
|