HariLogicgo commited on
Commit
b8fc47f
·
1 Parent(s): 2f6b940

deployment ready

Browse files
.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