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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
__pycache__/
*.pyc
node_modules/
.env
.idea/
49 changes: 41 additions & 8 deletions backend/app/api/v1/dashboard.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,58 @@
from fastapi import APIRouter, Depends, HTTPException
from typing import Dict, Any
from typing import Dict, Any, List
from sqlalchemy import text
from app.core.database_pool import db_pool
from app.services.cache import get_revenue_summary
from app.core.auth import authenticate_request as get_current_user
import logging

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

@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'])


try:
revenue_data = await get_revenue_summary(property_id, tenant_id)
except Exception:
logger.exception(f"Revenue lookup failed for {property_id} (tenant: {tenant_id})")
raise HTTPException(status_code=503, detail="Revenue data temporarily unavailable")

return {
"property_id": revenue_data['property_id'],
"total_revenue": total_revenue_float,
"total_revenue": revenue_data['total'],
"currency": revenue_data['currency'],
"reservations_count": revenue_data['count']
}

@router.get("/dashboard/properties")
async def get_dashboard_properties(
current_user: dict = Depends(get_current_user)
) -> List[Dict[str, Any]]:
"""Returns only the properties that belong to the caller's tenant."""
tenant_id = getattr(current_user, "tenant_id", None)
if not tenant_id:
raise HTTPException(status_code=403, detail="Tenant could not be resolved")

await db_pool.initialize()
if not db_pool.session_factory:
raise HTTPException(status_code=503, detail="Properties temporarily unavailable")

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 id
"""),
{"tenant_id": tenant_id}
)
rows = result.fetchall()

return [{"id": r.id, "name": r.name, "timezone": r.timezone} for r in rows]
40 changes: 21 additions & 19 deletions backend/app/core/database_pool.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.pool import QueuePool
import logging
from ..config import settings

Expand All @@ -10,42 +9,45 @@ class DatabasePool:
def __init__(self):
self.engine = None
self.session_factory = None

async def initialize(self):
"""Initialize database connection pool"""
if self.engine:
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 = settings.database_url.replace(
"postgresql://", "postgresql+asyncpg://", 1
)

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

def get_session(self) -> AsyncSession:
"""Get database session from pool"""
if not self.session_factory:
raise Exception("Database pool not initialized")
Expand Down
12 changes: 6 additions & 6 deletions backend/app/services/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,20 @@ 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}"
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)

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

return result
162 changes: 69 additions & 93 deletions backend/app/services/reservations.py
Original file line number Diff line number Diff line change
@@ -1,109 +1,85 @@
from datetime import datetime
from decimal import Decimal
from decimal import Decimal, ROUND_HALF_UP
from typing import Dict, Any, List

async def calculate_monthly_revenue(property_id: str, month: int, year: int, db_session=None) -> Decimal:
async def calculate_monthly_revenue(property_id: str, tenant_id: str, month: int, year: int) -> Decimal:
"""
Calculates revenue for a specific month.
Check-ins are bucketed by the property's local timezone, not UTC.
"""
from sqlalchemy import text
from app.core.database_pool import db_pool

start_date = datetime(year, month, 1)
if month < 12:
end_date = datetime(year, month + 1, 1)
else:
end_date = datetime(year + 1, 1, 1)

print(f"DEBUG: Querying revenue for {property_id} from {start_date} to {end_date}")
await db_pool.initialize()
if not db_pool.session_factory:
raise RuntimeError("Database pool not available")

# SQL Simulation (This would be executed against the actual DB)
query = """
SELECT SUM(total_amount) as total
FROM reservations
WHERE property_id = $1
AND tenant_id = $2
AND check_in_date >= $3
AND check_in_date < $4
"""

# In production this query executes against a database session.
# result = await db.fetch_val(query, property_id, tenant_id, start_date, end_date)
# return result or Decimal('0')

return Decimal('0') # Placeholder for now until DB connection is finalized
query = text("""
SELECT COALESCE(SUM(r.total_amount), 0) AS total
FROM reservations r
JOIN properties p
ON p.id = r.property_id AND p.tenant_id = r.tenant_id
WHERE r.property_id = :property_id
AND r.tenant_id = :tenant_id
AND (r.check_in_date AT TIME ZONE p.timezone) >= make_timestamp(:year, :month, 1, 0, 0, 0)
AND (r.check_in_date AT TIME ZONE p.timezone) < make_timestamp(:year, :month, 1, 0, 0, 0) + INTERVAL '1 month'
""")

async with db_pool.get_session() as session:
result = await session.execute(query, {
"property_id": property_id,
"tenant_id": tenant_id,
"month": month,
"year": year
})
total = result.scalar_one()

return Decimal(str(total)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)

async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, Any]:
"""
Aggregates revenue from database.
"""
try:
# Import database pool
from app.core.database_pool import DatabasePool

# Initialize pool if needed
db_pool = DatabasePool()
await db_pool.initialize()

if db_pool.session_factory:
async with db_pool.get_session() as session:
# Use SQLAlchemy text for raw SQL
from sqlalchemy import text

query = text("""
SELECT
property_id,
SUM(total_amount) as total_revenue,
COUNT(*) as reservation_count
FROM reservations
WHERE property_id = :property_id AND tenant_id = :tenant_id
GROUP BY property_id
""")

result = await session.execute(query, {
"property_id": property_id,
"tenant_id": tenant_id
})
row = result.fetchone()

if row:
total_revenue = Decimal(str(row.total_revenue))
return {
"property_id": property_id,
"tenant_id": tenant_id,
"total": str(total_revenue),
"currency": "USD",
"count": row.reservation_count
}
else:
# No reservations found for this property
return {
"property_id": property_id,
"tenant_id": tenant_id,
"total": "0.00",
"currency": "USD",
"count": 0
}
else:
raise Exception("Database pool not available")

except Exception as e:
print(f"Database error for {property_id} (tenant: {tenant_id}): {e}")

# Create property-specific mock data for testing when DB is unavailable
# This ensures each property shows different figures
mock_data = {
'prop-001': {'total': '1000.00', 'count': 3},
'prop-002': {'total': '4975.50', 'count': 4},
'prop-003': {'total': '6100.50', 'count': 2},
'prop-004': {'total': '1776.50', 'count': 4},
'prop-005': {'total': '3256.00', 'count': 3}
}

mock_property_data = mock_data.get(property_id, {'total': '0.00', 'count': 0})

from sqlalchemy import text
from app.core.database_pool import db_pool

await db_pool.initialize()
if not db_pool.session_factory:
raise RuntimeError("Database pool not available")

async with db_pool.get_session() as session:
query = text("""
SELECT
property_id,
SUM(total_amount) as total_revenue,
COUNT(*) as reservation_count
FROM reservations
WHERE property_id = :property_id AND tenant_id = :tenant_id
GROUP BY property_id
""")

result = await session.execute(query, {
"property_id": property_id,
"tenant_id": tenant_id
})
row = result.fetchone()

if row:
total_revenue = Decimal(str(row.total_revenue)).quantize(
Decimal("0.01"), rounding=ROUND_HALF_UP
)
return {
"property_id": property_id,
"tenant_id": tenant_id,
"total": mock_property_data['total'],
"tenant_id": tenant_id,
"total": str(total_revenue),
"currency": "USD",
"count": mock_property_data['count']
"count": row.reservation_count
}

# No reservations found for this property
return {
"property_id": property_id,
"tenant_id": tenant_id,
"total": "0.00",
"currency": "USD",
"count": 0
}
2 changes: 1 addition & 1 deletion backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ pytz
redis>=5.0.0
requests
sendgrid
sqlalchemy>=2.0.0
sqlalchemy[asyncio]>=2.0.0
supabase
tenacity>=8.0.0
uvicorn[standard]
Expand Down
Loading