|
|
from fastapi import FastAPI, HTTPException, Depends, Request |
|
|
from fastapi.middleware.cors import CORSMiddleware |
|
|
from pymongo import MongoClient |
|
|
from pymongo.errors import ConnectionFailure |
|
|
import os |
|
|
import time |
|
|
from typing import List, Optional |
|
|
from datetime import datetime, timedelta, timezone |
|
|
from app.models import BudgetRecommendation, Expense, Budget, CategoryExpense, RecommendationRequest, RecommendationResponse, RecommendationByNameRequest, RecommendationByNameRequest |
|
|
from app.smart_recommendation import SmartBudgetRecommender |
|
|
|
|
|
app = FastAPI(title="Smart Budget Recommendation API", version="1.0.0") |
|
|
|
|
|
|
|
|
app.add_middleware( |
|
|
CORSMiddleware, |
|
|
allow_origins=["*"], |
|
|
allow_credentials=True, |
|
|
allow_methods=["*"], |
|
|
allow_headers=["*"], |
|
|
) |
|
|
|
|
|
|
|
|
MONGODB_URI = os.getenv("MONGODB_URI") |
|
|
if not MONGODB_URI: |
|
|
raise ValueError("MONGODB_URI environment variable is required. Please set it in Hugging Face secrets.") |
|
|
|
|
|
try: |
|
|
client = MongoClient(MONGODB_URI) |
|
|
db = client.expense |
|
|
|
|
|
client.admin.command('ping') |
|
|
print("Successfully connected to MongoDB") |
|
|
except ConnectionFailure as e: |
|
|
print(f"Failed to connect to MongoDB: {e}") |
|
|
raise |
|
|
|
|
|
|
|
|
recommender = SmartBudgetRecommender(db) |
|
|
|
|
|
|
|
|
IST = timezone(timedelta(hours=5, minutes=30)) |
|
|
|
|
|
def log_api_call(db, name: str, status: str, response_time: float, endpoint: str = None, error: str = None): |
|
|
""" |
|
|
Log API call to MongoDB api_logs collection |
|
|
|
|
|
Args: |
|
|
db: MongoDB database instance |
|
|
name: API name (e.g., "smart budget recommendation") |
|
|
status: "success" or "fail" |
|
|
response_time: Response time in seconds |
|
|
endpoint: Optional endpoint path |
|
|
error: Optional error message |
|
|
""" |
|
|
try: |
|
|
|
|
|
ist_time = datetime.now(IST) |
|
|
|
|
|
timestamp_str = ist_time.strftime("%d-%m-%Y %H:%M:%S:IST") |
|
|
|
|
|
|
|
|
user_id = None |
|
|
if endpoint: |
|
|
|
|
|
parts = endpoint.strip("/").split("/") |
|
|
if len(parts) >= 2: |
|
|
user_id = parts[1] |
|
|
|
|
|
log_entry = { |
|
|
"name": name, |
|
|
"status": status, |
|
|
"date": timestamp_str, |
|
|
"response_time": round(response_time, 3), |
|
|
"user_id": user_id, |
|
|
} |
|
|
|
|
|
if error: |
|
|
log_entry["error"] = error |
|
|
|
|
|
|
|
|
db.api_logs.insert_one(log_entry) |
|
|
except Exception as e: |
|
|
|
|
|
print(f"Failed to log API call: {e}") |
|
|
|
|
|
@app.middleware("http") |
|
|
async def log_requests(request: Request, call_next): |
|
|
"""Middleware to log API requests and track response time""" |
|
|
start_time = time.time() |
|
|
|
|
|
|
|
|
endpoint = request.url.path |
|
|
should_log = endpoint in ["/recommendations", "/category-expenses"] or endpoint.startswith("/recommendations/") or endpoint.startswith("/category-expenses/") |
|
|
|
|
|
if should_log: |
|
|
try: |
|
|
response = await call_next(request) |
|
|
process_time = time.time() - start_time |
|
|
|
|
|
|
|
|
status = "success" if response.status_code < 400 else "fail" |
|
|
|
|
|
|
|
|
log_api_call( |
|
|
db=db, |
|
|
name="smart budget recommendation", |
|
|
status=status, |
|
|
response_time=process_time, |
|
|
endpoint=endpoint |
|
|
) |
|
|
|
|
|
return response |
|
|
except Exception as e: |
|
|
process_time = time.time() - start_time |
|
|
|
|
|
log_api_call( |
|
|
db=db, |
|
|
name="smart budget recommendation", |
|
|
status="fail", |
|
|
response_time=process_time, |
|
|
endpoint=endpoint, |
|
|
error=str(e) |
|
|
) |
|
|
raise |
|
|
else: |
|
|
|
|
|
return await call_next(request) |
|
|
|
|
|
@app.get("/") |
|
|
async def root(): |
|
|
return {"message": "Smart Budget Recommendation API", "status": "running"} |
|
|
|
|
|
@app.get("/health") |
|
|
async def health_check(): |
|
|
try: |
|
|
client.admin.command('ping') |
|
|
return {"status": "healthy", "database": "connected"} |
|
|
except Exception as e: |
|
|
return {"status": "unhealthy", "error": str(e)} |
|
|
|
|
|
@app.post("/expenses", response_model=dict) |
|
|
async def create_expense(expense: Expense): |
|
|
""" |
|
|
Disabled: this service does not create expenses. |
|
|
|
|
|
All expenses should be created by the main WalletSync app and stored |
|
|
directly in MongoDB. This API only reads existing data for analytics. |
|
|
""" |
|
|
raise HTTPException( |
|
|
status_code=405, |
|
|
detail="Creating expenses is disabled. Use the main WalletSync app to add expenses.", |
|
|
) |
|
|
|
|
|
@app.get("/expenses", response_model=List[Expense]) |
|
|
async def get_expenses(user_id: str, limit: int = 100): |
|
|
"""Get expenses for a user""" |
|
|
expenses = list(db.expenses.find({"user_id": user_id}).sort("date", -1).limit(limit)) |
|
|
for expense in expenses: |
|
|
expense["id"] = str(expense["_id"]) |
|
|
del expense["_id"] |
|
|
return expenses |
|
|
|
|
|
@app.post("/budgets", response_model=dict) |
|
|
async def create_budget(budget: Budget): |
|
|
""" |
|
|
Disabled: this service does not create budgets. |
|
|
|
|
|
All budgets should be created by the main WalletSync app and stored |
|
|
directly in MongoDB. This API only reads existing data for analytics. |
|
|
""" |
|
|
raise HTTPException( |
|
|
status_code=405, |
|
|
detail="Creating budgets is disabled. Use the main WalletSync app to add budgets.", |
|
|
) |
|
|
|
|
|
@app.get("/budgets", response_model=List[Budget]) |
|
|
async def get_budgets(user_id: str): |
|
|
"""Get budgets for a user""" |
|
|
budgets = list(db.budgets.find({"user_id": user_id})) |
|
|
for budget in budgets: |
|
|
budget["id"] = str(budget["_id"]) |
|
|
del budget["_id"] |
|
|
return budgets |
|
|
|
|
|
@app.post("/recommendations/check", response_model=RecommendationResponse) |
|
|
async def check_and_get_recommendations(request: RecommendationRequest, month: Optional[int] = None, year: Optional[int] = None): |
|
|
""" |
|
|
Check if user has previous data for a category and return recommendations if available. |
|
|
|
|
|
Request body: |
|
|
{ |
|
|
"user_id": "68a834c3f4694b11efedacd2", |
|
|
"category_id": "688c80ca990b63f0e945ecf1" |
|
|
} |
|
|
|
|
|
Response: |
|
|
- If user has previous data: returns recommendations |
|
|
- If user doesn't have previous data: returns message indicating no previous data |
|
|
""" |
|
|
if not month or not year: |
|
|
|
|
|
next_month = datetime.now().replace(day=1) + timedelta(days=32) |
|
|
month = next_month.month |
|
|
year = next_month.year |
|
|
|
|
|
|
|
|
has_data = recommender.check_user_has_category_data(request.user_id, request.category_id) |
|
|
|
|
|
if has_data: |
|
|
|
|
|
if request.budget_amount and request.budget_amount > 0: |
|
|
print(f"✅ User {request.user_id} has previous data for category {request.category_id}, but provided budget_amount {request.budget_amount} - using provided amount") |
|
|
recommendations = recommender.get_recommendation_for_category( |
|
|
request.user_id, |
|
|
request.category_id, |
|
|
month, |
|
|
year, |
|
|
budget_amount=request.budget_amount |
|
|
) |
|
|
else: |
|
|
print(f"✅ User {request.user_id} has previous data for category {request.category_id} - using historical data") |
|
|
recommendations = recommender.get_recommendation_for_category( |
|
|
request.user_id, |
|
|
request.category_id, |
|
|
month, |
|
|
year, |
|
|
budget_amount=None |
|
|
) |
|
|
|
|
|
if recommendations: |
|
|
return RecommendationResponse( |
|
|
has_previous_data=True, |
|
|
recommendations=recommendations, |
|
|
message=None |
|
|
) |
|
|
else: |
|
|
|
|
|
return RecommendationResponse( |
|
|
has_previous_data=True, |
|
|
recommendations=[], |
|
|
message="User has previous data but no recommendations could be generated for this category." |
|
|
) |
|
|
elif request.budget_amount and request.budget_amount > 0: |
|
|
|
|
|
print(f"ℹ️ User {request.user_id} does not have previous data for category {request.category_id} - using provided budget_amount") |
|
|
recommendations = recommender.get_recommendation_for_category( |
|
|
request.user_id, |
|
|
request.category_id, |
|
|
month, |
|
|
year, |
|
|
request.budget_amount |
|
|
) |
|
|
|
|
|
if recommendations: |
|
|
return RecommendationResponse( |
|
|
has_previous_data=False, |
|
|
recommendations=recommendations, |
|
|
message="Recommendation generated based on provided budget amount. User does not have previous spending data for this category." |
|
|
) |
|
|
else: |
|
|
return RecommendationResponse( |
|
|
has_previous_data=False, |
|
|
recommendations=None, |
|
|
message="Could not generate recommendations even with provided budget amount." |
|
|
) |
|
|
else: |
|
|
|
|
|
return RecommendationResponse( |
|
|
has_previous_data=False, |
|
|
recommendations=None, |
|
|
message=f"User does not have previous data for category_id: {request.category_id}. Please provide a budget_amount or create a budget/expenses for this category first." |
|
|
) |
|
|
|
|
|
@app.post("/recommendations/by-name", response_model=RecommendationResponse) |
|
|
async def get_recommendations_by_category_name(request: RecommendationByNameRequest, month: Optional[int] = None, year: Optional[int] = None): |
|
|
""" |
|
|
Get budget recommendations by Head Category name (instead of category_id). |
|
|
This is useful for frontend integration where you only have the category name. |
|
|
|
|
|
Request body: |
|
|
{ |
|
|
"user_id": "6741abd38d30ab5b7176397f", |
|
|
"category_name": "Food & Drinks", |
|
|
"budget_amount": 350.0 // optional |
|
|
} |
|
|
|
|
|
Supported Head Category names: |
|
|
- Food & Drinks |
|
|
- Shopping |
|
|
- Housing |
|
|
- Financial Expenses |
|
|
- Communication, PC |
|
|
- Vehicle |
|
|
- Transportation |
|
|
- Life & Entertainment |
|
|
- Investments |
|
|
- Income |
|
|
- Loan Payment |
|
|
- Marketing |
|
|
|
|
|
Response: |
|
|
- If user has previous data: returns recommendations based on historical data |
|
|
- If user doesn't have previous data but provided budget_amount: returns recommendations based on budget_amount |
|
|
- If user doesn't have previous data and no budget_amount: returns message asking for budget_amount |
|
|
""" |
|
|
if not month or not year: |
|
|
|
|
|
next_month = datetime.now().replace(day=1) + timedelta(days=32) |
|
|
month = next_month.month |
|
|
year = next_month.year |
|
|
|
|
|
|
|
|
category_id = recommender._get_category_id_by_name(request.category_name) |
|
|
|
|
|
if not category_id: |
|
|
return RecommendationResponse( |
|
|
has_previous_data=False, |
|
|
recommendations=None, |
|
|
message=f"Category '{request.category_name}' not found in database. Please check the category name or create the category first." |
|
|
) |
|
|
|
|
|
|
|
|
has_data = recommender.check_user_has_category_data(request.user_id, category_id) |
|
|
|
|
|
if has_data: |
|
|
|
|
|
print(f"✅ User {request.user_id} has previous data for category '{request.category_name}' (id: {category_id}) - using historical data") |
|
|
recommendations = recommender.get_recommendation_for_category( |
|
|
request.user_id, |
|
|
category_id, |
|
|
month, |
|
|
year, |
|
|
budget_amount=None |
|
|
) |
|
|
|
|
|
if recommendations: |
|
|
return RecommendationResponse( |
|
|
has_previous_data=True, |
|
|
recommendations=recommendations, |
|
|
message=None |
|
|
) |
|
|
else: |
|
|
return RecommendationResponse( |
|
|
has_previous_data=True, |
|
|
recommendations=[], |
|
|
message="User has previous data but no recommendations could be generated for this category." |
|
|
) |
|
|
elif request.budget_amount and request.budget_amount > 0: |
|
|
|
|
|
print(f"ℹ️ User {request.user_id} does not have previous data for category '{request.category_name}' (id: {category_id}) - using provided budget_amount") |
|
|
recommendations = recommender.get_recommendation_for_category( |
|
|
request.user_id, |
|
|
category_id, |
|
|
month, |
|
|
year, |
|
|
request.budget_amount |
|
|
) |
|
|
|
|
|
if recommendations: |
|
|
return RecommendationResponse( |
|
|
has_previous_data=False, |
|
|
recommendations=recommendations, |
|
|
message="Recommendation generated based on provided budget amount. User does not have previous spending data for this category." |
|
|
) |
|
|
else: |
|
|
return RecommendationResponse( |
|
|
has_previous_data=False, |
|
|
recommendations=None, |
|
|
message="Could not generate recommendations even with provided budget amount." |
|
|
) |
|
|
else: |
|
|
|
|
|
return RecommendationResponse( |
|
|
has_previous_data=False, |
|
|
recommendations=None, |
|
|
message=f"User does not have previous data for category '{request.category_name}'. Please provide a budget_amount to get recommendations." |
|
|
) |
|
|
|
|
|
@app.get("/recommendations/{user_id}", response_model=List[BudgetRecommendation]) |
|
|
async def get_budget_recommendations(user_id: str, month: Optional[int] = None, year: Optional[int] = None): |
|
|
""" |
|
|
Get smart budget recommendations for a user based on past spending behavior. |
|
|
|
|
|
Example response: |
|
|
{ |
|
|
"category": "Groceries", |
|
|
"category_id": "688c80ca990b63f0e945ecf1", |
|
|
"average_expense": 3800, |
|
|
"recommended_budget": 4000, |
|
|
"reason": "Your average monthly grocery expense is Rs.3,800. We suggest setting your budget to Rs.4,000 for next month.", |
|
|
"confidence": 0.85, |
|
|
"action": "increase" |
|
|
} |
|
|
""" |
|
|
if not month or not year: |
|
|
|
|
|
next_month = datetime.now().replace(day=1) + timedelta(days=32) |
|
|
month = next_month.month |
|
|
year = next_month.year |
|
|
|
|
|
recommendations = recommender.get_recommendations(user_id, month, year) |
|
|
return recommendations |
|
|
|
|
|
@app.get("/category-expenses/{user_id}", response_model=List[CategoryExpense]) |
|
|
async def get_category_expenses(user_id: str, months: int = 3): |
|
|
"""Get average expenses by category for the past N months""" |
|
|
category_expenses = recommender.get_category_averages(user_id, months) |
|
|
return category_expenses |
|
|
|
|
|
if __name__ == "__main__": |
|
|
import uvicorn |
|
|
uvicorn.run(app, host="0.0.0.0", port=8000) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|