Update app.py
Browse files
app.py
CHANGED
|
@@ -5,7 +5,7 @@ import os
|
|
| 5 |
from collections import defaultdict
|
| 6 |
from datetime import datetime, timezone
|
| 7 |
from typing import Dict, List, Optional, Tuple
|
| 8 |
-
|
| 9 |
from bson import ObjectId
|
| 10 |
from dotenv import load_dotenv
|
| 11 |
from fastapi import FastAPI, HTTPException
|
|
@@ -32,7 +32,11 @@ class MonthlyExpense(BaseModel):
|
|
| 32 |
year: int
|
| 33 |
month: int
|
| 34 |
total: float = Field(..., description="Total expenses recorded for the month")
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
class CategoryPrediction(BaseModel):
|
| 38 |
headCategoryId: str
|
|
@@ -56,6 +60,7 @@ class MongoConnection:
|
|
| 56 |
self._database = self._client.get_default_database()
|
| 57 |
self.transactions: Collection = self._database["transactions"]
|
| 58 |
self.headcategories: Collection = self._database["headcategories"]
|
|
|
|
| 59 |
|
| 60 |
|
| 61 |
mongo = MongoConnection()
|
|
@@ -82,6 +87,31 @@ def index_to_month(idx: int) -> Tuple[int, int]:
|
|
| 82 |
year = idx // 12
|
| 83 |
month = (idx % 12) + 1
|
| 84 |
return year, month
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
# ------------------------------------------------
|
| 86 |
|
| 87 |
# ----------------- Time series utilities -----------------
|
|
@@ -466,98 +496,145 @@ def _predict_next_month(history: List[MonthlyExpense]) -> float:
|
|
| 466 |
|
| 467 |
|
| 468 |
# ----------------- API endpoint -----------------
|
| 469 |
-
@app.get("/users/{user_id}/expense-prediction",
|
| 470 |
-
def predict_expense(user_id: str)
|
|
|
|
|
|
|
| 471 |
try:
|
| 472 |
user_object_id = ObjectId(user_id)
|
| 473 |
-
except Exception
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
pipeline = [
|
| 482 |
-
{
|
| 483 |
-
"$match": {
|
| 484 |
-
"user": user_object_id,
|
| 485 |
-
"type": "EXPENSE",
|
| 486 |
-
"headCategory": {"$ne": None},
|
| 487 |
-
"date": {"$gte": start_period},
|
| 488 |
-
}
|
| 489 |
-
},
|
| 490 |
-
{
|
| 491 |
-
"$project": {
|
| 492 |
-
"amount": 1,
|
| 493 |
-
"headCategory": 1,
|
| 494 |
-
"year": {"$year": "$date"},
|
| 495 |
-
"month": {"$month": "$date"},
|
| 496 |
-
}
|
| 497 |
-
},
|
| 498 |
-
{
|
| 499 |
-
"$group": {
|
| 500 |
-
"_id": {
|
| 501 |
-
"headCategory": "$headCategory",
|
| 502 |
-
"year": "$year",
|
| 503 |
-
"month": "$month",
|
| 504 |
-
},
|
| 505 |
-
"total": {"$sum": "$amount"},
|
| 506 |
-
}
|
| 507 |
-
},
|
| 508 |
-
{
|
| 509 |
-
"$lookup": {
|
| 510 |
-
"from": "headcategories",
|
| 511 |
-
"localField": "_id.headCategory",
|
| 512 |
-
"foreignField": "_id",
|
| 513 |
-
"as": "headCategoryDoc",
|
| 514 |
-
}
|
| 515 |
-
},
|
| 516 |
-
{"$unwind": "$headCategoryDoc"},
|
| 517 |
-
{"$sort": {"_id.headCategory": 1, "_id.year": 1, "_id.month": 1}},
|
| 518 |
-
]
|
| 519 |
-
|
| 520 |
-
results = list(mongo.transactions.aggregate(pipeline))
|
| 521 |
-
|
| 522 |
-
grouped: Dict[ObjectId, Dict[str, List[MonthlyExpense]]] = defaultdict(lambda: {"history": []})
|
| 523 |
-
|
| 524 |
-
for item in results:
|
| 525 |
-
head_category_id: ObjectId = item["_id"]["headCategory"]
|
| 526 |
-
category_record = grouped[head_category_id]
|
| 527 |
-
category_record["title"] = item["headCategoryDoc"].get("title", "Unknown")
|
| 528 |
-
category_record["history"].append(
|
| 529 |
-
MonthlyExpense(
|
| 530 |
-
year=item["_id"]["year"],
|
| 531 |
-
month=item["_id"]["month"],
|
| 532 |
-
total=float(item["total"]),
|
| 533 |
-
)
|
| 534 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 535 |
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
|
|
|
| 551 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 552 |
)
|
| 553 |
|
| 554 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 555 |
|
| 556 |
|
| 557 |
-
# Optional: health check
|
| 558 |
@app.get("/health")
|
| 559 |
def health():
|
| 560 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 561 |
|
| 562 |
|
| 563 |
|
|
|
|
| 5 |
from collections import defaultdict
|
| 6 |
from datetime import datetime, timezone
|
| 7 |
from typing import Dict, List, Optional, Tuple
|
| 8 |
+
from time import perf_counter
|
| 9 |
from bson import ObjectId
|
| 10 |
from dotenv import load_dotenv
|
| 11 |
from fastapi import FastAPI, HTTPException
|
|
|
|
| 32 |
year: int
|
| 33 |
month: int
|
| 34 |
total: float = Field(..., description="Total expenses recorded for the month")
|
| 35 |
+
|
| 36 |
+
class APIResponse(BaseModel):
|
| 37 |
+
status: str
|
| 38 |
+
message: str
|
| 39 |
+
data: Optional[PredictionResponse] = None
|
| 40 |
|
| 41 |
class CategoryPrediction(BaseModel):
|
| 42 |
headCategoryId: str
|
|
|
|
| 60 |
self._database = self._client.get_default_database()
|
| 61 |
self.transactions: Collection = self._database["transactions"]
|
| 62 |
self.headcategories: Collection = self._database["headcategories"]
|
| 63 |
+
self.api_logs: Collection = self._database["api_logs"]
|
| 64 |
|
| 65 |
|
| 66 |
mongo = MongoConnection()
|
|
|
|
| 87 |
year = idx // 12
|
| 88 |
month = (idx % 12) + 1
|
| 89 |
return year, month
|
| 90 |
+
|
| 91 |
+
def log_api_event(
|
| 92 |
+
name: str,
|
| 93 |
+
status: str,
|
| 94 |
+
response_time: float,
|
| 95 |
+
user_id: Optional[str] = None,
|
| 96 |
+
error_message: Optional[str] = None,
|
| 97 |
+
):
|
| 98 |
+
payload = {
|
| 99 |
+
"name": name,
|
| 100 |
+
"status": status,
|
| 101 |
+
"response_time": round(response_time, 3),
|
| 102 |
+
"user_id": user_id or "anonymous",
|
| 103 |
+
"timestamp": datetime.now(timezone.utc),
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
if error_message:
|
| 107 |
+
payload["error_message"] = error_message
|
| 108 |
+
|
| 109 |
+
try:
|
| 110 |
+
mongo.api_logs.insert_one(payload)
|
| 111 |
+
except Exception:
|
| 112 |
+
# never crash API because of logging
|
| 113 |
+
pass
|
| 114 |
+
|
| 115 |
# ------------------------------------------------
|
| 116 |
|
| 117 |
# ----------------- Time series utilities -----------------
|
|
|
|
| 496 |
|
| 497 |
|
| 498 |
# ----------------- API endpoint -----------------
|
| 499 |
+
@app.get("/users/{user_id}/expense-prediction",response_model=APIResponse,)
|
| 500 |
+
def predict_expense(user_id: str):
|
| 501 |
+
start_time = perf_counter()
|
| 502 |
+
|
| 503 |
try:
|
| 504 |
user_object_id = ObjectId(user_id)
|
| 505 |
+
except Exception:
|
| 506 |
+
log_api_event(
|
| 507 |
+
name="Expense Prediction",
|
| 508 |
+
status="failed",
|
| 509 |
+
response_time=0,
|
| 510 |
+
user_id=user_id,
|
| 511 |
+
error_message="Invalid user id",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 512 |
)
|
| 513 |
+
raise HTTPException(status_code=400, detail="Invalid user id")
|
| 514 |
+
|
| 515 |
+
try:
|
| 516 |
+
now = datetime.now(timezone.utc)
|
| 517 |
+
start_period = _shift_months(_first_day_of_month(now), -MAX_HISTORY_MONTHS + 1)
|
| 518 |
+
prediction_month = _shift_months(_first_day_of_month(now), 1)
|
| 519 |
+
|
| 520 |
+
pipeline = [
|
| 521 |
+
{
|
| 522 |
+
"$match": {
|
| 523 |
+
"user": user_object_id,
|
| 524 |
+
"type": "EXPENSE",
|
| 525 |
+
"headCategory": {"$ne": None},
|
| 526 |
+
"date": {"$gte": start_period},
|
| 527 |
+
}
|
| 528 |
+
},
|
| 529 |
+
{
|
| 530 |
+
"$project": {
|
| 531 |
+
"amount": 1,
|
| 532 |
+
"headCategory": 1,
|
| 533 |
+
"year": {"$year": "$date"},
|
| 534 |
+
"month": {"$month": "$date"},
|
| 535 |
+
}
|
| 536 |
+
},
|
| 537 |
+
{
|
| 538 |
+
"$group": {
|
| 539 |
+
"_id": {
|
| 540 |
+
"headCategory": "$headCategory",
|
| 541 |
+
"year": "$year",
|
| 542 |
+
"month": "$month",
|
| 543 |
+
},
|
| 544 |
+
"total": {"$sum": "$amount"},
|
| 545 |
+
}
|
| 546 |
+
},
|
| 547 |
+
{
|
| 548 |
+
"$lookup": {
|
| 549 |
+
"from": "headcategories",
|
| 550 |
+
"localField": "_id.headCategory",
|
| 551 |
+
"foreignField": "_id",
|
| 552 |
+
"as": "headCategoryDoc",
|
| 553 |
+
}
|
| 554 |
+
},
|
| 555 |
+
{"$unwind": "$headCategoryDoc"},
|
| 556 |
+
{"$sort": {"_id.headCategory": 1, "_id.year": 1, "_id.month": 1}},
|
| 557 |
+
]
|
| 558 |
+
|
| 559 |
+
results = list(mongo.transactions.aggregate(pipeline))
|
| 560 |
+
|
| 561 |
+
grouped: Dict[ObjectId, Dict[str, List[MonthlyExpense]]] = defaultdict(lambda: {"history": []})
|
| 562 |
+
|
| 563 |
+
for item in results:
|
| 564 |
+
head_category_id: ObjectId = item["_id"]["headCategory"]
|
| 565 |
+
category_record = grouped[head_category_id]
|
| 566 |
+
category_record["title"] = item["headCategoryDoc"].get("title", "Unknown")
|
| 567 |
+
category_record["history"].append(
|
| 568 |
+
MonthlyExpense(
|
| 569 |
+
year=item["_id"]["year"],
|
| 570 |
+
month=item["_id"]["month"],
|
| 571 |
+
total=float(item["total"]),
|
| 572 |
+
)
|
| 573 |
+
)
|
| 574 |
|
| 575 |
+
categories: List[CategoryPrediction] = []
|
| 576 |
+
for head_category_id, record in grouped.items():
|
| 577 |
+
history = sorted(record["history"], key=lambda doc: (doc.year, doc.month))
|
| 578 |
+
predicted_total = _predict_next_month(history)
|
| 579 |
+
|
| 580 |
+
categories.append(
|
| 581 |
+
CategoryPrediction(
|
| 582 |
+
headCategoryId=str(head_category_id),
|
| 583 |
+
title=record.get("title", "Unknown"),
|
| 584 |
+
history=history,
|
| 585 |
+
predictionMonth=MonthlyExpense(
|
| 586 |
+
year=prediction_month.year,
|
| 587 |
+
month=prediction_month.month,
|
| 588 |
+
total=predicted_total,
|
| 589 |
+
),
|
| 590 |
+
)
|
| 591 |
)
|
| 592 |
+
|
| 593 |
+
response_data = PredictionResponse(userId=user_id, categories=categories)
|
| 594 |
+
|
| 595 |
+
log_api_event(
|
| 596 |
+
name="Expense Prediction",
|
| 597 |
+
status="success",
|
| 598 |
+
response_time=perf_counter() - start_time,
|
| 599 |
+
user_id=user_id,
|
| 600 |
+
)
|
| 601 |
+
|
| 602 |
+
return APIResponse(
|
| 603 |
+
status="success",
|
| 604 |
+
message="Expense prediction generated successfully",
|
| 605 |
+
data=response_data,
|
| 606 |
)
|
| 607 |
|
| 608 |
+
except Exception as exc:
|
| 609 |
+
log_api_event(
|
| 610 |
+
name="Expense Prediction",
|
| 611 |
+
status="failed",
|
| 612 |
+
response_time=perf_counter() - start_time,
|
| 613 |
+
user_id=user_id,
|
| 614 |
+
error_message=str(exc),
|
| 615 |
+
)
|
| 616 |
+
raise HTTPException(status_code=500, detail="Internal server error")
|
| 617 |
|
| 618 |
|
|
|
|
| 619 |
@app.get("/health")
|
| 620 |
def health():
|
| 621 |
+
try:
|
| 622 |
+
mongo._client.admin.command("ping")
|
| 623 |
+
return {
|
| 624 |
+
"status": "ok",
|
| 625 |
+
"message": "Service is healthy",
|
| 626 |
+
"timestamp": datetime.now(timezone.utc),
|
| 627 |
+
}
|
| 628 |
+
except Exception as exc:
|
| 629 |
+
raise HTTPException(
|
| 630 |
+
status_code=503,
|
| 631 |
+
detail={
|
| 632 |
+
"status": "down",
|
| 633 |
+
"message": "Database connectivity failed",
|
| 634 |
+
"error": str(exc),
|
| 635 |
+
},
|
| 636 |
+
)
|
| 637 |
+
|
| 638 |
|
| 639 |
|
| 640 |
|