This commit is contained in:
Iliyan Angelov
2025-11-20 21:06:30 +02:00
parent 44e11520c5
commit a38ab4fa82
77 changed files with 7169 additions and 360 deletions

View File

@@ -200,7 +200,8 @@ from .routes import (
room_routes, booking_routes, payment_routes, invoice_routes, banner_routes,
favorite_routes, service_routes, service_booking_routes, promotion_routes, report_routes,
review_routes, user_routes, audit_routes, admin_privacy_routes,
system_settings_routes, contact_routes, page_content_routes
system_settings_routes, contact_routes, page_content_routes,
home_routes, about_routes, contact_content_routes, footer_routes
)
# Legacy routes (maintain backward compatibility)
@@ -220,6 +221,10 @@ 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")
app.include_router(home_routes.router, prefix="/api")
app.include_router(about_routes.router, prefix="/api")
app.include_router(contact_content_routes.router, prefix="/api")
app.include_router(footer_routes.router, prefix="/api")
# Versioned routes (v1)
app.include_router(room_routes.router, prefix=settings.API_V1_PREFIX)
@@ -238,6 +243,10 @@ 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)
app.include_router(home_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(about_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(contact_content_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(footer_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(page_content_routes.router, prefix="/api")
app.include_router(page_content_routes.router, prefix=settings.API_V1_PREFIX)

View File

@@ -8,6 +8,7 @@ import os
from ..config.database import get_db
from ..config.settings import settings
from ..models.user import User
from ..models.role import Role
security = HTTPBearer()
@@ -46,10 +47,20 @@ def authorize_roles(*allowed_roles: str):
"""
Check if user has required role
"""
def role_checker(current_user: User = Depends(get_current_user)) -> User:
# Map role IDs to role names
role_map = {1: "admin", 2: "staff", 3: "customer"}
user_role_name = role_map.get(current_user.role_id)
def role_checker(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
) -> User:
# Query the role from database instead of using hardcoded IDs
role = db.query(Role).filter(Role.id == current_user.role_id).first()
if not role:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User role not found"
)
user_role_name = role.name
if user_role_name not in allowed_roles:
raise HTTPException(

View File

@@ -41,6 +41,7 @@ class PageContent(Base):
social_links = Column(Text, nullable=True) # JSON: facebook, twitter, instagram, etc.
footer_links = Column(Text, nullable=True) # JSON: quick links, support links
badges = Column(Text, nullable=True) # JSON: array of badges with text and icon
copyright_text = Column(Text, nullable=True) # Copyright text with {YEAR} placeholder for automatic year
# Home page specific
hero_title = Column(String(500), nullable=True)
@@ -51,6 +52,57 @@ class PageContent(Base):
story_content = Column(Text, nullable=True)
values = Column(Text, nullable=True) # JSON array of values
features = Column(Text, nullable=True) # JSON array of features
about_hero_image = Column(Text, nullable=True) # Hero image for about page
mission = Column(Text, nullable=True) # Mission statement
vision = Column(Text, nullable=True) # Vision statement
team = Column(Text, nullable=True) # JSON array of team members with name, role, image, bio, social_links
timeline = Column(Text, nullable=True) # JSON array of timeline events with year, title, description, image
achievements = Column(Text, nullable=True) # JSON array of achievements with icon, title, description, year, image
# Home page luxury sections
luxury_section_title = Column(Text, nullable=True)
luxury_section_subtitle = Column(Text, nullable=True)
luxury_section_image = Column(Text, nullable=True)
luxury_features = Column(Text, nullable=True) # JSON array of features with icon, title, description
luxury_gallery_section_title = Column(Text, nullable=True)
luxury_gallery_section_subtitle = Column(Text, nullable=True)
luxury_gallery = Column(Text, nullable=True) # JSON array of image URLs
luxury_testimonials_section_title = Column(Text, nullable=True)
luxury_testimonials_section_subtitle = Column(Text, nullable=True)
luxury_testimonials = Column(Text, nullable=True) # JSON array of testimonials
amenities_section_title = Column(String(500), nullable=True)
amenities_section_subtitle = Column(String(1000), nullable=True)
amenities = Column(Text, nullable=True) # JSON array of amenities with icon, title, description, image
testimonials_section_title = Column(String(500), nullable=True)
testimonials_section_subtitle = Column(String(1000), nullable=True)
testimonials = Column(Text, nullable=True) # JSON array of testimonials with name, role, image, rating, comment
gallery_section_title = Column(String(500), nullable=True)
gallery_section_subtitle = Column(String(1000), nullable=True)
gallery_images = Column(Text, nullable=True) # JSON array of image URLs
about_preview_title = Column(String(500), nullable=True)
about_preview_subtitle = Column(String(1000), nullable=True)
about_preview_content = Column(Text, nullable=True)
about_preview_image = Column(String(1000), nullable=True)
stats = Column(Text, nullable=True) # JSON array of stats with number, label, icon
# Additional luxury sections
luxury_services_section_title = Column(Text, nullable=True)
luxury_services_section_subtitle = Column(Text, nullable=True)
luxury_services = Column(Text, nullable=True) # JSON array of services with icon, title, description, image
luxury_experiences_section_title = Column(Text, nullable=True)
luxury_experiences_section_subtitle = Column(Text, nullable=True)
luxury_experiences = Column(Text, nullable=True) # JSON array of experiences with icon, title, description, image
awards_section_title = Column(Text, nullable=True)
awards_section_subtitle = Column(Text, nullable=True)
awards = Column(Text, nullable=True) # JSON array of awards with icon, title, description, image, year
cta_title = Column(Text, nullable=True)
cta_subtitle = Column(Text, nullable=True)
cta_button_text = Column(Text, nullable=True)
cta_button_link = Column(Text, nullable=True)
cta_image = Column(Text, nullable=True)
partners_section_title = Column(Text, nullable=True)
partners_section_subtitle = Column(Text, nullable=True)
partners = Column(Text, nullable=True) # JSON array of partners with name, logo, link
# Status
is_active = Column(Boolean, default=True, nullable=False)

View File

@@ -0,0 +1,75 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
import json
from ..config.database import get_db
from ..config.logging_config import get_logger
from ..models.page_content import PageContent, PageType
logger = get_logger(__name__)
router = APIRouter(prefix="/about", tags=["about"])
def serialize_page_content(content: PageContent) -> dict:
"""Serialize PageContent model to dictionary"""
return {
"id": content.id,
"page_type": content.page_type.value,
"title": content.title,
"subtitle": content.subtitle,
"description": content.description,
"content": content.content,
"meta_title": content.meta_title,
"meta_description": content.meta_description,
"meta_keywords": content.meta_keywords,
"og_title": content.og_title,
"og_description": content.og_description,
"og_image": content.og_image,
"canonical_url": content.canonical_url,
"story_content": content.story_content,
"values": json.loads(content.values) if content.values else None,
"features": json.loads(content.features) if content.features else None,
"about_hero_image": content.about_hero_image,
"mission": content.mission,
"vision": content.vision,
"team": json.loads(content.team) if content.team else None,
"timeline": json.loads(content.timeline) if content.timeline else None,
"achievements": json.loads(content.achievements) if content.achievements else None,
"is_active": content.is_active,
"created_at": content.created_at.isoformat() if content.created_at else None,
"updated_at": content.updated_at.isoformat() if content.updated_at else None,
}
@router.get("/")
async def get_about_content(
db: Session = Depends(get_db)
):
"""Get about page content"""
try:
content = db.query(PageContent).filter(PageContent.page_type == PageType.ABOUT).first()
if not content:
return {
"status": "success",
"data": {
"page_content": None
}
}
content_dict = serialize_page_content(content)
return {
"status": "success",
"data": {
"page_content": content_dict
}
}
except Exception as e:
logger.error(f"Error fetching about content: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error fetching about content: {str(e)}"
)

View File

@@ -495,6 +495,7 @@ async def upload_avatar(
full_url = normalize_image_url(image_url, base_url)
return {
"success": True,
"status": "success",
"message": "Avatar uploaded successfully",
"data": {

View File

@@ -246,11 +246,25 @@ async def upload_banner_image(
):
"""Upload banner image (Admin only)"""
try:
# Validate file exists
if not image:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No file provided"
)
# 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"
detail=f"File must be an image. Received: {image.content_type}"
)
# Validate filename
if not image.filename:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Filename is required"
)
# Create uploads directory
@@ -258,21 +272,27 @@ async def upload_banner_image(
upload_dir.mkdir(parents=True, exist_ok=True)
# Generate filename
ext = Path(image.filename).suffix
ext = Path(image.filename).suffix or '.jpg'
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()
if not content:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File is empty"
)
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 {
"success": True,
"status": "success",
"message": "Image uploaded successfully",
"data": {

View File

@@ -61,6 +61,11 @@ def _generate_invoice_email_html(invoice: dict, is_proforma: bool = False) -> st
text-align: center;
border-radius: 8px 8px 0 0;
}}
.company-logo {{
max-width: 150px;
max-height: 80px;
margin-bottom: 10px;
}}
.content {{
background-color: #ffffff;
padding: 30px;
@@ -109,6 +114,8 @@ def _generate_invoice_email_html(invoice: dict, is_proforma: bool = False) -> st
<body>
<div class="container">
<div class="header">
{f'<img src="{invoice.get("company_logo_url")}" alt="Company Logo" class="company-logo" />' if invoice.get('company_logo_url') else ''}
{f'<h2 style="margin: 10px 0; color: #0f0f0f;">{invoice.get("company_name", "")}</h2>' if invoice.get('company_name') else ''}
<h1>{invoice_type}</h1>
</div>
<div class="content">
@@ -139,6 +146,7 @@ def _generate_invoice_email_html(invoice: dict, is_proforma: bool = False) -> st
<div class="total">
<p>Subtotal: {invoice.get('subtotal', 0):.2f}</p>
{f'<p style="color: #059669;">Discount: -{invoice.get("discount_amount", 0):.2f}</p>' if invoice.get('discount_amount', 0) > 0 else ''}
{f'<p style="color: #059669; font-size: 14px;">Promotion Code: {invoice.get("promotion_code", "")}</p>' if invoice.get('promotion_code') else ''}
<p>Tax: {invoice.get('tax_amount', 0):.2f}</p>
<p><strong>Total Amount: {invoice.get('total_amount', 0):.2f}</strong></p>
<p>Amount Paid: {invoice.get('amount_paid', 0):.2f}</p>
@@ -729,19 +737,31 @@ async def create_booking(
# Get discount from booking
booking_discount = float(booking.discount_amount) if booking.discount_amount else 0.0
# Add promotion code to invoice notes if present
invoice_notes = invoice_kwargs.get("notes", "")
if booking.promotion_code:
promotion_note = f"Promotion Code: {booking.promotion_code}"
invoice_notes = f"{promotion_note}\n{invoice_notes}".strip() if invoice_notes else promotion_note
invoice_kwargs["notes"] = invoice_notes
# Create invoices based on payment method
if payment_method == "cash":
# For cash bookings: create invoice for 20% deposit + proforma for 80% remaining
deposit_amount = float(total_price) * 0.2
remaining_amount = float(total_price) * 0.8
# Calculate proportional discount for partial invoices
# Deposit invoice gets 20% of the discount, proforma gets 80%
deposit_discount = booking_discount * 0.2 if booking_discount > 0 else 0.0
proforma_discount = booking_discount * 0.8 if booking_discount > 0 else 0.0
# Create invoice for deposit (20%)
deposit_invoice = InvoiceService.create_invoice_from_booking(
booking_id=booking.id,
db=db,
created_by_id=current_user.id,
tax_rate=tax_rate,
discount_amount=booking_discount,
discount_amount=deposit_discount,
due_days=30,
is_proforma=False,
invoice_amount=deposit_amount,
@@ -754,7 +774,7 @@ async def create_booking(
db=db,
created_by_id=current_user.id,
tax_rate=tax_rate,
discount_amount=booking_discount,
discount_amount=proforma_discount,
due_days=30,
is_proforma=True,
invoice_amount=remaining_amount,

View File

@@ -0,0 +1,68 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
import json
from ..config.database import get_db
from ..config.logging_config import get_logger
from ..models.page_content import PageContent, PageType
logger = get_logger(__name__)
router = APIRouter(prefix="/contact-content", tags=["contact-content"])
def serialize_page_content(content: PageContent) -> dict:
"""Serialize PageContent model to dictionary"""
return {
"id": content.id,
"page_type": content.page_type.value,
"title": content.title,
"subtitle": content.subtitle,
"description": content.description,
"content": content.content,
"meta_title": content.meta_title,
"meta_description": content.meta_description,
"meta_keywords": content.meta_keywords,
"og_title": content.og_title,
"og_description": content.og_description,
"og_image": content.og_image,
"canonical_url": content.canonical_url,
"contact_info": json.loads(content.contact_info) if content.contact_info else None,
"map_url": content.map_url,
"is_active": content.is_active,
"created_at": content.created_at.isoformat() if content.created_at else None,
"updated_at": content.updated_at.isoformat() if content.updated_at else None,
}
@router.get("/")
async def get_contact_content(
db: Session = Depends(get_db)
):
"""Get contact page content"""
try:
content = db.query(PageContent).filter(PageContent.page_type == PageType.CONTACT).first()
if not content:
return {
"status": "success",
"data": {
"page_content": None
}
}
content_dict = serialize_page_content(content)
return {
"status": "success",
"data": {
"page_content": content_dict
}
}
except Exception as e:
logger.error(f"Error fetching contact content: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error fetching contact content: {str(e)}"
)

View File

@@ -0,0 +1,63 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
import json
from ..config.database import get_db
from ..config.logging_config import get_logger
from ..models.page_content import PageContent, PageType
logger = get_logger(__name__)
router = APIRouter(prefix="/footer", tags=["footer"])
def serialize_page_content(content: PageContent) -> dict:
"""Serialize PageContent model to dictionary"""
return {
"id": content.id,
"page_type": content.page_type.value,
"title": content.title,
"subtitle": content.subtitle,
"description": content.description,
"content": content.content,
"social_links": json.loads(content.social_links) if content.social_links else None,
"footer_links": json.loads(content.footer_links) if content.footer_links else None,
"badges": json.loads(content.badges) if content.badges else None,
"copyright_text": content.copyright_text,
"is_active": content.is_active,
"created_at": content.created_at.isoformat() if content.created_at else None,
"updated_at": content.updated_at.isoformat() if content.updated_at else None,
}
@router.get("/")
async def get_footer_content(
db: Session = Depends(get_db)
):
"""Get footer content"""
try:
content = db.query(PageContent).filter(PageContent.page_type == PageType.FOOTER).first()
if not content:
return {
"status": "success",
"data": {
"page_content": None
}
}
content_dict = serialize_page_content(content)
return {
"status": "success",
"data": {
"page_content": content_dict
}
}
except Exception as e:
logger.error(f"Error fetching footer content: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error fetching footer content: {str(e)}"
)

View File

@@ -0,0 +1,110 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
import json
from ..config.database import get_db
from ..config.logging_config import get_logger
from ..models.page_content import PageContent, PageType
logger = get_logger(__name__)
router = APIRouter(prefix="/home", tags=["home"])
def serialize_page_content(content: PageContent) -> dict:
"""Serialize PageContent model to dictionary"""
return {
"id": content.id,
"page_type": content.page_type.value,
"title": content.title,
"subtitle": content.subtitle,
"description": content.description,
"content": content.content,
"meta_title": content.meta_title,
"meta_description": content.meta_description,
"meta_keywords": content.meta_keywords,
"og_title": content.og_title,
"og_description": content.og_description,
"og_image": content.og_image,
"canonical_url": content.canonical_url,
"hero_title": content.hero_title,
"hero_subtitle": content.hero_subtitle,
"hero_image": content.hero_image,
"amenities_section_title": content.amenities_section_title,
"amenities_section_subtitle": content.amenities_section_subtitle,
"amenities": json.loads(content.amenities) if content.amenities else None,
"testimonials_section_title": content.testimonials_section_title,
"testimonials_section_subtitle": content.testimonials_section_subtitle,
"testimonials": json.loads(content.testimonials) if content.testimonials else None,
"gallery_section_title": content.gallery_section_title,
"gallery_section_subtitle": content.gallery_section_subtitle,
"gallery_images": json.loads(content.gallery_images) if content.gallery_images else None,
"luxury_section_title": content.luxury_section_title,
"luxury_section_subtitle": content.luxury_section_subtitle,
"luxury_section_image": content.luxury_section_image,
"luxury_features": json.loads(content.luxury_features) if content.luxury_features else None,
"luxury_gallery_section_title": content.luxury_gallery_section_title,
"luxury_gallery_section_subtitle": content.luxury_gallery_section_subtitle,
"luxury_gallery": json.loads(content.luxury_gallery) if content.luxury_gallery else None,
"luxury_testimonials_section_title": content.luxury_testimonials_section_title,
"luxury_testimonials_section_subtitle": content.luxury_testimonials_section_subtitle,
"luxury_testimonials": json.loads(content.luxury_testimonials) if content.luxury_testimonials else None,
"about_preview_title": content.about_preview_title,
"about_preview_subtitle": content.about_preview_subtitle,
"about_preview_content": content.about_preview_content,
"about_preview_image": content.about_preview_image,
"stats": json.loads(content.stats) if content.stats else None,
"luxury_services_section_title": content.luxury_services_section_title,
"luxury_services_section_subtitle": content.luxury_services_section_subtitle,
"luxury_services": json.loads(content.luxury_services) if content.luxury_services else None,
"luxury_experiences_section_title": content.luxury_experiences_section_title,
"luxury_experiences_section_subtitle": content.luxury_experiences_section_subtitle,
"luxury_experiences": json.loads(content.luxury_experiences) if content.luxury_experiences else None,
"awards_section_title": content.awards_section_title,
"awards_section_subtitle": content.awards_section_subtitle,
"awards": json.loads(content.awards) if content.awards else None,
"cta_title": content.cta_title,
"cta_subtitle": content.cta_subtitle,
"cta_button_text": content.cta_button_text,
"cta_button_link": content.cta_button_link,
"cta_image": content.cta_image,
"partners_section_title": content.partners_section_title,
"partners_section_subtitle": content.partners_section_subtitle,
"partners": json.loads(content.partners) if content.partners else None,
"is_active": content.is_active,
"created_at": content.created_at.isoformat() if content.created_at else None,
"updated_at": content.updated_at.isoformat() if content.updated_at else None,
}
@router.get("/")
async def get_home_content(
db: Session = Depends(get_db)
):
"""Get homepage content"""
try:
content = db.query(PageContent).filter(PageContent.page_type == PageType.HOME).first()
if not content:
return {
"status": "success",
"data": {
"page_content": None
}
}
content_dict = serialize_page_content(content)
return {
"status": "success",
"data": {
"page_content": content_dict
}
}
except Exception as e:
logger.error(f"Error fetching home content: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error fetching home content: {str(e)}"
)

View File

@@ -87,11 +87,38 @@ async def create_invoice(
if not booking_id:
raise HTTPException(status_code=400, detail="booking_id is required")
# Ensure booking_id is an integer
try:
booking_id = int(booking_id)
except (ValueError, TypeError):
raise HTTPException(status_code=400, detail="booking_id must be a valid integer")
# 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")
# Prepare invoice kwargs
invoice_kwargs = {
"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"),
}
# Add promotion code to invoice notes if present in booking
invoice_notes = invoice_kwargs.get("notes", "")
if booking.promotion_code:
promotion_note = f"Promotion Code: {booking.promotion_code}"
invoice_notes = f"{promotion_note}\n{invoice_notes}".strip() if invoice_notes else promotion_note
invoice_kwargs["notes"] = invoice_notes
# Create invoice
invoice = InvoiceService.create_invoice_from_booking(
booking_id=booking_id,
@@ -100,16 +127,7 @@ async def create_invoice(
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"),
**invoice_kwargs
)
return {

View File

@@ -1,14 +1,21 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, status, Request, UploadFile, File
from sqlalchemy.orm import Session
from typing import Optional
from datetime import datetime
from pathlib import Path
import json
import os
import aiofiles
import uuid
from ..config.database import get_db
from ..config.logging_config import get_logger
from ..middleware.auth import get_current_user, authorize_roles
from ..models.user import User
from ..models.page_content import PageContent, PageType
logger = get_logger(__name__)
router = APIRouter(prefix="/page-content", tags=["page-content"])
@@ -40,12 +47,60 @@ async def get_all_page_contents(
"social_links": json.loads(content.social_links) if content.social_links else None,
"footer_links": json.loads(content.footer_links) if content.footer_links else None,
"badges": json.loads(content.badges) if content.badges else None,
"copyright_text": content.copyright_text,
"hero_title": content.hero_title,
"hero_subtitle": content.hero_subtitle,
"hero_image": content.hero_image,
"story_content": content.story_content,
"values": json.loads(content.values) if content.values else None,
"features": json.loads(content.features) if content.features else None,
"about_hero_image": content.about_hero_image,
"mission": content.mission,
"vision": content.vision,
"team": json.loads(content.team) if content.team else None,
"timeline": json.loads(content.timeline) if content.timeline else None,
"achievements": json.loads(content.achievements) if content.achievements else None,
"amenities_section_title": content.amenities_section_title,
"amenities_section_subtitle": content.amenities_section_subtitle,
"amenities": json.loads(content.amenities) if content.amenities else None,
"testimonials_section_title": content.testimonials_section_title,
"testimonials_section_subtitle": content.testimonials_section_subtitle,
"testimonials": json.loads(content.testimonials) if content.testimonials else None,
"gallery_section_title": content.gallery_section_title,
"gallery_section_subtitle": content.gallery_section_subtitle,
"gallery_images": json.loads(content.gallery_images) if content.gallery_images else None,
"luxury_section_title": content.luxury_section_title,
"luxury_section_subtitle": content.luxury_section_subtitle,
"luxury_section_image": content.luxury_section_image,
"luxury_features": json.loads(content.luxury_features) if content.luxury_features else None,
"luxury_gallery_section_title": content.luxury_gallery_section_title,
"luxury_gallery_section_subtitle": content.luxury_gallery_section_subtitle,
"luxury_gallery": json.loads(content.luxury_gallery) if content.luxury_gallery else None,
"luxury_testimonials_section_title": content.luxury_testimonials_section_title,
"luxury_testimonials_section_subtitle": content.luxury_testimonials_section_subtitle,
"luxury_testimonials": json.loads(content.luxury_testimonials) if content.luxury_testimonials else None,
"about_preview_title": content.about_preview_title,
"about_preview_subtitle": content.about_preview_subtitle,
"about_preview_content": content.about_preview_content,
"about_preview_image": content.about_preview_image,
"stats": json.loads(content.stats) if content.stats else None,
"luxury_services_section_title": content.luxury_services_section_title,
"luxury_services_section_subtitle": content.luxury_services_section_subtitle,
"luxury_services": json.loads(content.luxury_services) if content.luxury_services else None,
"luxury_experiences_section_title": content.luxury_experiences_section_title,
"luxury_experiences_section_subtitle": content.luxury_experiences_section_subtitle,
"luxury_experiences": json.loads(content.luxury_experiences) if content.luxury_experiences else None,
"awards_section_title": content.awards_section_title,
"awards_section_subtitle": content.awards_section_subtitle,
"awards": json.loads(content.awards) if content.awards else None,
"cta_title": content.cta_title,
"cta_subtitle": content.cta_subtitle,
"cta_button_text": content.cta_button_text,
"cta_button_link": content.cta_button_link,
"cta_image": content.cta_image,
"partners_section_title": content.partners_section_title,
"partners_section_subtitle": content.partners_section_subtitle,
"partners": json.loads(content.partners) if content.partners else None,
"is_active": content.is_active,
"created_at": content.created_at.isoformat() if content.created_at else None,
"updated_at": content.updated_at.isoformat() if content.updated_at else None,
@@ -65,6 +120,104 @@ async def get_all_page_contents(
)
def get_base_url(request: Request) -> str:
"""Get base URL for image normalization"""
return os.getenv("SERVER_URL") or f"http://{request.headers.get('host', 'localhost:8000')}"
def normalize_image_url(image_url: str, base_url: str) -> str:
"""Normalize image URL to absolute URL"""
if not image_url:
return image_url
if image_url.startswith('http://') or image_url.startswith('https://'):
return image_url
if image_url.startswith('/'):
return f"{base_url}{image_url}"
return f"{base_url}/{image_url}"
@router.post("/upload", dependencies=[Depends(authorize_roles("admin"))])
async def upload_page_content_image(
request: Request,
image: UploadFile = File(...),
current_user: User = Depends(authorize_roles("admin")),
):
"""Upload page content image (Admin only)"""
try:
logger.info(f"Upload request received: filename={image.filename}, content_type={image.content_type}")
# Validate file exists
if not image:
logger.error("No file provided in upload request")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No file provided"
)
# Validate file type
if not image.content_type or not image.content_type.startswith('image/'):
logger.error(f"Invalid file type: {image.content_type}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File must be an image. Received: {image.content_type}"
)
# Validate filename
if not image.filename:
logger.error("No filename provided in upload request")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Filename is required"
)
# Create uploads directory
upload_dir = Path(__file__).parent.parent.parent / "uploads" / "page-content"
upload_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"Upload directory: {upload_dir}")
# Generate filename
ext = Path(image.filename).suffix or '.jpg'
filename = f"page-content-{uuid.uuid4()}{ext}"
file_path = upload_dir / filename
# Save file
async with aiofiles.open(file_path, 'wb') as f:
content = await image.read()
if not content:
logger.error("Empty file uploaded")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File is empty"
)
await f.write(content)
logger.info(f"File saved successfully: {file_path}, size: {len(content)} bytes")
# Return the image URL
image_url = f"/uploads/page-content/{filename}"
base_url = get_base_url(request)
full_url = normalize_image_url(image_url, base_url)
logger.info(f"Upload successful: {image_url}")
return {
"success": True,
"status": "success",
"message": "Image uploaded successfully",
"data": {
"image_url": image_url,
"full_url": full_url
}
}
except HTTPException as e:
logger.error(f"HTTPException in upload: {e.detail}")
raise
except Exception as e:
logger.error(f"Unexpected error uploading image: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error uploading image: {str(e)}"
)
@router.get("/{page_type}")
async def get_page_content(
page_type: PageType,
@@ -102,12 +255,60 @@ async def get_page_content(
"social_links": json.loads(content.social_links) if content.social_links else None,
"footer_links": json.loads(content.footer_links) if content.footer_links else None,
"badges": json.loads(content.badges) if content.badges else None,
"copyright_text": content.copyright_text,
"hero_title": content.hero_title,
"hero_subtitle": content.hero_subtitle,
"hero_image": content.hero_image,
"story_content": content.story_content,
"values": json.loads(content.values) if content.values else None,
"features": json.loads(content.features) if content.features else None,
"about_hero_image": content.about_hero_image,
"mission": content.mission,
"vision": content.vision,
"team": json.loads(content.team) if content.team else None,
"timeline": json.loads(content.timeline) if content.timeline else None,
"achievements": json.loads(content.achievements) if content.achievements else None,
"amenities_section_title": content.amenities_section_title,
"amenities_section_subtitle": content.amenities_section_subtitle,
"amenities": json.loads(content.amenities) if content.amenities else None,
"testimonials_section_title": content.testimonials_section_title,
"testimonials_section_subtitle": content.testimonials_section_subtitle,
"testimonials": json.loads(content.testimonials) if content.testimonials else None,
"gallery_section_title": content.gallery_section_title,
"gallery_section_subtitle": content.gallery_section_subtitle,
"gallery_images": json.loads(content.gallery_images) if content.gallery_images else None,
"luxury_section_title": content.luxury_section_title,
"luxury_section_subtitle": content.luxury_section_subtitle,
"luxury_section_image": content.luxury_section_image,
"luxury_features": json.loads(content.luxury_features) if content.luxury_features else None,
"luxury_gallery_section_title": content.luxury_gallery_section_title,
"luxury_gallery_section_subtitle": content.luxury_gallery_section_subtitle,
"luxury_gallery": json.loads(content.luxury_gallery) if content.luxury_gallery else None,
"luxury_testimonials_section_title": content.luxury_testimonials_section_title,
"luxury_testimonials_section_subtitle": content.luxury_testimonials_section_subtitle,
"luxury_testimonials": json.loads(content.luxury_testimonials) if content.luxury_testimonials else None,
"about_preview_title": content.about_preview_title,
"about_preview_subtitle": content.about_preview_subtitle,
"about_preview_content": content.about_preview_content,
"about_preview_image": content.about_preview_image,
"stats": json.loads(content.stats) if content.stats else None,
"luxury_services_section_title": content.luxury_services_section_title,
"luxury_services_section_subtitle": content.luxury_services_section_subtitle,
"luxury_services": json.loads(content.luxury_services) if content.luxury_services else None,
"luxury_experiences_section_title": content.luxury_experiences_section_title,
"luxury_experiences_section_subtitle": content.luxury_experiences_section_subtitle,
"luxury_experiences": json.loads(content.luxury_experiences) if content.luxury_experiences else None,
"awards_section_title": content.awards_section_title,
"awards_section_subtitle": content.awards_section_subtitle,
"awards": json.loads(content.awards) if content.awards else None,
"cta_title": content.cta_title,
"cta_subtitle": content.cta_subtitle,
"cta_button_text": content.cta_button_text,
"cta_button_link": content.cta_button_link,
"cta_image": content.cta_image,
"partners_section_title": content.partners_section_title,
"partners_section_subtitle": content.partners_section_subtitle,
"partners": json.loads(content.partners) if content.partners else None,
"is_active": content.is_active,
"created_at": content.created_at.isoformat() if content.created_at else None,
"updated_at": content.updated_at.isoformat() if content.updated_at else None,
@@ -151,6 +352,12 @@ async def create_or_update_page_content(
story_content: Optional[str] = None,
values: Optional[str] = None,
features: Optional[str] = None,
about_hero_image: Optional[str] = None,
mission: Optional[str] = None,
vision: Optional[str] = None,
team: Optional[str] = None,
timeline: Optional[str] = None,
achievements: Optional[str] = None,
is_active: bool = True,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
@@ -263,6 +470,18 @@ async def create_or_update_page_content(
existing_content.values = values
if features is not None:
existing_content.features = features
if about_hero_image is not None:
existing_content.about_hero_image = about_hero_image
if mission is not None:
existing_content.mission = mission
if vision is not None:
existing_content.vision = vision
if team is not None:
existing_content.team = team
if timeline is not None:
existing_content.timeline = timeline
if achievements is not None:
existing_content.achievements = achievements
if is_active is not None:
existing_content.is_active = is_active
@@ -308,6 +527,12 @@ async def create_or_update_page_content(
story_content=story_content,
values=values,
features=features,
about_hero_image=about_hero_image,
mission=mission,
vision=vision,
team=team,
timeline=timeline,
achievements=achievements,
is_active=is_active,
)
@@ -362,7 +587,10 @@ async def update_page_content(
for key, value in page_data.items():
if hasattr(existing_content, key):
# Handle JSON fields - convert dict/list to JSON string
if key in ["contact_info", "social_links", "footer_links", "badges", "values", "features"] and value is not None:
if key in ["contact_info", "social_links", "footer_links", "badges", "values", "features",
"amenities", "testimonials", "gallery_images", "stats", "luxury_features",
"luxury_gallery", "luxury_testimonials", "luxury_services", "luxury_experiences",
"awards", "partners", "team", "timeline", "achievements"] and value is not None:
if isinstance(value, str):
# Already a string, validate it's valid JSON
try:
@@ -403,12 +631,60 @@ async def update_page_content(
"social_links": json.loads(existing_content.social_links) if existing_content.social_links else None,
"footer_links": json.loads(existing_content.footer_links) if existing_content.footer_links else None,
"badges": json.loads(existing_content.badges) if existing_content.badges else None,
"copyright_text": existing_content.copyright_text,
"hero_title": existing_content.hero_title,
"hero_subtitle": existing_content.hero_subtitle,
"hero_image": existing_content.hero_image,
"story_content": existing_content.story_content,
"values": json.loads(existing_content.values) if existing_content.values else None,
"features": json.loads(existing_content.features) if existing_content.features else None,
"about_hero_image": existing_content.about_hero_image,
"mission": existing_content.mission,
"vision": existing_content.vision,
"team": json.loads(existing_content.team) if existing_content.team else None,
"timeline": json.loads(existing_content.timeline) if existing_content.timeline else None,
"achievements": json.loads(existing_content.achievements) if existing_content.achievements else None,
"amenities_section_title": existing_content.amenities_section_title,
"amenities_section_subtitle": existing_content.amenities_section_subtitle,
"amenities": json.loads(existing_content.amenities) if existing_content.amenities else None,
"testimonials_section_title": existing_content.testimonials_section_title,
"testimonials_section_subtitle": existing_content.testimonials_section_subtitle,
"testimonials": json.loads(existing_content.testimonials) if existing_content.testimonials else None,
"gallery_section_title": existing_content.gallery_section_title,
"gallery_section_subtitle": existing_content.gallery_section_subtitle,
"gallery_images": json.loads(existing_content.gallery_images) if existing_content.gallery_images else None,
"luxury_section_title": existing_content.luxury_section_title,
"luxury_section_subtitle": existing_content.luxury_section_subtitle,
"luxury_section_image": existing_content.luxury_section_image,
"luxury_features": json.loads(existing_content.luxury_features) if existing_content.luxury_features else None,
"luxury_gallery_section_title": existing_content.luxury_gallery_section_title,
"luxury_gallery_section_subtitle": existing_content.luxury_gallery_section_subtitle,
"luxury_gallery": json.loads(existing_content.luxury_gallery) if existing_content.luxury_gallery else None,
"luxury_testimonials_section_title": existing_content.luxury_testimonials_section_title,
"luxury_testimonials_section_subtitle": existing_content.luxury_testimonials_section_subtitle,
"luxury_testimonials": json.loads(existing_content.luxury_testimonials) if existing_content.luxury_testimonials else None,
"about_preview_title": existing_content.about_preview_title,
"about_preview_subtitle": existing_content.about_preview_subtitle,
"about_preview_content": existing_content.about_preview_content,
"about_preview_image": existing_content.about_preview_image,
"stats": json.loads(existing_content.stats) if existing_content.stats else None,
"luxury_services_section_title": existing_content.luxury_services_section_title,
"luxury_services_section_subtitle": existing_content.luxury_services_section_subtitle,
"luxury_services": json.loads(existing_content.luxury_services) if existing_content.luxury_services else None,
"luxury_experiences_section_title": existing_content.luxury_experiences_section_title,
"luxury_experiences_section_subtitle": existing_content.luxury_experiences_section_subtitle,
"luxury_experiences": json.loads(existing_content.luxury_experiences) if existing_content.luxury_experiences else None,
"awards_section_title": existing_content.awards_section_title,
"awards_section_subtitle": existing_content.awards_section_subtitle,
"awards": json.loads(existing_content.awards) if existing_content.awards else None,
"cta_title": existing_content.cta_title,
"cta_subtitle": existing_content.cta_subtitle,
"cta_button_text": existing_content.cta_button_text,
"cta_button_link": existing_content.cta_button_link,
"cta_image": existing_content.cta_image,
"partners_section_title": existing_content.partners_section_title,
"partners_section_subtitle": existing_content.partners_section_subtitle,
"partners": json.loads(existing_content.partners) if existing_content.partners else None,
"is_active": existing_content.is_active,
"updated_at": existing_content.updated_at.isoformat() if existing_content.updated_at else None,
}

View File

@@ -694,18 +694,24 @@ async def upload_room_images(
image_urls = []
for image in images:
# Validate file type
if not image.content_type.startswith('image/'):
if not image.content_type or not image.content_type.startswith('image/'):
continue
# Validate filename
if not image.filename:
continue
# Generate filename
import uuid
ext = Path(image.filename).suffix
ext = Path(image.filename).suffix or '.jpg'
filename = f"room-{uuid.uuid4()}{ext}"
file_path = upload_dir / filename
# Save file
async with aiofiles.open(file_path, 'wb') as f:
content = await image.read()
if not content:
continue
await f.write(content)
image_urls.append(f"/uploads/rooms/{filename}")
@@ -717,6 +723,7 @@ async def upload_room_images(
db.commit()
return {
"success": True,
"status": "success",
"message": "Images uploaded successfully",
"data": {"images": updated_images}

View File

@@ -1127,6 +1127,7 @@ async def upload_company_logo(
full_url = normalize_image_url(image_url, base_url)
return {
"success": True,
"status": "success",
"message": "Logo uploaded successfully",
"data": {
@@ -1235,6 +1236,7 @@ async def upload_company_favicon(
full_url = normalize_image_url(image_url, base_url)
return {
"success": True,
"status": "success",
"message": "Favicon uploaded successfully",
"data": {

View File

@@ -146,16 +146,28 @@ class AuthService:
async def login(self, db: Session, email: str, password: str, remember_me: bool = False, mfa_token: str = None) -> dict:
"""Login user with optional MFA verification"""
# Normalize email (lowercase and strip whitespace)
email = email.lower().strip() if email else ""
if not email:
raise ValueError("Invalid email or password")
# Find user with role and password
user = db.query(User).filter(User.email == email).first()
if not user:
logger.warning(f"Login attempt with non-existent email: {email}")
raise ValueError("Invalid email or password")
# Check if user is active
if not user.is_active:
logger.warning(f"Login attempt for inactive user: {email}")
raise ValueError("Account is disabled. Please contact support.")
# Load role
user.role = db.query(Role).filter(Role.id == user.role_id).first()
# Check password
if not self.verify_password(password, user.password):
logger.warning(f"Login attempt with invalid password for user: {email}")
raise ValueError("Invalid email or password")
# Check if MFA is enabled

View File

@@ -87,10 +87,20 @@ class InvoiceService:
# Calculate amounts - subtotal will be recalculated after adding items
# Initial subtotal is booking total (room + services) or invoice_amount if specified
booking_total = float(booking.total_price)
if invoice_amount is not None:
subtotal = float(invoice_amount)
# For partial invoices, ensure discount is proportional
# If discount_amount seems too large (greater than subtotal), recalculate proportionally
if invoice_amount < booking_total and discount_amount > 0:
# Check if discount seems disproportionate (greater than 50% of subtotal suggests it's the full discount)
if discount_amount > subtotal * 0.5:
# Recalculate proportionally from booking's original discount
proportion = float(invoice_amount) / booking_total
original_discount = float(booking.discount_amount) if booking.discount_amount else discount_amount
discount_amount = original_discount * proportion
else:
subtotal = float(booking.total_price)
subtotal = booking_total
# Calculate tax and total amounts
tax_amount = (subtotal - discount_amount) * (tax_rate / 100)
@@ -388,6 +398,14 @@ class InvoiceService:
@staticmethod
def invoice_to_dict(invoice: Invoice) -> Dict[str, Any]:
"""Convert invoice model to dictionary"""
# Extract promotion code from notes if present
promotion_code = None
if invoice.notes and "Promotion Code:" in invoice.notes:
try:
promotion_code = invoice.notes.split("Promotion Code:")[1].split("\n")[0].strip()
except:
pass
return {
"id": invoice.id,
"invoice_number": invoice.invoice_number,
@@ -419,6 +437,7 @@ class InvoiceService:
"terms_and_conditions": invoice.terms_and_conditions,
"payment_instructions": invoice.payment_instructions,
"is_proforma": invoice.is_proforma if hasattr(invoice, 'is_proforma') else False,
"promotion_code": promotion_code,
"items": [
{
"id": item.id,

View File

@@ -505,10 +505,36 @@ class PayPalService:
except Exception as email_error:
logger.error(f"Failed to send booking confirmation email: {str(email_error)}")
# Send invoice email if payment is completed and invoice is now paid
from ..utils.mailer import send_email
from ..services.invoice_service import InvoiceService
from ..routes.booking_routes import _generate_invoice_email_html
# Load user for email
from ..models.user import User
user = db.query(User).filter(User.id == booking.user_id).first()
for invoice in invoices:
if invoice.status == InvoiceStatus.paid and invoice.balance_due <= 0:
try:
invoice_dict = InvoiceService.invoice_to_dict(invoice)
invoice_html = _generate_invoice_email_html(invoice_dict, is_proforma=invoice.is_proforma)
invoice_type = "Proforma Invoice" if invoice.is_proforma else "Invoice"
if user:
await send_email(
to=user.email,
subject=f"{invoice_type} {invoice.invoice_number} - Payment Confirmed",
html=invoice_html
)
logger.info(f"{invoice_type} {invoice.invoice_number} sent to {user.email}")
except Exception as email_error:
logger.error(f"Failed to send invoice email: {str(email_error)}")
# Send invoice email if payment is completed and invoice is now paid
from ..utils.mailer import send_email
from ..services.invoice_service import InvoiceService
from ..models.invoice import InvoiceStatus
from ..routes.booking_routes import _generate_invoice_email_html
# Load user for email
from ..models.user import User

View File

@@ -404,6 +404,7 @@ class StripeService:
# Send invoice email if payment is completed and invoice is now paid
from ..utils.mailer import send_email
from ..services.invoice_service import InvoiceService
from ..routes.booking_routes import _generate_invoice_email_html
# Load user for email
from ..models.user import User