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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
__pycache__/
**/__pycache__/
4 changes: 1 addition & 3 deletions backend/app/api/v1/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,9 @@ async def get_dashboard_summary(

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,
"total_revenue": revenue_data['total'],
"currency": revenue_data['currency'],
"reservations_count": revenue_data['count']
}
21 changes: 21 additions & 0 deletions backend/app/api/v1/properties.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from fastapi import APIRouter, Depends, HTTPException
from typing import List, Dict, Any
from ...core.auth import authenticate_request
from ...models.auth import AuthenticatedUser
from ...services.cache import get_tenant_properties

router = APIRouter()

@router.get("/properties")
async def get_properties(
current_user: AuthenticatedUser = Depends(authenticate_request)
) -> List[Dict[str, Any]]:
tenant_id = current_user.tenant_id

try:
return await get_tenant_properties(tenant_id)
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Failed to fetch properties: {str(e)}"
)
6 changes: 3 additions & 3 deletions backend/app/core/database_pool.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import asyncio
import re
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.pool import QueuePool
import logging
Expand All @@ -15,11 +16,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 = re.sub(r"^postgresql://", "postgresql+asyncpg://", settings.database_url)

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 +45,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
7 changes: 7 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
persistent_auth,
dashboard,
login,
properties,
)

from .monitoring.middleware import PerformanceMonitoringMiddleware
Expand Down Expand Up @@ -100,6 +101,9 @@ async def lifespan(app: FastAPI):
logger.error(f"❌ Supabase connection pool initialization failed: {e}")
# Continue startup - fallback to direct connections

from .core.database_pool import db_pool
await db_pool.initialize()

# Initialize Redis connection with timeout
try:
await redis_client.initialize()
Expand Down Expand Up @@ -184,6 +188,9 @@ async def lifespan(app: FastAPI):
# Dashboard
app.include_router(dashboard.router, prefix="/api/v1", tags=["dashboard"])

# Properties
app.include_router(properties.router, prefix="/api/v1", tags=["properties"])

# Bootstrap & Settings (for AppContext)
app.include_router(company_settings.router, prefix="/api/v1", tags=["company-settings"])
app.include_router(bootstrap.router, prefix="/api/v1", tags=["bootstrap"])
Expand Down
20 changes: 18 additions & 2 deletions backend/app/services/cache.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,32 @@
import json
import redis.asyncio as redis
from typing import Dict, Any
from typing import Dict, Any, List
import os

# Initialize Redis client (typically configured centrally).
redis_client = redis.Redis.from_url(os.getenv("REDIS_URL", "redis://localhost:6379/0"))

async def get_tenant_properties(tenant_id: str) -> List[Dict[str, Any]]:
cache_key = f"properties:{tenant_id}"

cached = await redis_client.get(cache_key)
if cached:
return json.loads(cached)

from app.services.properties import get_tenant_properties_db

result = await get_tenant_properties_db(tenant_id)

await redis_client.setex(cache_key, 300, json.dumps(result))

return result


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)
Expand Down
29 changes: 29 additions & 0 deletions backend/app/services/properties.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from typing import List, Dict, Any


async def get_tenant_properties_db(tenant_id: str) -> List[Dict[str, Any]]:
from app.core.database_pool import db_pool
from sqlalchemy import text

if not db_pool.session_factory:
raise Exception("Database pool not available")

async with db_pool.get_session() as session:
result = await session.execute(
text(
"SELECT id, name, timezone, tenant_id "
"FROM properties "
"WHERE tenant_id = :tenant_id"
),
{"tenant_id": tenant_id},
)
rows = result.fetchall()
return [
{
"id": row.id,
"name": row.name,
"timezone": row.timezone,
"tenant_id": row.tenant_id,
}
for row in rows
]
11 changes: 3 additions & 8 deletions backend/app/services/reservations.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
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:
Expand Down Expand Up @@ -36,12 +36,7 @@ async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str,
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()
from app.core.database_pool import db_pool

if db_pool.session_factory:
async with db_pool.get_session() as session:
Expand All @@ -65,7 +60,7 @@ async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str,
row = result.fetchone()

if row:
total_revenue = Decimal(str(row.total_revenue))
total_revenue = row.total_revenue.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
return {
"property_id": property_id,
"tenant_id": tenant_id,
Expand Down
2 changes: 2 additions & 0 deletions backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,5 @@ loguru
redis>=5.0.0
asyncpg>=0.27.0
requests
pytest>=8.0.0
pytest-asyncio>=0.24.0
Empty file added backend/tests/__init__.py
Empty file.
51 changes: 51 additions & 0 deletions backend/tests/test_db_connection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import pytest
from decimal import Decimal
from unittest.mock import MagicMock, AsyncMock, patch


@pytest.mark.asyncio
async def test_calculate_total_revenue_never_hits_mock_fallback():
"""
When database pool is properly initialized, calculate_total_revenue
queries the database and does NOT fall back to mock data.
"""
print_calls = []

def capture(*args, **kwargs):
msg = args[0] if args else ""
if isinstance(msg, str):
print_calls.append(msg)

from app.core.database_pool import db_pool

mock_row = MagicMock()
mock_row.total_revenue = Decimal("2250.000")
mock_row.reservation_count = 4

mock_result = MagicMock()
mock_result.fetchone.return_value = mock_row

mock_session = AsyncMock()
mock_session.execute = AsyncMock(return_value=mock_result)
mock_session.__aenter__.return_value = mock_session

mock_factory = MagicMock(return_value=mock_session)

try:
with patch(
"app.core.database_pool.async_sessionmaker", return_value=mock_factory
):
await db_pool.initialize()

with patch("builtins.print", capture):
from app.services.reservations import calculate_total_revenue

result = await calculate_total_revenue("prop-001", "tenant-a")

errors = [c for c in print_calls if "Database error" in c]
assert not errors, f"Encountered database error: {errors[0]}"
assert result["total"] == "2250.00"
assert result["count"] == 4
finally:
db_pool.session_factory = None
db_pool.engine = None
85 changes: 85 additions & 0 deletions backend/tests/test_properties_isolation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import pytest
from unittest.mock import MagicMock, AsyncMock, patch


@pytest.mark.asyncio
async def test_properties_returned_only_for_correct_tenant():
"""
get_tenant_properties_db must return only the calling tenant's
properties, even when property IDs overlap across tenants.
"""
mock_session = AsyncMock()
mock_session.__aenter__.return_value = mock_session

def _row(id, name, timezone, tenant_id):
r = MagicMock()
r.id = id
r.name = name
r.timezone = timezone
r.tenant_id = tenant_id
return r

tenant_a_props = [
_row("prop-001", "Beach House Alpha", "Europe/Paris", "tenant-a"),
_row("prop-002", "City Apartment Downtown", "Europe/Paris", "tenant-a"),
_row("prop-003", "Country Villa Estate", "Europe/Paris", "tenant-a"),
]
tenant_b_props = [
_row("prop-001", "Mountain Lodge Beta", "America/New_York", "tenant-b"),
_row("prop-004", "Lakeside Cottage", "America/New_York", "tenant-b"),
_row("prop-005", "Urban Loft Modern", "America/New_York", "tenant-b"),
]

executed_params = []

async def mock_execute(query, params):
executed_params.append(dict(params))
result = MagicMock()
if params["tenant_id"] == "tenant-a":
result.fetchall.return_value = tenant_a_props
else:
result.fetchall.return_value = tenant_b_props
return result

mock_session.execute = mock_execute

mock_factory = MagicMock(return_value=mock_session)

from app.core.database_pool import db_pool

try:
with patch(
"app.core.database_pool.async_sessionmaker", return_value=mock_factory
):
await db_pool.initialize()

from app.services.properties import get_tenant_properties_db

a_result = await get_tenant_properties_db("tenant-a")
b_result = await get_tenant_properties_db("tenant-b")

# tenant-a: has prop-001 (Beach House Alpha), not prop-004 or prop-005
a_ids = [p["id"] for p in a_result]
assert "prop-001" in a_ids
assert "prop-002" in a_ids
assert "prop-003" in a_ids
assert "prop-004" not in a_ids
assert "prop-005" not in a_ids
assert a_result[0]["name"] == "Beach House Alpha"

# tenant-b: has prop-001 (Mountain Lodge Beta), not prop-002 or prop-003
b_ids = [p["id"] for p in b_result]
assert "prop-001" in b_ids
assert "prop-004" in b_ids
assert "prop-005" in b_ids
assert "prop-002" not in b_ids
assert "prop-003" not in b_ids
assert b_result[0]["name"] == "Mountain Lodge Beta"

# Both queries include tenant_id in params
assert executed_params[0]["tenant_id"] == "tenant-a"
assert executed_params[1]["tenant_id"] == "tenant-b"

finally:
db_pool.session_factory = None
db_pool.engine = None
Loading