diff --git a/__pycache__/auth.cpython-312.pyc b/__pycache__/auth.cpython-312.pyc index 6cbc913..87c5b39 100644 Binary files a/__pycache__/auth.cpython-312.pyc and b/__pycache__/auth.cpython-312.pyc differ diff --git a/__pycache__/r2_storage.cpython-312.pyc b/__pycache__/r2_storage.cpython-312.pyc index 9ffbb17..863f476 100644 Binary files a/__pycache__/r2_storage.cpython-312.pyc and b/__pycache__/r2_storage.cpython-312.pyc differ diff --git a/__pycache__/server.cpython-312.pyc b/__pycache__/server.cpython-312.pyc index d4011c1..9eb9b40 100644 Binary files a/__pycache__/server.cpython-312.pyc and b/__pycache__/server.cpython-312.pyc differ diff --git a/r2_storage.py b/r2_storage.py index 5d8928b..699d859 100644 --- a/r2_storage.py +++ b/r2_storage.py @@ -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') diff --git a/server.py b/server.py index ec068e7..3706a38 100644 --- a/server.py +++ b/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)