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
82 changes: 72 additions & 10 deletions backend/app/api/v1/dashboard.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,87 @@
from fastapi import APIRouter, Depends, HTTPException
from typing import Dict, Any
import logging
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.exc import SQLAlchemyError
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 get_tenant_properties, get_reservations, property_belongs_to_tenant
from app.core.auth import authenticate_request as get_current_user

logger = logging.getLogger(__name__)

router = APIRouter()


def get_tenant_id(current_user) -> str:
tenant_id = getattr(current_user, "tenant_id", None)
if not tenant_id:
raise HTTPException(status_code=403, detail="No tenant for this user")
return tenant_id


def db_unavailable(e: Exception):
logger.error(f"Dashboard query failed: {e}")
return HTTPException(status_code=503, detail="Dashboard data is unavailable, try again shortly")


async def check_property(property_id: str, tenant_id: str):
try:
found = await property_belongs_to_tenant(property_id, tenant_id)
except (SQLAlchemyError, OSError) as e:
raise db_unavailable(e)
if not found:
raise HTTPException(status_code=404, detail="Property not found")


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


tenant_id = get_tenant_id(current_user)
await check_property(property_id, tenant_id)

try:
revenue_data = await get_revenue_summary(property_id, tenant_id, month, year)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))
except (SQLAlchemyError, OSError) as e:
raise db_unavailable(e)

total_revenue = Decimal(revenue_data['total']).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)

return {
"property_id": revenue_data['property_id'],
"total_revenue": total_revenue_float,
"total_revenue": float(total_revenue),
"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)
) -> Dict[str, Any]:
tenant_id = get_tenant_id(current_user)
try:
return {"properties": await get_tenant_properties(tenant_id)}
except (SQLAlchemyError, OSError) as e:
raise db_unavailable(e)


@router.get("/dashboard/reservations")
async def get_dashboard_reservations(
property_id: str,
month: Optional[int] = Query(None, ge=1, le=12),
year: Optional[int] = None,
current_user: dict = Depends(get_current_user)
) -> Dict[str, Any]:
tenant_id = get_tenant_id(current_user)
await check_property(property_id, tenant_id)
try:
return {"reservations": await get_reservations(property_id, tenant_id, month, year)}
except (SQLAlchemyError, OSError) as e:
raise db_unavailable(e)
6 changes: 2 additions & 4 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 @@ -15,11 +14,10 @@ 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}"
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
Expand All @@ -45,7 +43,7 @@ async def close(self):
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
18 changes: 15 additions & 3 deletions backend/app/core/tenant_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ def resolve_tenant_from_user(user_data: dict) -> Optional[str]:
return None

@staticmethod
async def resolve_tenant_id(user_id: str, user_email: str, token: Optional[str] = None) -> str:
async def resolve_tenant_id(user_id: str, user_email: str, token: Optional[str] = None) -> Optional[str]:
"""
Resolve tenant ID for a user.

Expand All @@ -80,6 +80,18 @@ async def resolve_tenant_id(user_id: str, user_email: str, token: Optional[str]
Returns:
Tenant ID
"""
if token:
try:
from jose import jwt
from app.config import settings

payload = jwt.decode(token, settings.secret_key, algorithms=["HS256"], audience="authenticated")
tenant_id = TenantResolver.resolve_tenant_from_token(payload)
if tenant_id:
return tenant_id
except Exception:
pass

# Fallback mapping by known user email.
if user_email == "sunset@propertyflow.com":
return "tenant-a"
Expand All @@ -88,8 +100,8 @@ async def resolve_tenant_id(user_id: str, user_email: str, token: Optional[str]
if user_email == "candidate@propertyflow.com":
return "tenant-a"

# Default fallback
return "tenant-a"
# Unknown user, don't guess a tenant
return None

@staticmethod
async def update_user_tenant_metadata(user_id: str, tenant_id: str) -> None:
Expand Down
44 changes: 29 additions & 15 deletions backend/app/services/cache.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,43 @@
import json
import redis.asyncio as redis
from typing import Dict, Any
from typing import Dict, Any, Optional
import os
import logging

logger = logging.getLogger(__name__)

# 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: Optional[int] = None, year: Optional[int] = None) -> 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)

# property ids are not unique across tenants
period = f"{year}-{month:02d}" if month and year else "all"
cache_key = f"revenue:{tenant_id}:{property_id}:{period}"

# Try to get from cache, if redis is down just go to the db
try:
cached = await redis_client.get(cache_key)
if cached:
return json.loads(cached)
except Exception as e:
logger.warning(f"Redis read failed for {cache_key}: {e}")

# Revenue calculation is delegated to the reservation service.
from app.services.reservations import calculate_total_revenue
from app.services.reservations import calculate_total_revenue, calculate_monthly_revenue

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

if month and year:
result = await calculate_monthly_revenue(property_id, tenant_id, month, year)
else:
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:
logger.warning(f"Redis write failed for {cache_key}: {e}")

return result
Loading