Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
__pycache__/
*.py[cod]
40 changes: 27 additions & 13 deletions backend/app/api/v1/dashboard.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,39 @@
from fastapi import APIRouter, Depends, HTTPException
from typing import Dict, Any
from typing import Any, Dict, List
from app.services.cache import get_revenue_summary
from app.services.reservations import REPORT_MONTH, REPORT_YEAR, list_tenant_properties
from app.core.auth import authenticate_request as get_current_user

router = APIRouter()


def _tenant_id(current_user) -> str:
tenant_id = getattr(current_user, "tenant_id", None)
if not tenant_id:
raise HTTPException(status_code=401, detail="Tenant not resolved")
return tenant_id


@router.get("/dashboard/properties")
async def get_dashboard_properties(
current_user: dict = Depends(get_current_user),
) -> List[Dict[str, Any]]:
return await list_tenant_properties(_tenant_id(current_user))


@router.get("/dashboard/summary")
async def get_dashboard_summary(
property_id: str,
current_user: dict = Depends(get_current_user)
year: int = REPORT_YEAR,
month: int = REPORT_MONTH,
Comment on lines +27 to +28

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Lack of parameter validation on year and month causes invalid inputs to swallow ValueError and return HTTP 200 with zero revenue.
Severity: LOW

Suggested Fix

Add parameter validation constraints (e.g. Query(ge=1, le=12) for month and valid range constraints for year) on the endpoint in backend/app/api/v1/dashboard.py.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: backend/app/api/v1/dashboard.py#L27-L28

Potential issue: The `/dashboard/summary` endpoint accepts `year` and `month` query
parameters without validating their ranges. Passing invalid values such as `month=13`
triggers a `ValueError` in `datetime` construction within `_month_window`. Because
`calculate_total_revenue` catches all exceptions, the error is swallowed and an empty
summary with zero revenue is returned with HTTP 200 and cached for 5 minutes instead of
returning a 4xx validation error.

Also affects:

  • backend/app/services/reservations.py:31~38

Did we get this right? 👍 / 👎 to inform future reviews.

current_user: dict = Depends(get_current_user),
) -> Dict[str, Any]:

tenant_id = getattr(current_user, "tenant_id", "default_tenant") or "default_tenant"

revenue_data = await get_revenue_summary(property_id, tenant_id)

total_revenue_float = float(revenue_data['total'])

tenant_id = _tenant_id(current_user)
revenue_data = await get_revenue_summary(property_id, tenant_id, year=year, month=month)

return {
"property_id": revenue_data['property_id'],
"total_revenue": total_revenue_float,
"currency": revenue_data['currency'],
"reservations_count": revenue_data['count']
"property_id": revenue_data["property_id"],
"total_revenue": revenue_data["total"],
"currency": revenue_data["currency"],
"reservations_count": revenue_data["count"],
}
93 changes: 48 additions & 45 deletions backend/app/core/database_pool.py
Original file line number Diff line number Diff line change
@@ -1,60 +1,63 @@
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.pool import QueuePool
from contextlib import asynccontextmanager
import logging

import asyncpg

from ..config import settings

logger = logging.getLogger(__name__)


def _asyncpg_database_url(url: str) -> str:
if url.startswith("postgresql+asyncpg://"):
return "postgresql://" + url[len("postgresql+asyncpg://"):]
if url.startswith("postgres+asyncpg://"):
return "postgresql://" + url[len("postgres+asyncpg://"):]
return url


class DatabasePool:
def __init__(self):
self.engine = None
self.session_factory = None
self.pool = None
self._init_lock = asyncio.Lock()

async def initialize(self):
"""Initialize database connection pool"""
try:
# Create async engine with connection pooling
database_url = f"postgresql+asyncpg://{settings.supabase_db_user}:{settings.supabase_db_password}@{settings.supabase_db_host}:{settings.supabase_db_port}/{settings.supabase_db_name}"

self.engine = create_async_engine(
database_url,
poolclass=QueuePool,
pool_size=20, # Number of connections to maintain
max_overflow=30, # Additional connections when needed
pool_pre_ping=True, # Validate connections
pool_recycle=3600, # Recycle connections every hour
echo=False # Set to True for SQL debugging
)

self.session_factory = async_sessionmaker(
bind=self.engine,
class_=AsyncSession,
expire_on_commit=False
)

logger.info("✅ Database connection pool initialized")

except Exception as e:
logger.error(f"❌ Database pool initialization failed: {e}")
self.engine = None
self.session_factory = None

if self.pool:
return

async with self._init_lock:
if self.pool:
return

try:
self.pool = await asyncpg.create_pool(
_asyncpg_database_url(settings.database_url),
min_size=1,
max_size=20,
)
logger.info("Database connection pool initialized")
except Exception as e:
logger.error(f"Database pool initialization failed: {e}")
self.pool = None

async def close(self):
"""Close database connections"""
if self.engine:
await self.engine.dispose()

async def get_session(self) -> AsyncSession:
"""Get database session from pool"""
if not self.session_factory:
if self.pool:
await self.pool.close()
self.pool = None

@asynccontextmanager
async def get_connection(self):
if not self.pool:
await self.initialize()
if not self.pool:
raise Exception("Database pool not initialized")
return self.session_factory()
async with self.pool.acquire() as connection:
yield connection

# Global database pool instance
db_pool = DatabasePool()

async def get_db_session() -> AsyncSession:
"""Dependency to get database session"""
async with db_pool.get_session() as session:
yield session
async def get_db_session():
async with db_pool.get_connection() as connection:
yield connection
43 changes: 30 additions & 13 deletions backend/app/services/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,44 @@
from typing import Dict, Any
import os

from app.services.reservations import REPORT_MONTH, REPORT_YEAR

# Initialize Redis client (typically configured centrally).
redis_client = redis.Redis.from_url(os.getenv("REDIS_URL", "redis://localhost:6379/0"))

async def get_revenue_summary(property_id: str, tenant_id: str) -> Dict[str, Any]:

def _revenue_cache_key(tenant_id: str, property_id: str, year: int, month: int) -> str:
return f"revenue:{tenant_id}:{property_id}:{year}-{month:02d}"


async def get_revenue_summary(
property_id: str,
tenant_id: str,
year: int = REPORT_YEAR,
month: int = REPORT_MONTH,
) -> Dict[str, Any]:
"""
Fetches revenue summary, utilizing caching to improve performance.
"""
cache_key = f"revenue:{property_id}"

# Try to get from cache
cache_key = _revenue_cache_key(tenant_id, property_id, year, month)

cached = await redis_client.get(cache_key)
if cached:
return json.loads(cached)

# Revenue calculation is delegated to the reservation service.
payload = json.loads(cached)
if payload.get("tenant_id") == tenant_id:
return payload

from app.services.reservations import calculate_total_revenue

# Calculate revenue
result = await calculate_total_revenue(property_id, tenant_id)

# Cache the result for 5 minutes

result = await calculate_total_revenue(property_id, tenant_id, year=year, month=month)
if result.get("tenant_id") != tenant_id:
result = {
"property_id": property_id,
"tenant_id": tenant_id,
"total": "0.00",
"currency": "USD",
"count": 0,
}

await redis_client.setex(cache_key, 300, json.dumps(result))

return result
Loading