This commit is contained in:
Iliyan Angelov
2025-11-17 18:26:30 +02:00
parent 48353cde9c
commit 0c59fe1173
2535 changed files with 278997 additions and 2480 deletions

View File

@@ -196,7 +196,8 @@ async def update_profile(
email=profile_data.get("email"),
phone_number=profile_data.get("phone_number"),
password=profile_data.get("password"),
current_password=profile_data.get("currentPassword")
current_password=profile_data.get("currentPassword"),
currency=profile_data.get("currency")
)
return {
"status": "success",

View File

@@ -1,5 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from sqlalchemy.orm import Session, joinedload, selectinload
from sqlalchemy import and_, or_
from typing import Optional
from datetime import datetime
@@ -11,9 +11,11 @@ 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 import Room, RoomStatus
from ..models.room_type import RoomType
from ..models.payment import Payment, PaymentMethod, PaymentType, PaymentStatus
from ..services.room_service import normalize_images, get_base_url
from fastapi import Request
from ..utils.mailer import send_email
from ..utils.email_templates import (
booking_confirmation_email_template,
@@ -129,6 +131,7 @@ async def get_all_bookings(
@router.get("/me")
async def get_my_bookings(
request: Request,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
@@ -138,6 +141,7 @@ async def get_my_bookings(
Booking.user_id == current_user.id
).order_by(Booking.created_at.desc()).all()
base_url = get_base_url(request)
result = []
for booking in bookings:
booking_dict = {
@@ -157,11 +161,25 @@ async def get_my_bookings(
# Add room info
if booking.room and booking.room.room_type:
# Normalize room images if they exist
room_images = []
if booking.room.images:
try:
room_images = normalize_images(booking.room.images, base_url)
except:
room_images = []
booking_dict["room"] = {
"id": booking.room.id,
"room_number": booking.room.room_number,
"floor": booking.room.floor,
"images": room_images, # Include room images
"room_type": {
"id": booking.room.room_type.id,
"name": booking.room.room_type.name,
"base_price": float(booking.room.room_type.base_price) if booking.room.room_type.base_price else 0.0,
"capacity": booking.room.room_type.capacity,
"images": room_images, # Also include in room_type for backwards compatibility
}
}
@@ -221,10 +239,17 @@ async def create_booking(
booking_number = generate_booking_number()
# Determine if deposit is required
# Cash requires deposit, Stripe doesn't require deposit (full payment or deposit handled via payment flow)
requires_deposit = payment_method == "cash"
deposit_percentage = 20 if requires_deposit else 0
deposit_amount = (float(total_price) * deposit_percentage) / 100 if requires_deposit else 0
# For Stripe, booking can be confirmed immediately after payment
initial_status = BookingStatus.pending
if payment_method == "stripe":
# Will be confirmed after successful Stripe payment
initial_status = BookingStatus.pending
# Create booking
booking = Booking(
booking_number=booking_number,
@@ -235,7 +260,7 @@ async def create_booking(
num_guests=guest_count,
total_price=total_price,
special_requests=notes,
status=BookingStatus.pending,
status=initial_status,
requires_deposit=requires_deposit,
deposit_paid=False,
)
@@ -243,24 +268,101 @@ async def create_booking(
db.add(booking)
db.flush()
# Create deposit payment if required
if requires_deposit:
# Create payment record if Stripe payment method is selected
if payment_method == "stripe":
from ..models.payment import Payment, PaymentMethod, PaymentStatus, PaymentType
payment = Payment(
booking_id=booking.id,
amount=deposit_amount,
payment_method=PaymentMethod.bank_transfer,
payment_type=PaymentType.deposit,
deposit_percentage=deposit_percentage,
amount=total_price,
payment_method=PaymentMethod.stripe,
payment_type=PaymentType.full,
payment_status=PaymentStatus.pending,
notes=f"Deposit payment ({deposit_percentage}%) for booking {booking_number}",
payment_date=None,
)
db.add(payment)
db.flush()
# Create deposit payment if required (for cash method)
# Note: For cash payments, deposit is paid on arrival, so we don't create a pending payment record
# The payment will be created when the customer pays at check-in
db.commit()
db.refresh(booking)
# Fetch with relations
booking = db.query(Booking).filter(Booking.id == booking.id).first()
# Fetch with relations for proper serialization (eager load payments)
from sqlalchemy.orm import joinedload
booking = db.query(Booking).options(joinedload(Booking.payments)).filter(Booking.id == booking.id).first()
# Determine payment_method and payment_status from payments
payment_method_from_payments = None
payment_status_from_payments = "unpaid"
if booking.payments:
latest_payment = sorted(booking.payments, key=lambda p: p.created_at, reverse=True)[0]
payment_method_from_payments = latest_payment.payment_method.value if isinstance(latest_payment.payment_method, PaymentMethod) else latest_payment.payment_method
if latest_payment.payment_status == PaymentStatus.completed:
payment_status_from_payments = "paid"
elif latest_payment.payment_status == PaymentStatus.refunded:
payment_status_from_payments = "refunded"
# Serialize booking properly
booking_dict = {
"id": booking.id,
"booking_number": booking.booking_number,
"user_id": booking.user_id,
"room_id": booking.room_id,
"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,
"guest_count": booking.num_guests,
"total_price": float(booking.total_price) if booking.total_price else 0.0,
"status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status,
"payment_method": payment_method_from_payments or payment_method,
"payment_status": payment_status_from_payments,
"deposit_paid": booking.deposit_paid,
"requires_deposit": booking.requires_deposit,
"notes": booking.special_requests,
"guest_info": {
"full_name": current_user.full_name,
"email": current_user.email,
"phone": current_user.phone_number if hasattr(current_user, 'phone_number') else (current_user.phone if hasattr(current_user, 'phone') else ""),
},
"createdAt": booking.created_at.isoformat() if booking.created_at else None,
"updatedAt": booking.updated_at.isoformat() if booking.updated_at else None,
"created_at": booking.created_at.isoformat() if booking.created_at else None,
}
# Add payments if they exist
if booking.payments:
booking_dict["payments"] = [
{
"id": p.id,
"booking_id": p.booking_id,
"amount": float(p.amount) if p.amount else 0.0,
"payment_method": p.payment_method.value if isinstance(p.payment_method, PaymentMethod) else p.payment_method,
"payment_type": p.payment_type.value if isinstance(p.payment_type, PaymentType) else p.payment_type,
"deposit_percentage": p.deposit_percentage,
"payment_status": p.payment_status.value if isinstance(p.payment_status, PaymentStatus) else p.payment_status,
"transaction_id": p.transaction_id,
"payment_date": p.payment_date.isoformat() if p.payment_date else None,
"notes": p.notes,
"created_at": p.created_at.isoformat() if p.created_at else None,
}
for p in booking.payments
]
# Add room info if available
if booking.room:
booking_dict["room"] = {
"id": booking.room.id,
"room_number": booking.room.room_number,
"floor": booking.room.floor,
}
if booking.room.room_type:
booking_dict["room"]["room_type"] = {
"id": booking.room.room_type.id,
"name": booking.room.room_type.name,
"base_price": float(booking.room.room_type.base_price) if booking.room.room_type.base_price else 0.0,
"capacity": booking.room.room_type.capacity,
}
# Send booking confirmation email (non-blocking)
try:
@@ -291,7 +393,7 @@ async def create_booking(
return {
"success": True,
"data": {"booking": booking},
"data": {"booking": booking_dict},
"message": f"Booking created. Please pay {deposit_percentage}% deposit to confirm." if requires_deposit else "Booking created successfully"
}
except HTTPException:
@@ -304,12 +406,22 @@ async def create_booking(
@router.get("/{id}")
async def get_booking_by_id(
id: int,
request: Request,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get booking by ID"""
try:
booking = db.query(Booking).filter(Booking.id == id).first()
# Eager load all relationships to avoid N+1 queries
# Using selectinload for better performance with multiple relationships
booking = db.query(Booking)\
.options(
selectinload(Booking.payments),
joinedload(Booking.user),
joinedload(Booking.room).joinedload(Room.room_type)
)\
.filter(Booking.id == id)\
.first()
if not booking:
raise HTTPException(status_code=404, detail="Booking not found")
@@ -318,6 +430,19 @@ async def get_booking_by_id(
if current_user.role_id != 1 and booking.user_id != current_user.id: # Not admin
raise HTTPException(status_code=403, detail="Forbidden")
# Determine payment_method and payment_status from payments
# Get latest payment efficiently (already loaded via joinedload)
payment_method = None
payment_status = "unpaid"
if booking.payments:
# Find latest payment (payments are already loaded, so this is fast)
latest_payment = max(booking.payments, key=lambda p: p.created_at if p.created_at else datetime.min)
payment_method = latest_payment.payment_method.value if isinstance(latest_payment.payment_method, PaymentMethod) else latest_payment.payment_method
if latest_payment.payment_status == PaymentStatus.completed:
payment_status = "paid"
elif latest_payment.payment_status == PaymentStatus.refunded:
payment_status = "refunded"
booking_dict = {
"id": booking.id,
"booking_number": booking.booking_number,
@@ -325,24 +450,56 @@ async def get_booking_by_id(
"room_id": booking.room_id,
"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,
"num_guests": booking.num_guests,
"guest_count": booking.num_guests, # Frontend expects guest_count
"total_price": float(booking.total_price) if booking.total_price else 0.0,
"status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status,
"payment_method": payment_method or "cash",
"payment_status": payment_status,
"deposit_paid": booking.deposit_paid,
"requires_deposit": booking.requires_deposit,
"special_requests": booking.special_requests,
"notes": booking.special_requests, # Frontend expects notes
"guest_info": {
"full_name": booking.user.full_name if booking.user else "",
"email": booking.user.email if booking.user else "",
"phone": booking.user.phone_number if booking.user and hasattr(booking.user, 'phone_number') else (booking.user.phone if booking.user and hasattr(booking.user, 'phone') else ""),
} if booking.user else None,
"createdAt": booking.created_at.isoformat() if booking.created_at else None,
"updatedAt": booking.updated_at.isoformat() if booking.updated_at else None,
"created_at": booking.created_at.isoformat() if booking.created_at else None,
}
# Add relations
# Only get base_url if we need it (room has images)
if booking.room and booking.room.images:
base_url = get_base_url(request)
# Normalize room images if they exist
try:
room_images = normalize_images(booking.room.images, base_url)
except:
room_images = []
else:
room_images = []
if booking.room:
booking_dict["room"] = {
"id": booking.room.id,
"room_number": booking.room.room_number,
"floor": booking.room.floor,
"status": booking.room.status.value if isinstance(booking.room.status, RoomStatus) else booking.room.status,
"images": room_images, # Include room images directly on room object
}
if booking.room.room_type:
# Use room images if room_type doesn't have images (which is typical)
# RoomType doesn't have images column, images are stored on Room
room_type_images = room_images if room_images else []
booking_dict["room"]["room_type"] = {
"id": booking.room.room_type.id,
"name": booking.room.room_type.name,
"base_price": float(booking.room.room_type.base_price) if booking.room.room_type.base_price else 0.0,
"capacity": booking.room.room_type.capacity,
"images": room_type_images,
}
if booking.payments:
@@ -385,6 +542,20 @@ async def cancel_booking(
if booking.status == BookingStatus.cancelled:
raise HTTPException(status_code=400, detail="Booking already cancelled")
# Prevent cancellation of confirmed bookings
if booking.status == BookingStatus.confirmed:
raise HTTPException(
status_code=400,
detail="Cannot cancel a confirmed booking. Please contact support for assistance."
)
# Only allow cancellation of pending bookings
if booking.status != BookingStatus.pending:
raise HTTPException(
status_code=400,
detail=f"Cannot cancel booking with status: {booking.status.value}. Only pending bookings can be cancelled."
)
booking.status = BookingStatus.cancelled
db.commit()

View File

@@ -0,0 +1,249 @@
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
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.invoice import Invoice, InvoiceStatus
from ..models.booking import Booking
from ..services.invoice_service import InvoiceService
router = APIRouter(prefix="/invoices", tags=["invoices"])
@router.get("/")
async def get_invoices(
booking_id: Optional[int] = Query(None),
status_filter: Optional[str] = Query(None, alias="status"),
page: int = Query(1, ge=1),
limit: int = Query(10, ge=1, le=100),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get invoices for current user (or all invoices for admin)"""
try:
# Admin can see all invoices, users can only see their own
user_id = None if current_user.role_id == 1 else current_user.id
result = InvoiceService.get_invoices(
db=db,
user_id=user_id,
booking_id=booking_id,
status=status_filter,
page=page,
limit=limit
)
return {
"status": "success",
"data": result
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{id}")
async def get_invoice_by_id(
id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get invoice by ID"""
try:
invoice = InvoiceService.get_invoice(id, db)
if not invoice:
raise HTTPException(status_code=404, detail="Invoice not found")
# Check access: admin can see all, users can only see their own
if current_user.role_id != 1 and invoice["user_id"] != current_user.id:
raise HTTPException(status_code=403, detail="Forbidden")
return {
"status": "success",
"data": {"invoice": invoice}
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/")
async def create_invoice(
invoice_data: dict,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Create a new invoice from a booking (Admin/Staff only)"""
try:
# Only admin/staff can create invoices
if current_user.role_id not in [1, 2]:
raise HTTPException(status_code=403, detail="Forbidden")
booking_id = invoice_data.get("booking_id")
if not booking_id:
raise HTTPException(status_code=400, detail="booking_id is required")
# Check if booking exists
booking = db.query(Booking).filter(Booking.id == booking_id).first()
if not booking:
raise HTTPException(status_code=404, detail="Booking not found")
# Create invoice
invoice = InvoiceService.create_invoice_from_booking(
booking_id=booking_id,
db=db,
created_by_id=current_user.id,
tax_rate=invoice_data.get("tax_rate", 0.0),
discount_amount=invoice_data.get("discount_amount", 0.0),
due_days=invoice_data.get("due_days", 30),
company_name=invoice_data.get("company_name"),
company_address=invoice_data.get("company_address"),
company_phone=invoice_data.get("company_phone"),
company_email=invoice_data.get("company_email"),
company_tax_id=invoice_data.get("company_tax_id"),
company_logo_url=invoice_data.get("company_logo_url"),
customer_tax_id=invoice_data.get("customer_tax_id"),
notes=invoice_data.get("notes"),
terms_and_conditions=invoice_data.get("terms_and_conditions"),
payment_instructions=invoice_data.get("payment_instructions"),
)
return {
"status": "success",
"message": "Invoice created successfully",
"data": {"invoice": invoice}
}
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.put("/{id}")
async def update_invoice(
id: int,
invoice_data: dict,
current_user: User = Depends(authorize_roles("admin", "staff")),
db: Session = Depends(get_db)
):
"""Update an invoice (Admin/Staff only)"""
try:
invoice = db.query(Invoice).filter(Invoice.id == id).first()
if not invoice:
raise HTTPException(status_code=404, detail="Invoice not found")
# Update invoice
updated_invoice = InvoiceService.update_invoice(
invoice_id=id,
db=db,
updated_by_id=current_user.id,
**invoice_data
)
return {
"status": "success",
"message": "Invoice updated successfully",
"data": {"invoice": updated_invoice}
}
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/{id}/mark-paid")
async def mark_invoice_as_paid(
id: int,
payment_data: dict,
current_user: User = Depends(authorize_roles("admin", "staff")),
db: Session = Depends(get_db)
):
"""Mark an invoice as paid (Admin/Staff only)"""
try:
amount = payment_data.get("amount")
updated_invoice = InvoiceService.mark_invoice_as_paid(
invoice_id=id,
db=db,
amount=amount,
updated_by_id=current_user.id
)
return {
"status": "success",
"message": "Invoice marked as paid successfully",
"data": {"invoice": updated_invoice}
}
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/{id}")
async def delete_invoice(
id: int,
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Delete an invoice (Admin only)"""
try:
invoice = db.query(Invoice).filter(Invoice.id == id).first()
if not invoice:
raise HTTPException(status_code=404, detail="Invoice not found")
db.delete(invoice)
db.commit()
return {
"status": "success",
"message": "Invoice deleted successfully"
}
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.get("/booking/{booking_id}")
async def get_invoices_by_booking(
booking_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get all invoices 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: admin can see all, users can only see their own bookings
if current_user.role_id != 1 and booking.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Forbidden")
result = InvoiceService.get_invoices(
db=db,
booking_id=booking_id
)
return {
"status": "success",
"data": result
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, status, Query
from fastapi import APIRouter, Depends, HTTPException, status, Query, Request, Header
from sqlalchemy.orm import Session
from typing import Optional
from datetime import datetime
@@ -12,6 +12,7 @@ from ..models.payment import Payment, PaymentMethod, PaymentType, PaymentStatus
from ..models.booking import Booking, BookingStatus
from ..utils.mailer import send_email
from ..utils.email_templates import payment_confirmation_email_template
from ..services.stripe_service import StripeService
router = APIRouter(prefix="/payments", tags=["payments"])
@@ -340,3 +341,250 @@ async def update_payment_status(
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.post("/stripe/create-intent")
async def create_stripe_payment_intent(
intent_data: dict,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Create a Stripe payment intent"""
try:
# Check if Stripe is configured (from database or environment)
from ..services.stripe_service import get_stripe_secret_key
secret_key = get_stripe_secret_key(db)
if not secret_key:
secret_key = settings.STRIPE_SECRET_KEY
if not secret_key:
raise HTTPException(
status_code=500,
detail="Stripe is not configured. Please configure Stripe settings in Admin Panel or set STRIPE_SECRET_KEY environment variable."
)
booking_id = intent_data.get("booking_id")
amount = float(intent_data.get("amount", 0))
currency = intent_data.get("currency", "usd")
# Log the incoming amount for debugging
import logging
logger = logging.getLogger(__name__)
logger.info(f"Creating Stripe payment intent - Booking ID: {booking_id}, Amount: ${amount:,.2f}, Currency: {currency}")
if not booking_id or amount <= 0:
raise HTTPException(
status_code=400,
detail="booking_id and amount are required"
)
# Validate amount is reasonable (Stripe max is $999,999.99)
if amount > 999999.99:
logger.error(f"Amount ${amount:,.2f} exceeds Stripe's maximum of $999,999.99")
raise HTTPException(
status_code=400,
detail=f"Amount ${amount:,.2f} exceeds Stripe's maximum of $999,999.99. Please contact support for large payments."
)
# Verify 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")
if current_user.role_id != 1 and booking.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Forbidden")
# Create payment intent
intent = StripeService.create_payment_intent(
amount=amount,
currency=currency,
metadata={
"booking_id": str(booking_id),
"booking_number": booking.booking_number,
"user_id": str(current_user.id),
},
db=db
)
# Get publishable key from database or environment
from ..services.stripe_service import get_stripe_publishable_key
publishable_key = get_stripe_publishable_key(db)
if not publishable_key:
publishable_key = settings.STRIPE_PUBLISHABLE_KEY
if not publishable_key:
import logging
logger = logging.getLogger(__name__)
logger.warning("Stripe publishable key is not configured")
raise HTTPException(
status_code=500,
detail="Stripe publishable key is not configured. Please configure it in Admin Panel (Settings > Stripe Settings) or set STRIPE_PUBLISHABLE_KEY environment variable."
)
if not intent.get("client_secret"):
import logging
logger = logging.getLogger(__name__)
logger.error("Payment intent created but client_secret is missing")
raise HTTPException(
status_code=500,
detail="Failed to create payment intent. Client secret is missing."
)
return {
"status": "success",
"message": "Payment intent created successfully",
"data": {
"client_secret": intent["client_secret"],
"payment_intent_id": intent["id"],
"publishable_key": publishable_key,
}
}
except HTTPException:
raise
except ValueError as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f"Payment intent creation error: {str(e)}")
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f"Unexpected error creating payment intent: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/stripe/confirm")
async def confirm_stripe_payment(
payment_data: dict,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Confirm a Stripe payment"""
try:
payment_intent_id = payment_data.get("payment_intent_id")
booking_id = payment_data.get("booking_id")
if not payment_intent_id:
raise HTTPException(
status_code=400,
detail="payment_intent_id is required"
)
# Confirm payment (this commits the transaction internally)
payment = StripeService.confirm_payment(
payment_intent_id=payment_intent_id,
db=db,
booking_id=booking_id
)
# Ensure the transaction is committed before proceeding
# The service method already commits, but we ensure it here too
try:
db.commit()
except Exception:
# If already committed, this will raise an error, which we can ignore
pass
# Get fresh booking from database to get updated status (after commit)
booking = db.query(Booking).filter(Booking.id == payment["booking_id"]).first()
if booking:
db.refresh(booking)
# Send payment confirmation email (non-blocking, after commit)
# This won't affect the transaction since it's already committed
if booking 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=payment["amount"],
payment_method="stripe",
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:
import logging
logger = logging.getLogger(__name__)
logger.warning(f"Failed to send payment confirmation email: {e}")
return {
"status": "success",
"message": "Payment confirmed successfully",
"data": {
"payment": payment,
"booking": {
"id": booking.id if booking else None,
"booking_number": booking.booking_number if booking else None,
"status": booking.status.value if booking else None,
}
}
}
except HTTPException:
db.rollback()
raise
except ValueError as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f"Payment confirmation error: {str(e)}")
db.rollback()
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f"Unexpected error confirming payment: {str(e)}", exc_info=True)
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.post("/stripe/webhook")
async def stripe_webhook(
request: Request,
db: Session = Depends(get_db)
):
"""Handle Stripe webhook events"""
try:
# Check if webhook secret is configured (from database or environment)
from ..services.stripe_service import get_stripe_webhook_secret
webhook_secret = get_stripe_webhook_secret(db)
if not webhook_secret:
webhook_secret = settings.STRIPE_WEBHOOK_SECRET
if not webhook_secret:
raise HTTPException(
status_code=503,
detail={
"status": "error",
"message": "Stripe webhook secret is not configured. Please configure it in Admin Panel (Settings > Stripe Settings) or set STRIPE_WEBHOOK_SECRET environment variable."
}
)
payload = await request.body()
signature = request.headers.get("stripe-signature")
if not signature:
raise HTTPException(
status_code=400,
detail="Missing stripe-signature header"
)
result = StripeService.handle_webhook(
payload=payload,
signature=signature,
db=db
)
return {
"status": "success",
"data": result
}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -197,7 +197,7 @@ async def search_available_rooms(
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{id}")
@router.get("/id/{id}")
async def get_room_by_id(id: int, request: Request, db: Session = Depends(get_db)):
"""Get room by ID"""
try:
@@ -225,9 +225,81 @@ async def get_room_by_id(id: int, request: Request, db: Session = Depends(get_db
"room_number": room.room_number,
"floor": room.floor,
"status": room.status.value if isinstance(room.status, RoomStatus) else room.status,
"price": float(room.price) if room.price else 0.0,
"price": float(room.price) if room.price is not None and room.price > 0 else None,
"featured": room.featured,
"description": room.description,
"capacity": room.capacity,
"room_size": room.room_size,
"view": room.view,
"amenities": room.amenities,
"created_at": room.created_at.isoformat() if room.created_at else None,
"updated_at": room.updated_at.isoformat() if room.updated_at else None,
"average_rating": round(float(review_stats.average_rating or 0), 1) if review_stats and review_stats.average_rating else None,
"total_reviews": review_stats.total_reviews or 0 if review_stats else 0,
}
# Normalize images
try:
room_dict["images"] = normalize_images(room.images, base_url)
except:
room_dict["images"] = []
# Add room type
if room.room_type:
room_dict["room_type"] = {
"id": room.room_type.id,
"name": room.room_type.name,
"description": room.room_type.description,
"base_price": float(room.room_type.base_price) if room.room_type.base_price else 0.0,
"capacity": room.room_type.capacity,
"amenities": room.room_type.amenities,
"images": [] # RoomType doesn't have images column in DB
}
return {
"status": "success",
"data": {"room": room_dict}
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{room_number}")
async def get_room_by_number(room_number: str, request: Request, db: Session = Depends(get_db)):
"""Get room by room number"""
try:
room = db.query(Room).filter(Room.room_number == room_number).first()
if not room:
raise HTTPException(status_code=404, detail="Room not found")
# Get review stats
review_stats = db.query(
func.avg(Review.rating).label('average_rating'),
func.count(Review.id).label('total_reviews')
).filter(
and_(
Review.room_id == room.id,
Review.status == ReviewStatus.approved
)
).first()
base_url = get_base_url(request)
room_dict = {
"id": room.id,
"room_type_id": room.room_type_id,
"room_number": room.room_number,
"floor": room.floor,
"status": room.status.value if isinstance(room.status, RoomStatus) else room.status,
"price": float(room.price) if room.price is not None and room.price > 0 else None,
"featured": room.featured,
"description": room.description,
"capacity": room.capacity,
"room_size": room.room_size,
"view": room.view,
"amenities": room.amenities,
"created_at": room.created_at.isoformat() if room.created_at else None,
"updated_at": room.updated_at.isoformat() if room.updated_at else None,
@@ -266,6 +338,7 @@ async def get_room_by_id(id: int, request: Request, db: Session = Depends(get_db
@router.post("/", dependencies=[Depends(authorize_roles("admin"))])
async def create_room(
room_data: dict,
request: Request,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
@@ -281,6 +354,13 @@ async def create_room(
if existing:
raise HTTPException(status_code=400, detail="Room number already exists")
# Ensure amenities is always a list
amenities_value = room_data.get("amenities", [])
if amenities_value is None:
amenities_value = []
elif not isinstance(amenities_value, list):
amenities_value = []
room = Room(
room_type_id=room_data.get("room_type_id"),
room_number=room_data.get("room_number"),
@@ -288,16 +368,60 @@ async def create_room(
status=RoomStatus(room_data.get("status", "available")),
featured=room_data.get("featured", False),
price=room_data.get("price", room_type.base_price),
description=room_data.get("description"),
capacity=room_data.get("capacity"),
room_size=room_data.get("room_size"),
view=room_data.get("view"),
amenities=amenities_value,
)
db.add(room)
db.commit()
db.refresh(room)
# Get base URL for proper response
base_url = get_base_url(request)
# Serialize room data
room_dict = {
"id": room.id,
"room_type_id": room.room_type_id,
"room_number": room.room_number,
"floor": room.floor,
"status": room.status.value if isinstance(room.status, RoomStatus) else room.status,
"price": float(room.price) if room.price is not None and room.price > 0 else None,
"featured": room.featured,
"description": room.description,
"capacity": room.capacity,
"room_size": room.room_size,
"view": room.view,
"amenities": room.amenities if room.amenities else [],
"created_at": room.created_at.isoformat() if room.created_at else None,
"updated_at": room.updated_at.isoformat() if room.updated_at else None,
}
# Normalize images
try:
room_dict["images"] = normalize_images(room.images, base_url)
except:
room_dict["images"] = []
# Add room type info
if room.room_type:
room_dict["room_type"] = {
"id": room.room_type.id,
"name": room.room_type.name,
"description": room.room_type.description,
"base_price": float(room.room_type.base_price) if room.room_type.base_price else 0.0,
"capacity": room.room_type.capacity,
"amenities": room.room_type.amenities if room.room_type.amenities else [],
"images": []
}
return {
"status": "success",
"message": "Room created successfully",
"data": {"room": room}
"data": {"room": room_dict}
}
except HTTPException:
raise
@@ -310,6 +434,7 @@ async def create_room(
async def update_room(
id: int,
room_data: dict,
request: Request,
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
@@ -337,14 +462,70 @@ async def update_room(
room.featured = room_data["featured"]
if "price" in room_data:
room.price = room_data["price"]
if "description" in room_data:
room.description = room_data["description"]
if "capacity" in room_data:
room.capacity = room_data["capacity"]
if "room_size" in room_data:
room.room_size = room_data["room_size"]
if "view" in room_data:
room.view = room_data["view"]
if "amenities" in room_data:
# Ensure amenities is always a list
amenities_value = room_data["amenities"]
if amenities_value is None:
room.amenities = []
elif isinstance(amenities_value, list):
room.amenities = amenities_value
else:
room.amenities = []
db.commit()
db.refresh(room)
# Get base URL for proper response
base_url = get_base_url(request)
# Serialize room data similar to get_room_by_id
room_dict = {
"id": room.id,
"room_type_id": room.room_type_id,
"room_number": room.room_number,
"floor": room.floor,
"status": room.status.value if isinstance(room.status, RoomStatus) else room.status,
"price": float(room.price) if room.price is not None and room.price > 0 else None,
"featured": room.featured,
"description": room.description,
"capacity": room.capacity,
"room_size": room.room_size,
"view": room.view,
"amenities": room.amenities if room.amenities else [],
"created_at": room.created_at.isoformat() if room.created_at else None,
"updated_at": room.updated_at.isoformat() if room.updated_at else None,
}
# Normalize images
try:
room_dict["images"] = normalize_images(room.images, base_url)
except:
room_dict["images"] = []
# Add room type info
if room.room_type:
room_dict["room_type"] = {
"id": room.room_type.id,
"name": room.room_type.name,
"description": room.room_type.description,
"base_price": float(room.room_type.base_price) if room.room_type.base_price else 0.0,
"capacity": room.room_type.capacity,
"amenities": room.room_type.amenities if room.room_type.amenities else [],
"images": []
}
return {
"status": "success",
"message": "Room updated successfully",
"data": {"room": room}
"data": {"room": room_dict}
}
except HTTPException:
raise
@@ -379,6 +560,57 @@ async def delete_room(
raise HTTPException(status_code=500, detail=str(e))
@router.post("/bulk-delete", dependencies=[Depends(authorize_roles("admin"))])
async def bulk_delete_rooms(
room_ids: dict,
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Bulk delete rooms (Admin only)"""
try:
ids = room_ids.get("ids", [])
if not ids or not isinstance(ids, list):
raise HTTPException(status_code=400, detail="Invalid room IDs provided")
if len(ids) == 0:
raise HTTPException(status_code=400, detail="No room IDs provided")
# Validate all IDs are integers
try:
ids = [int(id) for id in ids]
except (ValueError, TypeError):
raise HTTPException(status_code=400, detail="All room IDs must be integers")
# Check if all rooms exist
rooms = db.query(Room).filter(Room.id.in_(ids)).all()
found_ids = [room.id for room in rooms]
not_found_ids = [id for id in ids if id not in found_ids]
if not_found_ids:
raise HTTPException(
status_code=404,
detail=f"Rooms with IDs {not_found_ids} not found"
)
# Delete all rooms
deleted_count = db.query(Room).filter(Room.id.in_(ids)).delete(synchronize_session=False)
db.commit()
return {
"status": "success",
"message": f"Successfully deleted {deleted_count} room(s)",
"data": {
"deleted_count": deleted_count,
"deleted_ids": ids
}
}
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.post("/{id}/images", dependencies=[Depends(authorize_roles("admin", "staff"))])
async def upload_room_images(
id: int,
@@ -435,7 +667,7 @@ async def upload_room_images(
@router.delete("/{id}/images", dependencies=[Depends(authorize_roles("admin", "staff"))])
async def delete_room_images(
id: int,
image_url: str,
image_url: str = Query(..., description="Image URL or path to delete"),
current_user: User = Depends(authorize_roles("admin", "staff")),
db: Session = Depends(get_db)
):
@@ -445,12 +677,39 @@ async def delete_room_images(
if not room:
raise HTTPException(status_code=404, detail="Room not found")
# Update room images (images are stored on Room, not RoomType)
# Normalize the incoming image_url to extract the path
# Handle both full URLs and relative paths
normalized_url = image_url
if image_url.startswith('http://') or image_url.startswith('https://'):
# Extract path from URL
from urllib.parse import urlparse
parsed = urlparse(image_url)
normalized_url = parsed.path
# Normalize paths for comparison (ensure leading slash)
if not normalized_url.startswith('/'):
normalized_url = f"/{normalized_url}"
# Get filename from normalized path
filename = Path(normalized_url).name
# Update room images - compare by filename or full path
existing_images = room.images or []
updated_images = [img for img in existing_images if img != image_url]
updated_images = []
for img in existing_images:
# Normalize stored image path
stored_path = img if img.startswith('/') else f"/{img}"
stored_filename = Path(stored_path).name
# Compare by filename or full path
# Keep images that don't match
if (img != normalized_url and
stored_path != normalized_url and
stored_filename != filename):
updated_images.append(img)
# Delete file from disk
filename = Path(image_url).name
file_path = Path(__file__).parent.parent.parent / "uploads" / "rooms" / filename
if file_path.exists():
file_path.unlink()

View File

@@ -0,0 +1,302 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import Optional
from ..config.database import get_db
from ..middleware.auth import get_current_user, authorize_roles
from ..models.user import User
from ..models.system_settings import SystemSettings
router = APIRouter(prefix="/admin/system-settings", tags=["admin-system-settings"])
@router.get("/currency")
async def get_platform_currency(
db: Session = Depends(get_db)
):
"""Get platform currency setting (public endpoint for frontend)"""
try:
setting = db.query(SystemSettings).filter(
SystemSettings.key == "platform_currency"
).first()
if not setting:
# Default to VND if not set
return {
"status": "success",
"data": {
"currency": "VND",
"updated_at": None,
"updated_by": None
}
}
return {
"status": "success",
"data": {
"currency": setting.value,
"updated_at": setting.updated_at.isoformat() if setting.updated_at else None,
"updated_by": setting.updated_by.full_name if setting.updated_by else None
}
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.put("/currency")
async def update_platform_currency(
currency_data: dict,
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Update platform currency (Admin only)"""
try:
currency = currency_data.get("currency", "").upper()
# Validate currency code
if not currency or len(currency) != 3 or not currency.isalpha():
raise HTTPException(
status_code=400,
detail="Invalid currency code. Must be a 3-letter ISO 4217 code (e.g., USD, EUR, VND)"
)
# Get or create setting
setting = db.query(SystemSettings).filter(
SystemSettings.key == "platform_currency"
).first()
if setting:
setting.value = currency
setting.updated_by_id = current_user.id
else:
setting = SystemSettings(
key="platform_currency",
value=currency,
description="Platform-wide currency setting for displaying prices across the application",
updated_by_id=current_user.id
)
db.add(setting)
db.commit()
db.refresh(setting)
return {
"status": "success",
"message": "Platform currency updated successfully",
"data": {
"currency": setting.value,
"updated_at": setting.updated_at.isoformat() if setting.updated_at else None,
"updated_by": setting.updated_by.full_name if setting.updated_by else None
}
}
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.get("/")
async def get_all_settings(
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Get all system settings (Admin only)"""
try:
settings = db.query(SystemSettings).all()
result = []
for setting in settings:
result.append({
"key": setting.key,
"value": setting.value,
"description": setting.description,
"updated_at": setting.updated_at.isoformat() if setting.updated_at else None,
"updated_by": setting.updated_by.full_name if setting.updated_by else None
})
return {
"status": "success",
"data": {
"settings": result
}
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/stripe")
async def get_stripe_settings(
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Get Stripe payment settings (Admin only)"""
try:
secret_key_setting = db.query(SystemSettings).filter(
SystemSettings.key == "stripe_secret_key"
).first()
publishable_key_setting = db.query(SystemSettings).filter(
SystemSettings.key == "stripe_publishable_key"
).first()
webhook_secret_setting = db.query(SystemSettings).filter(
SystemSettings.key == "stripe_webhook_secret"
).first()
# Mask secret keys for security (only show last 4 characters)
def mask_key(key_value: str) -> str:
if not key_value or len(key_value) < 4:
return ""
return "*" * (len(key_value) - 4) + key_value[-4:]
result = {
"stripe_secret_key": "",
"stripe_publishable_key": "",
"stripe_webhook_secret": "",
"stripe_secret_key_masked": "",
"stripe_webhook_secret_masked": "",
"has_secret_key": False,
"has_publishable_key": False,
"has_webhook_secret": False,
}
if secret_key_setting:
result["stripe_secret_key"] = secret_key_setting.value
result["stripe_secret_key_masked"] = mask_key(secret_key_setting.value)
result["has_secret_key"] = bool(secret_key_setting.value)
result["updated_at"] = secret_key_setting.updated_at.isoformat() if secret_key_setting.updated_at else None
result["updated_by"] = secret_key_setting.updated_by.full_name if secret_key_setting.updated_by else None
if publishable_key_setting:
result["stripe_publishable_key"] = publishable_key_setting.value
result["has_publishable_key"] = bool(publishable_key_setting.value)
if webhook_secret_setting:
result["stripe_webhook_secret"] = webhook_secret_setting.value
result["stripe_webhook_secret_masked"] = mask_key(webhook_secret_setting.value)
result["has_webhook_secret"] = bool(webhook_secret_setting.value)
return {
"status": "success",
"data": result
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.put("/stripe")
async def update_stripe_settings(
stripe_data: dict,
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Update Stripe payment settings (Admin only)"""
try:
secret_key = stripe_data.get("stripe_secret_key", "").strip()
publishable_key = stripe_data.get("stripe_publishable_key", "").strip()
webhook_secret = stripe_data.get("stripe_webhook_secret", "").strip()
# Validate secret key format (should start with sk_)
if secret_key and not secret_key.startswith("sk_"):
raise HTTPException(
status_code=400,
detail="Invalid Stripe secret key format. Must start with 'sk_'"
)
# Validate publishable key format (should start with pk_)
if publishable_key and not publishable_key.startswith("pk_"):
raise HTTPException(
status_code=400,
detail="Invalid Stripe publishable key format. Must start with 'pk_'"
)
# Validate webhook secret format (should start with whsec_)
if webhook_secret and not webhook_secret.startswith("whsec_"):
raise HTTPException(
status_code=400,
detail="Invalid Stripe webhook secret format. Must start with 'whsec_'"
)
# Update or create secret key setting
if secret_key:
setting = db.query(SystemSettings).filter(
SystemSettings.key == "stripe_secret_key"
).first()
if setting:
setting.value = secret_key
setting.updated_by_id = current_user.id
else:
setting = SystemSettings(
key="stripe_secret_key",
value=secret_key,
description="Stripe secret key for processing payments",
updated_by_id=current_user.id
)
db.add(setting)
# Update or create publishable key setting
if publishable_key:
setting = db.query(SystemSettings).filter(
SystemSettings.key == "stripe_publishable_key"
).first()
if setting:
setting.value = publishable_key
setting.updated_by_id = current_user.id
else:
setting = SystemSettings(
key="stripe_publishable_key",
value=publishable_key,
description="Stripe publishable key for frontend payment forms",
updated_by_id=current_user.id
)
db.add(setting)
# Update or create webhook secret setting
if webhook_secret:
setting = db.query(SystemSettings).filter(
SystemSettings.key == "stripe_webhook_secret"
).first()
if setting:
setting.value = webhook_secret
setting.updated_by_id = current_user.id
else:
setting = SystemSettings(
key="stripe_webhook_secret",
value=webhook_secret,
description="Stripe webhook secret for verifying webhook events",
updated_by_id=current_user.id
)
db.add(setting)
db.commit()
# Return masked values
def mask_key(key_value: str) -> str:
if not key_value or len(key_value) < 4:
return ""
return "*" * (len(key_value) - 4) + key_value[-4:]
return {
"status": "success",
"message": "Stripe settings updated successfully",
"data": {
"stripe_secret_key": secret_key if secret_key else "",
"stripe_publishable_key": publishable_key,
"stripe_webhook_secret": webhook_secret if webhook_secret else "",
"stripe_secret_key_masked": mask_key(secret_key) if secret_key else "",
"stripe_webhook_secret_masked": mask_key(webhook_secret) if webhook_secret else "",
"has_secret_key": bool(secret_key),
"has_publishable_key": bool(publishable_key),
"has_webhook_secret": bool(webhook_secret),
}
}
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -66,6 +66,7 @@ async def get_users(
"phone_number": user.phone, # For frontend compatibility
"address": user.address,
"avatar": user.avatar,
"currency": getattr(user, 'currency', 'VND'),
"is_active": user.is_active,
"status": "active" if user.is_active else "inactive",
"role_id": user.role_id,
@@ -117,6 +118,7 @@ async def get_user_by_id(
"phone_number": user.phone,
"address": user.address,
"avatar": user.avatar,
"currency": getattr(user, 'currency', 'VND'),
"is_active": user.is_active,
"status": "active" if user.is_active else "inactive",
"role_id": user.role_id,
@@ -194,6 +196,7 @@ async def create_user(
"full_name": user.full_name,
"phone": user.phone,
"phone_number": user.phone,
"currency": getattr(user, 'currency', 'VND'),
"role_id": user.role_id,
"is_active": user.is_active,
}
@@ -248,6 +251,10 @@ async def update_user(
user.role_id = role_map.get(user_data["role"], 3)
if "status" in user_data and current_user.role_id == 1:
user.is_active = user_data["status"] == "active"
if "currency" in user_data:
currency = user_data["currency"]
if len(currency) == 3 and currency.isalpha():
user.currency = currency.upper()
if "password" in user_data:
password_bytes = user_data["password"].encode('utf-8')
salt = bcrypt.gensalt()
@@ -263,6 +270,7 @@ async def update_user(
"full_name": user.full_name,
"phone": user.phone,
"phone_number": user.phone,
"currency": getattr(user, 'currency', 'VND'),
"role_id": user.role_id,
"is_active": user.is_active,
}