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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
__pycache__/
*.py[cod]
25 changes: 18 additions & 7 deletions backend/app/api/v1/dashboard.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,36 @@
from fastapi import APIRouter, Depends, HTTPException
from typing import Dict, Any
from fastapi import APIRouter, Depends, Query
from typing import Any, Dict, List
from app.services.cache import get_revenue_summary
from app.services.properties import list_tenant_properties
from app.core.auth import authenticate_request as get_current_user

router = APIRouter()


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

@router.get("/dashboard/summary")
async def get_dashboard_summary(
property_id: str,
month: int = Query(3, ge=1, le=12),
year: int = Query(2024, ge=1),
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'])
revenue_data = await get_revenue_summary(property_id, tenant_id, month, year)

return {
"property_id": revenue_data['property_id'],
"total_revenue": total_revenue_float,
"property_id": revenue_data["property_id"],
"month": revenue_data["month"],
"year": revenue_data["year"],
"total_revenue": revenue_data["total"],
"currency": revenue_data['currency'],
"reservations_count": revenue_data['count']
}
70 changes: 45 additions & 25 deletions backend/app/core/database_pool.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.pool import QueuePool
from sqlalchemy import text
import logging
from ..config import settings

Expand All @@ -10,42 +10,62 @@ class DatabasePool:
def __init__(self):
self.engine = None
self.session_factory = None
self._initialize_lock = asyncio.Lock()

async def initialize(self):
"""Initialize database connection pool"""
async def initialize(self) -> bool:
"""Initialize and verify the shared database connection pool once."""
if self.session_factory:
return True

candidate_engine = None
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")

async with self._initialize_lock:
if self.session_factory:
return True

database_url = settings.database_url.replace(
"postgresql://",
"postgresql+asyncpg://",
1,
)

candidate_engine = create_async_engine(
database_url,
pool_size=20,
max_overflow=30,
pool_pre_ping=True,
pool_recycle=3600,
echo=False,
)

async with candidate_engine.connect() as connection:
await connection.execute(text("SELECT 1"))

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

logger.info("✅ Database connection pool initialized")
return True
except Exception as e:
logger.error(f"❌ Database pool initialization failed: {e}")
if candidate_engine:
await candidate_engine.dispose()
self.engine = None
self.session_factory = None
return False

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

async def get_session(self) -> AsyncSession:
def get_session(self) -> AsyncSession:
"""Get database session from pool"""
if not self.session_factory:
raise Exception("Database pool not initialized")
Expand Down
20 changes: 19 additions & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,15 @@ async def lifespan(app: FastAPI):
# Startup
logger.info("Starting up...")

# Initialize and verify the shared PostgreSQL pool before serving requests.
try:
from .core.database_pool import db_pool

if not await db_pool.initialize():
raise RuntimeError("PostgreSQL connection verification failed")
except Exception as e:
logger.error(f"❌ PostgreSQL connection pool initialization failed: {e}")

# Initialize Supabase connection pool
try:
from .core.supabase_connection_pool import supabase_pool
Expand Down Expand Up @@ -127,7 +136,16 @@ async def lifespan(app: FastAPI):
await async_processor.shutdown()
logger.info("Async processor shutdown completed")

# Close connection pool
# Close PostgreSQL connection pool
try:
from .core.database_pool import db_pool

await db_pool.close()
logger.info("✅ Database connection pool closed")
except Exception as e:
logger.warning(f"⚠️ Error closing database connection pool: {e}")

# Close Supabase connection pool
try:
from .core.supabase_connection_pool import supabase_pool

Expand Down
18 changes: 14 additions & 4 deletions backend/app/services/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,32 @@
# 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,
month: int,
year: int,
) -> Dict[str, Any]:
"""
Fetches revenue summary, utilizing caching to improve performance.
"""
cache_key = f"revenue:{property_id}"
cache_key = f"revenue:{tenant_id}:{property_id}:{year}:{month}"

# 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
from app.services.reservations import calculate_monthly_revenue_summary

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

# Cache the result for 5 minutes
await redis_client.setex(cache_key, 300, json.dumps(result))
Expand Down
33 changes: 33 additions & 0 deletions backend/app/services/properties.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from typing import Any, Dict, List

from sqlalchemy import text

from app.core.database_pool import db_pool


async def list_tenant_properties(tenant_id: str) -> List[Dict[str, Any]]:
"""Return only properties owned by the authenticated tenant."""
if not db_pool.session_factory:
await db_pool.initialize()
if not db_pool.session_factory:
raise RuntimeError("Database pool not available")

async with db_pool.get_session() as session:
result = await session.execute(
text("""
SELECT id, name, timezone
FROM properties
WHERE tenant_id = :tenant_id
ORDER BY name, id
"""),
{"tenant_id": tenant_id},
)

return [
{
"id": row.id,
"name": row.name,
"timezone": row.timezone,
}
for row in result.fetchall()
]
Loading