This commit is contained in:
Iliyan Angelov
2025-11-18 18:35:46 +02:00
parent a1bd576540
commit ab832f851b
26 changed files with 8878 additions and 355 deletions

View File

@@ -0,0 +1,62 @@
"""add_page_content_table
Revision ID: 163657e72e93
Revises: 6a126cc5b23c
Create Date: 2025-11-18 18:02:03.480951
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '163657e72e93'
down_revision = '6a126cc5b23c'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
# Only create the page_contents table, skip other schema changes
op.create_table('page_contents',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('page_type', sa.Enum('home', 'contact', 'about', 'footer', 'seo', name='pagetype'), nullable=False),
sa.Column('title', sa.String(length=500), nullable=True),
sa.Column('subtitle', sa.String(length=1000), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('content', sa.Text(), nullable=True),
sa.Column('meta_title', sa.String(length=500), nullable=True),
sa.Column('meta_description', sa.Text(), nullable=True),
sa.Column('meta_keywords', sa.String(length=1000), nullable=True),
sa.Column('og_title', sa.String(length=500), nullable=True),
sa.Column('og_description', sa.Text(), nullable=True),
sa.Column('og_image', sa.String(length=1000), nullable=True),
sa.Column('canonical_url', sa.String(length=1000), nullable=True),
sa.Column('contact_info', sa.Text(), nullable=True),
sa.Column('social_links', sa.Text(), nullable=True),
sa.Column('footer_links', sa.Text(), nullable=True),
sa.Column('hero_title', sa.String(length=500), nullable=True),
sa.Column('hero_subtitle', sa.String(length=1000), nullable=True),
sa.Column('hero_image', sa.String(length=1000), nullable=True),
sa.Column('story_content', sa.Text(), nullable=True),
sa.Column('values', sa.Text(), nullable=True),
sa.Column('features', sa.Text(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_page_contents_id'), 'page_contents', ['id'], unique=False)
op.create_index(op.f('ix_page_contents_page_type'), 'page_contents', ['page_type'], unique=True)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_page_contents_page_type'), table_name='page_contents')
op.drop_index(op.f('ix_page_contents_id'), table_name='page_contents')
op.drop_table('page_contents')
op.execute("DROP TYPE IF EXISTS pagetype")
# ### end Alembic commands ###

View File

@@ -0,0 +1,28 @@
"""add_map_url_to_page_content
Revision ID: cce764ef7a50
Revises: 163657e72e93
Create Date: 2025-11-18 18:11:41.071053
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'cce764ef7a50'
down_revision = '163657e72e93'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
# Only add the map_url column to page_contents table
op.add_column('page_contents', sa.Column('map_url', sa.String(length=1000), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('page_contents', 'map_url')
# ### end Alembic commands ###

View File

@@ -42,15 +42,17 @@ if settings.is_development:
logger.info("Creating database tables (development mode)")
Base.metadata.create_all(bind=engine)
else:
# Ensure new cookie-related tables exist even if full migrations haven't been run yet.
# Ensure new tables exist even if full migrations haven't been run yet.
try:
from .models.cookie_policy import CookiePolicy
from .models.cookie_integration_config import CookieIntegrationConfig
logger.info("Ensuring cookie-related tables exist")
from .models.page_content import PageContent
logger.info("Ensuring required tables exist")
CookiePolicy.__table__.create(bind=engine, checkfirst=True)
CookieIntegrationConfig.__table__.create(bind=engine, checkfirst=True)
PageContent.__table__.create(bind=engine, checkfirst=True)
except Exception as e:
logger.error(f"Failed to ensure cookie tables exist: {e}")
logger.error(f"Failed to ensure required tables exist: {e}")
from .routes import auth_routes
from .routes import privacy_routes
@@ -125,9 +127,11 @@ app.add_exception_handler(Exception, general_exception_handler)
# Enhanced Health check with database connectivity
@app.get("/health", tags=["health"])
@app.get("/api/health", tags=["health"])
async def health_check(db: Session = Depends(get_db)):
"""
Enhanced health check endpoint with database connectivity test
Available at both /health and /api/health for consistency
"""
health_status = {
"status": "healthy",
@@ -196,7 +200,7 @@ 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
system_settings_routes, contact_routes, page_content_routes
)
# Legacy routes (maintain backward compatibility)
@@ -234,6 +238,8 @@ 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(page_content_routes.router, prefix="/api")
app.include_router(page_content_routes.router, prefix=settings.API_V1_PREFIX)
logger.info("All routes registered successfully")

View File

@@ -19,6 +19,7 @@ from .cookie_policy import CookiePolicy
from .cookie_integration_config import CookieIntegrationConfig
from .system_settings import SystemSettings
from .invoice import Invoice, InvoiceItem
from .page_content import PageContent, PageType
__all__ = [
"Role",
@@ -48,5 +49,7 @@ __all__ = [
"SystemSettings",
"Invoice",
"InvoiceItem",
"PageContent",
"PageType",
]

View File

@@ -0,0 +1,60 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, Enum as SQLEnum
from sqlalchemy.orm import relationship
from datetime import datetime
import enum
from ..config.database import Base
class PageType(str, enum.Enum):
HOME = "home"
CONTACT = "contact"
ABOUT = "about"
FOOTER = "footer"
SEO = "seo"
class PageContent(Base):
__tablename__ = "page_contents"
id = Column(Integer, primary_key=True, index=True)
page_type = Column(SQLEnum(PageType), nullable=False, unique=True, index=True)
# General content fields
title = Column(String(500), nullable=True)
subtitle = Column(String(1000), nullable=True)
description = Column(Text, nullable=True)
content = Column(Text, nullable=True) # Rich text content
# SEO fields
meta_title = Column(String(500), nullable=True)
meta_description = Column(Text, nullable=True)
meta_keywords = Column(String(1000), nullable=True)
og_title = Column(String(500), nullable=True)
og_description = Column(Text, nullable=True)
og_image = Column(String(1000), nullable=True)
canonical_url = Column(String(1000), nullable=True)
# Contact/Footer specific fields (stored as JSON strings)
contact_info = Column(Text, nullable=True) # JSON: phone, email, address
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
# Home page specific
hero_title = Column(String(500), nullable=True)
hero_subtitle = Column(String(1000), nullable=True)
hero_image = Column(String(1000), nullable=True)
# About page specific
story_content = Column(Text, nullable=True)
values = Column(Text, nullable=True) # JSON array of values
features = Column(Text, nullable=True) # JSON array of features
# Status
is_active = Column(Boolean, default=True, nullable=False)
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)

View File

@@ -0,0 +1,413 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import Optional
from datetime import datetime
import json
from ..config.database import get_db
from ..middleware.auth import get_current_user, authorize_roles
from ..models.user import User
from ..models.page_content import PageContent, PageType
router = APIRouter(prefix="/page-content", tags=["page-content"])
@router.get("/")
async def get_all_page_contents(
db: Session = Depends(get_db)
):
"""Get all page contents"""
try:
contents = db.query(PageContent).all()
result = []
for content in contents:
content_dict = {
"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,
"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,
"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,
"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,
}
result.append(content_dict)
return {
"status": "success",
"data": {
"page_contents": result
}
}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error fetching page contents: {str(e)}"
)
@router.get("/{page_type}")
async def get_page_content(
page_type: PageType,
db: Session = Depends(get_db)
):
"""Get content for a specific page"""
try:
content = db.query(PageContent).filter(PageContent.page_type == page_type).first()
if not content:
# Return default structure if not found
return {
"status": "success",
"data": {
"page_content": None
}
}
content_dict = {
"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,
"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,
"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,
"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,
}
return {
"status": "success",
"data": {
"page_content": content_dict
}
}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error fetching page content: {str(e)}"
)
@router.post("/{page_type}")
async def create_or_update_page_content(
page_type: PageType,
title: Optional[str] = None,
subtitle: Optional[str] = None,
description: Optional[str] = None,
content: Optional[str] = None,
meta_title: Optional[str] = None,
meta_description: Optional[str] = None,
meta_keywords: Optional[str] = None,
og_title: Optional[str] = None,
og_description: Optional[str] = None,
og_image: Optional[str] = None,
canonical_url: Optional[str] = None,
contact_info: Optional[str] = None,
map_url: Optional[str] = None,
social_links: Optional[str] = None,
footer_links: Optional[str] = None,
hero_title: Optional[str] = None,
hero_subtitle: Optional[str] = None,
hero_image: Optional[str] = None,
story_content: Optional[str] = None,
values: Optional[str] = None,
features: Optional[str] = None,
is_active: bool = True,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Create or update page content (admin only)"""
try:
authorize_roles(current_user, ["admin"])
# Validate JSON fields if provided
if contact_info:
try:
json.loads(contact_info)
except json.JSONDecodeError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid JSON in contact_info"
)
if social_links:
try:
json.loads(social_links)
except json.JSONDecodeError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid JSON in social_links"
)
if footer_links:
try:
json.loads(footer_links)
except json.JSONDecodeError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid JSON in footer_links"
)
if values:
try:
json.loads(values)
except json.JSONDecodeError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid JSON in values"
)
if features:
try:
json.loads(features)
except json.JSONDecodeError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid JSON in features"
)
# Check if content exists
existing_content = db.query(PageContent).filter(PageContent.page_type == page_type).first()
if existing_content:
# Update existing
if title is not None:
existing_content.title = title
if subtitle is not None:
existing_content.subtitle = subtitle
if description is not None:
existing_content.description = description
if content is not None:
existing_content.content = content
if meta_title is not None:
existing_content.meta_title = meta_title
if meta_description is not None:
existing_content.meta_description = meta_description
if meta_keywords is not None:
existing_content.meta_keywords = meta_keywords
if og_title is not None:
existing_content.og_title = og_title
if og_description is not None:
existing_content.og_description = og_description
if og_image is not None:
existing_content.og_image = og_image
if canonical_url is not None:
existing_content.canonical_url = canonical_url
if contact_info is not None:
existing_content.contact_info = contact_info
if map_url is not None:
existing_content.map_url = map_url
if social_links is not None:
existing_content.social_links = social_links
if footer_links is not None:
existing_content.footer_links = footer_links
if hero_title is not None:
existing_content.hero_title = hero_title
if hero_subtitle is not None:
existing_content.hero_subtitle = hero_subtitle
if hero_image is not None:
existing_content.hero_image = hero_image
if story_content is not None:
existing_content.story_content = story_content
if values is not None:
existing_content.values = values
if features is not None:
existing_content.features = features
if is_active is not None:
existing_content.is_active = is_active
existing_content.updated_at = datetime.utcnow()
db.commit()
db.refresh(existing_content)
return {
"status": "success",
"message": "Page content updated successfully",
"data": {
"page_content": {
"id": existing_content.id,
"page_type": existing_content.page_type.value,
"title": existing_content.title,
"updated_at": existing_content.updated_at.isoformat() if existing_content.updated_at else None,
}
}
}
else:
# Create new
new_content = PageContent(
page_type=page_type,
title=title,
subtitle=subtitle,
description=description,
content=content,
meta_title=meta_title,
meta_description=meta_description,
meta_keywords=meta_keywords,
og_title=og_title,
og_description=og_description,
og_image=og_image,
canonical_url=canonical_url,
contact_info=contact_info,
map_url=map_url,
social_links=social_links,
footer_links=footer_links,
hero_title=hero_title,
hero_subtitle=hero_subtitle,
hero_image=hero_image,
story_content=story_content,
values=values,
features=features,
is_active=is_active,
)
db.add(new_content)
db.commit()
db.refresh(new_content)
return {
"status": "success",
"message": "Page content created successfully",
"data": {
"page_content": {
"id": new_content.id,
"page_type": new_content.page_type.value,
"title": new_content.title,
"created_at": new_content.created_at.isoformat() if new_content.created_at else None,
}
}
}
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error saving page content: {str(e)}"
)
@router.put("/{page_type}")
async def update_page_content(
page_type: PageType,
page_data: dict,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Create or update page content using JSON body (admin only)"""
try:
authorize_roles(current_user, ["admin"])
existing_content = db.query(PageContent).filter(PageContent.page_type == page_type).first()
if not existing_content:
# Create new content if it doesn't exist
existing_content = PageContent(
page_type=page_type,
is_active=True,
)
db.add(existing_content)
# Update fields from request body
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 isinstance(value, str):
# Already a string, validate it's valid JSON
try:
json.loads(value)
except json.JSONDecodeError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid JSON in {key}"
)
elif isinstance(value, (dict, list)):
# Convert dict/list to JSON string for storage
value = json.dumps(value)
# Skip None values to allow partial updates
if value is not None:
setattr(existing_content, key, value)
existing_content.updated_at = datetime.utcnow()
db.commit()
db.refresh(existing_content)
content_dict = {
"id": existing_content.id,
"page_type": existing_content.page_type.value,
"title": existing_content.title,
"subtitle": existing_content.subtitle,
"description": existing_content.description,
"content": existing_content.content,
"meta_title": existing_content.meta_title,
"meta_description": existing_content.meta_description,
"meta_keywords": existing_content.meta_keywords,
"og_title": existing_content.og_title,
"og_description": existing_content.og_description,
"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,
"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,
"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,
"is_active": existing_content.is_active,
"updated_at": existing_content.updated_at.isoformat() if existing_content.updated_at else None,
}
return {
"status": "success",
"message": "Page content updated successfully",
"data": {
"page_content": content_dict
}
}
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error updating page content: {str(e)}"
)