diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..43ae0e2a6c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.py[cod] diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py index 1ec352d7e3..d4cc891efb 100644 --- a/backend/app/api/v1/dashboard.py +++ b/backend/app/api/v1/dashboard.py @@ -1,25 +1,39 @@ from fastapi import APIRouter, Depends, HTTPException -from typing import Dict, Any +from typing import Any, Dict, List from app.services.cache import get_revenue_summary +from app.services.reservations import REPORT_MONTH, REPORT_YEAR, list_tenant_properties from app.core.auth import authenticate_request as get_current_user router = APIRouter() + +def _tenant_id(current_user) -> str: + tenant_id = getattr(current_user, "tenant_id", None) + if not tenant_id: + raise HTTPException(status_code=401, detail="Tenant not resolved") + return tenant_id + + +@router.get("/dashboard/properties") +async def get_dashboard_properties( + current_user: dict = Depends(get_current_user), +) -> List[Dict[str, Any]]: + return await list_tenant_properties(_tenant_id(current_user)) + + @router.get("/dashboard/summary") async def get_dashboard_summary( property_id: str, - current_user: dict = Depends(get_current_user) + year: int = REPORT_YEAR, + month: int = REPORT_MONTH, + 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 = _tenant_id(current_user) + revenue_data = await get_revenue_summary(property_id, tenant_id, year=year, month=month) + return { - "property_id": revenue_data['property_id'], - "total_revenue": total_revenue_float, - "currency": revenue_data['currency'], - "reservations_count": revenue_data['count'] + "property_id": revenue_data["property_id"], + "total_revenue": revenue_data["total"], + "currency": revenue_data["currency"], + "reservations_count": revenue_data["count"], } diff --git a/backend/app/core/database_pool.py b/backend/app/core/database_pool.py index d638dfcfe3..0e0a375864 100644 --- a/backend/app/core/database_pool.py +++ b/backend/app/core/database_pool.py @@ -1,60 +1,63 @@ import asyncio -from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker -from sqlalchemy.pool import QueuePool +from contextlib import asynccontextmanager import logging + +import asyncpg + from ..config import settings logger = logging.getLogger(__name__) + +def _asyncpg_database_url(url: str) -> str: + if url.startswith("postgresql+asyncpg://"): + return "postgresql://" + url[len("postgresql+asyncpg://"):] + if url.startswith("postgres+asyncpg://"): + return "postgresql://" + url[len("postgres+asyncpg://"):] + return url + + class DatabasePool: def __init__(self): - self.engine = None - self.session_factory = None - + self.pool = None + self._init_lock = asyncio.Lock() + 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}" - - 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") - - except Exception as e: - logger.error(f"❌ Database pool initialization failed: {e}") - self.engine = None - self.session_factory = None - + if self.pool: + return + + async with self._init_lock: + if self.pool: + return + + try: + self.pool = await asyncpg.create_pool( + _asyncpg_database_url(settings.database_url), + min_size=1, + max_size=20, + ) + logger.info("Database connection pool initialized") + except Exception as e: + logger.error(f"Database pool initialization failed: {e}") + self.pool = None + async def close(self): - """Close database connections""" - if self.engine: - await self.engine.dispose() - - async def get_session(self) -> AsyncSession: - """Get database session from pool""" - if not self.session_factory: + if self.pool: + await self.pool.close() + self.pool = None + + @asynccontextmanager + async def get_connection(self): + if not self.pool: + await self.initialize() + if not self.pool: raise Exception("Database pool not initialized") - return self.session_factory() + async with self.pool.acquire() as connection: + yield connection # Global database pool instance db_pool = DatabasePool() -async def get_db_session() -> AsyncSession: - """Dependency to get database session""" - async with db_pool.get_session() as session: - yield session +async def get_db_session(): + async with db_pool.get_connection() as connection: + yield connection diff --git a/backend/app/services/cache.py b/backend/app/services/cache.py index b81474957f..bb83f67968 100644 --- a/backend/app/services/cache.py +++ b/backend/app/services/cache.py @@ -3,27 +3,44 @@ from typing import Dict, Any import os +from app.services.reservations import REPORT_MONTH, REPORT_YEAR + # 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]: + +def _revenue_cache_key(tenant_id: str, property_id: str, year: int, month: int) -> str: + return f"revenue:{tenant_id}:{property_id}:{year}-{month:02d}" + + +async def get_revenue_summary( + property_id: str, + tenant_id: str, + year: int = REPORT_YEAR, + month: int = REPORT_MONTH, +) -> Dict[str, Any]: """ Fetches revenue summary, utilizing caching to improve performance. """ - cache_key = f"revenue:{property_id}" - - # Try to get from cache + cache_key = _revenue_cache_key(tenant_id, property_id, year, month) + cached = await redis_client.get(cache_key) if cached: - return json.loads(cached) - - # Revenue calculation is delegated to the reservation service. + payload = json.loads(cached) + if payload.get("tenant_id") == tenant_id: + return payload + 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 + + result = await calculate_total_revenue(property_id, tenant_id, year=year, month=month) + if result.get("tenant_id") != tenant_id: + result = { + "property_id": property_id, + "tenant_id": tenant_id, + "total": "0.00", + "currency": "USD", + "count": 0, + } + await redis_client.setex(cache_key, 300, json.dumps(result)) - return result diff --git a/backend/app/services/reservations.py b/backend/app/services/reservations.py index 384bd00abe..28149a12af 100644 --- a/backend/app/services/reservations.py +++ b/backend/app/services/reservations.py @@ -1,109 +1,126 @@ from datetime import datetime -from decimal import Decimal -from typing import Dict, Any, List +from decimal import Decimal, ROUND_HALF_UP +from typing import Any, Dict, Tuple +from zoneinfo import ZoneInfo -async def calculate_monthly_revenue(property_id: str, month: int, year: int, db_session=None) -> Decimal: - """ - Calculates revenue for a specific month. - """ +REPORT_YEAR = 2024 +REPORT_MONTH = 3 +_CENTS = Decimal("0.01") - start_date = datetime(year, month, 1) - if month < 12: - end_date = datetime(year, month + 1, 1) + +def _quantize_money(amount: Decimal) -> Decimal: + return amount.quantize(_CENTS, rounding=ROUND_HALF_UP) + + +def _empty_summary(property_id: str, tenant_id: str) -> Dict[str, Any]: + return { + "property_id": property_id, + "tenant_id": tenant_id, + "total": "0.00", + "currency": "USD", + "count": 0, + } + + +def _month_window(year: int, month: int, timezone_name: str) -> Tuple[datetime, datetime]: + try: + tz = ZoneInfo(timezone_name) + except Exception: + tz = ZoneInfo("UTC") + + start_local = datetime(year, month, 1, tzinfo=tz) + if month == 12: + end_local = datetime(year + 1, 1, 1, tzinfo=tz) else: - end_date = datetime(year + 1, 1, 1) - - print(f"DEBUG: Querying revenue for {property_id} from {start_date} to {end_date}") - - # 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 - -async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, Any]: - """ - Aggregates revenue from database. - """ + end_local = datetime(year, month + 1, 1, tzinfo=tz) + + utc = ZoneInfo("UTC") + return start_local.astimezone(utc), end_local.astimezone(utc) + + +async def _monthly_totals( + property_id: str, + tenant_id: str, + year: int, + month: int, +) -> Tuple[Decimal, int]: + from app.core.database_pool import db_pool + + async with db_pool.get_connection() as connection: + timezone_name = await connection.fetchval( + """ + SELECT timezone + FROM properties + WHERE id = $1 AND tenant_id = $2 + """, + property_id, + tenant_id, + ) + start_date, end_date = _month_window(year, month, timezone_name or "UTC") + + row = await connection.fetchrow( + """ + SELECT + COALESCE(SUM(total_amount), 0) AS total_revenue, + COUNT(*) AS reservation_count + FROM reservations + WHERE property_id = $1 + AND tenant_id = $2 + AND check_in_date >= $3 + AND check_in_date < $4 + """, + property_id, + tenant_id, + start_date, + end_date, + ) + + total = Decimal(str(row["total_revenue"] if row else 0)) + count = int(row["reservation_count"] if row else 0) + return _quantize_money(total), count + + +async def calculate_monthly_revenue( + property_id: str, + month: int, + year: int, + tenant_id: str, +) -> Decimal: + total, _count = await _monthly_totals(property_id, tenant_id, year, month) + return total + + +async def calculate_total_revenue( + property_id: str, + tenant_id: str, + year: int = REPORT_YEAR, + month: int = REPORT_MONTH, +) -> Dict[str, Any]: 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}) - + total, count = await _monthly_totals(property_id, tenant_id, year, month) return { "property_id": property_id, - "tenant_id": tenant_id, - "total": mock_property_data['total'], + "tenant_id": tenant_id, + "total": f"{total:.2f}", "currency": "USD", - "count": mock_property_data['count'] + "count": count, } + except Exception as e: + print(f"Database error for {property_id} (tenant: {tenant_id}): {e}") + return _empty_summary(property_id, tenant_id) + + +async def list_tenant_properties(tenant_id: str) -> list: + from app.core.database_pool import db_pool + + async with db_pool.get_connection() as connection: + rows = await connection.fetch( + """ + SELECT id, name + FROM properties + WHERE tenant_id = $1 + ORDER BY id + """, + tenant_id, + ) + return [{"id": row["id"], "name": row["name"]} for row in rows] diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index a21bba404e..205d80dbe5 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -1,16 +1,39 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { RevenueSummary } from "./RevenueSummary"; +import { SecureAPI } from "../lib/secureApi"; -const PROPERTIES = [ - { id: 'prop-001', name: 'Beach House Alpha' }, - { id: 'prop-002', name: 'City Apartment Downtown' }, - { id: 'prop-003', name: 'Country Villa Estate' }, - { id: 'prop-004', name: 'Lakeside Cottage' }, - { id: 'prop-005', name: 'Urban Loft Modern' } -]; +interface PropertyOption { + id: string; + name: string; +} const Dashboard: React.FC = () => { - const [selectedProperty, setSelectedProperty] = useState('prop-001'); + const [properties, setProperties] = useState([]); + const [selectedProperty, setSelectedProperty] = useState(""); + const [loadingProperties, setLoadingProperties] = useState(true); + + useEffect(() => { + let cancelled = false; + + const loadProperties = async () => { + try { + const rows = await SecureAPI.getDashboardProperties(); + if (cancelled) return; + setProperties(rows); + setSelectedProperty(rows[0]?.id ?? ""); + } catch (err) { + console.error(err); + if (!cancelled) setProperties([]); + } finally { + if (!cancelled) setLoadingProperties(false); + } + }; + + loadProperties(); + return () => { + cancelled = true; + }; + }, []); return (
@@ -26,16 +49,16 @@ const Dashboard: React.FC = () => { Monthly performance insights for your properties

- - {/* Property Selector */} +