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.
82 changes: 77 additions & 5 deletions backend/app/api/v1/dashboard.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,97 @@
from fastapi import APIRouter, Depends, HTTPException
from typing import Dict, Any
from fastapi import APIRouter, Depends, HTTPException, Query
from typing import Dict, Any, List
from app.services.cache import get_revenue_summary
from app.core.auth import authenticate_request as get_current_user
from app.models.auth import AuthenticatedUser
import logging
from app.config import settings
import asyncpg
from datetime import datetime, timezone

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

@router.get("/dashboard/summary")
async def get_dashboard_summary(
property_id: str,
timestamp: int = Query(..., alias="_t"),
current_user: dict = Depends(get_current_user)
) -> Dict[str, Any]:

tenant_id = getattr(current_user, "tenant_id", "default_tenant") or "default_tenant"
requested_date = datetime.fromtimestamp(
timestamp / 1000,
tz=timezone.utc,
)
year = requested_date.year
month = requested_date.month

revenue_data = await get_revenue_summary(property_id, tenant_id)
revenue_data = await get_revenue_summary(property_id, tenant_id, year, month)

total_revenue_float = float(revenue_data['total'])
# total_revenue_float = float(revenue_data['total'])

return {
"property_id": revenue_data['property_id'],
"total_revenue": total_revenue_float,
# "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: AuthenticatedUser = Depends(get_current_user),
) -> List[Dict[str, Any]]:
"""
Return only properties belonging to the authenticated user's tenant.
"""

tenant_id = current_user.tenant_id

if not tenant_id:
raise HTTPException(
status_code=403,
detail="Authenticated user has no tenant",
)

connection = None

try:
connection = await asyncpg.connect(settings.database_url)

rows = await connection.fetch(
"""
SELECT
id,
name,
timezone
FROM properties
WHERE tenant_id = $1
ORDER BY name
""",
tenant_id,
)

return [
{
"id": row["id"],
"name": row["name"],
"timezone": row["timezone"],
}
for row in rows
]

except Exception:
logger.exception(
"Failed to load properties for tenant %s",
tenant_id,
)

raise HTTPException(
status_code=503,
detail="Properties are temporarily unavailable",
)

finally:
if connection is not None:
await connection.close()
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.
47 changes: 30 additions & 17 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.pool import QueuePool
import logging
from ..config import settings

Expand All @@ -13,30 +13,43 @@ def __init__(self):

async def initialize(self):
"""Initialize database connection pool"""
if self.engine and self.session_factory:
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

# SQLAlchemy async engine requires the asyncpg driver.
if database_url.startswith("postgresql://"):
database_url = 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=settings.database_pool_size,
max_overflow=settings.database_max_overflow,
pool_pre_ping=True,
pool_recycle=settings.database_pool_recycle,
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")

except Exception as e:
logger.error(f"❌ Database pool initialization failed: {e}")

logger.info("Database connection pool initialized")

except Exception as error:
logger.exception(
"Database pool initialization failed: %s",
error,
)

self.engine = None
self.session_factory = None

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

# Try to get from cache
cached = await redis_client.get(cache_key)
Expand All @@ -21,7 +22,7 @@ async def get_revenue_summary(property_id: str, tenant_id: str) -> Dict[str, Any
from app.services.reservations import calculate_total_revenue

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

# Cache the result for 5 minutes
await redis_client.setex(cache_key, 300, json.dumps(result))
Expand Down
140 changes: 71 additions & 69 deletions backend/app/services/reservations.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
from datetime import datetime
from decimal import Decimal
from typing import Dict, Any, List
import logging
from sqlalchemy import text
from app.core.database_pool import db_pool

logger = logging.getLogger(__name__)

async def calculate_monthly_revenue(property_id: str, month: int, year: int, db_session=None) -> Decimal:
"""
Expand Down Expand Up @@ -31,79 +36,76 @@ async def calculate_monthly_revenue(property_id: str, month: int, year: int, db_

return Decimal('0') # Placeholder for now until DB connection is finalized

async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, Any]:
async def calculate_total_revenue(property_id: str, tenant_id: str, year: int, month: int) -> Dict[str, Any]:

"""
Aggregates revenue from database.
Calculate monthly revenue for one tenant's property.

March 2024 is the default reporting period for this assignment.
"""
try:
# Import database pool
from app.core.database_pool import DatabasePool

# Initialize pool if needed
db_pool = DatabasePool()

# Initialize the shared database pool when needed.
if db_pool.session_factory is None:
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})


if db_pool.session_factory is None:
raise RuntimeError("Database pool is unavailable")

session = await db_pool.get_session()

try:
result = await session.execute(
text(
"""
SELECT
COALESCE(SUM(r.total_amount), 0) AS total_revenue,
COUNT(*) AS reservation_count
FROM reservations AS r
JOIN properties AS 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

-- CHANGED:
-- Convert the reservation time into the property's
-- local timezone before checking the month and year.
AND EXTRACT(
YEAR FROM
r.check_in_date AT TIME ZONE p.timezone
) = :year

AND EXTRACT(
MONTH FROM
r.check_in_date AT TIME ZONE p.timezone
) = :month
"""
),
{
"property_id": property_id,
"tenant_id": tenant_id,
"year": year,
"month": month,
},
)

row = result.one()

total_revenue = Decimal(str(row.total_revenue))

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,
}

except Exception:
# CHANGED:
# Do not return hardcoded fake financial data.
# Let the API report the actual database failure.
raise

finally:
await session.close()
Loading