This commit is contained in:
Iliyan Angelov
2025-11-16 20:05:08 +02:00
parent 98ccd5b6ff
commit 48353cde9c
118 changed files with 9488 additions and 1336 deletions

View File

@@ -0,0 +1,120 @@
from fastapi import APIRouter, Depends, status
from sqlalchemy.orm import Session
from ..config.database import get_db
from ..middleware.auth import authorize_roles
from ..models.user import User
from ..schemas.admin_privacy import (
CookieIntegrationSettings,
CookieIntegrationSettingsResponse,
CookiePolicySettings,
CookiePolicySettingsResponse,
)
from ..services.privacy_admin_service import privacy_admin_service
router = APIRouter(prefix="/admin/privacy", tags=["admin-privacy"])
@router.get(
"/cookie-policy",
response_model=CookiePolicySettingsResponse,
status_code=status.HTTP_200_OK,
)
def get_cookie_policy(
db: Session = Depends(get_db),
_: User = Depends(authorize_roles("admin")),
) -> CookiePolicySettingsResponse:
"""
Get global cookie policy configuration (admin only).
"""
settings = privacy_admin_service.get_policy_settings(db)
policy = privacy_admin_service.get_or_create_policy(db)
updated_by_name = (
policy.updated_by.full_name if getattr(policy, "updated_by", None) else None
)
return CookiePolicySettingsResponse(
data=settings,
updated_at=policy.updated_at,
updated_by=updated_by_name,
)
@router.put(
"/cookie-policy",
response_model=CookiePolicySettingsResponse,
status_code=status.HTTP_200_OK,
)
def update_cookie_policy(
payload: CookiePolicySettings,
db: Session = Depends(get_db),
current_user: User = Depends(authorize_roles("admin")),
) -> CookiePolicySettingsResponse:
"""
Update global cookie policy configuration (admin only).
"""
policy = privacy_admin_service.update_policy(db, payload, current_user)
settings = privacy_admin_service.get_policy_settings(db)
updated_by_name = (
policy.updated_by.full_name if getattr(policy, "updated_by", None) else None
)
return CookiePolicySettingsResponse(
data=settings,
updated_at=policy.updated_at,
updated_by=updated_by_name,
)
@router.get(
"/integrations",
response_model=CookieIntegrationSettingsResponse,
status_code=status.HTTP_200_OK,
)
def get_cookie_integrations(
db: Session = Depends(get_db),
_: User = Depends(authorize_roles("admin")),
) -> CookieIntegrationSettingsResponse:
"""
Get IDs for third-party integrations (admin only).
"""
settings = privacy_admin_service.get_integration_settings(db)
cfg = privacy_admin_service.get_or_create_integrations(db)
updated_by_name = (
cfg.updated_by.full_name if getattr(cfg, "updated_by", None) else None
)
return CookieIntegrationSettingsResponse(
data=settings,
updated_at=cfg.updated_at,
updated_by=updated_by_name,
)
@router.put(
"/integrations",
response_model=CookieIntegrationSettingsResponse,
status_code=status.HTTP_200_OK,
)
def update_cookie_integrations(
payload: CookieIntegrationSettings,
db: Session = Depends(get_db),
current_user: User = Depends(authorize_roles("admin")),
) -> CookieIntegrationSettingsResponse:
"""
Update IDs for third-party integrations (admin only).
"""
cfg = privacy_admin_service.update_integrations(db, payload, current_user)
settings = privacy_admin_service.get_integration_settings(db)
updated_by_name = (
cfg.updated_by.full_name if getattr(cfg, "updated_by", None) else None
)
return CookieIntegrationSettingsResponse(
data=settings,
updated_at=cfg.updated_at,
updated_by=updated_by_name,
)

View File

@@ -0,0 +1,239 @@
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from sqlalchemy import desc, or_, func
from typing import Optional
from datetime import datetime
from ..config.database import get_db
from ..middleware.auth import get_current_user, authorize_roles
from ..models.user import User
from ..models.audit_log import AuditLog
router = APIRouter(prefix="/audit-logs", tags=["audit-logs"])
@router.get("/")
async def get_audit_logs(
action: Optional[str] = Query(None, description="Filter by action"),
resource_type: Optional[str] = Query(None, description="Filter by resource type"),
user_id: Optional[int] = Query(None, description="Filter by user ID"),
status_filter: Optional[str] = Query(None, alias="status", description="Filter by status"),
search: Optional[str] = Query(None, description="Search in action, resource_type, or details"),
start_date: Optional[str] = Query(None, description="Start date (YYYY-MM-DD)"),
end_date: Optional[str] = Query(None, description="End date (YYYY-MM-DD)"),
page: int = Query(1, ge=1, description="Page number"),
limit: int = Query(20, ge=1, le=100, description="Items per page"),
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Get audit logs (Admin only)"""
try:
query = db.query(AuditLog)
# Apply filters
if action:
query = query.filter(AuditLog.action.like(f"%{action}%"))
if resource_type:
query = query.filter(AuditLog.resource_type == resource_type)
if user_id:
query = query.filter(AuditLog.user_id == user_id)
if status_filter:
query = query.filter(AuditLog.status == status_filter)
if search:
search_filter = or_(
AuditLog.action.like(f"%{search}%"),
AuditLog.resource_type.like(f"%{search}%"),
AuditLog.ip_address.like(f"%{search}%")
)
query = query.filter(search_filter)
# Date range filter
if start_date:
try:
start = datetime.strptime(start_date, "%Y-%m-%d")
query = query.filter(AuditLog.created_at >= start)
except ValueError:
pass
if end_date:
try:
end = datetime.strptime(end_date, "%Y-%m-%d")
# Set to end of day
end = end.replace(hour=23, minute=59, second=59)
query = query.filter(AuditLog.created_at <= end)
except ValueError:
pass
# Get total count
total = query.count()
# Apply pagination and ordering
offset = (page - 1) * limit
logs = query.order_by(desc(AuditLog.created_at)).offset(offset).limit(limit).all()
# Format response
result = []
for log in logs:
log_dict = {
"id": log.id,
"user_id": log.user_id,
"action": log.action,
"resource_type": log.resource_type,
"resource_id": log.resource_id,
"ip_address": log.ip_address,
"user_agent": log.user_agent,
"request_id": log.request_id,
"details": log.details,
"status": log.status,
"error_message": log.error_message,
"created_at": log.created_at.isoformat() if log.created_at else None,
}
# Add user info if available
if log.user:
log_dict["user"] = {
"id": log.user.id,
"full_name": log.user.full_name,
"email": log.user.email,
}
result.append(log_dict)
return {
"status": "success",
"data": {
"logs": result,
"pagination": {
"total": total,
"page": page,
"limit": limit,
"totalPages": (total + limit - 1) // limit,
},
},
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/stats")
async def get_audit_stats(
start_date: Optional[str] = Query(None, description="Start date (YYYY-MM-DD)"),
end_date: Optional[str] = Query(None, description="End date (YYYY-MM-DD)"),
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Get audit log statistics (Admin only)"""
try:
query = db.query(AuditLog)
# Date range filter
if start_date:
try:
start = datetime.strptime(start_date, "%Y-%m-%d")
query = query.filter(AuditLog.created_at >= start)
except ValueError:
pass
if end_date:
try:
end = datetime.strptime(end_date, "%Y-%m-%d")
end = end.replace(hour=23, minute=59, second=59)
query = query.filter(AuditLog.created_at <= end)
except ValueError:
pass
# Get statistics
total_logs = query.count()
success_count = query.filter(AuditLog.status == "success").count()
failed_count = query.filter(AuditLog.status == "failed").count()
error_count = query.filter(AuditLog.status == "error").count()
# Get top actions
top_actions = (
db.query(
AuditLog.action,
func.count(AuditLog.id).label("count")
)
.group_by(AuditLog.action)
.order_by(desc("count"))
.limit(10)
.all()
)
# Get top resource types
top_resource_types = (
db.query(
AuditLog.resource_type,
func.count(AuditLog.id).label("count")
)
.group_by(AuditLog.resource_type)
.order_by(desc("count"))
.limit(10)
.all()
)
return {
"status": "success",
"data": {
"total": total_logs,
"by_status": {
"success": success_count,
"failed": failed_count,
"error": error_count,
},
"top_actions": [{"action": action, "count": count} for action, count in top_actions],
"top_resource_types": [{"resource_type": rt, "count": count} for rt, count in top_resource_types],
},
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{id}")
async def get_audit_log_by_id(
id: int,
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Get audit log by ID (Admin only)"""
try:
log = db.query(AuditLog).filter(AuditLog.id == id).first()
if not log:
raise HTTPException(status_code=404, detail="Audit log not found")
log_dict = {
"id": log.id,
"user_id": log.user_id,
"action": log.action,
"resource_type": log.resource_type,
"resource_id": log.resource_id,
"ip_address": log.ip_address,
"user_agent": log.user_agent,
"request_id": log.request_id,
"details": log.details,
"status": log.status,
"error_message": log.error_message,
"created_at": log.created_at.isoformat() if log.created_at else None,
}
if log.user:
log_dict["user"] = {
"id": log.user.id,
"full_name": log.user.full_name,
"email": log.user.email,
}
return {
"status": "success",
"data": {"log": log_dict}
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -163,7 +163,12 @@ async def get_profile(
"""Get current user profile"""
try:
user = await auth_service.get_profile(db, current_user.id)
return user
return {
"status": "success",
"data": {
"user": user
}
}
except ValueError as e:
if "User not found" in str(e):
raise HTTPException(
@@ -176,6 +181,46 @@ async def get_profile(
)
@router.put("/profile")
async def update_profile(
profile_data: dict,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Update current user profile"""
try:
user = await auth_service.update_profile(
db=db,
user_id=current_user.id,
full_name=profile_data.get("full_name"),
email=profile_data.get("email"),
phone_number=profile_data.get("phone_number"),
password=profile_data.get("password"),
current_password=profile_data.get("currentPassword")
)
return {
"status": "success",
"message": "Profile updated successfully",
"data": {
"user": user
}
}
except ValueError as e:
error_message = str(e)
status_code = status.HTTP_400_BAD_REQUEST
if "not found" in error_message.lower():
status_code = status.HTTP_404_NOT_FOUND
raise HTTPException(
status_code=status_code,
detail=error_message
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"An error occurred: {str(e)}"
)
@router.post("/forgot-password", response_model=MessageResponse)
async def forgot_password(
request: ForgotPasswordRequest,

View File

@@ -1,9 +1,12 @@
from fastapi import APIRouter, Depends, HTTPException, status, Query, Request
from fastapi import APIRouter, Depends, HTTPException, status, Query, Request, UploadFile, File
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_
from typing import Optional
from datetime import datetime
from pathlib import Path
import os
import aiofiles
import uuid
from ..config.database import get_db
from ..middleware.auth import get_current_user, authorize_roles
@@ -215,6 +218,12 @@ async def delete_banner(
if not banner:
raise HTTPException(status_code=404, detail="Banner not found")
# Delete image file if it exists and is a local upload
if banner.image_url and banner.image_url.startswith('/uploads/banners/'):
file_path = Path(__file__).parent.parent.parent / "uploads" / "banners" / Path(banner.image_url).name
if file_path.exists():
file_path.unlink()
db.delete(banner)
db.commit()
@@ -227,3 +236,51 @@ async def delete_banner(
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.post("/upload", dependencies=[Depends(authorize_roles("admin"))])
async def upload_banner_image(
request: Request,
image: UploadFile = File(...),
current_user: User = Depends(authorize_roles("admin")),
):
"""Upload banner image (Admin only)"""
try:
# Validate file type
if not image.content_type or not image.content_type.startswith('image/'):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File must be an image"
)
# Create uploads directory
upload_dir = Path(__file__).parent.parent.parent / "uploads" / "banners"
upload_dir.mkdir(parents=True, exist_ok=True)
# Generate filename
ext = Path(image.filename).suffix
filename = f"banner-{uuid.uuid4()}{ext}"
file_path = upload_dir / filename
# Save file
async with aiofiles.open(file_path, 'wb') as f:
content = await image.read()
await f.write(content)
# Return the image URL
image_url = f"/uploads/banners/{filename}"
base_url = get_base_url(request)
full_url = normalize_image_url(image_url, base_url)
return {
"status": "success",
"message": "Image uploaded successfully",
"data": {
"image_url": image_url,
"full_url": full_url
}
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -4,14 +4,21 @@ from sqlalchemy import and_, or_
from typing import Optional
from datetime import datetime
import random
import os
from ..config.database import get_db
from ..config.settings import settings
from ..middleware.auth import get_current_user, authorize_roles
from ..models.user import User
from ..models.booking import Booking, BookingStatus
from ..models.room import Room
from ..models.room_type import RoomType
from ..models.payment import Payment, PaymentMethod, PaymentType, PaymentStatus
from ..utils.mailer import send_email
from ..utils.email_templates import (
booking_confirmation_email_template,
booking_status_changed_email_template
)
router = APIRouter(prefix="/bookings", tags=["bookings"])
@@ -255,6 +262,33 @@ async def create_booking(
# Fetch with relations
booking = db.query(Booking).filter(Booking.id == booking.id).first()
# Send booking confirmation email (non-blocking)
try:
client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")
room = db.query(Room).filter(Room.id == room_id).first()
room_type_name = room.room_type.name if room and room.room_type else "Room"
email_html = booking_confirmation_email_template(
booking_number=booking.booking_number,
guest_name=current_user.full_name,
room_number=room.room_number if room else "N/A",
room_type=room_type_name,
check_in=check_in.strftime("%B %d, %Y"),
check_out=check_out.strftime("%B %d, %Y"),
num_guests=guest_count,
total_price=float(total_price),
requires_deposit=requires_deposit,
deposit_amount=deposit_amount if requires_deposit else None,
client_url=client_url
)
await send_email(
to=current_user.email,
subject=f"Booking Confirmation - {booking.booking_number}",
html=email_html
)
except Exception as e:
print(f"Failed to send booking confirmation email: {e}")
return {
"success": True,
"data": {"booking": booking},
@@ -354,6 +388,23 @@ async def cancel_booking(
booking.status = BookingStatus.cancelled
db.commit()
# Send cancellation email (non-blocking)
try:
client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")
email_html = booking_status_changed_email_template(
booking_number=booking.booking_number,
guest_name=booking.user.full_name if booking.user else "Guest",
status="cancelled",
client_url=client_url
)
await send_email(
to=booking.user.email if booking.user else None,
subject=f"Booking Cancelled - {booking.booking_number}",
html=email_html
)
except Exception as e:
print(f"Failed to send cancellation email: {e}")
return {
"success": True,
"data": {"booking": booking}
@@ -378,6 +429,7 @@ async def update_booking(
if not booking:
raise HTTPException(status_code=404, detail="Booking not found")
old_status = booking.status
status_value = booking_data.get("status")
if status_value:
try:
@@ -388,6 +440,24 @@ async def update_booking(
db.commit()
db.refresh(booking)
# Send status change email if status changed (non-blocking)
if status_value and old_status != booking.status:
try:
client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")
email_html = booking_status_changed_email_template(
booking_number=booking.booking_number,
guest_name=booking.user.full_name if booking.user else "Guest",
status=booking.status.value,
client_url=client_url
)
await send_email(
to=booking.user.email if booking.user else None,
subject=f"Booking Status Updated - {booking.booking_number}",
html=email_html
)
except Exception as e:
print(f"Failed to send status change email: {e}")
return {
"status": "success",
"message": "Booking updated successfully",

View File

@@ -2,12 +2,16 @@ from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from typing import Optional
from datetime import datetime
import os
from ..config.database import get_db
from ..config.settings import settings
from ..middleware.auth import get_current_user, authorize_roles
from ..models.user import User
from ..models.payment import Payment, PaymentMethod, PaymentType, PaymentStatus
from ..models.booking import Booking
from ..models.booking import Booking, BookingStatus
from ..utils.mailer import send_email
from ..utils.email_templates import payment_confirmation_email_template
router = APIRouter(prefix="/payments", tags=["payments"])
@@ -85,6 +89,63 @@ async def get_payments(
raise HTTPException(status_code=500, detail=str(e))
@router.get("/booking/{booking_id}")
async def get_payments_by_booking_id(
booking_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get all payments for a specific booking"""
try:
# Check if booking exists and user has access
booking = db.query(Booking).filter(Booking.id == booking_id).first()
if not booking:
raise HTTPException(status_code=404, detail="Booking not found")
# Check access - users can only see their own bookings unless admin
if current_user.role_id != 1 and booking.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Forbidden")
# Get all payments for this booking
payments = db.query(Payment).filter(Payment.booking_id == booking_id).order_by(Payment.created_at.desc()).all()
result = []
for payment in payments:
payment_dict = {
"id": payment.id,
"booking_id": payment.booking_id,
"amount": float(payment.amount) if payment.amount else 0.0,
"payment_method": payment.payment_method.value if isinstance(payment.payment_method, PaymentMethod) else payment.payment_method,
"payment_type": payment.payment_type.value if isinstance(payment.payment_type, PaymentType) else payment.payment_type,
"deposit_percentage": payment.deposit_percentage,
"related_payment_id": payment.related_payment_id,
"payment_status": payment.payment_status.value if isinstance(payment.payment_status, PaymentStatus) else payment.payment_status,
"transaction_id": payment.transaction_id,
"payment_date": payment.payment_date.isoformat() if payment.payment_date else None,
"notes": payment.notes,
"created_at": payment.created_at.isoformat() if payment.created_at else None,
}
if payment.booking:
payment_dict["booking"] = {
"id": payment.booking.id,
"booking_number": payment.booking.booking_number,
}
result.append(payment_dict)
return {
"status": "success",
"data": {
"payments": result
}
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{id}")
async def get_payment_by_id(
id: int,
@@ -169,11 +230,32 @@ async def create_payment(
# If marked as paid, update status
if payment_data.get("mark_as_paid"):
payment.payment_status = PaymentStatus.completed
payment.payment_date = datetime.utcnow()
db.add(payment)
db.commit()
db.refresh(payment)
# Send payment confirmation email if payment was marked as paid (non-blocking)
if payment.payment_status == PaymentStatus.completed and booking.user:
try:
client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")
email_html = payment_confirmation_email_template(
booking_number=booking.booking_number,
guest_name=booking.user.full_name,
amount=float(payment.amount),
payment_method=payment.payment_method.value if isinstance(payment.payment_method, PaymentMethod) else str(payment.payment_method),
transaction_id=payment.transaction_id,
client_url=client_url
)
await send_email(
to=booking.user.email,
subject=f"Payment Confirmed - {booking.booking_number}",
html=email_html
)
except Exception as e:
print(f"Failed to send payment confirmation email: {e}")
return {
"status": "success",
"message": "Payment created successfully",
@@ -209,6 +291,7 @@ async def update_payment_status(
if status_data.get("transaction_id"):
payment.transaction_id = status_data["transaction_id"]
old_status = payment.payment_status
if status_data.get("mark_as_paid"):
payment.payment_status = PaymentStatus.completed
payment.payment_date = datetime.utcnow()
@@ -216,6 +299,37 @@ async def update_payment_status(
db.commit()
db.refresh(payment)
# Send payment confirmation email if payment was just completed (non-blocking)
if payment.payment_status == PaymentStatus.completed and old_status != PaymentStatus.completed:
try:
# Refresh booking relationship
payment = db.query(Payment).filter(Payment.id == id).first()
if payment.booking and payment.booking.user:
client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")
email_html = payment_confirmation_email_template(
booking_number=payment.booking.booking_number,
guest_name=payment.booking.user.full_name,
amount=float(payment.amount),
payment_method=payment.payment_method.value if isinstance(payment.payment_method, PaymentMethod) else str(payment.payment_method),
transaction_id=payment.transaction_id,
client_url=client_url
)
await send_email(
to=payment.booking.user.email,
subject=f"Payment Confirmed - {payment.booking.booking_number}",
html=email_html
)
# If this is a deposit payment, update booking deposit_paid status
if payment.payment_type == PaymentType.deposit and payment.booking:
payment.booking.deposit_paid = True
# Optionally auto-confirm booking if deposit is paid
if payment.booking.status == BookingStatus.pending:
payment.booking.status = BookingStatus.confirmed
db.commit()
except Exception as e:
print(f"Failed to send payment confirmation email: {e}")
return {
"status": "success",
"message": "Payment status updated successfully",

View File

@@ -0,0 +1,111 @@
from fastapi import APIRouter, Depends, Request, Response, status
from sqlalchemy.orm import Session
from ..config.database import get_db
from ..config.logging_config import get_logger
from ..config.settings import settings
from ..middleware.cookie_consent import COOKIE_CONSENT_COOKIE_NAME, _parse_consent_cookie
from ..schemas.admin_privacy import PublicPrivacyConfigResponse
from ..schemas.privacy import (
CookieCategoryPreferences,
CookieConsent,
CookieConsentResponse,
UpdateCookieConsentRequest,
)
from ..services.privacy_admin_service import privacy_admin_service
logger = get_logger(__name__)
router = APIRouter(prefix="/privacy", tags=["privacy"])
@router.get(
"/cookie-consent",
response_model=CookieConsentResponse,
status_code=status.HTTP_200_OK,
)
async def get_cookie_consent(request: Request) -> CookieConsentResponse:
"""
Return the current cookie consent preferences.
Reads from the cookie (if present) or returns default (necessary only).
"""
raw_cookie = request.cookies.get(COOKIE_CONSENT_COOKIE_NAME)
consent = _parse_consent_cookie(raw_cookie)
# Ensure necessary is always true
consent.categories.necessary = True
return CookieConsentResponse(data=consent)
@router.post(
"/cookie-consent",
response_model=CookieConsentResponse,
status_code=status.HTTP_200_OK,
)
async def update_cookie_consent(
request: UpdateCookieConsentRequest, response: Response
) -> CookieConsentResponse:
"""
Update cookie consent preferences.
The 'necessary' category is controlled by the server and always true.
"""
# Build categories from existing cookie (if any) so partial updates work
existing_raw = response.headers.get("cookie") # usually empty here
# We can't reliably read cookies from the response; rely on defaults.
# For the purposes of this API, we always start from defaults and then
# override with the request payload.
categories = CookieCategoryPreferences()
if request.analytics is not None:
categories.analytics = request.analytics
if request.marketing is not None:
categories.marketing = request.marketing
if request.preferences is not None:
categories.preferences = request.preferences
# 'necessary' enforced server-side
categories.necessary = True
consent = CookieConsent(categories=categories, has_decided=True)
# Persist consent as a secure, HttpOnly cookie
response.set_cookie(
key=COOKIE_CONSENT_COOKIE_NAME,
value=consent.model_dump_json(),
httponly=True,
secure=settings.is_production,
samesite="lax",
max_age=365 * 24 * 60 * 60, # 1 year
path="/",
)
logger.info(
"Cookie consent updated: analytics=%s, marketing=%s, preferences=%s",
consent.categories.analytics,
consent.categories.marketing,
consent.categories.preferences,
)
return CookieConsentResponse(data=consent)
@router.get(
"/config",
response_model=PublicPrivacyConfigResponse,
status_code=status.HTTP_200_OK,
)
async def get_public_privacy_config(
db: Session = Depends(get_db),
) -> PublicPrivacyConfigResponse:
"""
Public privacy configuration for the frontend:
- Global policy flags
- Public integration IDs (e.g. GA measurement ID)
"""
config = privacy_admin_service.get_public_privacy_config(db)
return PublicPrivacyConfigResponse(data=config)

View File

@@ -10,6 +10,8 @@ from ..models.user import User
from ..models.booking import Booking, BookingStatus
from ..models.payment import Payment, PaymentStatus
from ..models.room import Room
from ..models.service_usage import ServiceUsage
from ..models.service import Service
router = APIRouter(prefix="/reports", tags=["reports"])
@@ -140,6 +142,33 @@ async def get_reports(
for room_id, room_number, bookings, revenue in top_rooms_data
]
# Service usage statistics
service_usage_query = db.query(
Service.id,
Service.name,
func.count(ServiceUsage.id).label('usage_count'),
func.sum(ServiceUsage.total_price).label('total_revenue')
).join(ServiceUsage, Service.id == ServiceUsage.service_id)
if start_date:
service_usage_query = service_usage_query.filter(ServiceUsage.usage_date >= start_date)
if end_date:
service_usage_query = service_usage_query.filter(ServiceUsage.usage_date <= end_date)
service_usage_data = service_usage_query.group_by(Service.id, Service.name).order_by(
func.sum(ServiceUsage.total_price).desc()
).limit(10).all()
service_usage = [
{
"service_id": service_id,
"service_name": service_name,
"usage_count": int(usage_count or 0),
"total_revenue": float(total_revenue or 0)
}
for service_id, service_name, usage_count, total_revenue in service_usage_data
]
return {
"status": "success",
"success": True,
@@ -152,6 +181,7 @@ async def get_reports(
"revenue_by_date": revenue_by_date if revenue_by_date else None,
"bookings_by_status": bookings_by_status,
"top_rooms": top_rooms if top_rooms else None,
"service_usage": service_usage if service_usage else None,
}
}
except Exception as e:
@@ -221,6 +251,171 @@ async def get_dashboard_stats(
raise HTTPException(status_code=500, detail=str(e))
@router.get("/customer/dashboard")
async def get_customer_dashboard_stats(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get customer dashboard statistics"""
try:
from datetime import datetime, timedelta
# Total bookings count for user
total_bookings = db.query(Booking).filter(
Booking.user_id == current_user.id
).count()
# Total spending (sum of completed payments from user's bookings)
user_bookings = db.query(Booking.id).filter(
Booking.user_id == current_user.id
).subquery()
total_spending = db.query(func.sum(Payment.amount)).filter(
and_(
Payment.booking_id.in_(db.query(user_bookings.c.id)),
Payment.payment_status == PaymentStatus.completed
)
).scalar() or 0.0
# Currently staying (checked_in bookings)
now = datetime.utcnow()
currently_staying = db.query(Booking).filter(
and_(
Booking.user_id == current_user.id,
Booking.status == BookingStatus.checked_in,
Booking.check_in_date <= now,
Booking.check_out_date >= now
)
).count()
# Upcoming bookings (confirmed/pending with check_in_date in future)
upcoming_bookings_query = db.query(Booking).filter(
and_(
Booking.user_id == current_user.id,
Booking.status.in_([BookingStatus.confirmed, BookingStatus.pending]),
Booking.check_in_date > now
)
).order_by(Booking.check_in_date.asc()).limit(5).all()
upcoming_bookings = []
for booking in upcoming_bookings_query:
booking_dict = {
"id": booking.id,
"booking_number": booking.booking_number,
"check_in_date": booking.check_in_date.isoformat() if booking.check_in_date else None,
"check_out_date": booking.check_out_date.isoformat() if booking.check_out_date else None,
"status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status,
"total_price": float(booking.total_price) if booking.total_price else 0.0,
}
if booking.room:
booking_dict["room"] = {
"id": booking.room.id,
"room_number": booking.room.room_number,
"room_type": {
"name": booking.room.room_type.name if booking.room.room_type else None
}
}
upcoming_bookings.append(booking_dict)
# Recent activity (last 5 bookings ordered by created_at)
recent_bookings_query = db.query(Booking).filter(
Booking.user_id == current_user.id
).order_by(Booking.created_at.desc()).limit(5).all()
recent_activity = []
for booking in recent_bookings_query:
activity_type = None
if booking.status == BookingStatus.checked_out:
activity_type = "Check-out"
elif booking.status == BookingStatus.checked_in:
activity_type = "Check-in"
elif booking.status == BookingStatus.confirmed:
activity_type = "Booking Confirmed"
elif booking.status == BookingStatus.pending:
activity_type = "Booking"
else:
activity_type = "Booking"
activity_dict = {
"action": activity_type,
"booking_id": booking.id,
"booking_number": booking.booking_number,
"created_at": booking.created_at.isoformat() if booking.created_at else None,
}
if booking.room:
activity_dict["room"] = {
"room_number": booking.room.room_number,
}
recent_activity.append(activity_dict)
# Calculate percentage change (placeholder - can be enhanced)
# For now, compare last month vs this month
last_month_start = (now - timedelta(days=30)).replace(day=1, hour=0, minute=0, second=0)
last_month_end = now.replace(day=1, hour=0, minute=0, second=0) - timedelta(seconds=1)
last_month_bookings = db.query(Booking).filter(
and_(
Booking.user_id == current_user.id,
Booking.created_at >= last_month_start,
Booking.created_at <= last_month_end
)
).count()
this_month_bookings = db.query(Booking).filter(
and_(
Booking.user_id == current_user.id,
Booking.created_at >= now.replace(day=1, hour=0, minute=0, second=0),
Booking.created_at <= now
)
).count()
booking_change_percentage = 0
if last_month_bookings > 0:
booking_change_percentage = ((this_month_bookings - last_month_bookings) / last_month_bookings) * 100
last_month_spending = db.query(func.sum(Payment.amount)).filter(
and_(
Payment.booking_id.in_(db.query(user_bookings.c.id)),
Payment.payment_status == PaymentStatus.completed,
Payment.payment_date >= last_month_start,
Payment.payment_date <= last_month_end
)
).scalar() or 0.0
this_month_spending = db.query(func.sum(Payment.amount)).filter(
and_(
Payment.booking_id.in_(db.query(user_bookings.c.id)),
Payment.payment_status == PaymentStatus.completed,
Payment.payment_date >= now.replace(day=1, hour=0, minute=0, second=0),
Payment.payment_date <= now
)
).scalar() or 0.0
spending_change_percentage = 0
if last_month_spending > 0:
spending_change_percentage = ((this_month_spending - last_month_spending) / last_month_spending) * 100
return {
"status": "success",
"success": True,
"data": {
"total_bookings": total_bookings,
"total_spending": float(total_spending),
"currently_staying": currently_staying,
"upcoming_bookings": upcoming_bookings,
"recent_activity": recent_activity,
"booking_change_percentage": round(booking_change_percentage, 1),
"spending_change_percentage": round(spending_change_percentage, 1),
}
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/revenue")
async def get_revenue_report(
start_date: Optional[str] = Query(None),