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
Binary file added backend/app/__pycache__/config.cpython-311.pyc
Binary file not shown.
Binary file added backend/app/__pycache__/database.cpython-311.pyc
Binary file not shown.
Binary file added backend/app/__pycache__/main.cpython-311.pyc
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
35 changes: 27 additions & 8 deletions backend/app/api/v1/dashboard.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,44 @@
from fastapi import APIRouter, Depends, HTTPException
from typing import Dict, Any
from typing import Dict, Any, List
from app.services.cache import get_revenue_summary
from app.services.reservations import get_tenant_properties
from app.core.auth import authenticate_request as get_current_user

router = APIRouter()


def _require_tenant(current_user) -> str:
"""The tenant always comes from the authenticated user, never a default."""
tenant_id = getattr(current_user, "tenant_id", None)
if not tenant_id:
raise HTTPException(status_code=403, detail="No tenant associated with this user")
return tenant_id


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


@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"

tenant_id = _require_tenant(current_user)

revenue_data = await get_revenue_summary(property_id, tenant_id)

total_revenue_float = float(revenue_data['total'])
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,
# Exact decimal string, already rounded to cents; never a float
"total_revenue": revenue_data['total'],
"currency": revenue_data['currency'],
"reservations_count": revenue_data['count']
}
Binary file not shown.
Binary file added backend/app/core/__pycache__/auth.cpython-311.pyc
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
80 changes: 47 additions & 33 deletions backend/app/core/database_pool.py
Original file line number Diff line number Diff line change
@@ -1,52 +1,65 @@
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.pool import QueuePool
import logging
from ..config import settings

logger = logging.getLogger(__name__)


def _async_database_url(url: str) -> str:
"""Convert a plain postgresql:// URL into the asyncpg driver form."""
for prefix in ("postgresql+asyncpg://", "postgresql://", "postgres://"):
if url.startswith(prefix):
return "postgresql+asyncpg://" + url[len(prefix):]
return url


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

self._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

"""Initialize database connection pool (idempotent)"""
if self.session_factory:
return
async with self._lock:
if self.session_factory:
return
try:
# Async engines use AsyncAdaptedQueuePool by default
self.engine = create_async_engine(
_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 database session from pool (use with `async with`)"""
if not self.session_factory:
raise Exception("Database pool not initialized")
return self.session_factory()
Expand All @@ -56,5 +69,6 @@ async def get_session(self) -> AsyncSession:

async def get_db_session() -> AsyncSession:
"""Dependency to get database session"""
await db_pool.initialize()
async with db_pool.get_session() as session:
yield session
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
21 changes: 13 additions & 8 deletions backend/app/services/cache.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,34 @@
import json
import redis.asyncio as redis
from typing import Dict, Any
from typing import Dict, Any, Optional
import os

# 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]:
async def get_revenue_summary(property_id: str, tenant_id: str) -> Optional[Dict[str, Any]]:
"""
Fetches revenue summary, utilizing caching to improve performance.
Returns None when the property does not belong to the tenant.
"""
cache_key = f"revenue:{property_id}"

# Property IDs are only unique per tenant, so the tenant must be part
# of the key to keep tenants isolated.
cache_key = f"revenue:{tenant_id}:{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)

if result is None:
return None

# Cache the result for 5 minutes
await redis_client.setex(cache_key, 300, json.dumps(result))

return result
Loading