Skip to content
Open

fixes #141

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
9 changes: 0 additions & 9 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,9 @@ FROM python:3.11-slim

WORKDIR /app

# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*

# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copy application code
COPY . .

# Run the application
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
60 changes: 46 additions & 14 deletions backend/app/api/v1/dashboard.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,57 @@
from fastapi import APIRouter, Depends, HTTPException
from typing import Dict, Any
from fastapi import APIRouter, Depends, HTTPException, Query
from typing import Dict, Any, Optional
from decimal import Decimal, ROUND_HALF_UP
from app.services.cache import get_revenue_summary
from app.services.reservations import (
calculate_monthly_revenue,
DatabaseUnavailableError,
)
from app.core.auth import authenticate_request as get_current_user

router = APIRouter()


def _money_string(value) -> str:
amount = Decimal(str(value)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
return format(amount, "f")


@router.get("/dashboard/summary")
async def get_dashboard_summary(
property_id: str,
current_user: dict = Depends(get_current_user)
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 {
"property_id": revenue_data['property_id'],
"total_revenue": total_revenue_float,
"currency": revenue_data['currency'],
"reservations_count": revenue_data['count']

try:
revenue_data = await get_revenue_summary(property_id, tenant_id)
except DatabaseUnavailableError as e:
raise HTTPException(status_code=503, detail=str(e)) from e

response: Dict[str, Any] = {
"property_id": revenue_data["property_id"],
"tenant_id": tenant_id,
"total_revenue": _money_string(revenue_data["total"]),
"currency": revenue_data["currency"],
"reservations_count": revenue_data["count"],
}

if month is not None and year is not None:
try:
monthly = await calculate_monthly_revenue(
property_id=property_id,
month=month,
year=year,
tenant_id=tenant_id,
)
except DatabaseUnavailableError as e:
raise HTTPException(status_code=503, detail=str(e)) from e

response["monthly_revenue"] = _money_string(monthly)
response["month"] = month
response["year"] = year

return response
62 changes: 44 additions & 18 deletions backend/app/core/database_pool.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,42 @@
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.pool import QueuePool
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.pool import AsyncAdaptedQueuePool
import logging
from ..config import settings

logger = logging.getLogger(__name__)


def _async_database_url(url: str) -> str:
"""Convert a sync Postgres URL to an asyncpg SQLAlchemy URL."""
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"""
"""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}"

database_url = _async_database_url(settings.database_url)

self.engine = create_async_engine(
database_url,
poolclass=QueuePool,
poolclass=AsyncAdaptedQueuePool,
pool_size=20, # Number of connections to maintain
max_overflow=30, # Additional connections when needed
pool_pre_ping=True, # Validate connections
Expand All @@ -30,7 +47,7 @@ async def initialize(self):
self.session_factory = async_sessionmaker(
bind=self.engine,
class_=AsyncSession,
expire_on_commit=False
expire_on_commit=False,
)

logger.info("✅ Database connection pool initialized")
Expand All @@ -39,22 +56,31 @@ async def initialize(self):
logger.error(f"❌ Database pool initialization failed: {e}")
self.engine = None
self.session_factory = None

raise

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:
"""Return a new AsyncSession from the shared pool (sync — not a coroutine)."""
if not self.session_factory:
raise Exception("Database pool not initialized")
raise RuntimeError("Database pool not initialized")
return self.session_factory()

# Global database pool instance

# Global database pool instance — reuse this; do not construct a new engine per request.
db_pool = DatabasePool()


async def get_db_session() -> AsyncSession:
"""Dependency to get database session"""
async with db_pool.get_session() as session:
"""FastAPI dependency that yields a database session."""
await db_pool.initialize()
session = db_pool.get_session()
try:
yield session
finally:
await session.close()
37 changes: 23 additions & 14 deletions backend/app/services/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,33 @@
# Initialize Redis client (typically configured centrally).
redis_client = redis.Redis.from_url(os.getenv("REDIS_URL", "redis://localhost:6379/0"))


def _revenue_cache_key(tenant_id: str, property_id: str) -> str:
return f"revenue:{tenant_id}:{property_id}"


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.
cache_key = _revenue_cache_key(tenant_id, property_id)

try:
cached = await redis_client.get(cache_key)
if cached:
payload = json.loads(cached)
if payload.get("tenant_id") == tenant_id:
return payload
except Exception as e:
print(f"Redis read error for {cache_key}: {e}")

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


try:
await redis_client.setex(cache_key, 300, json.dumps(result))
except Exception as e:
print(f"Redis write error for {cache_key}: {e}")

return result
Loading