This commit is contained in:
Iliyan Angelov
2025-11-18 23:35:19 +02:00
parent ab832f851b
commit 2043ac897c
27 changed files with 2947 additions and 323 deletions

View File

@@ -0,0 +1,26 @@
"""add_badges_to_page_content
Revision ID: add_badges_to_page_content
Revises: cce764ef7a50
Create Date: 2025-01-14 10:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'add_badges_to_page_content'
down_revision = 'cce764ef7a50'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Add badges column to page_contents table
op.add_column('page_contents', sa.Column('badges', sa.Text(), nullable=True))
def downgrade() -> None:
# Remove badges column from page_contents table
op.drop_column('page_contents', 'badges')

View File

@@ -40,6 +40,7 @@ class PageContent(Base):
map_url = Column(String(1000), nullable=True) # Google Maps embed URL
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
# Home page specific
hero_title = Column(String(500), nullable=True)

View File

@@ -39,6 +39,7 @@ async def get_all_page_contents(
"map_url": content.map_url,
"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,
"hero_title": content.hero_title,
"hero_subtitle": content.hero_subtitle,
"hero_image": content.hero_image,
@@ -97,8 +98,10 @@ async def get_page_content(
"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,
"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,
"hero_title": content.hero_title,
"hero_subtitle": content.hero_subtitle,
"hero_image": content.hero_image,
@@ -141,6 +144,7 @@ async def create_or_update_page_content(
map_url: Optional[str] = None,
social_links: Optional[str] = None,
footer_links: Optional[str] = None,
badges: Optional[str] = None,
hero_title: Optional[str] = None,
hero_subtitle: Optional[str] = None,
hero_image: Optional[str] = None,
@@ -183,6 +187,15 @@ async def create_or_update_page_content(
detail="Invalid JSON in footer_links"
)
if badges:
try:
json.loads(badges)
except json.JSONDecodeError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid JSON in badges"
)
if values:
try:
json.loads(values)
@@ -236,6 +249,8 @@ async def create_or_update_page_content(
existing_content.social_links = social_links
if footer_links is not None:
existing_content.footer_links = footer_links
if badges is not None:
existing_content.badges = badges
if hero_title is not None:
existing_content.hero_title = hero_title
if hero_subtitle is not None:
@@ -286,6 +301,7 @@ async def create_or_update_page_content(
map_url=map_url,
social_links=social_links,
footer_links=footer_links,
badges=badges,
hero_title=hero_title,
hero_subtitle=hero_subtitle,
hero_image=hero_image,
@@ -346,7 +362,7 @@ 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", "values", "features"] and value is not None:
if key in ["contact_info", "social_links", "footer_links", "badges", "values", "features"] and value is not None:
if isinstance(value, str):
# Already a string, validate it's valid JSON
try:
@@ -383,8 +399,10 @@ async def update_page_content(
"og_image": existing_content.og_image,
"canonical_url": existing_content.canonical_url,
"contact_info": json.loads(existing_content.contact_info) if existing_content.contact_info else None,
"map_url": existing_content.map_url,
"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,
"hero_title": existing_content.hero_title,
"hero_subtitle": existing_content.hero_subtitle,
"hero_image": existing_content.hero_image,

View File

@@ -1,11 +1,33 @@
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 pydantic import BaseModel, EmailStr
from pathlib import Path
import logging
import aiofiles
import uuid
import os
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
from ..utils.mailer import send_email
from ..services.room_service import get_base_url
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}"
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/system-settings", tags=["admin-system-settings"])
@@ -300,3 +322,768 @@ async def update_stripe_settings(
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.get("/smtp")
async def get_smtp_settings(
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Get SMTP email server settings (Admin only)"""
try:
# Get all SMTP settings
smtp_settings = {}
setting_keys = [
"smtp_host",
"smtp_port",
"smtp_user",
"smtp_password",
"smtp_from_email",
"smtp_from_name",
"smtp_use_tls",
]
for key in setting_keys:
setting = db.query(SystemSettings).filter(
SystemSettings.key == key
).first()
if setting:
smtp_settings[key] = setting.value
# Mask password for security (only show last 4 characters if set)
def mask_password(password_value: str) -> str:
if not password_value or len(password_value) < 4:
return ""
return "*" * (len(password_value) - 4) + password_value[-4:]
result = {
"smtp_host": smtp_settings.get("smtp_host", ""),
"smtp_port": smtp_settings.get("smtp_port", ""),
"smtp_user": smtp_settings.get("smtp_user", ""),
"smtp_password": "",
"smtp_password_masked": mask_password(smtp_settings.get("smtp_password", "")),
"smtp_from_email": smtp_settings.get("smtp_from_email", ""),
"smtp_from_name": smtp_settings.get("smtp_from_name", ""),
"smtp_use_tls": smtp_settings.get("smtp_use_tls", "true").lower() == "true",
"has_host": bool(smtp_settings.get("smtp_host")),
"has_user": bool(smtp_settings.get("smtp_user")),
"has_password": bool(smtp_settings.get("smtp_password")),
}
# Get updated_at and updated_by from any setting (prefer password setting if exists)
password_setting = db.query(SystemSettings).filter(
SystemSettings.key == "smtp_password"
).first()
if password_setting:
result["updated_at"] = password_setting.updated_at.isoformat() if password_setting.updated_at else None
result["updated_by"] = password_setting.updated_by.full_name if password_setting.updated_by else None
else:
# Try to get from any other SMTP setting
any_setting = db.query(SystemSettings).filter(
SystemSettings.key.in_(setting_keys)
).first()
if any_setting:
result["updated_at"] = any_setting.updated_at.isoformat() if any_setting.updated_at else None
result["updated_by"] = any_setting.updated_by.full_name if any_setting.updated_by else None
else:
result["updated_at"] = None
result["updated_by"] = None
return {
"status": "success",
"data": result
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.put("/smtp")
async def update_smtp_settings(
smtp_data: dict,
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Update SMTP email server settings (Admin only)"""
try:
smtp_host = smtp_data.get("smtp_host", "").strip()
smtp_port = smtp_data.get("smtp_port", "").strip()
smtp_user = smtp_data.get("smtp_user", "").strip()
smtp_password = smtp_data.get("smtp_password", "").strip()
smtp_from_email = smtp_data.get("smtp_from_email", "").strip()
smtp_from_name = smtp_data.get("smtp_from_name", "").strip()
smtp_use_tls = smtp_data.get("smtp_use_tls", True)
# Validate required fields if provided
if smtp_host and not smtp_host:
raise HTTPException(
status_code=400,
detail="SMTP host cannot be empty"
)
if smtp_port:
try:
port_num = int(smtp_port)
if port_num < 1 or port_num > 65535:
raise HTTPException(
status_code=400,
detail="SMTP port must be between 1 and 65535"
)
except ValueError:
raise HTTPException(
status_code=400,
detail="SMTP port must be a valid number"
)
if smtp_from_email:
# Basic email validation
if "@" not in smtp_from_email or "." not in smtp_from_email.split("@")[1]:
raise HTTPException(
status_code=400,
detail="Invalid email address format for 'From Email'"
)
# Helper function to update or create setting
def update_setting(key: str, value: str, description: str):
setting = db.query(SystemSettings).filter(
SystemSettings.key == key
).first()
if setting:
setting.value = value
setting.updated_by_id = current_user.id
else:
setting = SystemSettings(
key=key,
value=value,
description=description,
updated_by_id=current_user.id
)
db.add(setting)
# Update or create settings (only update if value is provided)
if smtp_host:
update_setting(
"smtp_host",
smtp_host,
"SMTP server hostname (e.g., smtp.gmail.com)"
)
if smtp_port:
update_setting(
"smtp_port",
smtp_port,
"SMTP server port (e.g., 587 for STARTTLS, 465 for SSL)"
)
if smtp_user:
update_setting(
"smtp_user",
smtp_user,
"SMTP authentication username/email"
)
if smtp_password:
update_setting(
"smtp_password",
smtp_password,
"SMTP authentication password (stored securely)"
)
if smtp_from_email:
update_setting(
"smtp_from_email",
smtp_from_email,
"Default 'From' email address for outgoing emails"
)
if smtp_from_name:
update_setting(
"smtp_from_name",
smtp_from_name,
"Default 'From' name for outgoing emails"
)
# Update TLS setting (convert boolean to string)
if smtp_use_tls is not None:
update_setting(
"smtp_use_tls",
"true" if smtp_use_tls else "false",
"Use TLS/SSL for SMTP connection (true for port 465, false for port 587 with STARTTLS)"
)
db.commit()
# Return updated settings with masked password
def mask_password(password_value: str) -> str:
if not password_value or len(password_value) < 4:
return ""
return "*" * (len(password_value) - 4) + password_value[-4:]
# Get updated settings
updated_settings = {}
for key in ["smtp_host", "smtp_port", "smtp_user", "smtp_password", "smtp_from_email", "smtp_from_name", "smtp_use_tls"]:
setting = db.query(SystemSettings).filter(
SystemSettings.key == key
).first()
if setting:
updated_settings[key] = setting.value
result = {
"smtp_host": updated_settings.get("smtp_host", ""),
"smtp_port": updated_settings.get("smtp_port", ""),
"smtp_user": updated_settings.get("smtp_user", ""),
"smtp_password": smtp_password if smtp_password else "",
"smtp_password_masked": mask_password(updated_settings.get("smtp_password", "")),
"smtp_from_email": updated_settings.get("smtp_from_email", ""),
"smtp_from_name": updated_settings.get("smtp_from_name", ""),
"smtp_use_tls": updated_settings.get("smtp_use_tls", "true").lower() == "true",
"has_host": bool(updated_settings.get("smtp_host")),
"has_user": bool(updated_settings.get("smtp_user")),
"has_password": bool(updated_settings.get("smtp_password")),
}
# Get updated_by from password setting if it exists
password_setting = db.query(SystemSettings).filter(
SystemSettings.key == "smtp_password"
).first()
if password_setting:
result["updated_at"] = password_setting.updated_at.isoformat() if password_setting.updated_at else None
result["updated_by"] = password_setting.updated_by.full_name if password_setting.updated_by else None
return {
"status": "success",
"message": "SMTP settings updated successfully",
"data": result
}
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
class TestEmailRequest(BaseModel):
email: EmailStr
@router.post("/smtp/test")
async def test_smtp_email(
request: TestEmailRequest,
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Send a test email to verify SMTP settings (Admin only)"""
try:
test_email = str(request.email)
admin_name = str(current_user.full_name or current_user.email or "Admin")
timestamp_str = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC")
# Create test email HTML content
test_html = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body {{
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}}
.header {{
background: linear-gradient(135deg, #d4af37 0%, #c9a227 100%);
color: white;
padding: 30px;
text-align: center;
border-radius: 10px 10px 0 0;
}}
.content {{
background: #f9f9f9;
padding: 30px;
border: 1px solid #e0e0e0;
border-radius: 0 0 10px 10px;
}}
.success-icon {{
font-size: 48px;
margin-bottom: 20px;
}}
.info-box {{
background: white;
padding: 20px;
margin: 20px 0;
border-left: 4px solid #d4af37;
border-radius: 5px;
}}
.footer {{
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #e0e0e0;
text-align: center;
color: #666;
font-size: 12px;
}}
</style>
</head>
<body>
<div class="header">
<h1>✅ SMTP Test Email</h1>
</div>
<div class="content">
<div style="text-align: center;">
<div class="success-icon">🎉</div>
<h2>Email Configuration Test Successful!</h2>
</div>
<p>This is a test email sent from your Hotel Booking system to verify that the SMTP email settings are configured correctly.</p>
<div class="info-box">
<strong>📧 Test Details:</strong>
<ul>
<li><strong>Recipient:</strong> {test_email}</li>
<li><strong>Sent by:</strong> {admin_name}</li>
<li><strong>Time:</strong> {timestamp_str}</li>
</ul>
</div>
<p>If you received this email, it means your SMTP server settings are working correctly and the system can send emails through your configured email server.</p>
<p><strong>What's next?</strong></p>
<ul>
<li>Welcome emails for new user registrations</li>
<li>Password reset emails</li>
<li>Booking confirmation emails</li>
<li>Payment notifications</li>
<li>And other system notifications</li>
</ul>
<div class="footer">
<p>This is an automated test email from Hotel Booking System</p>
<p>If you did not request this test, please ignore this email.</p>
</div>
</div>
</body>
</html>
"""
# Plain text version
test_text = f"""
SMTP Test Email
This is a test email sent from your Hotel Booking system to verify that the SMTP email settings are configured correctly.
Test Details:
- Recipient: {test_email}
- Sent by: {admin_name}
- Time: {timestamp_str}
If you received this email, it means your SMTP server settings are working correctly and the system can send emails through your configured email server.
This is an automated test email from Hotel Booking System
If you did not request this test, please ignore this email.
""".strip()
# Send the test email
await send_email(
to=test_email,
subject="SMTP Test Email - Hotel Booking System",
html=test_html,
text=test_text
)
sent_at = datetime.utcnow()
return {
"status": "success",
"message": f"Test email sent successfully to {test_email}",
"data": {
"recipient": test_email,
"sent_at": sent_at.isoformat()
}
}
except HTTPException:
# Re-raise HTTP exceptions (like validation errors from send_email)
raise
except Exception as e:
error_msg = str(e)
logger.error(f"Error sending test email: {type(e).__name__}: {error_msg}", exc_info=True)
# Provide more user-friendly error messages
if "SMTP mailer not configured" in error_msg:
raise HTTPException(
status_code=400,
detail="SMTP settings are not fully configured. Please configure SMTP Host, Username, and Password in the Email Server settings."
)
elif "authentication failed" in error_msg.lower() or "invalid credentials" in error_msg.lower():
raise HTTPException(
status_code=400,
detail="SMTP authentication failed. Please check your SMTP username and password."
)
elif "connection" in error_msg.lower() or "timeout" in error_msg.lower():
raise HTTPException(
status_code=400,
detail=f"Cannot connect to SMTP server. Please check your SMTP host and port settings. Error: {error_msg}"
)
else:
raise HTTPException(
status_code=500,
detail=f"Failed to send test email: {error_msg}"
)
class UpdateCompanySettingsRequest(BaseModel):
company_name: Optional[str] = None
company_tagline: Optional[str] = None
company_phone: Optional[str] = None
company_email: Optional[str] = None
company_address: Optional[str] = None
@router.get("/company")
async def get_company_settings(
db: Session = Depends(get_db)
):
"""Get company settings (public endpoint for frontend)"""
try:
setting_keys = [
"company_name",
"company_tagline",
"company_logo_url",
"company_favicon_url",
"company_phone",
"company_email",
"company_address",
]
settings_dict = {}
for key in setting_keys:
setting = db.query(SystemSettings).filter(
SystemSettings.key == key
).first()
if setting:
settings_dict[key] = setting.value
else:
settings_dict[key] = None
# Get updated_at and updated_by from logo setting if exists
logo_setting = db.query(SystemSettings).filter(
SystemSettings.key == "company_logo_url"
).first()
updated_at = None
updated_by = None
if logo_setting:
updated_at = logo_setting.updated_at.isoformat() if logo_setting.updated_at else None
updated_by = logo_setting.updated_by.full_name if logo_setting.updated_by else None
return {
"status": "success",
"data": {
"company_name": settings_dict.get("company_name", ""),
"company_tagline": settings_dict.get("company_tagline", ""),
"company_logo_url": settings_dict.get("company_logo_url", ""),
"company_favicon_url": settings_dict.get("company_favicon_url", ""),
"company_phone": settings_dict.get("company_phone", ""),
"company_email": settings_dict.get("company_email", ""),
"company_address": settings_dict.get("company_address", ""),
"updated_at": updated_at,
"updated_by": updated_by,
}
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.put("/company")
async def update_company_settings(
request_data: UpdateCompanySettingsRequest,
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Update company settings (Admin only)"""
try:
db_settings = {}
if request_data.company_name is not None:
db_settings["company_name"] = request_data.company_name
if request_data.company_tagline is not None:
db_settings["company_tagline"] = request_data.company_tagline
if request_data.company_phone is not None:
db_settings["company_phone"] = request_data.company_phone
if request_data.company_email is not None:
db_settings["company_email"] = request_data.company_email
if request_data.company_address is not None:
db_settings["company_address"] = request_data.company_address
for key, value in db_settings.items():
# Find or create setting
setting = db.query(SystemSettings).filter(
SystemSettings.key == key
).first()
if setting:
# Update existing
setting.value = value if value else None
setting.updated_at = datetime.utcnow()
setting.updated_by_id = current_user.id
else:
# Create new
setting = SystemSettings(
key=key,
value=value if value else None,
updated_by_id=current_user.id
)
db.add(setting)
db.commit()
# Get updated settings
updated_settings = {}
for key in ["company_name", "company_tagline", "company_logo_url", "company_favicon_url", "company_phone", "company_email", "company_address"]:
setting = db.query(SystemSettings).filter(
SystemSettings.key == key
).first()
if setting:
updated_settings[key] = setting.value
else:
updated_settings[key] = None
# Get updated_at and updated_by
logo_setting = db.query(SystemSettings).filter(
SystemSettings.key == "company_logo_url"
).first()
if not logo_setting:
logo_setting = db.query(SystemSettings).filter(
SystemSettings.key == "company_name"
).first()
updated_at = None
updated_by = None
if logo_setting:
updated_at = logo_setting.updated_at.isoformat() if logo_setting.updated_at else None
updated_by = logo_setting.updated_by.full_name if logo_setting.updated_by else None
return {
"status": "success",
"message": "Company settings updated successfully",
"data": {
"company_name": updated_settings.get("company_name", ""),
"company_tagline": updated_settings.get("company_tagline", ""),
"company_logo_url": updated_settings.get("company_logo_url", ""),
"company_favicon_url": updated_settings.get("company_favicon_url", ""),
"company_phone": updated_settings.get("company_phone", ""),
"company_email": updated_settings.get("company_email", ""),
"company_address": updated_settings.get("company_address", ""),
"updated_at": updated_at,
"updated_by": updated_by,
}
}
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.post("/company/logo")
async def upload_company_logo(
request: Request,
image: UploadFile = File(...),
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Upload company logo (Admin only)"""
try:
# Validate file type
if not image.content_type or not image.content_type.startswith('image/'):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File must be an image"
)
# Validate file size (max 2MB)
content = await image.read()
if len(content) > 2 * 1024 * 1024: # 2MB
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Logo file size must be less than 2MB"
)
# Create uploads directory
upload_dir = Path(__file__).parent.parent.parent / "uploads" / "company"
upload_dir.mkdir(parents=True, exist_ok=True)
# Delete old logo if exists
old_logo_setting = db.query(SystemSettings).filter(
SystemSettings.key == "company_logo_url"
).first()
if old_logo_setting and old_logo_setting.value:
old_logo_path = Path(__file__).parent.parent.parent / old_logo_setting.value.lstrip('/')
if old_logo_path.exists() and old_logo_path.is_file():
try:
old_logo_path.unlink()
except Exception as e:
logger.warning(f"Could not delete old logo: {e}")
# Generate filename
ext = Path(image.filename).suffix or '.png'
# Always use logo.png to ensure we only have one logo
filename = "logo.png"
file_path = upload_dir / filename
# Save file
async with aiofiles.open(file_path, 'wb') as f:
await f.write(content)
# Store the URL in system_settings
image_url = f"/uploads/company/{filename}"
# Update or create setting
logo_setting = db.query(SystemSettings).filter(
SystemSettings.key == "company_logo_url"
).first()
if logo_setting:
logo_setting.value = image_url
logo_setting.updated_at = datetime.utcnow()
logo_setting.updated_by_id = current_user.id
else:
logo_setting = SystemSettings(
key="company_logo_url",
value=image_url,
updated_by_id=current_user.id
)
db.add(logo_setting)
db.commit()
# Return the image URL
base_url = get_base_url(request)
full_url = normalize_image_url(image_url, base_url)
return {
"status": "success",
"message": "Logo uploaded successfully",
"data": {
"logo_url": image_url,
"full_url": full_url
}
}
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"Error uploading logo: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/company/favicon")
async def upload_company_favicon(
request: Request,
image: UploadFile = File(...),
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Upload company favicon (Admin only)"""
try:
# Validate file type (favicon can be ico, png, svg)
if not image.content_type:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File type could not be determined"
)
allowed_types = ['image/x-icon', 'image/vnd.microsoft.icon', 'image/png', 'image/svg+xml', 'image/ico']
if image.content_type not in allowed_types:
# Check filename extension as fallback
filename_lower = (image.filename or '').lower()
if not any(filename_lower.endswith(ext) for ext in ['.ico', '.png', '.svg']):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Favicon must be .ico, .png, or .svg file"
)
# Validate file size (max 500KB)
content = await image.read()
if len(content) > 500 * 1024: # 500KB
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Favicon file size must be less than 500KB"
)
# Create uploads directory
upload_dir = Path(__file__).parent.parent.parent / "uploads" / "company"
upload_dir.mkdir(parents=True, exist_ok=True)
# Delete old favicon if exists
old_favicon_setting = db.query(SystemSettings).filter(
SystemSettings.key == "company_favicon_url"
).first()
if old_favicon_setting and old_favicon_setting.value:
old_favicon_path = Path(__file__).parent.parent.parent / old_favicon_setting.value.lstrip('/')
if old_favicon_path.exists() and old_favicon_path.is_file():
try:
old_favicon_path.unlink()
except Exception as e:
logger.warning(f"Could not delete old favicon: {e}")
# Generate filename - preserve extension but use standard name
filename_lower = (image.filename or '').lower()
if filename_lower.endswith('.ico'):
filename = "favicon.ico"
elif filename_lower.endswith('.svg'):
filename = "favicon.svg"
else:
filename = "favicon.png"
file_path = upload_dir / filename
# Save file
async with aiofiles.open(file_path, 'wb') as f:
await f.write(content)
# Store the URL in system_settings
image_url = f"/uploads/company/{filename}"
# Update or create setting
favicon_setting = db.query(SystemSettings).filter(
SystemSettings.key == "company_favicon_url"
).first()
if favicon_setting:
favicon_setting.value = image_url
favicon_setting.updated_at = datetime.utcnow()
favicon_setting.updated_by_id = current_user.id
else:
favicon_setting = SystemSettings(
key="company_favicon_url",
value=image_url,
updated_by_id=current_user.id
)
db.add(favicon_setting)
db.commit()
# Return the image URL
base_url = get_base_url(request)
full_url = normalize_image_url(image_url, base_url)
return {
"status": "success",
"message": "Favicon uploaded successfully",
"data": {
"favicon_url": image_url,
"full_url": full_url
}
}
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"Error uploading favicon: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -3,10 +3,123 @@ Email templates for various notifications
"""
from datetime import datetime
from typing import Optional
from ..config.database import SessionLocal
from ..models.system_settings import SystemSettings
def get_base_template(content: str, title: str = "Hotel Booking") -> str:
"""Base HTML email template"""
def _get_company_settings():
"""Get company settings from database"""
try:
db = SessionLocal()
try:
settings = {}
setting_keys = [
"company_name",
"company_tagline",
"company_logo_url",
"company_phone",
"company_email",
"company_address",
]
for key in setting_keys:
setting = db.query(SystemSettings).filter(
SystemSettings.key == key
).first()
if setting and setting.value:
settings[key] = setting.value
else:
settings[key] = None
return settings
finally:
db.close()
except Exception:
return {
"company_name": None,
"company_tagline": None,
"company_logo_url": None,
"company_phone": None,
"company_email": None,
"company_address": None,
}
def get_base_template(content: str, title: str = "Hotel Booking", client_url: str = "http://localhost:5173") -> str:
"""Luxury HTML email template with premium company branding"""
company_settings = _get_company_settings()
company_name = company_settings.get("company_name") or "Hotel Booking"
company_tagline = company_settings.get("company_tagline") or "Excellence Redefined"
company_logo_url = company_settings.get("company_logo_url")
company_phone = company_settings.get("company_phone")
company_email = company_settings.get("company_email")
company_address = company_settings.get("company_address")
# Build logo HTML if logo exists
logo_html = ""
if company_logo_url:
# Convert relative URL to absolute if needed
if not company_logo_url.startswith('http'):
# Try to construct full URL
server_url = client_url.replace('://localhost:5173', '').replace('://localhost:3000', '')
if not server_url.startswith('http'):
server_url = f"http://{server_url}" if ':' not in server_url.split('//')[-1] else server_url
full_logo_url = f"{server_url}{company_logo_url}" if company_logo_url.startswith('/') else f"{server_url}/{company_logo_url}"
else:
full_logo_url = company_logo_url
logo_html = f'''
<div style="text-align: center; padding: 15px 0;">
<img src="{full_logo_url}" alt="{company_name}" style="max-height: 80px; max-width: 280px; margin: 0 auto; display: block; filter: drop-shadow(0 4px 6px rgba(0,0,0,0.1));" />
{f'<p style="color: #D4AF37; margin: 8px 0 0 0; font-size: 11px; letter-spacing: 2px; text-transform: uppercase; font-weight: 300;">{company_tagline}</p>' if company_tagline else ''}
</div>
'''
else:
logo_html = f'''
<div style="text-align: center; padding: 15px 0;">
<h1 style="color: #ffffff; margin: 0; font-size: 32px; font-weight: 600; letter-spacing: 1px;">{company_name}</h1>
{f'<p style="color: #D4AF37; margin: 8px 0 0 0; font-size: 11px; letter-spacing: 2px; text-transform: uppercase; font-weight: 300;">{company_tagline}</p>' if company_tagline else ''}
</div>
'''
# Build footer contact info
footer_contact = ""
if company_phone or company_email or company_address:
footer_contact = '''
<div style="margin-top: 25px; padding-top: 25px; border-top: 1px solid rgba(212, 175, 55, 0.2);">
<table role="presentation" style="width: 100%; max-width: 500px; margin: 0 auto;">
'''
if company_phone:
footer_contact += f'''
<tr>
<td style="padding: 5px 0; text-align: center;">
<p style="margin: 0; color: #999999; font-size: 13px;">📞 {company_phone}</p>
</td>
</tr>
'''
if company_email:
footer_contact += f'''
<tr>
<td style="padding: 5px 0; text-align: center;">
<p style="margin: 0; color: #999999; font-size: 13px;">✉️ <a href="mailto:{company_email}" style="color: #D4AF37; text-decoration: none;">{company_email}</a></p>
</td>
</tr>
'''
if company_address:
# Replace newlines with <br> for address
formatted_address = company_address.replace('\n', '<br>')
footer_contact += f'''
<tr>
<td style="padding: 5px 0; text-align: center;">
<p style="margin: 0; color: #999999; font-size: 13px; line-height: 1.6;">📍 {formatted_address}</p>
</td>
</tr>
'''
footer_contact += '''
</table>
</div>
'''
return f"""
<!DOCTYPE html>
<html>
@@ -14,19 +127,22 @@ def get_base_template(content: str, title: str = "Hotel Booking") -> str:
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title}</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;600;700&family=Inter:wght@300;400;500;600&display=swap');
</style>
</head>
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #f4f4f4;">
<table role="presentation" style="width: 100%; border-collapse: collapse;">
<body style="margin: 0; padding: 0; font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%); background-color: #1a1a1a;">
<table role="presentation" style="width: 100%; border-collapse: collapse; background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%);">
<tr>
<td style="padding: 20px 0; text-align: center; background-color: #4F46E5;">
<h1 style="color: #ffffff; margin: 0;">Hotel Booking</h1>
<td style="padding: 40px 20px; background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 50%, #1a1a1a 100%); background-color: #1a1a1a; border-bottom: 2px solid rgba(212, 175, 55, 0.3);">
{logo_html}
</td>
</tr>
<tr>
<td style="padding: 40px 20px; background-color: #ffffff;">
<table role="presentation" style="width: 100%; max-width: 600px; margin: 0 auto;">
<td style="padding: 0;">
<table role="presentation" style="width: 100%; max-width: 650px; margin: 0 auto; background-color: #ffffff; box-shadow: 0 10px 40px rgba(0,0,0,0.3);">
<tr>
<td>
<td style="padding: 50px 40px; background: linear-gradient(to bottom, #ffffff 0%, #fafafa 100%);">
{content}
</td>
</tr>
@@ -34,9 +150,10 @@ def get_base_template(content: str, title: str = "Hotel Booking") -> str:
</td>
</tr>
<tr>
<td style="padding: 20px; text-align: center; background-color: #f4f4f4; color: #666666; font-size: 12px;">
<p style="margin: 0;">This is an automated email. Please do not reply.</p>
<p style="margin: 5px 0 0 0;"{datetime.now().year} Hotel Booking. All rights reserved.</p>
<td style="padding: 40px 20px; text-align: center; background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%); background-color: #1a1a1a; color: #999999; font-size: 12px;">
<p style="margin: 0 0 15px 0; color: #666666; font-size: 12px;">This is an automated email. Please do not reply.</p>
<p style="margin: 0 0 20px 0; color: #D4AF37; font-size: 13px; font-weight: 500;"{datetime.now().year} {company_name}. All rights reserved.</p>
{footer_contact}
</td>
</tr>
</table>
@@ -47,51 +164,89 @@ def get_base_template(content: str, title: str = "Hotel Booking") -> str:
def welcome_email_template(name: str, email: str, client_url: str) -> str:
"""Welcome email template for new registrations"""
company_settings = _get_company_settings()
company_name = company_settings.get("company_name") or "Hotel Booking"
content = f"""
<h2 style="color: #4F46E5; margin-top: 0;">Welcome {name}!</h2>
<p>Thank you for registering an account at <strong>Hotel Booking</strong>.</p>
<p>Your account has been successfully created with email: <strong>{email}</strong></p>
<div style="background-color: #F3F4F6; padding: 20px; border-radius: 8px; margin: 20px 0;">
<p style="margin: 0;"><strong>You can:</strong></p>
<ul style="margin-top: 10px;">
<li>Search and book hotel rooms</li>
<li>Manage your bookings</li>
<li>Update your personal information</li>
<div style="text-align: center; margin-bottom: 30px;">
<div style="display: inline-block; background: linear-gradient(135deg, #D4AF37 0%, #C9A227 100%); padding: 3px; border-radius: 50%; margin-bottom: 20px;">
<div style="background-color: #ffffff; border-radius: 50%; padding: 15px;">
<span style="font-size: 32px;">✨</span>
</div>
</div>
</div>
<h2 style="color: #1a1a1a; margin-top: 0; margin-bottom: 15px; font-family: 'Playfair Display', serif; font-size: 32px; font-weight: 700; text-align: center; letter-spacing: -0.5px;">Welcome, {name}!</h2>
<p style="color: #666666; font-size: 16px; line-height: 1.7; text-align: center; margin-bottom: 25px;">We are delighted to welcome you to <strong style="color: #1a1a1a;">{company_name}</strong>.</p>
<p style="color: #666666; font-size: 15px; line-height: 1.7; text-align: center; margin-bottom: 30px;">Your account has been successfully created with email: <strong style="color: #D4AF37;">{email}</strong></p>
<div style="background: linear-gradient(135deg, #fef9e7 0%, #fdf6e3 100%); border-left: 4px solid #D4AF37; padding: 25px; border-radius: 10px; margin: 30px 0; box-shadow: 0 4px 15px rgba(212, 175, 55, 0.1);">
<p style="margin: 0 0 15px 0; color: #1a1a1a; font-weight: 600; font-size: 16px;">🎁 What you can do:</p>
<ul style="margin: 0; padding-left: 20px; color: #555555; line-height: 2;">
<li style="margin-bottom: 8px;">Search and book our exquisite hotel rooms</li>
<li style="margin-bottom: 8px;">Manage your bookings with ease</li>
<li style="margin-bottom: 8px;">Update your personal information anytime</li>
<li>Enjoy exclusive member benefits and offers</li>
</ul>
</div>
<p style="text-align: center; margin-top: 30px;">
<a href="{client_url}/login" style="background-color: #4F46E5; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">
Login Now
<div style="text-align: center; margin-top: 40px;">
<a href="{client_url}/login" style="background: linear-gradient(135deg, #D4AF37 0%, #C9A227 100%); color: #1a1a1a; padding: 16px 40px; text-decoration: none; border-radius: 8px; display: inline-block; font-weight: 600; font-size: 15px; letter-spacing: 0.5px; box-shadow: 0 6px 20px rgba(212, 175, 55, 0.4); transition: all 0.3s ease;">
Access Your Account
</a>
</p>
</div>
"""
return get_base_template(content, "Welcome to Hotel Booking")
return get_base_template(content, f"Welcome to {company_name}", client_url)
def password_reset_email_template(reset_url: str) -> str:
"""Password reset email template"""
content = f"""
<h2 style="color: #4F46E5; margin-top: 0;">Password Reset Request</h2>
<p>You (or someone) has requested to reset your password.</p>
<p>Click the link below to reset your password. This link will expire in 1 hour:</p>
<p style="text-align: center; margin: 30px 0;">
<a href="{reset_url}" style="background-color: #4F46E5; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">
<div style="text-align: center; margin-bottom: 30px;">
<div style="display: inline-block; background: linear-gradient(135deg, #D4AF37 0%, #C9A227 100%); padding: 3px; border-radius: 50%; margin-bottom: 20px;">
<div style="background-color: #ffffff; border-radius: 50%; padding: 15px;">
<span style="font-size: 32px;">🔐</span>
</div>
</div>
</div>
<h2 style="color: #1a1a1a; margin-top: 0; margin-bottom: 15px; font-family: 'Playfair Display', serif; font-size: 28px; font-weight: 700; text-align: center;">Password Reset Request</h2>
<p style="color: #666666; font-size: 16px; line-height: 1.7; text-align: center; margin-bottom: 20px;">A password reset request has been received for your account.</p>
<p style="color: #666666; font-size: 15px; line-height: 1.7; text-align: center; margin-bottom: 30px;">Click the button below to reset your password. <strong style="color: #D4AF37;">This link will expire in 1 hour.</strong></p>
<div style="text-align: center; margin: 35px 0;">
<a href="{reset_url}" style="background: linear-gradient(135deg, #D4AF37 0%, #C9A227 100%); color: #1a1a1a; padding: 16px 40px; text-decoration: none; border-radius: 8px; display: inline-block; font-weight: 600; font-size: 15px; letter-spacing: 0.5px; box-shadow: 0 6px 20px rgba(212, 175, 55, 0.4);">
Reset Password
</a>
</p>
<p style="color: #666666; font-size: 14px;">If you did not request this, please ignore this email.</p>
</div>
<div style="background-color: #fff9e6; border: 1px solid #ffe0b2; padding: 15px; border-radius: 8px; margin-top: 30px;">
<p style="margin: 0; color: #856404; font-size: 13px; text-align: center; line-height: 1.6;">⚠️ If you did not request this password reset, please ignore this email and your password will remain unchanged.</p>
</div>
"""
return get_base_template(content, "Password Reset")
company_settings = _get_company_settings()
company_name = company_settings.get("company_name") or "Hotel Booking"
return get_base_template(content, f"Password Reset - {company_name}", reset_url.split('/reset-password')[0] if '/reset-password' in reset_url else "http://localhost:5173")
def password_changed_email_template(email: str) -> str:
"""Password changed confirmation email template"""
content = f"""
<h2 style="color: #4F46E5; margin-top: 0;">Password Changed Successfully</h2>
<p>The password for account <strong>{email}</strong> has been changed successfully.</p>
<p>If you did not make this change, please contact our support team immediately.</p>
<div style="text-align: center; margin-bottom: 30px;">
<div style="display: inline-block; background: linear-gradient(135deg, #10B981 0%, #059669 100%); padding: 3px; border-radius: 50%; margin-bottom: 20px;">
<div style="background-color: #ffffff; border-radius: 50%; padding: 15px;">
<span style="font-size: 32px;">✓</span>
</div>
</div>
</div>
<h2 style="color: #1a1a1a; margin-top: 0; margin-bottom: 15px; font-family: 'Playfair Display', serif; font-size: 28px; font-weight: 700; text-align: center;">Password Changed Successfully</h2>
<p style="color: #666666; font-size: 16px; line-height: 1.7; text-align: center; margin-bottom: 25px;">The password for account <strong style="color: #1a1a1a;">{email}</strong> has been changed successfully.</p>
<div style="background: linear-gradient(135deg, #ecfdf5 0%, #d1fae5 100%); border-left: 4px solid #10B981; padding: 20px; border-radius: 10px; margin: 30px 0; box-shadow: 0 4px 15px rgba(16, 185, 129, 0.1);">
<p style="margin: 0; color: #065F46; font-size: 14px; line-height: 1.6; text-align: center;">🔒 If you did not make this change, please contact our support team immediately to secure your account.</p>
</div>
"""
return get_base_template(content, "Password Changed")
company_settings = _get_company_settings()
company_name = company_settings.get("company_name") or "Hotel Booking"
return get_base_template(content, f"Password Changed - {company_name}", "http://localhost:5173")
def booking_confirmation_email_template(
@@ -111,57 +266,66 @@ def booking_confirmation_email_template(
deposit_info = ""
if requires_deposit and deposit_amount:
deposit_info = f"""
<div style="background-color: #FEF3C7; border-left: 4px solid #F59E0B; padding: 15px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: bold; color: #92400E;">⚠️ Deposit Required</p>
<p style="margin: 5px 0 0 0; color: #78350F;">Please pay a deposit of <strong>€{deposit_amount:.2f}</strong> to confirm your booking.</p>
<p style="margin: 5px 0 0 0; color: #78350F;">Your booking will be confirmed once the deposit is received.</p>
<div style="background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); border-left: 4px solid #F59E0B; padding: 25px; margin: 30px 0; border-radius: 10px; box-shadow: 0 4px 15px rgba(245, 158, 11, 0.2);">
<p style="margin: 0 0 10px 0; font-weight: 700; color: #92400E; font-size: 16px;">⚠️ Deposit Required</p>
<p style="margin: 0 0 8px 0; color: #78350F; font-size: 15px; line-height: 1.6;">Please pay a deposit of <strong style="color: #92400E; font-size: 18px;">€{deposit_amount:.2f}</strong> to confirm your booking.</p>
<p style="margin: 0; color: #78350F; font-size: 14px;">Your booking will be confirmed once the deposit is received.</p>
</div>
"""
content = f"""
<h2 style="color: #4F46E5; margin-top: 0;">Booking Confirmation</h2>
<p>Dear {guest_name},</p>
<p>Thank you for your booking! We have received your reservation request.</p>
<div style="text-align: center; margin-bottom: 30px;">
<div style="display: inline-block; background: linear-gradient(135deg, #D4AF37 0%, #C9A227 100%); padding: 3px; border-radius: 50%; margin-bottom: 20px;">
<div style="background-color: #ffffff; border-radius: 50%; padding: 15px;">
<span style="font-size: 32px;">🏨</span>
</div>
</div>
</div>
<h2 style="color: #1a1a1a; margin-top: 0; margin-bottom: 20px; font-family: 'Playfair Display', serif; font-size: 32px; font-weight: 700; text-align: center;">Booking Confirmation</h2>
<p style="color: #666666; font-size: 16px; line-height: 1.7; text-align: center; margin-bottom: 10px;">Dear <strong style="color: #1a1a1a;">{guest_name}</strong>,</p>
<p style="color: #666666; font-size: 15px; line-height: 1.7; text-align: center; margin-bottom: 35px;">Thank you for choosing us! We have received your reservation request and are delighted to welcome you.</p>
<div style="background-color: #F3F4F6; padding: 20px; border-radius: 8px; margin: 20px 0;">
<h3 style="margin-top: 0; color: #1F2937;">Booking Details</h3>
<div style="background: linear-gradient(135deg, #fafafa 0%, #f5f5f5 100%); padding: 30px; border-radius: 12px; margin: 30px 0; border: 1px solid #e5e5e5; box-shadow: 0 4px 20px rgba(0,0,0,0.05);">
<h3 style="margin-top: 0; margin-bottom: 25px; color: #1a1a1a; font-family: 'Playfair Display', serif; font-size: 24px; font-weight: 600; text-align: center; border-bottom: 2px solid #D4AF37; padding-bottom: 15px;">Booking Details</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; color: #6B7280; width: 40%;">Booking Number:</td>
<td style="padding: 8px 0; font-weight: bold; color: #1F2937;">{booking_number}</td>
<td style="padding: 12px 0; color: #888888; width: 45%; font-size: 14px; font-weight: 500;">Booking Number:</td>
<td style="padding: 12px 0; font-weight: 700; color: #1a1a1a; font-size: 15px; letter-spacing: 0.5px;">{booking_number}</td>
</tr>
<tr style="background-color: rgba(212, 175, 55, 0.05);">
<td style="padding: 12px 0; color: #888888; font-size: 14px; font-weight: 500;">Room:</td>
<td style="padding: 12px 0; color: #1a1a1a; font-size: 15px; font-weight: 600;">{room_type} - Room {room_number}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6B7280;">Room:</td>
<td style="padding: 8px 0; color: #1F2937;">{room_type} - Room {room_number}</td>
<td style="padding: 12px 0; color: #888888; font-size: 14px; font-weight: 500;">Check-in:</td>
<td style="padding: 12px 0; color: #1a1a1a; font-size: 15px; font-weight: 600;">{check_in}</td>
</tr>
<tr style="background-color: rgba(212, 175, 55, 0.05);">
<td style="padding: 12px 0; color: #888888; font-size: 14px; font-weight: 500;">Check-out:</td>
<td style="padding: 12px 0; color: #1a1a1a; font-size: 15px; font-weight: 600;">{check_out}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6B7280;">Check-in:</td>
<td style="padding: 8px 0; color: #1F2937;">{check_in}</td>
<td style="padding: 12px 0; color: #888888; font-size: 14px; font-weight: 500;">Guests:</td>
<td style="padding: 12px 0; color: #1a1a1a; font-size: 15px; font-weight: 600;">{num_guests} guest{'s' if num_guests > 1 else ''}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6B7280;">Check-out:</td>
<td style="padding: 8px 0; color: #1F2937;">{check_out}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6B7280;">Guests:</td>
<td style="padding: 8px 0; color: #1F2937;">{num_guests}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6B7280;">Total Price:</td>
<td style="padding: 8px 0; font-weight: bold; color: #1F2937;">€{total_price:.2f}</td>
<tr style="background: linear-gradient(135deg, #fef9e7 0%, #fdf6e3 100%); border-top: 2px solid #D4AF37; border-bottom: 2px solid #D4AF37;">
<td style="padding: 15px 0; color: #1a1a1a; font-size: 16px; font-weight: 600;">Total Price:</td>
<td style="padding: 15px 0; font-weight: 700; color: #D4AF37; font-size: 22px; font-family: 'Playfair Display', serif;">{total_price:.2f}</td>
</tr>
</table>
</div>
{deposit_info}
<p style="text-align: center; margin-top: 30px;">
<a href="{client_url}/bookings/{booking_number}" style="background-color: #4F46E5; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">
<div style="text-align: center; margin-top: 40px;">
<a href="{client_url}/bookings/{booking_number}" style="background: linear-gradient(135deg, #D4AF37 0%, #C9A227 100%); color: #1a1a1a; padding: 16px 40px; text-decoration: none; border-radius: 8px; display: inline-block; font-weight: 600; font-size: 15px; letter-spacing: 0.5px; box-shadow: 0 6px 20px rgba(212, 175, 55, 0.4);">
View Booking Details
</a>
</p>
</div>
"""
return get_base_template(content, "Booking Confirmation")
company_settings = _get_company_settings()
company_name = company_settings.get("company_name") or "Hotel Booking"
return get_base_template(content, f"Booking Confirmation - {company_name}", client_url)
def payment_confirmation_email_template(
@@ -176,45 +340,54 @@ def payment_confirmation_email_template(
transaction_info = ""
if transaction_id:
transaction_info = f"""
<tr>
<td style="padding: 8px 0; color: #6B7280;">Transaction ID:</td>
<td style="padding: 8px 0; color: #1F2937; font-family: monospace;">{transaction_id}</td>
</tr>
<tr style="background-color: rgba(16, 185, 129, 0.05);">
<td style="padding: 12px 0; color: #888888; font-size: 14px; font-weight: 500;">Transaction ID:</td>
<td style="padding: 12px 0; color: #1a1a1a; font-family: 'Courier New', monospace; font-size: 13px; letter-spacing: 0.5px;">{transaction_id}</td>
</tr>
"""
content = f"""
<h2 style="color: #10B981; margin-top: 0;">Payment Received</h2>
<p>Dear {guest_name},</p>
<p>We have successfully received your payment for booking <strong>{booking_number}</strong>.</p>
<div style="text-align: center; margin-bottom: 30px;">
<div style="display: inline-block; background: linear-gradient(135deg, #10B981 0%, #059669 100%); padding: 3px; border-radius: 50%; margin-bottom: 20px;">
<div style="background-color: #ffffff; border-radius: 50%; padding: 15px;">
<span style="font-size: 32px;">💳</span>
</div>
</div>
</div>
<h2 style="color: #1a1a1a; margin-top: 0; margin-bottom: 20px; font-family: 'Playfair Display', serif; font-size: 32px; font-weight: 700; text-align: center;">Payment Received</h2>
<p style="color: #666666; font-size: 16px; line-height: 1.7; text-align: center; margin-bottom: 10px;">Dear <strong style="color: #1a1a1a;">{guest_name}</strong>,</p>
<p style="color: #666666; font-size: 15px; line-height: 1.7; text-align: center; margin-bottom: 35px;">We have successfully received your payment for booking <strong style="color: #10B981;">{booking_number}</strong>.</p>
<div style="background-color: #ECFDF5; border-left: 4px solid #10B981; padding: 20px; border-radius: 8px; margin: 20px 0;">
<h3 style="margin-top: 0; color: #065F46;">Payment Details</h3>
<div style="background: linear-gradient(135deg, #ecfdf5 0%, #d1fae5 100%); border-left: 4px solid #10B981; padding: 30px; border-radius: 12px; margin: 30px 0; box-shadow: 0 4px 20px rgba(16, 185, 129, 0.15);">
<h3 style="margin-top: 0; margin-bottom: 25px; color: #065F46; font-family: 'Playfair Display', serif; font-size: 24px; font-weight: 600; text-align: center; border-bottom: 2px solid #10B981; padding-bottom: 15px;">Payment Details</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; color: #6B7280; width: 40%;">Booking Number:</td>
<td style="padding: 8px 0; font-weight: bold; color: #1F2937;">{booking_number}</td>
<td style="padding: 12px 0; color: #888888; width: 45%; font-size: 14px; font-weight: 500;">Booking Number:</td>
<td style="padding: 12px 0; font-weight: 700; color: #1a1a1a; font-size: 15px; letter-spacing: 0.5px;">{booking_number}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6B7280;">Amount:</td>
<td style="padding: 8px 0; font-weight: bold; color: #065F46; font-size: 18px;">{amount:.2f}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6B7280;">Payment Method:</td>
<td style="padding: 8px 0; color: #1F2937;">{payment_method}</td>
<tr style="background-color: rgba(16, 185, 129, 0.1);">
<td style="padding: 12px 0; color: #888888; font-size: 14px; font-weight: 500;">Payment Method:</td>
<td style="padding: 12px 0; color: #1a1a1a; font-size: 15px; font-weight: 600;">{payment_method}</td>
</tr>
{transaction_info}
<tr style="background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%); border-top: 2px solid #10B981; border-bottom: 2px solid #10B981;">
<td style="padding: 15px 0; color: #065F46; font-size: 16px; font-weight: 600;">Amount Paid:</td>
<td style="padding: 15px 0; font-weight: 700; color: #059669; font-size: 24px; font-family: 'Playfair Display', serif;">€{amount:.2f}</td>
</tr>
</table>
</div>
<p>Your booking is now confirmed. We look forward to hosting you!</p>
<p style="color: #666666; font-size: 16px; line-height: 1.7; text-align: center; margin: 35px 0 25px 0;">✨ Your booking is now confirmed. We look forward to hosting you!</p>
<p style="text-align: center; margin-top: 30px;">
<a href="{client_url}/bookings/{booking_number}" style="background-color: #10B981; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">
<div style="text-align: center; margin-top: 40px;">
<a href="{client_url}/bookings/{booking_number}" style="background: linear-gradient(135deg, #10B981 0%, #059669 100%); color: #ffffff; padding: 16px 40px; text-decoration: none; border-radius: 8px; display: inline-block; font-weight: 600; font-size: 15px; letter-spacing: 0.5px; box-shadow: 0 6px 20px rgba(16, 185, 129, 0.4);">
View Booking
</a>
</p>
</div>
"""
return get_base_template(content, "Payment Confirmation")
company_settings = _get_company_settings()
company_name = company_settings.get("company_name") or "Hotel Booking"
return get_base_template(content, f"Payment Confirmation - {company_name}", client_url)
def booking_status_changed_email_template(
@@ -225,37 +398,47 @@ def booking_status_changed_email_template(
) -> str:
"""Booking status change email template"""
status_colors = {
"confirmed": ("#10B981", "Confirmed"),
"cancelled": ("#EF4444", "Cancelled"),
"checked_in": ("#3B82F6", "Checked In"),
"checked_out": ("#8B5CF6", "Checked Out"),
"confirmed": ("#10B981", "Confirmed", "", "#ecfdf5", "#d1fae5"),
"cancelled": ("#EF4444", "Cancelled", "", "#fef2f2", "#fee2e2"),
"checked_in": ("#3B82F6", "Checked In", "🔑", "#eff6ff", "#dbeafe"),
"checked_out": ("#8B5CF6", "Checked Out", "🏃", "#f5f3ff", "#e9d5ff"),
}
color, status_text = status_colors.get(status.lower(), ("#6B7280", status.title()))
color, status_text, icon, bg_start, bg_end = status_colors.get(status.lower(), ("#6B7280", status.title(), "📋", "#f3f4f6", "#e5e7eb"))
content = f"""
<h2 style="color: {color}; margin-top: 0;">Booking Status Updated</h2>
<p>Dear {guest_name},</p>
<p>Your booking status has been updated.</p>
<div style="text-align: center; margin-bottom: 30px;">
<div style="display: inline-block; background: linear-gradient(135deg, {color} 0%, {color}dd 100%); padding: 3px; border-radius: 50%; margin-bottom: 20px;">
<div style="background-color: #ffffff; border-radius: 50%; padding: 15px;">
<span style="font-size: 32px;">{icon}</span>
</div>
</div>
</div>
<h2 style="color: #1a1a1a; margin-top: 0; margin-bottom: 20px; font-family: 'Playfair Display', serif; font-size: 32px; font-weight: 700; text-align: center;">Booking Status Updated</h2>
<p style="color: #666666; font-size: 16px; line-height: 1.7; text-align: center; margin-bottom: 10px;">Dear <strong style="color: #1a1a1a;">{guest_name}</strong>,</p>
<p style="color: #666666; font-size: 15px; line-height: 1.7; text-align: center; margin-bottom: 35px;">Your booking status has been updated.</p>
<div style="background-color: #F3F4F6; padding: 20px; border-radius: 8px; margin: 20px 0;">
<div style="background: linear-gradient(135deg, {bg_start} 0%, {bg_end} 100%); border-left: 4px solid {color}; padding: 30px; border-radius: 12px; margin: 30px 0; box-shadow: 0 4px 20px {color}20;">
<h3 style="margin-top: 0; margin-bottom: 25px; color: {color}; font-family: 'Playfair Display', serif; font-size: 22px; font-weight: 600; text-align: center; border-bottom: 2px solid {color}; padding-bottom: 15px;">Status Information</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; color: #6B7280; width: 40%;">Booking Number:</td>
<td style="padding: 8px 0; font-weight: bold; color: #1F2937;">{booking_number}</td>
<td style="padding: 12px 0; color: #888888; width: 45%; font-size: 14px; font-weight: 500;">Booking Number:</td>
<td style="padding: 12px 0; font-weight: 700; color: #1a1a1a; font-size: 15px; letter-spacing: 0.5px;">{booking_number}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6B7280;">New Status:</td>
<td style="padding: 8px 0; font-weight: bold; color: {color}; font-size: 18px;">{status_text}</td>
<tr style="background: linear-gradient(135deg, {bg_end} 0%, {bg_start} 100%); border-top: 2px solid {color}; border-bottom: 2px solid {color};">
<td style="padding: 15px 0; color: {color}; font-size: 16px; font-weight: 600;">New Status:</td>
<td style="padding: 15px 0; font-weight: 700; color: {color}; font-size: 22px; font-family: 'Playfair Display', serif;">{status_text}</td>
</tr>
</table>
</div>
<p style="text-align: center; margin-top: 30px;">
<a href="{client_url}/bookings/{booking_number}" style="background-color: {color}; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">
<div style="text-align: center; margin-top: 40px;">
<a href="{client_url}/bookings/{booking_number}" style="background: linear-gradient(135deg, {color} 0%, {color}dd 100%); color: #ffffff; padding: 16px 40px; text-decoration: none; border-radius: 8px; display: inline-block; font-weight: 600; font-size: 15px; letter-spacing: 0.5px; box-shadow: 0 6px 20px {color}40;">
View Booking
</a>
</p>
</div>
"""
return get_base_template(content, f"Booking {status_text}")
company_settings = _get_company_settings()
company_name = company_settings.get("company_name") or "Hotel Booking"
return get_base_template(content, f"Booking {status_text} - {company_name}", client_url)

View File

@@ -4,33 +4,89 @@ from email.mime.multipart import MIMEMultipart
import os
import logging
from ..config.settings import settings
from ..config.database import SessionLocal
from ..models.system_settings import SystemSettings
logger = logging.getLogger(__name__)
def _get_smtp_settings_from_db():
"""
Get SMTP settings from system_settings table.
Returns dict with settings or None if not available.
"""
try:
db = SessionLocal()
try:
smtp_settings = {}
setting_keys = [
"smtp_host",
"smtp_port",
"smtp_user",
"smtp_password",
"smtp_from_email",
"smtp_from_name",
"smtp_use_tls",
]
for key in setting_keys:
setting = db.query(SystemSettings).filter(
SystemSettings.key == key
).first()
if setting and setting.value:
smtp_settings[key] = setting.value
# Only return if we have at least host, user, and password
if smtp_settings.get("smtp_host") and smtp_settings.get("smtp_user") and smtp_settings.get("smtp_password"):
return smtp_settings
return None
finally:
db.close()
except Exception as e:
logger.debug(f"Could not fetch SMTP settings from database: {str(e)}")
return None
async def send_email(to: str, subject: str, html: str = None, text: str = None):
"""
Send email using SMTP
Uses settings from config/settings.py with fallback to environment variables
Uses system_settings first, then falls back to config/settings.py and environment variables
"""
try:
# Get SMTP settings from settings.py, fallback to env vars
mail_host = settings.SMTP_HOST or os.getenv("MAIL_HOST")
mail_user = settings.SMTP_USER or os.getenv("MAIL_USER")
mail_pass = settings.SMTP_PASSWORD or os.getenv("MAIL_PASS")
mail_port = settings.SMTP_PORT or int(os.getenv("MAIL_PORT", "587"))
mail_secure = os.getenv("MAIL_SECURE", "false").lower() == "true"
client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")
# Try to get SMTP settings from database first
db_smtp_settings = _get_smtp_settings_from_db()
# Get from address - prefer settings, then env, then generate from client_url
from_address = settings.SMTP_FROM_EMAIL or os.getenv("MAIL_FROM")
if not from_address:
# Generate from client_url if not set
domain = client_url.replace('https://', '').replace('http://', '').split('/')[0]
from_address = f"no-reply@{domain}"
if db_smtp_settings:
# Use settings from database
mail_host = db_smtp_settings.get("smtp_host")
mail_user = db_smtp_settings.get("smtp_user")
mail_pass = db_smtp_settings.get("smtp_password")
mail_port = int(db_smtp_settings.get("smtp_port", "587"))
mail_use_tls = db_smtp_settings.get("smtp_use_tls", "true").lower() == "true"
from_address = db_smtp_settings.get("smtp_from_email")
from_name = db_smtp_settings.get("smtp_from_name", "Hotel Booking")
logger.info("Using SMTP settings from system_settings database")
else:
# Fallback to config/settings.py and env vars
mail_host = settings.SMTP_HOST or os.getenv("MAIL_HOST")
mail_user = settings.SMTP_USER or os.getenv("MAIL_USER")
mail_pass = settings.SMTP_PASSWORD or os.getenv("MAIL_PASS")
mail_port = settings.SMTP_PORT or int(os.getenv("MAIL_PORT", "587"))
mail_secure = os.getenv("MAIL_SECURE", "false").lower() == "true"
mail_use_tls = mail_secure # For backward compatibility
client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")
# Get from address - prefer settings, then env, then generate from client_url
from_address = settings.SMTP_FROM_EMAIL or os.getenv("MAIL_FROM")
if not from_address:
# Generate from client_url if not set
domain = client_url.replace('https://', '').replace('http://', '').split('/')[0]
from_address = f"no-reply@{domain}"
# Use from name if available
from_name = settings.SMTP_FROM_NAME or "Hotel Booking"
logger.info("Using SMTP settings from config/environment variables")
# Use from name if available
from_name = settings.SMTP_FROM_NAME or "Hotel Booking"
from_header = f"{from_name} <{from_address}>"
if not (mail_host and mail_user and mail_pass):
@@ -54,10 +110,10 @@ async def send_email(to: str, subject: str, html: str = None, text: str = None):
message.attach(MIMEText("", "plain"))
# Determine TLS/SSL settings
# For port 587: use STARTTLS (use_tls=False, start_tls=True)
# For port 465: use SSL/TLS (use_tls=True, start_tls=False)
# For port 587: use STARTTLS (use_tls=False, start_tls=True)
# For port 25: plain (usually not used for authenticated sending)
if mail_port == 465 or mail_secure:
if mail_port == 465 or mail_use_tls:
# SSL/TLS connection (port 465)
use_tls = True
start_tls = False