Skip to content
Closed
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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
__pycache__/
*.pyc
.pytest_cache/
backend/.venv/
backend/venv/
.DS_Store
43 changes: 32 additions & 11 deletions backend/app/api/v1/dashboard.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,46 @@
from fastapi import APIRouter, Depends, HTTPException
from typing import Dict, Any
from fastapi import APIRouter, Depends, HTTPException, Query
from typing import Dict, Any, Optional
import logging
from app.services.cache import get_revenue_summary
from app.services.reservations import PropertyNotFoundError, to_cents
from app.core.auth import authenticate_request as get_current_user

logger = logging.getLogger(__name__)

router = APIRouter()

@router.get("/dashboard/summary")
async def get_dashboard_summary(
property_id: str,
month: Optional[int] = Query(None, ge=1, le=12),
year: Optional[int] = Query(None, ge=2000, le=2100),
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'])

return {

# Never fall back to a shared tenant: that would mix data between clients
tenant_id = getattr(current_user, "tenant_id", None)
if not tenant_id:
raise HTTPException(status_code=403, detail="No tenant associated with this user")

if (month is None) != (year is None):
raise HTTPException(status_code=422, detail="month and year must be provided together")

try:
revenue_data = await get_revenue_summary(property_id, tenant_id, month, year)
except PropertyNotFoundError:
raise HTTPException(status_code=404, detail="Property not found")
except Exception as e:
logger.error(f"Revenue lookup failed for {property_id} (tenant: {tenant_id}): {e}")
raise HTTPException(status_code=503, detail="Revenue data temporarily unavailable")

response = {
"property_id": revenue_data['property_id'],
"total_revenue": total_revenue_float,
# Rounded to cents as Decimal first, so the float sent over JSON is the exact cent value
"total_revenue": float(to_cents(revenue_data['total'])),
"currency": revenue_data['currency'],
"reservations_count": revenue_data['count']
}
if month and year:
response["month"] = month
response["year"] = year
return response
46 changes: 28 additions & 18 deletions backend/app/core/database_pool.py
Original file line number Diff line number Diff line change
@@ -1,60 +1,70 @@
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.pool import QueuePool
import logging
from typing import AsyncIterator
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from ..config import settings

logger = logging.getLogger(__name__)


def build_async_database_url(database_url: str) -> str:
"""Convert a plain postgres URL into one that uses the asyncpg driver."""
for prefix in ("postgresql+asyncpg://", "postgresql://", "postgres://"):
if database_url.startswith(prefix):
return "postgresql+asyncpg://" + database_url[len(prefix):]
raise ValueError(f"Unsupported database URL scheme: {database_url.split('://', 1)[0]}")


class DatabasePool:
def __init__(self):
self.engine = None
self.session_factory = None

async def initialize(self):
"""Initialize database connection pool"""
"""Initialize database connection pool (idempotent)."""
if self.session_factory is not None:
return
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}"

# Async engines use AsyncAdaptedQueuePool by default; passing QueuePool is rejected by SQLAlchemy.
self.engine = create_async_engine(
database_url,
poolclass=QueuePool,
build_async_database_url(settings.database_url),
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

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"""
self.engine = None
self.session_factory = None

def get_session(self) -> AsyncSession:
"""Get a database session from the pool, for use as `async with db_pool.get_session() as session`."""
if not self.session_factory:
raise Exception("Database pool not initialized")
return self.session_factory()

# Global database pool instance
db_pool = DatabasePool()

async def get_db_session() -> AsyncSession:
async def get_db_session() -> AsyncIterator[AsyncSession]:
"""Dependency to get database session"""
await db_pool.initialize()
async with db_pool.get_session() as session:
yield session
55 changes: 38 additions & 17 deletions backend/app/services/cache.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,50 @@
import json
import logging
import redis.asyncio as redis
from typing import Dict, Any
from typing import Dict, Any, Optional
import os

logger = logging.getLogger(__name__)

# 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]:
CACHE_TTL_SECONDS = 300


def revenue_cache_key(tenant_id: str, property_id: str, month: Optional[int] = None, year: Optional[int] = None) -> str:
"""Cache key scoped by tenant: property IDs are only unique per tenant."""
period = f"{year:04d}-{month:02d}" if month and year else "all"
return f"revenue:{tenant_id}:{property_id}:{period}"


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

# Try to get from cache
cached = await redis_client.get(cache_key)
if cached:
return json.loads(cached)

cache_key = revenue_cache_key(tenant_id, property_id, month, year)

# Try to get from cache; a cache outage must not break the dashboard
try:
cached = await redis_client.get(cache_key)
if cached:
return json.loads(cached)
except Exception as e:
logger.warning(f"Revenue cache read failed for {cache_key}: {e}")

# Revenue calculation is delegated to the reservation service.
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
await redis_client.setex(cache_key, 300, json.dumps(result))

from app.services.reservations import calculate_monthly_revenue, calculate_total_revenue

if month and year:
result = await calculate_monthly_revenue(property_id, tenant_id, month, year)
else:
result = await calculate_total_revenue(property_id, tenant_id)

try:
await redis_client.setex(cache_key, CACHE_TTL_SECONDS, json.dumps(result))
except Exception as e:
logger.warning(f"Revenue cache write failed for {cache_key}: {e}")

return result
Loading