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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/node_modules
75 changes: 58 additions & 17 deletions backend/app/api/v1/dashboard.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,66 @@
from fastapi import APIRouter, Depends, HTTPException
from typing import Dict, Any
from app.services.cache import get_revenue_summary
import logging

from fastapi import APIRouter, Depends, HTTPException, Query

from app.core.auth import authenticate_request as get_current_user
from app.services.cache import get_revenue_summary
from app.services.reservations import (
DEFAULT_REPORT_MONTH,
DEFAULT_REPORT_YEAR,
list_tenant_properties,
)

logger = logging.getLogger(__name__)
router = APIRouter()


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


@router.get("/properties")
async def get_properties(current_user: dict = Depends(get_current_user)):
tenant_id = _tenant_id(current_user)
try:
properties = await list_tenant_properties(tenant_id)
except Exception as exc:
logger.error("property list failed tenant_id=%s error=%s", tenant_id, exc)
raise HTTPException(status_code=503, detail="Property data is temporarily unavailable")
return {"data": properties, "total": len(properties)}


@router.get("/dashboard/summary")
async def get_dashboard_summary(
property_id: str,
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'])

month: int = Query(DEFAULT_REPORT_MONTH, ge=1, le=12),
year: int = Query(DEFAULT_REPORT_YEAR, ge=2000, le=2100),
current_user: dict = Depends(get_current_user),
):
tenant_id = _tenant_id(current_user)
try:
revenue_data = await get_revenue_summary(property_id, tenant_id, year, month)
except Exception as exc:
logger.error(
"revenue summary failed property_id=%s tenant_id=%s error=%s",
property_id,
tenant_id,
exc,
)
raise HTTPException(status_code=503, detail="Revenue data is temporarily unavailable")

if revenue_data is None:
raise HTTPException(status_code=404, detail="Property not found")

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"],
"property_name": revenue_data["property_name"],
"timezone": revenue_data["timezone"],
"year": revenue_data["year"],
"month": revenue_data["month"],
"total_revenue": revenue_data["total"],
"currency": revenue_data["currency"],
"reservations_count": revenue_data["count"],
}
73 changes: 42 additions & 31 deletions backend/app/core/database_pool.py
Original file line number Diff line number Diff line change
@@ -1,60 +1,71 @@
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.pool import QueuePool
import logging
from contextlib import asynccontextmanager

from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine

from ..config import settings

logger = logging.getLogger(__name__)


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


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

async def initialize(self):
"""Initialize database connection pool"""
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}"

database_url = to_asyncpg_url(settings.database_url)
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
pool_size=20,
max_overflow=30,
pool_pre_ping=True,
pool_recycle=3600,
echo=False,
)

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

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

logger.info("Database connection pool initialized")
except Exception as e:
logger.error(f"❌ Database pool initialization failed: {e}")
logger.error("Database pool initialization failed: %s", 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

@asynccontextmanager
async def get_session(self):
if not self.session_factory:
raise Exception("Database pool not initialized")
return self.session_factory()
await self.initialize()
if not self.session_factory:
raise RuntimeError("Database pool not available")
async with self.session_factory() as session:
yield session


# Global database pool instance
db_pool = DatabasePool()

async def get_db_session() -> AsyncSession:
"""Dependency to get database session"""

async def get_db_session():
async with db_pool.get_session() as session:
yield session
yield session
7 changes: 3 additions & 4 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,12 +92,11 @@ async def lifespan(app: FastAPI):

# Initialize Supabase connection pool
try:
from .core.supabase_connection_pool import supabase_pool
from .core.database_pool import db_pool

await supabase_pool.initialize()
logger.info("✅ Supabase connection pool initialized")
await db_pool.initialize()
except Exception as e:
logger.error(f"❌ Supabase connection pool initialization failed: {e}")
logger.warning(f"Database pool initialization warning: {e}")
# Continue startup - fallback to direct connections

# Initialize Redis connection with timeout
Expand Down
64 changes: 40 additions & 24 deletions backend/app/services/cache.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,45 @@
import json
import redis.asyncio as redis
from typing import Dict, Any
import logging
import os
from typing import Any, Dict

import redis.asyncio as redis

from app.services.reservations import (
DEFAULT_REPORT_MONTH,
DEFAULT_REPORT_YEAR,
calculate_monthly_revenue,
revenue_cache_key,
)

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]:
"""
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)

# 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))

return result

async def get_revenue_summary(
property_id: str,
tenant_id: str,
year: int = DEFAULT_REPORT_YEAR,
month: int = DEFAULT_REPORT_MONTH,
) -> Dict[str, Any] | None:
cache_key = revenue_cache_key(tenant_id, property_id, year, month)

try:
cached = await redis_client.get(cache_key)
if cached:
logger.info("revenue cache hit key=%s", cache_key)
return json.loads(cached)
except Exception as exc:
logger.warning("revenue cache read failed key=%s error=%s", cache_key, exc)

result = await calculate_monthly_revenue(property_id, month, year, tenant_id)
if result is None:
return None

try:
await redis_client.setex(cache_key, 300, json.dumps(result))
except Exception as exc:
logger.warning("revenue cache write failed key=%s error=%s", cache_key, exc)

return result
Loading