diff --git a/Backend/src/__pycache__/main.cpython-312.pyc b/Backend/src/__pycache__/main.cpython-312.pyc index 214e3ce3..bc96cf66 100644 Binary files a/Backend/src/__pycache__/main.cpython-312.pyc and b/Backend/src/__pycache__/main.cpython-312.pyc differ diff --git a/Backend/src/main.py b/Backend/src/main.py index ecfda6d7..be697f93 100644 --- a/Backend/src/main.py +++ b/Backend/src/main.py @@ -194,9 +194,9 @@ app.include_router(privacy_routes.router, prefix=settings.API_V1_PREFIX) # Import and include other routes from .routes import ( room_routes, booking_routes, payment_routes, invoice_routes, banner_routes, - favorite_routes, service_routes, promotion_routes, report_routes, + favorite_routes, service_routes, service_booking_routes, promotion_routes, report_routes, review_routes, user_routes, audit_routes, admin_privacy_routes, - system_settings_routes + system_settings_routes, contact_routes ) # Legacy routes (maintain backward compatibility) @@ -207,6 +207,7 @@ app.include_router(invoice_routes.router, prefix="/api") app.include_router(banner_routes.router, prefix="/api") app.include_router(favorite_routes.router, prefix="/api") app.include_router(service_routes.router, prefix="/api") +app.include_router(service_booking_routes.router, prefix="/api") app.include_router(promotion_routes.router, prefix="/api") app.include_router(report_routes.router, prefix="/api") app.include_router(review_routes.router, prefix="/api") @@ -214,6 +215,7 @@ app.include_router(user_routes.router, prefix="/api") app.include_router(audit_routes.router, prefix="/api") app.include_router(admin_privacy_routes.router, prefix="/api") app.include_router(system_settings_routes.router, prefix="/api") +app.include_router(contact_routes.router, prefix="/api") # Versioned routes (v1) app.include_router(room_routes.router, prefix=settings.API_V1_PREFIX) @@ -223,6 +225,7 @@ app.include_router(invoice_routes.router, prefix=settings.API_V1_PREFIX) app.include_router(banner_routes.router, prefix=settings.API_V1_PREFIX) app.include_router(favorite_routes.router, prefix=settings.API_V1_PREFIX) app.include_router(service_routes.router, prefix=settings.API_V1_PREFIX) +app.include_router(service_booking_routes.router, prefix=settings.API_V1_PREFIX) app.include_router(promotion_routes.router, prefix=settings.API_V1_PREFIX) app.include_router(report_routes.router, prefix=settings.API_V1_PREFIX) app.include_router(review_routes.router, prefix=settings.API_V1_PREFIX) @@ -230,6 +233,7 @@ app.include_router(user_routes.router, prefix=settings.API_V1_PREFIX) app.include_router(audit_routes.router, prefix=settings.API_V1_PREFIX) app.include_router(admin_privacy_routes.router, prefix=settings.API_V1_PREFIX) app.include_router(system_settings_routes.router, prefix=settings.API_V1_PREFIX) +app.include_router(contact_routes.router, prefix=settings.API_V1_PREFIX) logger.info("All routes registered successfully") diff --git a/Backend/src/models/__init__.py b/Backend/src/models/__init__.py index 20e9bdbe..edb1f882 100644 --- a/Backend/src/models/__init__.py +++ b/Backend/src/models/__init__.py @@ -8,6 +8,7 @@ from .booking import Booking from .payment import Payment from .service import Service from .service_usage import ServiceUsage +from .service_booking import ServiceBooking, ServiceBookingItem, ServicePayment, ServiceBookingStatus, ServicePaymentStatus, ServicePaymentMethod from .promotion import Promotion from .checkin_checkout import CheckInCheckOut from .banner import Banner @@ -30,6 +31,12 @@ __all__ = [ "Payment", "Service", "ServiceUsage", + "ServiceBooking", + "ServiceBookingItem", + "ServicePayment", + "ServiceBookingStatus", + "ServicePaymentStatus", + "ServicePaymentMethod", "Promotion", "CheckInCheckOut", "Banner", diff --git a/Backend/src/models/__pycache__/__init__.cpython-312.pyc b/Backend/src/models/__pycache__/__init__.cpython-312.pyc index bad4ebeb..b8a9853d 100644 Binary files a/Backend/src/models/__pycache__/__init__.cpython-312.pyc and b/Backend/src/models/__pycache__/__init__.cpython-312.pyc differ diff --git a/Backend/src/models/__pycache__/service_booking.cpython-312.pyc b/Backend/src/models/__pycache__/service_booking.cpython-312.pyc new file mode 100644 index 00000000..80c3f8a3 Binary files /dev/null and b/Backend/src/models/__pycache__/service_booking.cpython-312.pyc differ diff --git a/Backend/src/models/__pycache__/user.cpython-312.pyc b/Backend/src/models/__pycache__/user.cpython-312.pyc index fdbcb33e..bb54f889 100644 Binary files a/Backend/src/models/__pycache__/user.cpython-312.pyc and b/Backend/src/models/__pycache__/user.cpython-312.pyc differ diff --git a/Backend/src/models/service_booking.py b/Backend/src/models/service_booking.py new file mode 100644 index 00000000..31d09155 --- /dev/null +++ b/Backend/src/models/service_booking.py @@ -0,0 +1,78 @@ +from sqlalchemy import Column, Integer, String, DateTime, Numeric, Text, Enum, ForeignKey +from sqlalchemy.orm import relationship +from datetime import datetime +import enum +from ..config.database import Base + + +class ServiceBookingStatus(str, enum.Enum): + pending = "pending" + confirmed = "confirmed" + completed = "completed" + cancelled = "cancelled" + + +class ServiceBooking(Base): + __tablename__ = "service_bookings" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + booking_number = Column(String(50), unique=True, nullable=False, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + total_amount = Column(Numeric(10, 2), nullable=False) + status = Column(Enum(ServiceBookingStatus), nullable=False, default=ServiceBookingStatus.pending) + notes = Column(Text, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + # Relationships + user = relationship("User", back_populates="service_bookings") + service_items = relationship("ServiceBookingItem", back_populates="service_booking", cascade="all, delete-orphan") + payments = relationship("ServicePayment", back_populates="service_booking", cascade="all, delete-orphan") + + +class ServiceBookingItem(Base): + __tablename__ = "service_booking_items" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + service_booking_id = Column(Integer, ForeignKey("service_bookings.id"), nullable=False) + service_id = Column(Integer, ForeignKey("services.id"), nullable=False) + quantity = Column(Integer, nullable=False, default=1) + unit_price = Column(Numeric(10, 2), nullable=False) + total_price = Column(Numeric(10, 2), nullable=False) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + + # Relationships + service_booking = relationship("ServiceBooking", back_populates="service_items") + service = relationship("Service") + + +class ServicePaymentStatus(str, enum.Enum): + pending = "pending" + completed = "completed" + failed = "failed" + refunded = "refunded" + + +class ServicePaymentMethod(str, enum.Enum): + cash = "cash" + stripe = "stripe" + bank_transfer = "bank_transfer" + + +class ServicePayment(Base): + __tablename__ = "service_payments" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + service_booking_id = Column(Integer, ForeignKey("service_bookings.id"), nullable=False) + amount = Column(Numeric(10, 2), nullable=False) + payment_method = Column(Enum(ServicePaymentMethod), nullable=False) + payment_status = Column(Enum(ServicePaymentStatus), nullable=False, default=ServicePaymentStatus.pending) + transaction_id = Column(String(100), nullable=True) + payment_date = Column(DateTime, nullable=True) + notes = Column(Text, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + # Relationships + service_booking = relationship("ServiceBooking", back_populates="payments") + diff --git a/Backend/src/models/user.py b/Backend/src/models/user.py index 445e5772..ef7babf3 100644 --- a/Backend/src/models/user.py +++ b/Backend/src/models/user.py @@ -28,4 +28,5 @@ class User(Base): checkouts_processed = relationship("CheckInCheckOut", foreign_keys="CheckInCheckOut.checkout_by", back_populates="checked_out_by") reviews = relationship("Review", back_populates="user") favorites = relationship("Favorite", back_populates="user", cascade="all, delete-orphan") + service_bookings = relationship("ServiceBooking", back_populates="user") diff --git a/Backend/src/routes/__pycache__/booking_routes.cpython-312.pyc b/Backend/src/routes/__pycache__/booking_routes.cpython-312.pyc index dae85e6d..ec9b477c 100644 Binary files a/Backend/src/routes/__pycache__/booking_routes.cpython-312.pyc and b/Backend/src/routes/__pycache__/booking_routes.cpython-312.pyc differ diff --git a/Backend/src/routes/__pycache__/contact_routes.cpython-312.pyc b/Backend/src/routes/__pycache__/contact_routes.cpython-312.pyc new file mode 100644 index 00000000..7f221679 Binary files /dev/null and b/Backend/src/routes/__pycache__/contact_routes.cpython-312.pyc differ diff --git a/Backend/src/routes/__pycache__/room_routes.cpython-312.pyc b/Backend/src/routes/__pycache__/room_routes.cpython-312.pyc index 8132d57a..18c8c867 100644 Binary files a/Backend/src/routes/__pycache__/room_routes.cpython-312.pyc and b/Backend/src/routes/__pycache__/room_routes.cpython-312.pyc differ diff --git a/Backend/src/routes/__pycache__/service_booking_routes.cpython-312.pyc b/Backend/src/routes/__pycache__/service_booking_routes.cpython-312.pyc new file mode 100644 index 00000000..4940abc2 Binary files /dev/null and b/Backend/src/routes/__pycache__/service_booking_routes.cpython-312.pyc differ diff --git a/Backend/src/routes/booking_routes.py b/Backend/src/routes/booking_routes.py index 13a9110a..5bd28582 100644 --- a/Backend/src/routes/booking_routes.py +++ b/Backend/src/routes/booking_routes.py @@ -14,6 +14,7 @@ from ..models.booking import Booking, BookingStatus from ..models.room import Room, RoomStatus from ..models.room_type import RoomType from ..models.payment import Payment, PaymentMethod, PaymentType, PaymentStatus +from ..models.service_usage import ServiceUsage from ..services.room_service import normalize_images, get_base_url from fastapi import Request from ..utils.mailer import send_email @@ -83,8 +84,8 @@ async def get_all_bookings( "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, + "check_in_date": booking.check_in_date.strftime("%Y-%m-%d") if booking.check_in_date else None, + "check_out_date": booking.check_out_date.strftime("%Y-%m-%d") if booking.check_out_date else None, "num_guests": 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, @@ -148,8 +149,8 @@ async def get_my_bookings( "id": booking.id, "booking_number": booking.booking_number, "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, + "check_in_date": booking.check_in_date.strftime("%Y-%m-%d") if booking.check_in_date else None, + "check_out_date": booking.check_out_date.strftime("%Y-%m-%d") if booking.check_out_date else None, "num_guests": 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, @@ -217,8 +218,18 @@ async def create_booking( if not room: raise HTTPException(status_code=404, detail="Room not found") - check_in = datetime.fromisoformat(check_in_date.replace('Z', '+00:00')) - check_out = datetime.fromisoformat(check_out_date.replace('Z', '+00:00')) + # Parse dates as date-only strings (YYYY-MM-DD) - treat as naive datetime + if 'T' in check_in_date or 'Z' in check_in_date or '+' in check_in_date: + check_in = datetime.fromisoformat(check_in_date.replace('Z', '+00:00')) + else: + # Date-only format (YYYY-MM-DD) - parse as naive datetime + check_in = datetime.strptime(check_in_date, '%Y-%m-%d') + + if 'T' in check_out_date or 'Z' in check_out_date or '+' in check_out_date: + check_out = datetime.fromisoformat(check_out_date.replace('Z', '+00:00')) + else: + # Date-only format (YYYY-MM-DD) - parse as naive datetime + check_out = datetime.strptime(check_out_date, '%Y-%m-%d') # Check for overlapping bookings overlapping = db.query(Booking).filter( @@ -286,12 +297,72 @@ async def create_booking( # 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 + # Add services to booking if provided + services = booking_data.get("services", []) + if services: + from ..models.service import Service + from ..models.service_usage import ServiceUsage + + for service_item in services: + service_id = service_item.get("service_id") + quantity = service_item.get("quantity", 1) + + if not service_id: + continue + + # Check if service exists and is active + service = db.query(Service).filter(Service.id == service_id).first() + if not service or not service.is_active: + continue + + # Calculate total price for this service + unit_price = float(service.price) + total_price = unit_price * quantity + + # Create service usage + service_usage = ServiceUsage( + booking_id=booking.id, + service_id=service_id, + quantity=quantity, + unit_price=unit_price, + total_price=total_price, + ) + db.add(service_usage) + db.commit() db.refresh(booking) - # 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() + # Automatically create invoice for the booking + try: + from ..services.invoice_service import InvoiceService + from sqlalchemy.orm import joinedload, selectinload + + # Reload booking with service_usages for invoice creation + booking = db.query(Booking).options( + selectinload(Booking.service_usages).selectinload(ServiceUsage.service) + ).filter(Booking.id == booking.id).first() + + # Create invoice automatically + invoice = InvoiceService.create_invoice_from_booking( + booking_id=booking.id, + db=db, + created_by_id=current_user.id, + tax_rate=0.0, # Default no tax, can be configured + discount_amount=0.0, + due_days=30, + ) + except Exception as e: + # Log error but don't fail booking creation if invoice creation fails + import logging + logger = logging.getLogger(__name__) + logger.error(f"Failed to create invoice for booking {booking.id}: {str(e)}") + + # Fetch with relations for proper serialization (eager load payments and service_usages) + from sqlalchemy.orm import joinedload, selectinload + booking = db.query(Booking).options( + joinedload(Booking.payments), + selectinload(Booking.service_usages).selectinload(ServiceUsage.service) + ).filter(Booking.id == booking.id).first() # Determine payment_method and payment_status from payments payment_method_from_payments = None @@ -310,8 +381,8 @@ async def create_booking( "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, + "check_in_date": booking.check_in_date.strftime("%Y-%m-%d") if booking.check_in_date else None, + "check_out_date": booking.check_out_date.strftime("%Y-%m-%d") 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, @@ -349,6 +420,31 @@ async def create_booking( for p in booking.payments ] + # Add service usages if they exist + service_usages = getattr(booking, 'service_usages', None) + import logging + logger = logging.getLogger(__name__) + logger.info(f"Booking {booking.id} - service_usages: {service_usages}, type: {type(service_usages)}") + + if service_usages and len(service_usages) > 0: + logger.info(f"Booking {booking.id} - Found {len(service_usages)} service usages") + booking_dict["service_usages"] = [ + { + "id": su.id, + "service_id": su.service_id, + "service_name": su.service.name if hasattr(su, 'service') and su.service else "Unknown Service", + "quantity": su.quantity, + "unit_price": float(su.unit_price) if su.unit_price else 0.0, + "total_price": float(su.total_price) if su.total_price else 0.0, + } + for su in service_usages + ] + logger.info(f"Booking {booking.id} - Serialized service_usages: {booking_dict['service_usages']}") + else: + # Initialize empty array if no service_usages + logger.info(f"Booking {booking.id} - No service_usages found, initializing empty array") + booking_dict["service_usages"] = [] + # Add room info if available if booking.room: booking_dict["room"] = { @@ -414,9 +510,11 @@ async def get_booking_by_id( try: # Eager load all relationships to avoid N+1 queries # Using selectinload for better performance with multiple relationships + from sqlalchemy.orm import selectinload booking = db.query(Booking)\ .options( selectinload(Booking.payments), + selectinload(Booking.service_usages).selectinload(ServiceUsage.service), joinedload(Booking.user), joinedload(Booking.room).joinedload(Room.room_type) )\ @@ -448,8 +546,8 @@ async def get_booking_by_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, + "check_in_date": booking.check_in_date.strftime("%Y-%m-%d") if booking.check_in_date else None, + "check_out_date": booking.check_out_date.strftime("%Y-%m-%d") if booking.check_out_date else None, "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, @@ -513,6 +611,32 @@ async def get_booking_by_id( for p in booking.payments ] + # Add service usages if they exist + # Use getattr to safely access service_usages in case relationship isn't loaded + service_usages = getattr(booking, 'service_usages', None) + import logging + logger = logging.getLogger(__name__) + logger.info(f"Get booking {id} - service_usages: {service_usages}, type: {type(service_usages)}") + + if service_usages and len(service_usages) > 0: + logger.info(f"Get booking {id} - Found {len(service_usages)} service usages") + booking_dict["service_usages"] = [ + { + "id": su.id, + "service_id": su.service_id, + "service_name": su.service.name if hasattr(su, 'service') and su.service else "Unknown Service", + "quantity": su.quantity, + "unit_price": float(su.unit_price) if su.unit_price else 0.0, + "total_price": float(su.total_price) if su.total_price else 0.0, + } + for su in service_usages + ] + logger.info(f"Get booking {id} - Serialized service_usages: {booking_dict['service_usages']}") + else: + # Initialize empty array if no service_usages + logger.info(f"Get booking {id} - No service_usages found, initializing empty array") + booking_dict["service_usages"] = [] + return { "success": True, "data": {"booking": booking_dict} @@ -657,8 +781,8 @@ async def check_booking_by_number( "id": booking.id, "booking_number": booking.booking_number, "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, + "check_in_date": booking.check_in_date.strftime("%Y-%m-%d") if booking.check_in_date else None, + "check_out_date": booking.check_out_date.strftime("%Y-%m-%d") if booking.check_out_date else None, "status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status, } diff --git a/Backend/src/routes/contact_routes.py b/Backend/src/routes/contact_routes.py new file mode 100644 index 00000000..0c8b7db5 --- /dev/null +++ b/Backend/src/routes/contact_routes.py @@ -0,0 +1,195 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from pydantic import BaseModel, EmailStr +from typing import Optional +import logging + +from ..config.database import get_db +from ..models.user import User +from ..models.role import Role +from ..models.system_settings import SystemSettings +from ..utils.mailer import send_email + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/contact", tags=["contact"]) + + +class ContactForm(BaseModel): + name: str + email: EmailStr + subject: str + message: str + phone: Optional[str] = None + + +def get_admin_email(db: Session) -> str: + """Get admin email from system settings or find admin user""" + # First, try to get from system settings + admin_email_setting = db.query(SystemSettings).filter( + SystemSettings.key == "admin_email" + ).first() + + if admin_email_setting and admin_email_setting.value: + return admin_email_setting.value + + # If not found in settings, find the first admin user + admin_role = db.query(Role).filter(Role.name == "admin").first() + if admin_role: + admin_user = db.query(User).filter( + User.role_id == admin_role.id, + User.is_active == True + ).first() + + if admin_user: + return admin_user.email + + # Fallback to SMTP_FROM_EMAIL if configured + from ..config.settings import settings + if settings.SMTP_FROM_EMAIL: + return settings.SMTP_FROM_EMAIL + + # Last resort: raise error + raise HTTPException( + status_code=500, + detail="Admin email not configured. Please set admin_email in system settings or ensure an admin user exists." + ) + + +@router.post("/submit") +async def submit_contact_form( + contact_data: ContactForm, + db: Session = Depends(get_db) +): + """Submit contact form and send email to admin""" + try: + # Get admin email + admin_email = get_admin_email(db) + + # Create email subject + subject = f"Contact Form: {contact_data.subject}" + + # Create email body (HTML) + html_body = f""" + + +
+ + + + +{room.description || roomType.description}
)} {/* Capacity & Rating */} -From
-+ {!compact && ( +
From
+ )} +{formattedPrice}
-/ night
+ {!compact && ( +/ night
+ )}+ No rooms available +
+