- 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:
Koncept Kit
2026-01-27 21:31:17 +07:00
parent ab0f098f99
commit 03e5dd8bda
5 changed files with 520 additions and 0 deletions

Binary file not shown.

Binary file not shown.

View File

@@ -35,6 +35,21 @@ class R2Storage:
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx']
}
# Branding assets (logo and favicon)
ALLOWED_BRANDING_TYPES = {
'image/jpeg': ['.jpg', '.jpeg'],
'image/png': ['.png'],
'image/webp': ['.webp'],
'image/svg+xml': ['.svg']
}
ALLOWED_FAVICON_TYPES = {
'image/x-icon': ['.ico'],
'image/vnd.microsoft.icon': ['.ico'],
'image/png': ['.png'],
'image/svg+xml': ['.svg']
}
def __init__(self):
"""Initialize R2 client with credentials from environment"""
self.account_id = os.getenv('R2_ACCOUNT_ID')

505
server.py
View File

@@ -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)