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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Starting assignment.
32 changes: 25 additions & 7 deletions backend/app/api/v1/dashboard.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,43 @@
from fastapi import APIRouter, Depends, HTTPException
from decimal import Decimal, ROUND_HALF_UP
from fastapi import APIRouter, Depends, HTTPException, status
from typing import Dict, Any
from app.services.cache import get_revenue_summary
from app.core.auth import authenticate_request as get_current_user
from app.api.v1.properties import _TENANT_PROPERTIES

router = APIRouter()


def _tenant_owns_property(tenant_id: str, property_id: str) -> bool:
"""Return True if the property belongs to this tenant (DB fallback uses seed data)."""
owned = {p['id'] for p in _TENANT_PROPERTIES.get(tenant_id, [])}
return property_id in owned


@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"


# Prevent tenants from querying properties that belong to other tenants.
if not _tenant_owns_property(tenant_id, property_id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Property does not belong to your account."
)

revenue_data = await get_revenue_summary(property_id, tenant_id)

total_revenue_float = float(revenue_data['total'])


# Keep as Decimal to avoid float binary-representation errors (e.g. 333.333*3 in float
# produces 999.999... instead of 1000.000). Round to 2 decimal places for display.
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": str(total_revenue),
"currency": revenue_data['currency'],
"reservations_count": revenue_data['count']
}
54 changes: 54 additions & 0 deletions backend/app/api/v1/properties.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
from fastapi import APIRouter, Depends
from typing import Dict, Any
from app.core.auth import authenticate_request as get_current_user

router = APIRouter()

# Seed data mirrors database/seed.sql so the fallback matches actual DB contents.
_TENANT_PROPERTIES = {
'tenant-a': [
{'id': 'prop-001', 'name': 'Beach House Alpha', 'timezone': 'Europe/Paris'},
{'id': 'prop-002', 'name': 'City Apartment Downtown', 'timezone': 'Europe/Paris'},
{'id': 'prop-003', 'name': 'Country Villa Estate', 'timezone': 'Europe/Paris'},
],
'tenant-b': [
{'id': 'prop-001', 'name': 'Mountain Lodge Beta', 'timezone': 'America/New_York'},
{'id': 'prop-004', 'name': 'Lakeside Cottage', 'timezone': 'America/New_York'},
{'id': 'prop-005', 'name': 'Urban Loft Modern', 'timezone': 'America/New_York'},
],
}


@router.get("/properties")
async def list_properties(
current_user: dict = Depends(get_current_user)
) -> Dict[str, Any]:
tenant_id = getattr(current_user, "tenant_id", None) or "default_tenant"

try:
from sqlalchemy import text
from app.core.database_pool import DatabasePool

db_pool = DatabasePool()
await db_pool.initialize()

if db_pool.session_factory:
async with db_pool.get_session() as session:
query = text("""
SELECT id, name, timezone
FROM properties
WHERE tenant_id = :tenant_id
ORDER BY name
""")
result = await session.execute(query, {"tenant_id": tenant_id})
rows = result.fetchall()
properties = [
{"id": row.id, "name": row.name, "timezone": row.timezone}
for row in rows
]
return {"items": properties, "total": len(properties)}
except Exception as e:
print(f"DB error fetching properties for tenant {tenant_id}: {e}")

properties = _TENANT_PROPERTIES.get(tenant_id, [])
return {"items": properties, "total": len(properties)}
4 changes: 4 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 @@ -184,6 +185,9 @@ async def lifespan(app: FastAPI):
# Dashboard
app.include_router(dashboard.router, prefix="/api/v1", tags=["dashboard"])

# Properties (tenant-scoped)
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
2 changes: 1 addition & 1 deletion backend/app/services/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ 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
10 changes: 6 additions & 4 deletions backend/app/services/reservations.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from datetime import datetime
from datetime import datetime, timezone
from decimal import Decimal
from typing import Dict, Any, List

Expand All @@ -7,11 +7,13 @@ async def calculate_monthly_revenue(property_id: str, month: int, year: int, db_
Calculates revenue for a specific month.
"""

start_date = datetime(year, month, 1)
# Use timezone-aware UTC datetimes so comparisons against DB timestamps
# (which are stored in UTC) are correct regardless of property timezone.
start_date = datetime(year, month, 1, tzinfo=timezone.utc)
if month < 12:
end_date = datetime(year, month + 1, 1)
end_date = datetime(year, month + 1, 1, tzinfo=timezone.utc)
else:
end_date = datetime(year + 1, 1, 1)
end_date = datetime(year + 1, 1, 1, tzinfo=timezone.utc)

print(f"DEBUG: Querying revenue for {property_id} from {start_date} to {end_date}")

Expand Down
33 changes: 22 additions & 11 deletions frontend/src/components/Dashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,27 @@
import React, { useState } from "react";
import React, { useState, useEffect } 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 Property {
id: string;
name: string;
}

const Dashboard: React.FC = () => {
const [selectedProperty, setSelectedProperty] = useState('prop-001');
const [properties, setProperties] = useState<Property[]>([]);
const [selectedProperty, setSelectedProperty] = useState('');

useEffect(() => {
SecureAPI.getProperties()
.then((result) => {
const props: Property[] = result.data || [];
setProperties(props);
if (props.length > 0) {
setSelectedProperty(props[0].id);
}
})
.catch(console.error);
}, []);

return (
<div className="p-4 lg:p-6 min-h-full">
Expand All @@ -35,7 +46,7 @@ const Dashboard: React.FC = () => {
onChange={(e) => setSelectedProperty(e.target.value)}
className="block w-full sm:w-auto min-w-[200px] px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 text-sm"
>
{PROPERTIES.map((property) => (
{properties.map((property) => (
<option key={property.id} value={property.id}>
{property.name}
</option>
Expand All @@ -46,7 +57,7 @@ const Dashboard: React.FC = () => {
</div>

<div className="space-y-6">
<RevenueSummary propertyId={selectedProperty} />
{selectedProperty && <RevenueSummary propertyId={selectedProperty} />}
</div>
</div>
</div>
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/components/RevenueSummary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { SecureAPI } from '../lib/secureApi';

interface RevenueData {
property_id: string;
total_revenue: number;
total_revenue: string;
currency: string;
reservations_count: number;
}
Expand Down Expand Up @@ -61,7 +61,7 @@ export const RevenueSummary: React.FC<RevenueSummaryProps> = ({ propertyId = 'pr
if (error) return <div className="p-4 text-red-500 bg-red-50 rounded-lg">{error}</div>;
if (!data) return null;

const displayTotal = Math.round(data.total_revenue * 100) / 100;
const displayTotal = parseFloat(data.total_revenue);

return (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-shadow duration-300">
Expand Down Expand Up @@ -104,7 +104,7 @@ export const RevenueSummary: React.FC<RevenueSummaryProps> = ({ propertyId = 'pr

{/* Precision Warning Area */}
<div className="mt-4 h-6">
{Math.abs(data.total_revenue - displayTotal) > 0.000001 && showRaw && (
{Math.abs(parseFloat(data.total_revenue) - displayTotal) > 0.000001 && showRaw && (
<div className="flex items-center text-xs text-amber-600 bg-amber-50 px-2 py-1 rounded">
<svg className="h-4 w-4 mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
Expand Down