Merge to LOAF-PROD for Demo #27
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -35,6 +35,21 @@ class R2Storage:
|
|||||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx']
|
'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):
|
def __init__(self):
|
||||||
"""Initialize R2 client with credentials from environment"""
|
"""Initialize R2 client with credentials from environment"""
|
||||||
self.account_id = os.getenv('R2_ACCOUNT_ID')
|
self.account_id = os.getenv('R2_ACCOUNT_ID')
|
||||||
|
|||||||
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
|
# Include the router in the main app
|
||||||
app.include_router(api_router)
|
app.include_router(api_router)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user