- 7 new API endpoints\
- Public theme config endpoint for frontend initialization (with 5-min cache)/- Admin CRUD for theme settings (get, update, reset)/- Logo and favicon upload/delete via Cloudflare R2 storage
This commit is contained in:
505
server.py
505
server.py
@@ -7210,6 +7210,511 @@ async def reset_member_tiers(
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Theme Settings
|
||||
# ============================================================================
|
||||
|
||||
# Default theme configuration
|
||||
DEFAULT_THEME_CONFIG = {
|
||||
"site_name": "LOAF - Lesbians Over Age Fifty",
|
||||
"site_short_name": "LOAF",
|
||||
"site_description": "A community organization for lesbians over age fifty in Houston and surrounding areas.",
|
||||
"logo_url": None,
|
||||
"favicon_url": None,
|
||||
"colors": {
|
||||
"primary": "280 47% 27%",
|
||||
"primary_foreground": "0 0% 100%",
|
||||
"accent": "24 86% 55%",
|
||||
"brand_purple": "256 35% 47%",
|
||||
"brand_orange": "24 86% 55%",
|
||||
"brand_lavender": "262 46% 80%"
|
||||
},
|
||||
"meta_theme_color": "#664fa3"
|
||||
}
|
||||
|
||||
# Simple in-memory cache for theme config
|
||||
_theme_cache = {
|
||||
"config": None,
|
||||
"expires_at": None
|
||||
}
|
||||
THEME_CACHE_TTL_SECONDS = 300 # 5 minutes
|
||||
|
||||
|
||||
def get_theme_config_cached(db: Session) -> dict:
|
||||
"""Get theme config with caching."""
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Check cache
|
||||
if _theme_cache["config"] and _theme_cache["expires_at"] and _theme_cache["expires_at"] > now:
|
||||
return _theme_cache["config"]
|
||||
|
||||
# Build config from settings
|
||||
config = dict(DEFAULT_THEME_CONFIG)
|
||||
|
||||
# Fetch all theme.* settings
|
||||
theme_settings = db.query(SystemSettings).filter(
|
||||
SystemSettings.setting_key.like('theme.%')
|
||||
).all()
|
||||
|
||||
for setting in theme_settings:
|
||||
key = setting.setting_key.replace('theme.', '')
|
||||
value = setting.setting_value
|
||||
|
||||
if key == 'colors' and value:
|
||||
try:
|
||||
config['colors'] = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
elif key in config:
|
||||
config[key] = value
|
||||
|
||||
# Update cache
|
||||
_theme_cache["config"] = config
|
||||
_theme_cache["expires_at"] = now + timedelta(seconds=THEME_CACHE_TTL_SECONDS)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def invalidate_theme_cache():
|
||||
"""Invalidate the theme config cache."""
|
||||
_theme_cache["config"] = None
|
||||
_theme_cache["expires_at"] = None
|
||||
|
||||
|
||||
@api_router.get("/config/theme")
|
||||
async def get_theme_config(db: Session = Depends(get_db)):
|
||||
"""
|
||||
Get public theme configuration.
|
||||
|
||||
This endpoint is public (no authentication required) and returns
|
||||
the theme configuration for frontend initialization.
|
||||
|
||||
Returns cached config with 5-minute TTL for performance.
|
||||
"""
|
||||
return get_theme_config_cached(db)
|
||||
|
||||
|
||||
@api_router.get("/admin/settings/theme")
|
||||
async def get_theme_settings_admin(
|
||||
current_user: User = Depends(require_permission("settings.view")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get theme settings with metadata (admin view).
|
||||
|
||||
Returns the full theme configuration along with:
|
||||
- Whether using default values
|
||||
- Last update timestamp
|
||||
- Who made the last update
|
||||
"""
|
||||
import json
|
||||
|
||||
config = dict(DEFAULT_THEME_CONFIG)
|
||||
is_default = True
|
||||
updated_at = None
|
||||
updated_by = None
|
||||
|
||||
# Fetch all theme.* settings
|
||||
theme_settings = db.query(SystemSettings).filter(
|
||||
SystemSettings.setting_key.like('theme.%')
|
||||
).all()
|
||||
|
||||
if theme_settings:
|
||||
is_default = False
|
||||
|
||||
# Find the most recent update
|
||||
latest_setting = max(theme_settings, key=lambda s: s.updated_at or s.created_at)
|
||||
updated_at = latest_setting.updated_at or latest_setting.created_at
|
||||
if latest_setting.updater:
|
||||
updated_by = f"{latest_setting.updater.first_name} {latest_setting.updater.last_name}"
|
||||
|
||||
for setting in theme_settings:
|
||||
key = setting.setting_key.replace('theme.', '')
|
||||
value = setting.setting_value
|
||||
|
||||
if key == 'colors' and value:
|
||||
try:
|
||||
config['colors'] = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
elif key in config:
|
||||
config[key] = value
|
||||
|
||||
return {
|
||||
"config": config,
|
||||
"is_default": is_default,
|
||||
"updated_at": updated_at.isoformat() if updated_at else None,
|
||||
"updated_by": updated_by
|
||||
}
|
||||
|
||||
|
||||
class ThemeSettingsUpdate(BaseModel):
|
||||
"""Request model for updating theme settings"""
|
||||
site_name: Optional[str] = Field(None, max_length=200, description="Full site name")
|
||||
site_short_name: Optional[str] = Field(None, max_length=50, description="Short name for PWA")
|
||||
site_description: Optional[str] = Field(None, max_length=500, description="Site description for SEO meta tag")
|
||||
colors: Optional[dict] = Field(None, description="Color scheme as HSL values")
|
||||
meta_theme_color: Optional[str] = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$', description="PWA theme color (hex)")
|
||||
|
||||
|
||||
@api_router.put("/admin/settings/theme")
|
||||
async def update_theme_settings(
|
||||
request: ThemeSettingsUpdate,
|
||||
current_user: User = Depends(require_permission("settings.edit")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Update theme settings (admin only).
|
||||
|
||||
Updates one or more theme settings. Only provided fields are updated.
|
||||
Changes are applied immediately and the cache is invalidated.
|
||||
"""
|
||||
import json
|
||||
|
||||
updates = {}
|
||||
|
||||
if request.site_name is not None:
|
||||
set_setting(
|
||||
db=db,
|
||||
key='theme.site_name',
|
||||
value=request.site_name,
|
||||
user_id=str(current_user.id),
|
||||
setting_type='plaintext',
|
||||
description='Site name displayed in title and navigation',
|
||||
is_sensitive=False
|
||||
)
|
||||
updates['site_name'] = request.site_name
|
||||
|
||||
if request.site_short_name is not None:
|
||||
set_setting(
|
||||
db=db,
|
||||
key='theme.site_short_name',
|
||||
value=request.site_short_name,
|
||||
user_id=str(current_user.id),
|
||||
setting_type='plaintext',
|
||||
description='Short site name for PWA manifest',
|
||||
is_sensitive=False
|
||||
)
|
||||
updates['site_short_name'] = request.site_short_name
|
||||
|
||||
if request.site_description is not None:
|
||||
set_setting(
|
||||
db=db,
|
||||
key='theme.site_description',
|
||||
value=request.site_description,
|
||||
user_id=str(current_user.id),
|
||||
setting_type='plaintext',
|
||||
description='Site description for SEO meta tag',
|
||||
is_sensitive=False
|
||||
)
|
||||
updates['site_description'] = request.site_description
|
||||
|
||||
if request.colors is not None:
|
||||
set_setting(
|
||||
db=db,
|
||||
key='theme.colors',
|
||||
value=json.dumps(request.colors),
|
||||
user_id=str(current_user.id),
|
||||
setting_type='json',
|
||||
description='Theme color scheme as HSL values',
|
||||
is_sensitive=False
|
||||
)
|
||||
updates['colors'] = request.colors
|
||||
|
||||
if request.meta_theme_color is not None:
|
||||
set_setting(
|
||||
db=db,
|
||||
key='theme.meta_theme_color',
|
||||
value=request.meta_theme_color,
|
||||
user_id=str(current_user.id),
|
||||
setting_type='plaintext',
|
||||
description='PWA theme-color meta tag value',
|
||||
is_sensitive=False
|
||||
)
|
||||
updates['meta_theme_color'] = request.meta_theme_color
|
||||
|
||||
# Invalidate cache
|
||||
invalidate_theme_cache()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Theme settings updated successfully",
|
||||
"updated_fields": list(updates.keys()),
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"updated_by": f"{current_user.first_name} {current_user.last_name}"
|
||||
}
|
||||
|
||||
|
||||
@api_router.post("/admin/settings/theme/logo")
|
||||
async def upload_theme_logo(
|
||||
file: UploadFile = File(...),
|
||||
current_user: User = Depends(require_permission("settings.edit")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Upload organization logo (admin only).
|
||||
|
||||
Accepts PNG, JPEG, WebP, or SVG images.
|
||||
Replaces any existing logo.
|
||||
"""
|
||||
r2 = get_r2_storage()
|
||||
|
||||
# Get current logo key for deletion
|
||||
old_logo_key = get_setting(db, 'theme.logo_key')
|
||||
|
||||
# Delete old logo if exists
|
||||
if old_logo_key:
|
||||
try:
|
||||
await r2.delete_file(old_logo_key)
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to delete old logo: {e}")
|
||||
|
||||
# Upload new logo
|
||||
try:
|
||||
public_url, object_key, file_size = await r2.upload_file(
|
||||
file=file,
|
||||
folder="branding",
|
||||
allowed_types=r2.ALLOWED_BRANDING_TYPES,
|
||||
max_size_bytes=5 * 1024 * 1024 # 5MB limit for logos
|
||||
)
|
||||
|
||||
# Store URL and key in settings
|
||||
set_setting(
|
||||
db=db,
|
||||
key='theme.logo_url',
|
||||
value=public_url,
|
||||
user_id=str(current_user.id),
|
||||
setting_type='plaintext',
|
||||
description='Organization logo URL',
|
||||
is_sensitive=False
|
||||
)
|
||||
|
||||
set_setting(
|
||||
db=db,
|
||||
key='theme.logo_key',
|
||||
value=object_key,
|
||||
user_id=str(current_user.id),
|
||||
setting_type='plaintext',
|
||||
description='R2 object key for logo (for deletion)',
|
||||
is_sensitive=False
|
||||
)
|
||||
|
||||
# Invalidate cache
|
||||
invalidate_theme_cache()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Logo uploaded successfully",
|
||||
"logo_url": public_url,
|
||||
"file_size": file_size
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to upload logo: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@api_router.post("/admin/settings/theme/favicon")
|
||||
async def upload_theme_favicon(
|
||||
file: UploadFile = File(...),
|
||||
current_user: User = Depends(require_permission("settings.edit")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Upload site favicon (admin only).
|
||||
|
||||
Accepts ICO, PNG, or SVG images.
|
||||
Replaces any existing favicon.
|
||||
"""
|
||||
r2 = get_r2_storage()
|
||||
|
||||
# Get current favicon key for deletion
|
||||
old_favicon_key = get_setting(db, 'theme.favicon_key')
|
||||
|
||||
# Delete old favicon if exists
|
||||
if old_favicon_key:
|
||||
try:
|
||||
await r2.delete_file(old_favicon_key)
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to delete old favicon: {e}")
|
||||
|
||||
# Upload new favicon
|
||||
try:
|
||||
public_url, object_key, file_size = await r2.upload_file(
|
||||
file=file,
|
||||
folder="branding",
|
||||
allowed_types=r2.ALLOWED_FAVICON_TYPES,
|
||||
max_size_bytes=1 * 1024 * 1024 # 1MB limit for favicons
|
||||
)
|
||||
|
||||
# Store URL and key in settings
|
||||
set_setting(
|
||||
db=db,
|
||||
key='theme.favicon_url',
|
||||
value=public_url,
|
||||
user_id=str(current_user.id),
|
||||
setting_type='plaintext',
|
||||
description='Site favicon URL',
|
||||
is_sensitive=False
|
||||
)
|
||||
|
||||
set_setting(
|
||||
db=db,
|
||||
key='theme.favicon_key',
|
||||
value=object_key,
|
||||
user_id=str(current_user.id),
|
||||
setting_type='plaintext',
|
||||
description='R2 object key for favicon (for deletion)',
|
||||
is_sensitive=False
|
||||
)
|
||||
|
||||
# Invalidate cache
|
||||
invalidate_theme_cache()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Favicon uploaded successfully",
|
||||
"favicon_url": public_url,
|
||||
"file_size": file_size
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to upload favicon: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@api_router.delete("/admin/settings/theme/logo")
|
||||
async def delete_theme_logo(
|
||||
current_user: User = Depends(require_permission("settings.edit")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Delete organization logo (admin only).
|
||||
|
||||
Removes the logo from R2 storage and clears the settings,
|
||||
reverting to the default logo.
|
||||
"""
|
||||
r2 = get_r2_storage()
|
||||
|
||||
# Get current logo key for deletion
|
||||
logo_key = get_setting(db, 'theme.logo_key')
|
||||
|
||||
if logo_key:
|
||||
try:
|
||||
await r2.delete_file(logo_key)
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to delete logo from R2: {e}")
|
||||
|
||||
# Delete the settings
|
||||
db.query(SystemSettings).filter(
|
||||
SystemSettings.setting_key.in_(['theme.logo_url', 'theme.logo_key'])
|
||||
).delete(synchronize_session=False)
|
||||
db.commit()
|
||||
|
||||
# Invalidate cache
|
||||
invalidate_theme_cache()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Logo deleted successfully"
|
||||
}
|
||||
|
||||
|
||||
@api_router.delete("/admin/settings/theme/favicon")
|
||||
async def delete_theme_favicon(
|
||||
current_user: User = Depends(require_permission("settings.edit")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Delete site favicon (admin only).
|
||||
|
||||
Removes the favicon from R2 storage and clears the settings,
|
||||
reverting to the default favicon.
|
||||
"""
|
||||
r2 = get_r2_storage()
|
||||
|
||||
# Get current favicon key for deletion
|
||||
favicon_key = get_setting(db, 'theme.favicon_key')
|
||||
|
||||
if favicon_key:
|
||||
try:
|
||||
await r2.delete_file(favicon_key)
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to delete favicon from R2: {e}")
|
||||
|
||||
# Delete the settings
|
||||
db.query(SystemSettings).filter(
|
||||
SystemSettings.setting_key.in_(['theme.favicon_url', 'theme.favicon_key'])
|
||||
).delete(synchronize_session=False)
|
||||
db.commit()
|
||||
|
||||
# Invalidate cache
|
||||
invalidate_theme_cache()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Favicon deleted successfully"
|
||||
}
|
||||
|
||||
|
||||
@api_router.post("/admin/settings/theme/reset")
|
||||
async def reset_theme_settings(
|
||||
current_user: User = Depends(get_current_superadmin),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Reset all theme settings to defaults (superadmin only).
|
||||
|
||||
Deletes all theme.* settings from the database and removes
|
||||
any uploaded logo/favicon from R2 storage.
|
||||
"""
|
||||
r2 = get_r2_storage()
|
||||
|
||||
# Get keys for uploaded files
|
||||
logo_key = get_setting(db, 'theme.logo_key')
|
||||
favicon_key = get_setting(db, 'theme.favicon_key')
|
||||
|
||||
# Delete files from R2
|
||||
if logo_key:
|
||||
try:
|
||||
await r2.delete_file(logo_key)
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to delete logo from R2: {e}")
|
||||
|
||||
if favicon_key:
|
||||
try:
|
||||
await r2.delete_file(favicon_key)
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to delete favicon from R2: {e}")
|
||||
|
||||
# Delete all theme settings
|
||||
deleted_count = db.query(SystemSettings).filter(
|
||||
SystemSettings.setting_key.like('theme.%')
|
||||
).delete(synchronize_session=False)
|
||||
db.commit()
|
||||
|
||||
# Invalidate cache
|
||||
invalidate_theme_cache()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Theme settings reset to defaults",
|
||||
"deleted_settings_count": deleted_count,
|
||||
"config": DEFAULT_THEME_CONFIG
|
||||
}
|
||||
|
||||
|
||||
# Include the router in the main app
|
||||
app.include_router(api_router)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user