From d017028719d8e8ea0b1397ba797cbb2106e5f92f Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Sat, 24 Jan 2026 18:02:49 -0500 Subject: [PATCH 01/11] updated the logging logic when running retention delete with archiving enabled (#642) --- application/single_app/config.py | 2 +- .../single_app/functions_retention_policy.py | 66 +++++-- .../RETENTION_POLICY_NOTFOUND_FIX.md | 95 +++++++++ ...test_retention_policy_notfound_handling.py | 180 ++++++++++++++++++ 4 files changed, 330 insertions(+), 13 deletions(-) create mode 100644 docs/explanation/fixes/v0.236.012/RETENTION_POLICY_NOTFOUND_FIX.md create mode 100644 functional_tests/test_retention_policy_notfound_handling.py diff --git a/application/single_app/config.py b/application/single_app/config.py index 0596e3ca..caf09fc8 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -88,7 +88,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.236.011" +VERSION = "0.236.012" SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') diff --git a/application/single_app/functions_retention_policy.py b/application/single_app/functions_retention_policy.py index 6ce6dee0..56167fa1 100644 --- a/application/single_app/functions_retention_policy.py +++ b/application/single_app/functions_retention_policy.py @@ -6,8 +6,9 @@ This module handles automated deletion of aged conversations and documents based on configurable retention policies for personal, group, and public workspaces. -Version: 0.234.067 +Version: 0.236.012 Implemented in: 0.234.067 +Updated in: 0.236.012 - Fixed race condition handling for NotFound errors during deletion """ from config import * @@ -565,10 +566,21 @@ def delete_aged_conversations(retention_days, workspace_type='personal', user_id conversation_title = conv.get('title', 'Untitled') # Read full conversation for archiving/logging - conversation_item = container.read_item( - item=conversation_id, - partition_key=conversation_id - ) + try: + conversation_item = container.read_item( + item=conversation_id, + partition_key=conversation_id + ) + except CosmosResourceNotFoundError: + # Conversation was already deleted (race condition) - this is fine, skip to next + debug_print(f"Conversation {conversation_id} already deleted (not found during read), skipping") + deleted_details.append({ + 'id': conversation_id, + 'title': conversation_title, + 'last_activity_at': conv.get('last_activity_at'), + 'already_deleted': True + }) + continue # Archive if enabled if archiving_enabled: @@ -613,7 +625,11 @@ def delete_aged_conversations(retention_days, workspace_type='personal', user_id archived_msg["archived_by_retention_policy"] = True cosmos_archived_messages_container.upsert_item(archived_msg) - messages_container.delete_item(msg['id'], partition_key=conversation_id) + try: + messages_container.delete_item(msg['id'], partition_key=conversation_id) + except CosmosResourceNotFoundError: + # Message was already deleted - this is fine, continue + debug_print(f"Message {msg['id']} already deleted (not found), skipping") # Log deletion log_conversation_deletion( @@ -631,10 +647,14 @@ def delete_aged_conversations(retention_days, workspace_type='personal', user_id ) # Delete conversation - container.delete_item( - item=conversation_id, - partition_key=conversation_id - ) + try: + container.delete_item( + item=conversation_id, + partition_key=conversation_id + ) + except CosmosResourceNotFoundError: + # Conversation was already deleted after we read it (race condition) - this is fine + debug_print(f"Conversation {conversation_id} already deleted (not found during delete)") deleted_details.append({ 'id': conversation_id, @@ -730,10 +750,21 @@ def delete_aged_documents(retention_days, workspace_type='personal', user_id=Non doc_user_id = doc.get('user_id') or deletion_user_id # Delete document chunks from search index - delete_document_chunks(document_id, group_id, public_workspace_id) + try: + delete_document_chunks(document_id, group_id, public_workspace_id) + except CosmosResourceNotFoundError: + # Document chunks already deleted - this is fine + debug_print(f"Document chunks for {document_id} already deleted (not found)") + except Exception as chunk_error: + # Log chunk deletion errors but continue with document deletion + debug_print(f"Error deleting chunks for document {document_id}: {chunk_error}") # Delete document from Cosmos DB and blob storage - delete_document(doc_user_id, document_id, group_id, public_workspace_id) + try: + delete_document(doc_user_id, document_id, group_id, public_workspace_id) + except CosmosResourceNotFoundError: + # Document was already deleted (race condition) - this is fine + debug_print(f"Document {document_id} already deleted (not found)") deleted_details.append({ 'id': document_id, @@ -744,6 +775,17 @@ def delete_aged_documents(retention_days, workspace_type='personal', user_id=Non debug_print(f"Deleted document {document_id} ({file_name}) due to retention policy") + except CosmosResourceNotFoundError: + # Document was already deleted - count as success + doc_id = doc.get('id', 'unknown') if doc else 'unknown' + debug_print(f"Document {doc_id} already deleted (not found)") + deleted_details.append({ + 'id': doc_id, + 'file_name': doc.get('file_name', 'Unknown'), + 'title': doc.get('title', doc.get('file_name', 'Unknown')), + 'last_updated': doc.get('last_updated'), + 'already_deleted': True + }) except Exception as e: doc_id = doc.get('id', 'unknown') if doc else 'unknown' log_event("delete_aged_documents_deletion_error", {"error": str(e), "document_id": doc_id, "workspace_type": workspace_type}) diff --git a/docs/explanation/fixes/v0.236.012/RETENTION_POLICY_NOTFOUND_FIX.md b/docs/explanation/fixes/v0.236.012/RETENTION_POLICY_NOTFOUND_FIX.md new file mode 100644 index 00000000..82a0ec15 --- /dev/null +++ b/docs/explanation/fixes/v0.236.012/RETENTION_POLICY_NOTFOUND_FIX.md @@ -0,0 +1,95 @@ +# Retention Policy NotFound Error Fix + +## Issue Description + +The retention policy deletion process was logging errors when attempting to delete conversations or documents that had already been deleted (e.g., by another process or user action between the query and delete operations). + +### Error Observed +``` +DEBUG: [Log] delete_aged_conversations_deletion_error -- {'error': '(NotFound) Entity with the specified id does not exist in the system. +``` + +### Root Cause + +This is a **race condition** scenario where: +1. The retention policy queries for aged conversations/documents +2. Between the query and the delete operation, the item is deleted by another process (user action, concurrent retention execution, etc.) +3. The delete operation fails with `CosmosResourceNotFoundError` (404 NotFound) + +## Fix Applied + +**Version: 0.236.012** + +The fix adds specific handling for `CosmosResourceNotFoundError` in both conversation and document deletion loops: + +### Conversations +- When reading a conversation before archiving: If not found, log debug message and count as already deleted +- When deleting messages: Catch NotFound and continue (message already gone) +- When deleting conversation: Catch NotFound and continue (conversation already gone) + +### Documents +- When deleting document chunks: Catch NotFound and continue +- When deleting document: Catch NotFound and continue +- Outer try/catch also handles NotFound to count as successful deletion + +## Files Modified + +- [functions_retention_policy.py](../../../application/single_app/functions_retention_policy.py) + - `delete_aged_conversations()` - Added CosmosResourceNotFoundError handling + - `delete_aged_documents()` - Added CosmosResourceNotFoundError handling + +## Technical Details + +### Before Fix +```python +# Read would throw exception if item was deleted between query and read +conversation_item = container.read_item( + item=conversation_id, + partition_key=conversation_id +) +# Delete would throw exception if item was deleted +container.delete_item( + item=conversation_id, + partition_key=conversation_id +) +``` + +### After Fix +```python +try: + conversation_item = container.read_item( + item=conversation_id, + partition_key=conversation_id + ) +except CosmosResourceNotFoundError: + # Already deleted - this is fine, count as success + debug_print(f"Conversation {conversation_id} already deleted (not found during read), skipping") + deleted_details.append({...}) + continue + +# ... archiving and message deletion ... + +try: + container.delete_item( + item=conversation_id, + partition_key=conversation_id + ) +except CosmosResourceNotFoundError: + # Already deleted between read and delete - this is fine + debug_print(f"Conversation {conversation_id} already deleted (not found during delete)") +``` + +## Benefits + +1. **No false error logs**: Items that are already deleted no longer generate error entries +2. **Accurate counts**: Already-deleted items are properly counted as successful deletions +3. **Graceful handling**: Race conditions are handled without disrupting the overall retention process +4. **Better debugging**: Debug messages clearly indicate when items were already deleted + +## Testing + +Test by: +1. Enabling retention policy with a short retention period +2. Running the retention policy execution +3. Verify no NotFound errors are logged +4. Verify deletion counts accurately reflect processed items diff --git a/functional_tests/test_retention_policy_notfound_handling.py b/functional_tests/test_retention_policy_notfound_handling.py new file mode 100644 index 00000000..ee417335 --- /dev/null +++ b/functional_tests/test_retention_policy_notfound_handling.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +""" +Functional test for Retention Policy NotFound Error Handling. +Version: 0.236.012 +Implemented in: 0.236.012 + +This test ensures that the retention policy correctly handles CosmosResourceNotFoundError +when attempting to delete conversations or documents that have already been deleted. +This prevents false error logging for race condition scenarios. +""" + +import sys +import os + +# Add parent directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +os.chdir(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'application', 'single_app')) + +def test_notfound_exception_import(): + """Test that CosmosResourceNotFoundError is properly imported.""" + print("πŸ” Testing CosmosResourceNotFoundError import...") + + try: + from config import CosmosResourceNotFoundError + print("βœ… CosmosResourceNotFoundError imported successfully from config") + return True + except ImportError as e: + print(f"❌ Failed to import CosmosResourceNotFoundError: {e}") + return False + + +def test_retention_policy_function_definitions(): + """Test that retention policy functions have proper exception handling.""" + print("\nπŸ” Testing retention policy function definitions...") + + try: + import inspect + from functions_retention_policy import delete_aged_conversations, delete_aged_documents + + # Get source code of delete_aged_conversations + conversations_source = inspect.getsource(delete_aged_conversations) + + # Check for CosmosResourceNotFoundError handling in conversations function + if 'CosmosResourceNotFoundError' in conversations_source: + print("βœ… delete_aged_conversations handles CosmosResourceNotFoundError") + else: + print("❌ delete_aged_conversations does not handle CosmosResourceNotFoundError") + return False + + # Check for 'already deleted' debug message pattern + if 'already deleted' in conversations_source: + print("βœ… delete_aged_conversations has 'already deleted' debug messaging") + else: + print("❌ delete_aged_conversations missing 'already deleted' debug messaging") + return False + + # Get source code of delete_aged_documents + documents_source = inspect.getsource(delete_aged_documents) + + # Check for CosmosResourceNotFoundError handling in documents function + if 'CosmosResourceNotFoundError' in documents_source: + print("βœ… delete_aged_documents handles CosmosResourceNotFoundError") + else: + print("❌ delete_aged_documents does not handle CosmosResourceNotFoundError") + return False + + # Check for 'already deleted' debug message pattern + if 'already deleted' in documents_source: + print("βœ… delete_aged_documents has 'already deleted' debug messaging") + else: + print("❌ delete_aged_documents missing 'already deleted' debug messaging") + return False + + return True + + except Exception as e: + print(f"❌ Failed to verify function definitions: {e}") + import traceback + traceback.print_exc() + return False + + +def test_already_deleted_flag_in_details(): + """Test that already_deleted flag is used in the response details.""" + print("\nπŸ” Testing 'already_deleted' flag in response details...") + + try: + import inspect + from functions_retention_policy import delete_aged_conversations, delete_aged_documents + + # Get source code + conversations_source = inspect.getsource(delete_aged_conversations) + documents_source = inspect.getsource(delete_aged_documents) + + # Check for 'already_deleted': True pattern in conversations + if "'already_deleted': True" in conversations_source or '"already_deleted": True' in conversations_source: + print("βœ… delete_aged_conversations includes 'already_deleted' flag in details") + else: + print("❌ delete_aged_conversations missing 'already_deleted' flag in details") + return False + + # Check for 'already_deleted': True pattern in documents + if "'already_deleted': True" in documents_source or '"already_deleted": True' in documents_source: + print("βœ… delete_aged_documents includes 'already_deleted' flag in details") + else: + print("❌ delete_aged_documents missing 'already_deleted' flag in details") + return False + + return True + + except Exception as e: + print(f"❌ Failed to verify already_deleted flag: {e}") + import traceback + traceback.print_exc() + return False + + +def test_version_number(): + """Test that the version is updated correctly.""" + print("\nπŸ” Testing version number...") + + try: + from config import VERSION + + # Version should be at least 0.236.012 + version_parts = VERSION.split('.') + major = int(version_parts[0]) + minor = int(version_parts[1]) + patch = int(version_parts[2]) + + if major == 0 and minor >= 236 and patch >= 12: + print(f"βœ… Version {VERSION} is correct (>= 0.236.012)") + return True + elif major > 0 or minor > 236: + print(f"βœ… Version {VERSION} is correct (later version)") + return True + else: + print(f"❌ Version {VERSION} is lower than expected 0.236.012") + return False + + except Exception as e: + print(f"❌ Failed to verify version: {e}") + import traceback + traceback.print_exc() + return False + + +if __name__ == "__main__": + print("=" * 60) + print("Retention Policy NotFound Error Handling Test") + print("=" * 60) + + tests = [ + test_notfound_exception_import, + test_retention_policy_function_definitions, + test_already_deleted_flag_in_details, + test_version_number + ] + + results = [] + for test in tests: + try: + result = test() + results.append(result) + except Exception as e: + print(f"❌ Test {test.__name__} failed with exception: {e}") + import traceback + traceback.print_exc() + results.append(False) + + print("\n" + "=" * 60) + print(f"πŸ“Š Results: {sum(results)}/{len(results)} tests passed") + print("=" * 60) + + if all(results): + print("\nβœ… All tests passed! NotFound error handling is correctly implemented.") + sys.exit(0) + else: + print("\n❌ Some tests failed. Please review the implementation.") + sys.exit(1) From 2e8e87a43b40c0d397a6cb8252408c65b0a2824d Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Mon, 26 Jan 2026 09:30:34 -0500 Subject: [PATCH 02/11] Corrected version to 0.236.011 (#645) --- application/single_app/config.py | 2 +- .../{v0.236.012 => v0.236.011}/RETENTION_POLICY_NOTFOUND_FIX.md | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename docs/explanation/fixes/{v0.236.012 => v0.236.011}/RETENTION_POLICY_NOTFOUND_FIX.md (100%) diff --git a/application/single_app/config.py b/application/single_app/config.py index caf09fc8..0596e3ca 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -88,7 +88,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.236.012" +VERSION = "0.236.011" SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') diff --git a/docs/explanation/fixes/v0.236.012/RETENTION_POLICY_NOTFOUND_FIX.md b/docs/explanation/fixes/v0.236.011/RETENTION_POLICY_NOTFOUND_FIX.md similarity index 100% rename from docs/explanation/fixes/v0.236.012/RETENTION_POLICY_NOTFOUND_FIX.md rename to docs/explanation/fixes/v0.236.011/RETENTION_POLICY_NOTFOUND_FIX.md From 604246126ddc931e300798ff2b7e5b7307f8c5a5 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Mon, 26 Jan 2026 09:44:27 -0500 Subject: [PATCH 03/11] v0.237.001 (#649) --- application/single_app/config.py | 2 +- .../CONTROL_CENTER_APPLICATION_ROLES.md | 2 +- .../{v0.236.011 => v0.237.001}/CONVERSATION_DEEP_LINKING.md | 2 +- .../{v0.236.011 => v0.237.001}/PLUGIN_AUTH_TYPE_CONSTRAINTS.md | 2 +- .../{v0.236.011 => v0.237.001}/PRIVATE_NETWORKING_SUPPORT.md | 2 +- .../{v0.236.011 => v0.237.001}/RETENTION_POLICY_DEFAULTS.md | 3 +-- .../features/{v0.236.011 => v0.237.001}/USER_AGREEMENT.md | 2 +- .../{v0.236.011 => v0.237.001}/WEB_SEARCH_AZURE_AI_FOUNDRY.md | 2 +- .../AGENT_PAYLOAD_FIELD_LENGTHS_FIX.md | 2 +- .../AGENT_TEMPLATE_MAX_LENGTHS_FIX.md | 2 +- .../CONTROL_CENTER_DATE_LABELS_FIX.md | 2 +- .../RETENTION_POLICY_NOTFOUND_FIX.md | 2 +- .../SOVEREIGN_CLOUD_COGNITIVE_SERVICES_SCOPE_FIX.md | 2 +- .../USER_SEARCH_TOAST_INLINE_MESSAGES_FIX.md | 2 +- .../WEB_SEARCH_FAILURE_GRACEFUL_HANDLING_FIX.md | 2 +- docs/explanation/release_notes.md | 2 +- 16 files changed, 16 insertions(+), 17 deletions(-) rename docs/explanation/features/{v0.236.011 => v0.237.001}/CONTROL_CENTER_APPLICATION_ROLES.md (99%) rename docs/explanation/features/{v0.236.011 => v0.237.001}/CONVERSATION_DEEP_LINKING.md (99%) rename docs/explanation/features/{v0.236.011 => v0.237.001}/PLUGIN_AUTH_TYPE_CONSTRAINTS.md (99%) rename docs/explanation/features/{v0.236.011 => v0.237.001}/PRIVATE_NETWORKING_SUPPORT.md (99%) rename docs/explanation/features/{v0.236.011 => v0.237.001}/RETENTION_POLICY_DEFAULTS.md (99%) rename docs/explanation/features/{v0.236.011 => v0.237.001}/USER_AGREEMENT.md (99%) rename docs/explanation/features/{v0.236.011 => v0.237.001}/WEB_SEARCH_AZURE_AI_FOUNDRY.md (99%) rename docs/explanation/fixes/{v0.236.011 => v0.237.001}/AGENT_PAYLOAD_FIELD_LENGTHS_FIX.md (95%) rename docs/explanation/fixes/{v0.236.011 => v0.237.001}/AGENT_TEMPLATE_MAX_LENGTHS_FIX.md (95%) rename docs/explanation/fixes/{v0.236.011 => v0.237.001}/CONTROL_CENTER_DATE_LABELS_FIX.md (96%) rename docs/explanation/fixes/{v0.236.011 => v0.237.001}/RETENTION_POLICY_NOTFOUND_FIX.md (99%) rename docs/explanation/fixes/{v0.236.011 => v0.237.001}/SOVEREIGN_CLOUD_COGNITIVE_SERVICES_SCOPE_FIX.md (98%) rename docs/explanation/fixes/{v0.236.011 => v0.237.001}/USER_SEARCH_TOAST_INLINE_MESSAGES_FIX.md (98%) rename docs/explanation/fixes/{v0.236.011 => v0.237.001}/WEB_SEARCH_FAILURE_GRACEFUL_HANDLING_FIX.md (99%) diff --git a/application/single_app/config.py b/application/single_app/config.py index 0596e3ca..12906ce8 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -88,7 +88,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.236.011" +VERSION = "0.237.001" SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') diff --git a/docs/explanation/features/v0.236.011/CONTROL_CENTER_APPLICATION_ROLES.md b/docs/explanation/features/v0.237.001/CONTROL_CENTER_APPLICATION_ROLES.md similarity index 99% rename from docs/explanation/features/v0.236.011/CONTROL_CENTER_APPLICATION_ROLES.md rename to docs/explanation/features/v0.237.001/CONTROL_CENTER_APPLICATION_ROLES.md index 29ffc1fc..3d61f752 100644 --- a/docs/explanation/features/v0.236.011/CONTROL_CENTER_APPLICATION_ROLES.md +++ b/docs/explanation/features/v0.237.001/CONTROL_CENTER_APPLICATION_ROLES.md @@ -4,7 +4,7 @@ Added two new application roles for finer-grained access control to the Control Center, enabling organizations to delegate administrative functions while maintaining security boundaries. -**Version Implemented:** 0.236.011 +**Version Implemented:** v0.237.001 ## New Roles diff --git a/docs/explanation/features/v0.236.011/CONVERSATION_DEEP_LINKING.md b/docs/explanation/features/v0.237.001/CONVERSATION_DEEP_LINKING.md similarity index 99% rename from docs/explanation/features/v0.236.011/CONVERSATION_DEEP_LINKING.md rename to docs/explanation/features/v0.237.001/CONVERSATION_DEEP_LINKING.md index d3c6e53e..cebf392b 100644 --- a/docs/explanation/features/v0.236.011/CONVERSATION_DEEP_LINKING.md +++ b/docs/explanation/features/v0.237.001/CONVERSATION_DEEP_LINKING.md @@ -4,7 +4,7 @@ SimpleChat now supports conversation deep linking through URL query parameters. Users can share direct links to specific conversations, and the application will automatically navigate to and load the referenced conversation when the link is accessed. -**Version Implemented:** 0.236.011 +**Version Implemented:** v0.237.001 ## Key Features diff --git a/docs/explanation/features/v0.236.011/PLUGIN_AUTH_TYPE_CONSTRAINTS.md b/docs/explanation/features/v0.237.001/PLUGIN_AUTH_TYPE_CONSTRAINTS.md similarity index 99% rename from docs/explanation/features/v0.236.011/PLUGIN_AUTH_TYPE_CONSTRAINTS.md rename to docs/explanation/features/v0.237.001/PLUGIN_AUTH_TYPE_CONSTRAINTS.md index 093923c6..9d2ea6e6 100644 --- a/docs/explanation/features/v0.236.011/PLUGIN_AUTH_TYPE_CONSTRAINTS.md +++ b/docs/explanation/features/v0.237.001/PLUGIN_AUTH_TYPE_CONSTRAINTS.md @@ -4,7 +4,7 @@ SimpleChat now enforces authentication type constraints per plugin type. Different plugin types may support different authentication methods based on their requirements and the APIs they integrate with. This feature provides a structured way to define and retrieve allowed authentication types for each plugin type. -**Version Implemented:** 0.236.011 +**Version Implemented:** v0.237.001 ## Key Features diff --git a/docs/explanation/features/v0.236.011/PRIVATE_NETWORKING_SUPPORT.md b/docs/explanation/features/v0.237.001/PRIVATE_NETWORKING_SUPPORT.md similarity index 99% rename from docs/explanation/features/v0.236.011/PRIVATE_NETWORKING_SUPPORT.md rename to docs/explanation/features/v0.237.001/PRIVATE_NETWORKING_SUPPORT.md index de2ae92f..5379b73d 100644 --- a/docs/explanation/features/v0.236.011/PRIVATE_NETWORKING_SUPPORT.md +++ b/docs/explanation/features/v0.237.001/PRIVATE_NETWORKING_SUPPORT.md @@ -4,7 +4,7 @@ Comprehensive private networking support for SimpleChat deployments via Azure Developer CLI (AZD) and Bicep infrastructure-as-code. This feature enables secure, isolated deployments with private endpoints, virtual networks, and private DNS zones. -**Version Implemented:** 0.236.011 +**Version Implemented:** v0.237.001 ## Key Features diff --git a/docs/explanation/features/v0.236.011/RETENTION_POLICY_DEFAULTS.md b/docs/explanation/features/v0.237.001/RETENTION_POLICY_DEFAULTS.md similarity index 99% rename from docs/explanation/features/v0.236.011/RETENTION_POLICY_DEFAULTS.md rename to docs/explanation/features/v0.237.001/RETENTION_POLICY_DEFAULTS.md index e3fe426d..cdca4ce5 100644 --- a/docs/explanation/features/v0.236.011/RETENTION_POLICY_DEFAULTS.md +++ b/docs/explanation/features/v0.237.001/RETENTION_POLICY_DEFAULTS.md @@ -1,8 +1,7 @@ # RETENTION_POLICY_DEFAULTS.md **Feature**: Admin-Configurable Default Retention Policies -**Version**: 0.236.011 -**Implemented in**: 0.236.011 +**Version**: v0.237.001 ## Overview and Purpose diff --git a/docs/explanation/features/v0.236.011/USER_AGREEMENT.md b/docs/explanation/features/v0.237.001/USER_AGREEMENT.md similarity index 99% rename from docs/explanation/features/v0.236.011/USER_AGREEMENT.md rename to docs/explanation/features/v0.237.001/USER_AGREEMENT.md index e87589d7..d72d6533 100644 --- a/docs/explanation/features/v0.236.011/USER_AGREEMENT.md +++ b/docs/explanation/features/v0.237.001/USER_AGREEMENT.md @@ -4,7 +4,7 @@ The User Agreement feature allows administrators to configure a global agreement that users must accept before uploading files to workspaces. This provides organizations with a mechanism to ensure users acknowledge terms, policies, or guidelines before contributing documents to the system. -**Version Implemented:** 0.236.011 +**Version Implemented:** v0.237.001 ## Key Features diff --git a/docs/explanation/features/v0.236.011/WEB_SEARCH_AZURE_AI_FOUNDRY.md b/docs/explanation/features/v0.237.001/WEB_SEARCH_AZURE_AI_FOUNDRY.md similarity index 99% rename from docs/explanation/features/v0.236.011/WEB_SEARCH_AZURE_AI_FOUNDRY.md rename to docs/explanation/features/v0.237.001/WEB_SEARCH_AZURE_AI_FOUNDRY.md index 7107017f..2e8e3b7c 100644 --- a/docs/explanation/features/v0.236.011/WEB_SEARCH_AZURE_AI_FOUNDRY.md +++ b/docs/explanation/features/v0.237.001/WEB_SEARCH_AZURE_AI_FOUNDRY.md @@ -4,7 +4,7 @@ SimpleChat now supports web search capability through Azure AI Foundry agents using the Grounding with Bing Search service. This feature enables AI responses to be augmented with real-time web search results, providing users with up-to-date information beyond the model's training data. -**Version Implemented:** 0.236.011 +**Version Implemented:** v0.237.001 ## Key Features diff --git a/docs/explanation/fixes/v0.236.011/AGENT_PAYLOAD_FIELD_LENGTHS_FIX.md b/docs/explanation/fixes/v0.237.001/AGENT_PAYLOAD_FIELD_LENGTHS_FIX.md similarity index 95% rename from docs/explanation/fixes/v0.236.011/AGENT_PAYLOAD_FIELD_LENGTHS_FIX.md rename to docs/explanation/fixes/v0.237.001/AGENT_PAYLOAD_FIELD_LENGTHS_FIX.md index e7f38582..e3119d5c 100644 --- a/docs/explanation/fixes/v0.236.011/AGENT_PAYLOAD_FIELD_LENGTHS_FIX.md +++ b/docs/explanation/fixes/v0.237.001/AGENT_PAYLOAD_FIELD_LENGTHS_FIX.md @@ -1,4 +1,4 @@ -# Agent Payload Field Lengths Fix (Version 0.237.009) +# Agent Payload Field Lengths Fix (Version v0.237.001) ## Header Information - **Fix Title:** Agent payload field length validation diff --git a/docs/explanation/fixes/v0.236.011/AGENT_TEMPLATE_MAX_LENGTHS_FIX.md b/docs/explanation/fixes/v0.237.001/AGENT_TEMPLATE_MAX_LENGTHS_FIX.md similarity index 95% rename from docs/explanation/fixes/v0.236.011/AGENT_TEMPLATE_MAX_LENGTHS_FIX.md rename to docs/explanation/fixes/v0.237.001/AGENT_TEMPLATE_MAX_LENGTHS_FIX.md index 71e1f0de..7748a093 100644 --- a/docs/explanation/fixes/v0.236.011/AGENT_TEMPLATE_MAX_LENGTHS_FIX.md +++ b/docs/explanation/fixes/v0.237.001/AGENT_TEMPLATE_MAX_LENGTHS_FIX.md @@ -1,4 +1,4 @@ -# Agent Template Max Lengths Fix (Version 0.237.010) +# Agent Template Max Lengths Fix (v0.237.001) ## Header Information - **Fix Title:** Agent template max length validation diff --git a/docs/explanation/fixes/v0.236.011/CONTROL_CENTER_DATE_LABELS_FIX.md b/docs/explanation/fixes/v0.237.001/CONTROL_CENTER_DATE_LABELS_FIX.md similarity index 96% rename from docs/explanation/fixes/v0.236.011/CONTROL_CENTER_DATE_LABELS_FIX.md rename to docs/explanation/fixes/v0.237.001/CONTROL_CENTER_DATE_LABELS_FIX.md index a7d1bf34..1add8e46 100644 --- a/docs/explanation/fixes/v0.236.011/CONTROL_CENTER_DATE_LABELS_FIX.md +++ b/docs/explanation/fixes/v0.237.001/CONTROL_CENTER_DATE_LABELS_FIX.md @@ -1,4 +1,4 @@ -# Control Center Date Labels Fix (Version 0.235.074) +# Control Center Date Labels Fix (v0.237.001) ## Header Information - **Fix Title:** Control Center Date Labels Fix diff --git a/docs/explanation/fixes/v0.236.011/RETENTION_POLICY_NOTFOUND_FIX.md b/docs/explanation/fixes/v0.237.001/RETENTION_POLICY_NOTFOUND_FIX.md similarity index 99% rename from docs/explanation/fixes/v0.236.011/RETENTION_POLICY_NOTFOUND_FIX.md rename to docs/explanation/fixes/v0.237.001/RETENTION_POLICY_NOTFOUND_FIX.md index 82a0ec15..e264920f 100644 --- a/docs/explanation/fixes/v0.236.011/RETENTION_POLICY_NOTFOUND_FIX.md +++ b/docs/explanation/fixes/v0.237.001/RETENTION_POLICY_NOTFOUND_FIX.md @@ -18,7 +18,7 @@ This is a **race condition** scenario where: ## Fix Applied -**Version: 0.236.012** +**Version:v0.237.001** The fix adds specific handling for `CosmosResourceNotFoundError` in both conversation and document deletion loops: diff --git a/docs/explanation/fixes/v0.236.011/SOVEREIGN_CLOUD_COGNITIVE_SERVICES_SCOPE_FIX.md b/docs/explanation/fixes/v0.237.001/SOVEREIGN_CLOUD_COGNITIVE_SERVICES_SCOPE_FIX.md similarity index 98% rename from docs/explanation/fixes/v0.236.011/SOVEREIGN_CLOUD_COGNITIVE_SERVICES_SCOPE_FIX.md rename to docs/explanation/fixes/v0.237.001/SOVEREIGN_CLOUD_COGNITIVE_SERVICES_SCOPE_FIX.md index f76993ba..4316dfcb 100644 --- a/docs/explanation/fixes/v0.236.011/SOVEREIGN_CLOUD_COGNITIVE_SERVICES_SCOPE_FIX.md +++ b/docs/explanation/fixes/v0.237.001/SOVEREIGN_CLOUD_COGNITIVE_SERVICES_SCOPE_FIX.md @@ -4,7 +4,7 @@ Fixed hardcoded commercial Azure cognitive services scope references in chat streaming and Smart HTTP Plugin that prevented proper authentication in Azure Government (MAG) and custom cloud environments. -**Version Implemented:** 0.236.011 +**Version Implemented:** v0.237.001 **Related Issue:** [#616](https://github.com/microsoft/simplechat/issues/616#issue-3835164022) diff --git a/docs/explanation/fixes/v0.236.011/USER_SEARCH_TOAST_INLINE_MESSAGES_FIX.md b/docs/explanation/fixes/v0.237.001/USER_SEARCH_TOAST_INLINE_MESSAGES_FIX.md similarity index 98% rename from docs/explanation/fixes/v0.236.011/USER_SEARCH_TOAST_INLINE_MESSAGES_FIX.md rename to docs/explanation/fixes/v0.237.001/USER_SEARCH_TOAST_INLINE_MESSAGES_FIX.md index f93a7871..7ba7ed08 100644 --- a/docs/explanation/fixes/v0.236.011/USER_SEARCH_TOAST_INLINE_MESSAGES_FIX.md +++ b/docs/explanation/fixes/v0.237.001/USER_SEARCH_TOAST_INLINE_MESSAGES_FIX.md @@ -4,7 +4,7 @@ Updated the `searchUsers()` function to use inline and toast messages instead of browser alert pop-ups, improving user experience and aligning with modern UI patterns. -**Version Implemented:** 0.236.011 +**Version Implemented:** v0.237.001 **Related PR:** [#608](https://github.com/microsoft/simplechat/pull/608#discussion_r2701900020) diff --git a/docs/explanation/fixes/v0.236.011/WEB_SEARCH_FAILURE_GRACEFUL_HANDLING_FIX.md b/docs/explanation/fixes/v0.237.001/WEB_SEARCH_FAILURE_GRACEFUL_HANDLING_FIX.md similarity index 99% rename from docs/explanation/fixes/v0.236.011/WEB_SEARCH_FAILURE_GRACEFUL_HANDLING_FIX.md rename to docs/explanation/fixes/v0.237.001/WEB_SEARCH_FAILURE_GRACEFUL_HANDLING_FIX.md index 233324c9..ad18d356 100644 --- a/docs/explanation/fixes/v0.236.011/WEB_SEARCH_FAILURE_GRACEFUL_HANDLING_FIX.md +++ b/docs/explanation/fixes/v0.237.001/WEB_SEARCH_FAILURE_GRACEFUL_HANDLING_FIX.md @@ -4,7 +4,7 @@ Fixed an issue where Azure AI Foundry web search agent failures would cause the AI model to answer questions using outdated training data instead of informing the user that the web search failed. -**Version Implemented:** 0.236.014 +**Version Implemented:** v0.237.001 ## Problem diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index 2d1e0e94..df88ebcd 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -1,7 +1,7 @@ # Feature Release -### **(v0.236.011)** +### **(v0.237.001)** #### New Features From 84e00cba645fc8dd800c17fbd3611527a003d0a1 Mon Sep 17 00:00:00 2001 From: Ed Clark Date: Mon, 26 Jan 2026 10:41:16 -0500 Subject: [PATCH 04/11] Use Microsoft python base image --- application/single_app/Dockerfile | 130 +++++++++++------------------- 1 file changed, 48 insertions(+), 82 deletions(-) diff --git a/application/single_app/Dockerfile b/application/single_app/Dockerfile index c6209334..c3434c8e 100644 --- a/application/single_app/Dockerfile +++ b/application/single_app/Dockerfile @@ -1,98 +1,64 @@ -# Stage 1: System dependencies and ODBC driver install -ARG PYTHON_MAJOR_VERSION_ARG="3" -ARG PYTHON_MINOR_VERSION_ARG="13" -ARG PYTHON_PATCH_VERSION_ARG="11" -FROM debian:12-slim AS builder - -ARG PYTHON_MAJOR_VERSION_ARG -ARG PYTHON_MINOR_VERSION_ARG -ARG PYTHON_PATCH_VERSION_ARG - -ENV DEBIAN_FRONTEND=noninteractive \ - PYTHONIOENCODING=utf-8 \ - LANG=C.UTF-8 \ - LC_ALL=C.UTF-8 - -# Build deps for CPython and pip stdlib modules -WORKDIR /deps -RUN apt-get update && apt-get install -y --no-install-recommends \ - build-essential \ - wget ca-certificates \ - libssl-dev zlib1g-dev libbz2-dev libreadline-dev libsqlite3-dev \ - libncursesw5-dev libffi-dev liblzma-dev uuid-dev tk-dev && \ - rm -rf /var/lib/apt/lists/* - -# Build and install Python from source -# Example: https://www.python.org/ftp/python/3.13.11/Python-3.13.11.tgz -WORKDIR /tmp -RUN wget https://www.python.org/ftp/python/${PYTHON_MAJOR_VERSION_ARG}.${PYTHON_MINOR_VERSION_ARG}.${PYTHON_PATCH_VERSION_ARG}/Python-${PYTHON_MAJOR_VERSION_ARG}.${PYTHON_MINOR_VERSION_ARG}.${PYTHON_PATCH_VERSION_ARG}.tgz && \ - tar -xzf Python-${PYTHON_MAJOR_VERSION_ARG}.${PYTHON_MINOR_VERSION_ARG}.${PYTHON_PATCH_VERSION_ARG}.tgz && \ - cd Python-${PYTHON_MAJOR_VERSION_ARG}.${PYTHON_MINOR_VERSION_ARG}.${PYTHON_PATCH_VERSION_ARG} && \ - LDFLAGS="-Wl,-rpath,/usr/local/lib" ./configure --enable-optimizations --enable-shared --with-ensurepip=install --prefix=/usr/local && \ - make -j"$(nproc)" && \ - make altinstall - -USER root -WORKDIR /app -RUN groupadd -g 65532 nonroot && useradd -m -u 65532 -g nonroot nonroot +# Create nonroot user/group with a stable UID/GID (choose values consistent with your org) +ARG UID=10001 +ARG GID=10001 + +FROM mcr.microsoft.com/azurelinux/base/python:3.12 AS builder + +ARG UID +ARG GID + +# CA +# copy certs to /etc/pki/ca-trust/source/anchors +#COPY caroots /etc/ssl/certs +RUN mkdir -p /etc/pki/ca-trust/source/anchors/ \ + && update-ca-trust enable \ + && cp /etc/ssl/certs/*.crt /etc/pki/ca-trust/source/anchors/ \ + && update-ca-trust extract + +ENV PYTHONUNBUFFERED=1 + +RUN set -eux; \ + echo "nonroot:x:${GID}:" >> /etc/group; \ + echo "nonroot:x:${UID}:${GID}:nonroot:/home/nonroot:/bin/bash" >> /etc/passwd; \ + mkdir -p /home/nonroot; \ + chown ${UID}:${GID} /home/nonroot; \ + mkdir -p /app; \ + chown ${UID}:${GID} /app; \ + chmod 744 /app -RUN python${PYTHON_MAJOR_VERSION_ARG}.${PYTHON_MINOR_VERSION_ARG} -m venv /app/venv -RUN python${PYTHON_MAJOR_VERSION_ARG}.${PYTHON_MINOR_VERSION_ARG} -m pip install wheel +WORKDIR /app -# Copy requirements and install them into the virtualenv -ENV PATH="/app/venv/bin:$PATH" -COPY application/single_app/requirements.txt /app/requirements.txt -RUN python${PYTHON_MAJOR_VERSION_ARG}.${PYTHON_MINOR_VERSION_ARG} -m pip install --no-cache-dir -r /app/requirements.txt +USER ${UID}:${GID} -# Fix permissions so nonroot can use everything -RUN chown -R 65532:65532 /app +# Copy requirements and install them to system +COPY application/single_app/requirements.txt . +RUN python3 -m pip install --no-cache-dir -r requirements.txt --user -RUN mkdir -p /app/flask_session && chown -R 65532:65532 /app/flask_session -RUN mkdir /sc-temp-files && chown -R 65532:65532 /sc-temp-files -USER 65532:65532 +FROM mcr.microsoft.com/azurelinux/distroless/python:3.12 -#Stage 2: Final containter -FROM gcr.io/distroless/base-debian12:latest -ARG PYTHON_MAJOR_VERSION_ARG -ARG PYTHON_MINOR_VERSION_ARG -ARG PYTHON_PATCH_VERSION_ARG +# Setup pip.conf if has content +#COPY pip.conf /etc/pip.conf -ENV PYTHONIOENCODING=utf-8 \ - LANG=C.UTF-8 \ - LC_ALL=C.UTF-8 \ - PYTHONUNBUFFERED=1 \ - PATH="/app/venv/bin:/usr/local/bin:$PATH" \ - LD_LIBRARY_PATH="/usr/local/lib:${LD_LIBRARY_PATH}" +COPY --from=builder /etc/pki /etc/pki +COPY --from=builder /home/nonroot /home/nonroot +COPY --from=builder /etc/passwd /etc/passwd +COPY --from=builder /etc/group /etc/group +COPY --from=builder /home/nonroot/.local /home/nonroot/.local +COPY --from=builder /app /app -WORKDIR /app +# RUN mkdir -p /.local/bin && chown -R ${UID}:${GID} /.local +ENV PATH="/.local/bin:$PATH" -USER root +# RUN mkdir -p /app/flask_session && chown -R ${UID}:${GID} /app -# Copy only the built Python interpreter (venv entrypoint handles python/python3) -# Copy the full CPython installation so stdlib modules (e.g., encodings) are available -COPY --from=builder /usr/local/ /usr/local/ +WORKDIR /app -# Copy system libraries for x86_64 -COPY --from=builder /lib/x86_64-linux-gnu/ \ - /lib64/ld-linux-x86-64.so.2 \ - /usr/lib/x86_64-linux-gnu/ - #/usr/share/ca-certificates \ - #/etc/ssl/certs \ - #/usr/bin/ffmpeg \ - #/usr/share/zoneinfo /usr/share/ +USER ${UID}:${GID} # Copy application code and set ownership -COPY --chown=65532:65532 application/single_app/ /app/ - -# Copy the virtualenv from the builder stage -COPY --from=builder --chown=65532:65532 /app/venv /app/venv -COPY --from=builder --chown=65532:65532 /app/flask_session /app/flask_session -COPY --from=builder --chown=65532:65532 /sc-temp-files /sc-temp-files +COPY --chown=${UID}:${GID} application/single_app ./ # Expose port EXPOSE 5000 -USER 65532:65532 - - -ENTRYPOINT ["/app/venv/bin/python", "-c", "import runpy; runpy.run_path('/app/app.py', run_name='__main__')"] \ No newline at end of file +ENTRYPOINT [ "python3", "/app/app.py" ] From 317c6eec6684b96f77d776cd0fd7e50f9cc5370a Mon Sep 17 00:00:00 2001 From: Ed Clark Date: Mon, 26 Jan 2026 10:44:49 -0500 Subject: [PATCH 05/11] Add python ENV vars --- application/single_app/Dockerfile | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/application/single_app/Dockerfile b/application/single_app/Dockerfile index c3434c8e..ec647884 100644 --- a/application/single_app/Dockerfile +++ b/application/single_app/Dockerfile @@ -46,10 +46,11 @@ COPY --from=builder /etc/group /etc/group COPY --from=builder /home/nonroot/.local /home/nonroot/.local COPY --from=builder /app /app -# RUN mkdir -p /.local/bin && chown -R ${UID}:${GID} /.local -ENV PATH="/.local/bin:$PATH" - -# RUN mkdir -p /app/flask_session && chown -R ${UID}:${GID} /app +ENV PATH="/.local/bin:$PATH" \ + PYTHONIOENCODING=utf-8 \ + LANG=C.UTF-8 \ + LC_ALL=C.UTF-8 \ + PYTHONUNBUFFERED=1 \ WORKDIR /app From 25f41fb89dd688d3551796d64d82474a33c3c61b Mon Sep 17 00:00:00 2001 From: Ed Clark Date: Mon, 26 Jan 2026 10:45:43 -0500 Subject: [PATCH 06/11] Add python ENV vars --- application/single_app/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/single_app/Dockerfile b/application/single_app/Dockerfile index ec647884..228904a4 100644 --- a/application/single_app/Dockerfile +++ b/application/single_app/Dockerfile @@ -50,7 +50,7 @@ ENV PATH="/.local/bin:$PATH" \ PYTHONIOENCODING=utf-8 \ LANG=C.UTF-8 \ LC_ALL=C.UTF-8 \ - PYTHONUNBUFFERED=1 \ + PYTHONUNBUFFERED=1 WORKDIR /app From 0753f529aa42c7b5eb2e3948e0d9e5fda95bd7f7 Mon Sep 17 00:00:00 2001 From: Ed Clark Date: Mon, 26 Jan 2026 12:01:25 -0500 Subject: [PATCH 07/11] Install deps to systme --- application/single_app/Dockerfile | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/application/single_app/Dockerfile b/application/single_app/Dockerfile index 228904a4..bdb54f1e 100644 --- a/application/single_app/Dockerfile +++ b/application/single_app/Dockerfile @@ -28,25 +28,30 @@ RUN set -eux; \ WORKDIR /app -USER ${UID}:${GID} - # Copy requirements and install them to system -COPY application/single_app/requirements.txt . -RUN python3 -m pip install --no-cache-dir -r requirements.txt --user +COPY --chown=${UID}:${GID} application/single_app/requirements.txt . +RUN python3 -m pip install --no-cache-dir -r requirements.txt FROM mcr.microsoft.com/azurelinux/distroless/python:3.12 -# Setup pip.conf if has content -#COPY pip.conf /etc/pip.conf +ARG UID +ARG GID COPY --from=builder /etc/pki /etc/pki COPY --from=builder /home/nonroot /home/nonroot COPY --from=builder /etc/passwd /etc/passwd COPY --from=builder /etc/group /etc/group -COPY --from=builder /home/nonroot/.local /home/nonroot/.local -COPY --from=builder /app /app +COPY --from=builder /usr/lib/python3.12 /usr/lib/python3.12 + +USER ${UID}:${GID} + +# Setup pip.conf if has content +#COPY pip.conf /etc/pip.conf -ENV PATH="/.local/bin:$PATH" \ +COPY --from=builder --chown=${UID}:${GID} /app /app + +ENV HOME=/home/nonroot \ + PATH="/home/nonroot/.local/bin:$PATH" \ PYTHONIOENCODING=utf-8 \ LANG=C.UTF-8 \ LC_ALL=C.UTF-8 \ @@ -54,8 +59,6 @@ ENV PATH="/.local/bin:$PATH" \ WORKDIR /app -USER ${UID}:${GID} - # Copy application code and set ownership COPY --chown=${UID}:${GID} application/single_app ./ From f2958f05ae1df6f2eb5ba70ff04d523a3d15a5b8 Mon Sep 17 00:00:00 2001 From: Ed Clark Date: Mon, 26 Jan 2026 12:59:58 -0500 Subject: [PATCH 08/11] Add temp dir to image and pip conf support --- application/single_app/Dockerfile | 14 +++++++++----- pip.conf.d/.gitkeep | 0 2 files changed, 9 insertions(+), 5 deletions(-) create mode 100644 pip.conf.d/.gitkeep diff --git a/application/single_app/Dockerfile b/application/single_app/Dockerfile index bdb54f1e..018ce81a 100644 --- a/application/single_app/Dockerfile +++ b/application/single_app/Dockerfile @@ -1,12 +1,15 @@ # Create nonroot user/group with a stable UID/GID (choose values consistent with your org) -ARG UID=10001 -ARG GID=10001 +ARG UID=65532 +ARG GID=65532 FROM mcr.microsoft.com/azurelinux/base/python:3.12 AS builder ARG UID ARG GID +# Setup pip.conf if has content +COPY pip.conf.d/ /etc/pip.conf.d + # CA # copy certs to /etc/pki/ca-trust/source/anchors #COPY caroots /etc/ssl/certs @@ -26,6 +29,9 @@ RUN set -eux; \ chown ${UID}:${GID} /app; \ chmod 744 /app +RUN mkdir -p /app/flask_session && chown -R ${UID}:${GID} /app/flask_session +RUN mkdir /sc-temp-files && chown -R ${UID}:${GID} /sc-temp-files + WORKDIR /app # Copy requirements and install them to system @@ -45,10 +51,8 @@ COPY --from=builder /usr/lib/python3.12 /usr/lib/python3.12 USER ${UID}:${GID} -# Setup pip.conf if has content -#COPY pip.conf /etc/pip.conf - COPY --from=builder --chown=${UID}:${GID} /app /app +COPY --from=builder --chown=${UID}:${GID} /sc-temp-files /sc-temp-files ENV HOME=/home/nonroot \ PATH="/home/nonroot/.local/bin:$PATH" \ diff --git a/pip.conf.d/.gitkeep b/pip.conf.d/.gitkeep new file mode 100644 index 00000000..e69de29b From efd6fe7a7a8d8720f8dadfe8997568935c674267 Mon Sep 17 00:00:00 2001 From: Ed Clark Date: Mon, 26 Jan 2026 13:04:02 -0500 Subject: [PATCH 09/11] Add custom-ca-certificates dir --- application/single_app/Dockerfile | 2 +- custom-ca-certificates/.gitkeep | 0 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 custom-ca-certificates/.gitkeep diff --git a/application/single_app/Dockerfile b/application/single_app/Dockerfile index 018ce81a..65483ac6 100644 --- a/application/single_app/Dockerfile +++ b/application/single_app/Dockerfile @@ -12,7 +12,7 @@ COPY pip.conf.d/ /etc/pip.conf.d # CA # copy certs to /etc/pki/ca-trust/source/anchors -#COPY caroots /etc/ssl/certs +COPY custom-ca-certificates/ /etc/ssl/certs RUN mkdir -p /etc/pki/ca-trust/source/anchors/ \ && update-ca-trust enable \ && cp /etc/ssl/certs/*.crt /etc/pki/ca-trust/source/anchors/ \ diff --git a/custom-ca-certificates/.gitkeep b/custom-ca-certificates/.gitkeep new file mode 100644 index 00000000..e69de29b From 7d0a792428dd8ecc74a9a2dd30a0d4109c2e53f0 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Mon, 26 Jan 2026 15:50:05 -0500 Subject: [PATCH 10/11] Logo bug fix (#654) * release note updating for github coplilot * fixed logo bug issue * added 2,3,4,5,6,14 days to rentention policy * added retention policy time updates --- .../update_release_notes.instructions.md | 90 +++++++++ application/single_app/config.py | 2 +- application/single_app/functions_settings.py | 9 + .../single_app/static/images/custom_logo.png | Bin 11705 -> 11877 bytes .../static/images/custom_logo_dark.png | Bin 13770 -> 13468 bytes .../single_app/static/images/favicon.ico | Bin 2237 -> 2237 bytes .../single_app/templates/admin_settings.html | 36 ++++ .../single_app/templates/control_center.html | 24 +++ application/single_app/templates/profile.html | 12 ++ .../CUSTOM_LOGO_NOT_DISPLAYING_FIX.md | 102 +++++++++++ docs/explanation/release_notes.md | 22 +++ .../test_custom_logo_sanitization_fix.py | 172 ++++++++++++++++++ 12 files changed, 468 insertions(+), 1 deletion(-) create mode 100644 .github/instructions/update_release_notes.instructions.md create mode 100644 docs/explanation/fixes/v0.237.003/CUSTOM_LOGO_NOT_DISPLAYING_FIX.md create mode 100644 functional_tests/test_custom_logo_sanitization_fix.py diff --git a/.github/instructions/update_release_notes.instructions.md b/.github/instructions/update_release_notes.instructions.md new file mode 100644 index 00000000..353cea48 --- /dev/null +++ b/.github/instructions/update_release_notes.instructions.md @@ -0,0 +1,90 @@ +--- +applyTo: '**' +--- + +# Release Notes Update Instructions + +## When to Update Release Notes + +After completing a code change (bug fix, new feature, enhancement, or breaking change), always ask the user: + +**"Would you like me to update the release notes in `docs/explanation/release_notes.md`?"** + +## If the User Confirms Yes + +Update the release notes file following these guidelines: + +### 1. Location +Release notes are located at: `docs/explanation/release_notes.md` + +### 2. Version Placement +- Add new entries under the **current version** from `config.py` +- If the version has changed, create a new version section at the TOP of the file +- Format: `### **(vX.XXX.XXX)**` + +### 3. Entry Categories + +Organize entries under the appropriate category: + +#### New Features +```markdown +#### New Features + +* **Feature Name** + * Brief description of what the feature does and its benefits. + * Additional details about functionality or configuration. + * (Ref: relevant files, components, or concepts) +``` + +#### Bug Fixes +```markdown +#### Bug Fixes + +* **Fix Name** + * Description of what was broken and how it was fixed. + * Impact or affected areas. + * (Ref: relevant files, functions, or components) +``` + +#### User Interface Enhancements +```markdown +#### User Interface Enhancements + +* **Enhancement Name** + * Description of UI/UX improvements. + * (Ref: relevant templates, CSS, or JavaScript files) +``` + +#### Breaking Changes +```markdown +#### Breaking Changes + +* **Change Name** + * Description of what changed and why. + * **Migration**: Steps users need to take (if any). +``` + +### 4. Entry Format Guidelines + +- **Bold the title** of each entry +- Use bullet points for details +- Include a `(Ref: ...)` line with relevant file names, functions, or concepts +- Keep descriptions concise but informative +- Focus on user-facing impact, not implementation details + +### 5. Example Entry + +```markdown +* **Custom Logo Display Fix** + * Fixed issue where custom logos uploaded via Admin Settings would only display on the admin page but not on other pages (chat, sidebar, landing page). + * Root cause was overly aggressive sanitization removing logo URLs from public settings. + * (Ref: logo display, settings sanitization, template conditionals) +``` + +### 6. Checklist Before Updating + +- [ ] Confirm the current version in `config.py` +- [ ] Determine the correct category (New Feature, Bug Fix, Enhancement, Breaking Change) +- [ ] Write a clear, user-focused description +- [ ] Include relevant file/component references +- [ ] Place entry under the correct version section diff --git a/application/single_app/config.py b/application/single_app/config.py index 12906ce8..9a5c892f 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -88,7 +88,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.237.001" +VERSION = "0.237.003" SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') diff --git a/application/single_app/functions_settings.py b/application/single_app/functions_settings.py index 7a411064..5fa59f12 100644 --- a/application/single_app/functions_settings.py +++ b/application/single_app/functions_settings.py @@ -794,6 +794,15 @@ def sanitize_settings_for_user(full_settings: dict) -> dict: else: sanitized[k] = v + # Add boolean flags for logo/favicon existence so templates can check without exposing base64 data + # These fields are stripped by the base64 filter above, but templates need to know if logos exist + if 'custom_logo_base64' in full_settings: + sanitized['custom_logo_base64'] = bool(full_settings.get('custom_logo_base64')) + if 'custom_logo_dark_base64' in full_settings: + sanitized['custom_logo_dark_base64'] = bool(full_settings.get('custom_logo_dark_base64')) + if 'custom_favicon_base64' in full_settings: + sanitized['custom_favicon_base64'] = bool(full_settings.get('custom_favicon_base64')) + return sanitized def sanitize_settings_for_logging(full_settings: dict) -> dict: diff --git a/application/single_app/static/images/custom_logo.png b/application/single_app/static/images/custom_logo.png index 45a99fd35f8834db8920ea29bd2bfee10fe754d2..ecf6e6521a737af56bcc82321caff1acefb63494 100644 GIT binary patch literal 11877 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 F{sTGwC=37q literal 11705 zcmbVSRa6~Iv>XoZ2Pe23+%-UOcXyW%G`Ks#NpK79?(P!Yf`s56+}-VQKi=p2d27w= z{@T5I?Vg^ls$CJvic+XZ1V{h?pvp*#tG>xcAr>>W>TnOaU_DBI+I)C%)bp z)DoVjSDG5`{x84BCr;~*>b_9INPx|uba42cuEGbiKVs_SN6>^?j;s7*^5Ty()1Si6 zmsAq{v1{tYzgx`qFKQ!1j-o=65^h`CMS^@d4aZIAmc5?i%Al=0>q<()^-kaY3%
txT=*o8&jKB+g$Gzp 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 Kc$KK}xBmg`z(eK$ diff --git a/application/single_app/static/images/custom_logo_dark.png b/application/single_app/static/images/custom_logo_dark.png index b3beb694201dc8b371e45c973895a95e211eab8e..4f28194576a32f4463ff13ac96521f979e739bb0 100644 GIT binary patch literal 13468 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 aj13{1xX1-4^7hveoPvysbhV^u=>Gr|kZNTB literal 13770 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 diff --git a/application/single_app/static/images/favicon.ico b/application/single_app/static/images/favicon.ico index d8f058f6866570b29f44c682b05a6a010bdedf60..3dc7742a5aa3422badbf8b10bf9d9a21db43f205 100644 GIT binary patch delta 2147 zcmV-p2%Pu55xo&M000310{{>Z00000AON%h001@s000;m00000AOP9|008O%001B$ z00000AOQIT006`U005K01QU@-5r2D0L_t(|oZXT=YgADXhMzg--n+TG!4Q;X!S$m^ zAdLutz!rjqm5qfz#M0iTa6}pyLj(TN8CT)v@OVyWBq9HCt}BrZ@b9zDDp|&mx43YKp8AzT{ysO6$xEn z?7vxfomD64T`^tE>rook&uomI^l}9mtq%>Xu zPW05gyWeK-9q-IN4Hvf1>2$gys>h1$wyJQ)q8-#*5Tuv#w8kNI{+FyVvk_Fa6|>M5 z)pq4R9g(tb;P@A&`1Qw?=6thgE|OneI?H7rsn+V==WEx@`=9&>>ixw;Z?Zl+xyMNDb1=my90x&(kH{Ld2Xs8Cj_vH0_%@Arf zPk*sl@{dpbPcF+g+bsF5ll(H!<>`5wrrV#1FTU?wksJZA(+Iv1!|&C|5CSHV;UmDU zvc?R!$Q!}x1{*L_mgTJP^J@hhrpR1qhQFygqM$>b4;u|z#8wy4!2Nb{y3B0>?0;!c zrvZCbMf#L-)DknI+(~ejMpvrn9g-`dI-$S>pchDf85O@;#cnmc4At#ww1LPqF3rDf z{$IasLNi=5q6bKGNfFM0vX9h9svIQnyH%V75JR)t51=8$rvUy6&2Uh_A1ruGj5Yxs z1+k8Tt1zrsuMd+@XLEl_fUiX21b@MXfkKrv5J92+6188+{K?gLHz)g_-#+zDet~E9B-Pc$^`d4~G_C2hLcjM(IzL}tPin~mfPo}Wc8HVNFc<#qwrt0AY-|kM zwq06M-irzN0L#*PdQf3z)r4CTamWhWt@lT?IAf?Mtk{iWQsoC z5h^7Vl?h1&LE=+kghHtl^iUB-Vh=$?1sPF65cCw$gG4V8g;`MKKo3PoFM{SnFM=ZK z%sAuBJ@=dy`_7qr@7$b8rH3Bw2b;V1zH6_aZ~Yu#fB^;=pbM(jLg#CNyMIz0?msqs zO-Qi>%Q~kzu7~PPeNNFLZ1vxB2;l)V8bb@b>B0xu>s8Kpl?$vg8Vz&c_kEC~eFiSa z*5&pN_4*C0VoqdhUn-J$vYhco&Qjx)cQf+_uX5Tczp2rQjPHv6RV$gtPy=(3MTDL^ ztr(0B?KY}+iPk#iI3Dy)o{|1v-1z@_F;%y=tb}C;H?0^|fQWI+^;9v=C zOmeI<#h0y1&6j+TpN%k1IaZTG4drXCi>*(g@RXDjE|YC!_AbgMA@H%*biNC~c-IR} zpKsG2naJcf5xLC;{@AIEa^NT8y$^n~5ArhCQgbSgap0Zpd-eCQ%73I+IV2)$eUSa} z-lgtAuX2t9KOJ>D<#&AW_p?fiL;8VzvE8lP4vF?_0IqANqsDi5m6KlOuv31|t9-|S zcSHrVj{5spr5X2GmaX6{&3*7&yvkXx{_aj%RmOafSH1F|PWgaWneu^u@PQAr%J)e5 z^*Pyuvvdb%>9|vQJby3Tc>tGSHVuQb*cr106!V%`Y4xZ-X1nEDjjA?cMG=tfgX)c5 z)8|0cFr!}qTAq|R5i<K5U_U(HG^|sgbR}e0}T9&+d^=x^>WKU`SO*`YJoEqHHrzMW0ps$V?4Ou(Vw} zfuBT{tw0UKJ?)mwu@v_qgjJpXJASrdHrtL=3HRb^CSl6TZ^af~M5cFk%sF?(-vWvU zAHpUd!sht6Vtzus8g}f2#1XSJ$;L#qVz52>-c+|G#7on}Az>!-D!}-$a#4-bo z8!3oZ+U)-|5Y~gNo@Lp}obsQ@k};fX4;nYm=t2hm1zc?!ISV@@8Tr)A=gho5&-3HV zd`m9*8E*2hb8ez6OKD2;0+&3JPvw79?|WJO|ADSTcCM0(s&+ax((~J^cTwsG7+?TY Z_!}p`4s?)md<*~p002ovPDHLkV1oEUB}o7P delta 2140 zcmV-i2&4DC5xo&J000310{{>Z00000AONfZ001@s000;m00000AOPP20080v001B$ z00000AOQRW006;}Gbev@NklmfY+^R8-~&_4nfII--Z}7J zAV2|V8)u=d{@CTyL<&Tk$3kxHSphiUQdQNuD_i00AU3zTy;y(NKGoHIGv1K%yFU|@ z-RfxsJ&AZu&~=r)z=z4xPsI0wcpoC0N#wOf+}2=hO0AyWHnaJ*6m>l!?4A$v)Ji!zgS)^x4;%0` zu%qyyX_|9a2UmZ_!$&tB7R^C^Qmt0&wwK31`faQo`IXC+GKupe=lM(Ez>dtj^zFjs z{ZH5L>R(&v^?Lmo%`L+}cN$&uO?ITJ1_Q~2A{r)At0CfeI^VEa#Qvj3S0WW{1NklE|Ni8Oau}^y_q+^_kO>7&uKAl-vA9=Va|0bb0zmr0?*)I{8PH|{cg|EtjC#Tmb80+H zaGqitP3>or8(}tMzzkrQNbXLCZ=B&R6W)f|K@;mBaf{E(_q%_#A4=@TyQ20kDK`!A zJg9Gz_J~R;Y*dqt8d)yCHeR4|5wz`+hYtxJ1#yGModoWK9F-ug-a_x% z%*TiJkL~~Mh)-2BosDD*04FW^FB)e*W+}Zvro~=jyU6ojtGb#7;FH?4(AEJI3SKhk zome$rg&@ZoueTku*Qj)5Oid{->sH^Hb@BL;n z>oqj8)NwA`<|5{5sq2zus;cUj$Avuu`QO<7>8JSA1jO>_uY4c!gzY*1Vg3T2y9jiB zlr?CR-T@zz*8~xN!%0LzRCt{2l})HsRTRhn>+H4G*=yh1RUi48A!bJCqlBU|A*mop z92G_=luAK^iZBuz1Q8WvL2TcS;^!e21d-vS4SA%!o zyVvVSDh(RkA8gKE`>eCq$6jk6V91amLwZnmTu#0o!qx775&mPt*F}jrm^UjmaU;x@ z4md>vGWz7fb%`-PfL3edGT-#zs5{v7IGc`%=>eE6l_>9X)Pn;CfQz$rsd+%NIbTdY zsBXZ8M^Rj$D@oQ!DqTw1CL*J3dMuc{X=Y0$$z1@rJBkZ3)WQgQ#J)8H05maQOslQ~ z0AOe|7G4y8yt^|IkPED+x%vj@unfRtyTsc>v>=#%MeyD3bdK6sK@od0u%@W6$_-z3 z&bMFUs6RVloGL6QgE{hRowJ=!VPGoqnUHiNxxb6tCPewOGg)l~Fy8aR%IDd1Oj7BR z^fy(#EkyY^m>!TQPe>}ABd_DAFH58JVEULu*&?ZbbT+<6OwY0DUR7PmQFloyJ(S@= zHa#g(o@UdrVDb(}zF$l`63Y+#i%ntGCMW30%kGnx$!<2C;3z+Elzn3QJxY0fMz@eu zZkAMkjtA4nt2&$la31cHa0Ca+ZUGs9!m~F42!K{6bXwio009IPJ7IPcTlpM_TJH2K zKnshE!vti2_6qU#n)3dK3j6l=ohv`u_2cauxtf^T!=`Hs;Io2qcF++<@j~VGGekH> zL}%UoZvdHyCfxlffVv39RHw>E0n`qR13t=ss1Fb@;*K5lRC_;1Ipc*NEI?bmz=s{= z7rD--k1pBp%h$Kw{Oor9q*n*67Dmi$$v|b7-6*ZwJ^~uG?OI&DaN>Lw*L;DXt__fBXyPgxt5!-6qC0^Wb_I;VL%?VH*54#{{jL4j&Uu2 z$G8qa(ajWD@1`u!z(N28$GE;sJ;Uxa=!thnzf1s}jqfdr{4fP9#|WC0 zS5HYP+iI`3O*^NrNUd)G$e?^DfJgg`_V1T_;gJKq^SK-Z0D;0>I8J4!!}A<8?qncZ z?9=ZXAgl&?Go`e+qUtB|;xtZT9Mh>^&;M%RDx4rmaSPyFO-y|*BCD&aIwT^uR9YRE zwk!xCoXI(BTiaujWl1$r{m~cs|Cej2_L)kqFeHPZL&-}j2CA2MXf4F3QpzYcVu Sxg3iC0000Default Retention Policies + + + + + + @@ -2479,8 +2485,14 @@
Default Retention Policies + + + + + + @@ -2502,8 +2514,14 @@
Default Retention Policies + + + + + + @@ -2518,8 +2536,14 @@
Default Retention Policies + + + + + + @@ -2541,8 +2565,14 @@
Default Retention Policies + + + + + + @@ -2557,8 +2587,14 @@
Default Retention Policies + + + + + + diff --git a/application/single_app/templates/control_center.html b/application/single_app/templates/control_center.html index 853b4631..7a86d961 100644 --- a/application/single_app/templates/control_center.html +++ b/application/single_app/templates/control_center.html @@ -1670,8 +1670,14 @@
Retention PolicyUsing organization default + + + + + + @@ -1687,8 +1693,14 @@
Retention PolicyUsing organization default + + + + + + @@ -2287,8 +2299,14 @@
Retention PolicyUsing organization default + + + + + + @@ -2304,8 +2322,14 @@
Retention PolicyUsing organization default + + + + + + diff --git a/application/single_app/templates/profile.html b/application/single_app/templates/profile.html index e5a62887..2ab543f7 100644 --- a/application/single_app/templates/profile.html +++ b/application/single_app/templates/profile.html @@ -319,8 +319,14 @@
Retention Policy Sett + + + + + + @@ -338,8 +344,14 @@
Retention Policy Sett + + + + + + diff --git a/docs/explanation/fixes/v0.237.003/CUSTOM_LOGO_NOT_DISPLAYING_FIX.md b/docs/explanation/fixes/v0.237.003/CUSTOM_LOGO_NOT_DISPLAYING_FIX.md new file mode 100644 index 00000000..166dc7c9 --- /dev/null +++ b/docs/explanation/fixes/v0.237.003/CUSTOM_LOGO_NOT_DISPLAYING_FIX.md @@ -0,0 +1,102 @@ +# Custom Logo Not Displaying Across App Fix + +## Issue Description +When an admin uploaded a custom logo via Admin Settings, the logo would display correctly on the admin settings page but **not appear elsewhere in the application** (e.g., chat page, sidebar navigation). + +### Symptoms +- Logo visible in Admin Settings preview +- Logo not appearing in sidebar navigation +- Logo not appearing on chat/chats pages +- Logo not appearing on index/landing page + +## Root Cause Analysis +The issue was in the `sanitize_settings_for_user()` function in [functions_settings.py](../../application/single_app/functions_settings.py). + +This function is designed to strip sensitive data before sending settings to the frontend. It filters out any keys containing terms like: +- `key` +- `secret` +- `password` +- `connection` +- **`base64`** +- `storage_account_url` + +The logo settings are stored with keys: +- `custom_logo_base64` +- `custom_logo_dark_base64` +- `custom_favicon_base64` + +Because these keys contain `base64`, they were being **completely removed** from the sanitized settings. + +### Template Logic Impact +Templates check for custom logos using conditions like: +```jinja2 +{% if app_settings.custom_logo_base64 %} + +{% else %} + +{% endif %} +``` + +When `custom_logo_base64` was stripped entirely, this condition always evaluated to `False`, causing the default logo to display instead of the custom uploaded logo. + +## Solution +Modified `sanitize_settings_for_user()` to add boolean flags for logo/favicon existence **after** the main sanitization loop. This allows templates to check if logos exist without exposing the actual base64 data. + +### Code Change +```python +def sanitize_settings_for_user(full_settings: dict) -> dict: + # ... existing sanitization logic ... + + # Add boolean flags for logo/favicon existence so templates can check without exposing base64 data + # These fields are stripped by the base64 filter above, but templates need to know if logos exist + if 'custom_logo_base64' in full_settings: + sanitized['custom_logo_base64'] = bool(full_settings.get('custom_logo_base64')) + if 'custom_logo_dark_base64' in full_settings: + sanitized['custom_logo_dark_base64'] = bool(full_settings.get('custom_logo_dark_base64')) + if 'custom_favicon_base64' in full_settings: + sanitized['custom_favicon_base64'] = bool(full_settings.get('custom_favicon_base64')) + + return sanitized +``` + +### How It Works +1. The sensitive base64 data is still stripped during the main loop +2. After sanitization, boolean flags are added: + - `True` if the logo exists (base64 string is non-empty) + - `False` if no logo is set (base64 string is empty) +3. Templates can still use `{% if app_settings.custom_logo_base64 %}` and it will correctly evaluate to `True` or `False` +4. The actual base64 data is never exposed to the frontend + +## Files Modified +- [functions_settings.py](../../application/single_app/functions_settings.py) - Modified `sanitize_settings_for_user()` function + +## Version +**Fixed in version:** 0.237.002 + +## Testing +A functional test was created: [test_custom_logo_sanitization_fix.py](../../functional_tests/test_custom_logo_sanitization_fix.py) + +### Test Cases +1. **Logo flags preserved as True** - When logos exist, boolean flags are `True` +2. **Logo flags preserved as False** - When logos are empty, boolean flags are `False` +3. **No spurious flags added** - If logo keys don't exist in settings, they're not added +4. **Template compatibility** - Boolean flags work correctly in Jinja2-style conditionals + +### Running the Test +```bash +cd functional_tests +python test_custom_logo_sanitization_fix.py +``` + +## Impact +This fix affects all pages that display the application logo: +- Landing/Index page +- Chat page +- Sidebar navigation (when left nav is enabled) +- Any other page using `base.html` that references logo settings + +## Security Considerations +- βœ… Actual base64 data is still never exposed to the frontend +- βœ… Only boolean True/False values are sent +- βœ… No sensitive data leakage +- βœ… Maintains the security intent of the original sanitization function diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index df88ebcd..3b3de6e6 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -1,6 +1,28 @@ # Feature Release +### **(v0.237.003)** + +#### New Features + +* **Extended Retention Policy Timeline Options** + * Added additional granular retention period options for conversations and documents across all workspace types. + * **New Options**: 2 days, 3 days, 4 days, 6 days, 7 days (1 week), and 14 days (2 weeks). + * **Full Option Set**: 1, 2, 3, 4, 5, 6, 7 (1 week), 10, 14 (2 weeks), 21 (3 weeks), 30, 60, 90 (3 months), 180 (6 months), 365 (1 year), 730 (2 years) days. + * **Scope**: Available in Admin Settings (organization defaults), Profile page (personal settings), and Control Center (group/public workspace management). + * **Files Modified**: `admin_settings.html`, `profile.html`, `control_center.html`. + * (Ref: retention policy configuration, workspace retention settings, granular time periods) + +#### Bug Fixes + +* **Custom Logo Not Displaying Across App Fix** + * Fixed issue where custom logos uploaded via Admin Settings would only display on the admin page but not on other pages (chat, sidebar, landing page). + * **Root Cause**: The `sanitize_settings_for_user()` function was stripping `custom_logo_base64`, `custom_logo_dark_base64`, and `custom_favicon_base64` keys entirely because they contained "base64" (a sensitive term filter), preventing templates from detecting logo existence. + * **Solution**: Modified sanitization to add boolean flags for logo/favicon existence after filtering, allowing templates to check if logos exist without exposing actual base64 data. + * **Security**: Actual base64 data remains hidden from frontend; only True/False boolean values are exposed. + * **Files Modified**: `functions_settings.py` (`sanitize_settings_for_user()` function). + * (Ref: logo display, settings sanitization, template conditionals) + ### **(v0.237.001)** #### New Features diff --git a/functional_tests/test_custom_logo_sanitization_fix.py b/functional_tests/test_custom_logo_sanitization_fix.py new file mode 100644 index 00000000..419a7a1f --- /dev/null +++ b/functional_tests/test_custom_logo_sanitization_fix.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +""" +Functional test for custom logo sanitization fix. +Version: 0.237.002 +Implemented in: 0.237.002 + +This test ensures that custom logo boolean flags are preserved in sanitized settings +so templates can detect if custom logos exist without exposing the actual base64 data. + +Issue: When a logo was uploaded via admin settings, it was visible on the admin page +but not on other pages (like the chat page) because the `sanitize_settings_for_user` +function was stripping `custom_logo_base64`, `custom_logo_dark_base64`, and +`custom_favicon_base64` keys entirely, which templates use to conditionally display logos. + +Fix: Modified `sanitize_settings_for_user` to add boolean flags for logo/favicon +existence after sanitization, allowing templates to check `app_settings.custom_logo_base64` +(which will be True/False) without exposing the actual base64 data. +""" + +import sys +import os + +# Add the application directory to the path +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'application', 'single_app')) + + +def test_sanitize_settings_preserves_logo_flags(): + """ + Test that sanitize_settings_for_user preserves boolean flags for logo existence. + """ + print("πŸ” Testing sanitize_settings_for_user preserves logo flags...") + + try: + from functions_settings import sanitize_settings_for_user + + # Test case 1: Settings with custom logos present + settings_with_logos = { + 'app_title': 'Test App', + 'show_logo': True, + 'custom_logo_base64': 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + 'custom_logo_dark_base64': 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + 'custom_favicon_base64': 'AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAAA==', + 'logo_version': 5, + 'some_api_key': 'secret-key-123', + 'azure_openai_key': 'another-secret-key', + } + + sanitized = sanitize_settings_for_user(settings_with_logos) + + # Verify non-sensitive fields are preserved + assert sanitized.get('app_title') == 'Test App', "app_title should be preserved" + assert sanitized.get('show_logo') == True, "show_logo should be preserved" + assert sanitized.get('logo_version') == 5, "logo_version should be preserved" + + # Verify sensitive keys are removed (api keys, secrets) + assert 'some_api_key' not in sanitized, "API keys should be removed" + assert 'azure_openai_key' not in sanitized, "Azure OpenAI key should be removed" + + # Verify logo flags are boolean True (not the actual base64 data) + assert sanitized.get('custom_logo_base64') == True, "custom_logo_base64 should be True (boolean flag)" + assert sanitized.get('custom_logo_dark_base64') == True, "custom_logo_dark_base64 should be True (boolean flag)" + assert sanitized.get('custom_favicon_base64') == True, "custom_favicon_base64 should be True (boolean flag)" + + # Verify the actual base64 data is NOT exposed + assert isinstance(sanitized.get('custom_logo_base64'), bool), "custom_logo_base64 should be a boolean, not a string" + + print("βœ… Test 1 passed: Logo flags are preserved as boolean True when logos exist") + + # Test case 2: Settings without custom logos + settings_without_logos = { + 'app_title': 'Test App', + 'show_logo': True, + 'custom_logo_base64': '', + 'custom_logo_dark_base64': '', + 'custom_favicon_base64': '', + } + + sanitized2 = sanitize_settings_for_user(settings_without_logos) + + # Verify logo flags are boolean False when logos are empty + assert sanitized2.get('custom_logo_base64') == False, "custom_logo_base64 should be False when empty" + assert sanitized2.get('custom_logo_dark_base64') == False, "custom_logo_dark_base64 should be False when empty" + assert sanitized2.get('custom_favicon_base64') == False, "custom_favicon_base64 should be False when empty" + + print("βœ… Test 2 passed: Logo flags are False when logos are empty/not set") + + # Test case 3: Settings without logo keys at all + settings_no_logo_keys = { + 'app_title': 'Test App', + 'show_logo': False, + } + + sanitized3 = sanitize_settings_for_user(settings_no_logo_keys) + + # Verify logo keys are not added if they didn't exist + assert 'custom_logo_base64' not in sanitized3, "custom_logo_base64 should not be added if not in original settings" + + print("βœ… Test 3 passed: Logo flags are not added if keys not in original settings") + + print("\nβœ… All tests passed!") + return True + + except AssertionError as e: + print(f"❌ Assertion failed: {e}") + import traceback + traceback.print_exc() + return False + except Exception as e: + print(f"❌ Test failed with exception: {e}") + import traceback + traceback.print_exc() + return False + + +def test_template_compatibility(): + """ + Test that the boolean flags work correctly in Jinja2-style conditionals. + """ + print("\nπŸ” Testing template compatibility with boolean flags...") + + try: + from functions_settings import sanitize_settings_for_user + + settings = { + 'custom_logo_base64': 'some-base64-data', + 'custom_logo_dark_base64': '', + } + + sanitized = sanitize_settings_for_user(settings) + + # Simulate Jinja2 conditional: {% if app_settings.custom_logo_base64 %} + if sanitized.get('custom_logo_base64'): + light_logo_condition = "show custom light logo" + else: + light_logo_condition = "show default light logo" + + assert light_logo_condition == "show custom light logo", "Light logo should use custom" + + # Simulate Jinja2 conditional: {% if app_settings.custom_logo_dark_base64 %} + if sanitized.get('custom_logo_dark_base64'): + dark_logo_condition = "show custom dark logo" + else: + dark_logo_condition = "show default dark logo" + + assert dark_logo_condition == "show default dark logo", "Dark logo should use default (empty base64)" + + print("βœ… Template compatibility test passed!") + return True + + except Exception as e: + print(f"❌ Template compatibility test failed: {e}") + import traceback + traceback.print_exc() + return False + + +if __name__ == "__main__": + results = [] + + print("=" * 60) + print("Custom Logo Sanitization Fix - Functional Tests") + print("=" * 60) + + results.append(test_sanitize_settings_preserves_logo_flags()) + results.append(test_template_compatibility()) + + print("\n" + "=" * 60) + success = all(results) + print(f"πŸ“Š Results: {sum(results)}/{len(results)} tests passed") + print("=" * 60) + + sys.exit(0 if success else 1) From 823e6fa8045939146b9bed0fed14b2eb2415a900 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Mon, 26 Jan 2026 16:31:34 -0500 Subject: [PATCH 11/11] Rentention policy (#657) * Critical Retention Policy Deletion Fix * Create RETENTION_POLICY_NULL_LAST_ACTIVITY_FIX.md --- application/single_app/config.py | 2 +- .../single_app/functions_control_center.py | 138 ++++++++++++++++++ .../single_app/functions_retention_policy.py | 32 ++-- .../single_app/static/js/control-center.js | 26 ++++ .../single_app/templates/control_center.html | 8 +- ...RETENTION_POLICY_NULL_LAST_ACTIVITY_FIX.md | 133 +++++++++++++++++ docs/explanation/release_notes.md | 17 +++ 7 files changed, 335 insertions(+), 21 deletions(-) create mode 100644 application/single_app/functions_control_center.py create mode 100644 docs/explanation/fixes/v0.237.004/RETENTION_POLICY_NULL_LAST_ACTIVITY_FIX.md diff --git a/application/single_app/config.py b/application/single_app/config.py index 9a5c892f..9aa4c4b8 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -88,7 +88,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.237.003" +VERSION = "0.237.004" SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') diff --git a/application/single_app/functions_control_center.py b/application/single_app/functions_control_center.py new file mode 100644 index 00000000..9337f408 --- /dev/null +++ b/application/single_app/functions_control_center.py @@ -0,0 +1,138 @@ +# functions_control_center.py +""" +Functions for Control Center operations including scheduled auto-refresh. +Version: 0.237.004 +""" + +from datetime import datetime, timezone, timedelta +from config import debug_print, cosmos_user_settings_container, cosmos_groups_container +from functions_settings import get_settings, update_settings +from functions_appinsights import log_event + + +def execute_control_center_refresh(manual_execution=False): + """ + Execute Control Center data refresh operation. + Refreshes user and group metrics data. + + Args: + manual_execution: True if triggered manually, False if scheduled + + Returns: + dict: Results containing success status and refresh counts + """ + results = { + 'success': True, + 'refreshed_users': 0, + 'failed_users': 0, + 'refreshed_groups': 0, + 'failed_groups': 0, + 'error': None, + 'manual_execution': manual_execution + } + + try: + debug_print(f"πŸ”„ [AUTO-REFRESH] Starting Control Center {'manual' if manual_execution else 'scheduled'} refresh...") + + # Import enhance functions from route module + from route_backend_control_center import enhance_user_with_activity, enhance_group_with_activity + + # Get all users to refresh their metrics + debug_print("πŸ”„ [AUTO-REFRESH] Querying all users...") + users_query = "SELECT c.id, c.email, c.display_name, c.lastUpdated, c.settings FROM c" + all_users = list(cosmos_user_settings_container.query_items( + query=users_query, + enable_cross_partition_query=True + )) + debug_print(f"πŸ”„ [AUTO-REFRESH] Found {len(all_users)} users to process") + + # Refresh metrics for each user + for user in all_users: + try: + user_id = user.get('id') + debug_print(f"πŸ”„ [AUTO-REFRESH] Processing user {user_id}") + + # Force refresh of metrics for this user + enhanced_user = enhance_user_with_activity(user, force_refresh=True) + results['refreshed_users'] += 1 + + except Exception as user_error: + results['failed_users'] += 1 + debug_print(f"❌ [AUTO-REFRESH] Failed to refresh user {user.get('id')}: {user_error}") + + debug_print(f"πŸ”„ [AUTO-REFRESH] User refresh completed. Refreshed: {results['refreshed_users']}, Failed: {results['failed_users']}") + + # Refresh metrics for all groups + debug_print("πŸ”„ [AUTO-REFRESH] Starting group refresh...") + + try: + groups_query = "SELECT * FROM c" + all_groups = list(cosmos_groups_container.query_items( + query=groups_query, + enable_cross_partition_query=True + )) + debug_print(f"πŸ”„ [AUTO-REFRESH] Found {len(all_groups)} groups to process") + + # Refresh metrics for each group + for group in all_groups: + try: + group_id = group.get('id') + debug_print(f"πŸ”„ [AUTO-REFRESH] Processing group {group_id}") + + # Force refresh of metrics for this group + enhanced_group = enhance_group_with_activity(group, force_refresh=True) + results['refreshed_groups'] += 1 + + except Exception as group_error: + results['failed_groups'] += 1 + debug_print(f"❌ [AUTO-REFRESH] Failed to refresh group {group.get('id')}: {group_error}") + + except Exception as groups_error: + debug_print(f"❌ [AUTO-REFRESH] Error querying groups: {groups_error}") + + debug_print(f"πŸ”„ [AUTO-REFRESH] Group refresh completed. Refreshed: {results['refreshed_groups']}, Failed: {results['failed_groups']}") + + # Update admin settings with refresh timestamp and calculate next run time + try: + settings = get_settings() + if settings: + current_time = datetime.now(timezone.utc) + settings['control_center_last_refresh'] = current_time.isoformat() + + # Calculate next scheduled auto-refresh time if enabled + if settings.get('control_center_auto_refresh_enabled', False): + execution_hour = settings.get('control_center_auto_refresh_hour', 2) + next_run = current_time.replace(hour=execution_hour, minute=0, second=0, microsecond=0) + if next_run <= current_time: + next_run += timedelta(days=1) + settings['control_center_auto_refresh_next_run'] = next_run.isoformat() + + update_success = update_settings(settings) + + if update_success: + debug_print("βœ… [AUTO-REFRESH] Admin settings updated with refresh timestamp") + else: + debug_print("⚠️ [AUTO-REFRESH] Failed to update admin settings") + + except Exception as settings_error: + debug_print(f"❌ [AUTO-REFRESH] Admin settings update failed: {settings_error}") + + # Log the activity + log_event("control_center_refresh", { + "manual_execution": manual_execution, + "refreshed_users": results['refreshed_users'], + "failed_users": results['failed_users'], + "refreshed_groups": results['refreshed_groups'], + "failed_groups": results['failed_groups'] + }) + + debug_print(f"πŸŽ‰ [AUTO-REFRESH] Refresh completed! Users: {results['refreshed_users']} refreshed, {results['failed_users']} failed. " + f"Groups: {results['refreshed_groups']} refreshed, {results['failed_groups']} failed") + + return results + + except Exception as e: + debug_print(f"πŸ’₯ [AUTO-REFRESH] Error executing Control Center refresh: {e}") + results['success'] = False + results['error'] = str(e) + return results diff --git a/application/single_app/functions_retention_policy.py b/application/single_app/functions_retention_policy.py index 56167fa1..07f391a0 100644 --- a/application/single_app/functions_retention_policy.py +++ b/application/single_app/functions_retention_policy.py @@ -6,9 +6,10 @@ This module handles automated deletion of aged conversations and documents based on configurable retention policies for personal, group, and public workspaces. -Version: 0.236.012 +Version: 0.237.004 Implemented in: 0.234.067 Updated in: 0.236.012 - Fixed race condition handling for NotFound errors during deletion +Updated in: 0.237.004 - Fixed critical bug where conversations with null/undefined last_activity_at were deleted regardless of age """ from config import * @@ -449,20 +450,11 @@ def process_public_retention(): 'document_details': [] } - # Process conversations - if conversation_retention_days != 'none': - try: - conv_results = delete_aged_conversations( - public_workspace_id=workspace_id, - retention_days=int(conversation_retention_days), - workspace_type='public' - ) - workspace_deletion_summary['conversations_deleted'] = conv_results['count'] - workspace_deletion_summary['conversation_details'] = conv_results['details'] - results['conversations'] += conv_results['count'] - except Exception as e: - log_event("process_public_retention_conversations_error", {"error": str(e), "public_workspace_id": workspace_id}) - debug_print(f"Error processing conversations for public workspace {workspace_id}: {e}") + # Note: Public workspaces do not have a separate conversations container. + # Conversations are only stored in personal (cosmos_conversations_container) or + # group (cosmos_group_conversations_container) workspaces. + # Therefore, we skip conversation processing for public workspaces. + # Only documents are processed for public workspace retention. # Process documents if document_retention_days != 'none': @@ -529,14 +521,16 @@ def delete_aged_conversations(retention_days, workspace_type='personal', user_id cutoff_iso = cutoff_date.isoformat() # Query for aged conversations - # Check for null/undefined FIRST to avoid comparing null values with dates + # ONLY delete conversations that have a valid last_activity_at that is older than the cutoff + # Conversations with null/undefined last_activity_at should be SKIPPED (not deleted) + # This prevents accidentally deleting new conversations that haven't had activity tracked yet query = f""" SELECT c.id, c.title, c.last_activity_at, c.{partition_field} FROM c WHERE c.{partition_field} = @partition_value - AND (NOT IS_DEFINED(c.last_activity_at) - OR IS_NULL(c.last_activity_at) - OR (IS_DEFINED(c.last_activity_at) AND NOT IS_NULL(c.last_activity_at) AND c.last_activity_at < @cutoff_date)) + AND IS_DEFINED(c.last_activity_at) + AND NOT IS_NULL(c.last_activity_at) + AND c.last_activity_at < @cutoff_date """ parameters = [ diff --git a/application/single_app/static/js/control-center.js b/application/single_app/static/js/control-center.js index bf155fe7..7e79d22a 100644 --- a/application/single_app/static/js/control-center.js +++ b/application/single_app/static/js/control-center.js @@ -3639,6 +3639,32 @@ async function loadRefreshStatus() { lastRefreshElement.textContent = 'Error loading'; } } + + // Load and display auto-refresh schedule info + try { + const response = await fetch('/api/admin/control-center/refresh-status'); + if (response.ok) { + const result = await response.json(); + const autoRefreshInfoElement = document.getElementById('autoRefreshInfo'); + const autoRefreshStatusElement = document.getElementById('autoRefreshStatus'); + + if (autoRefreshInfoElement && autoRefreshStatusElement) { + if (result.auto_refresh_enabled) { + // Build status text + let statusText = `Auto-refresh: daily at ${result.auto_refresh_hour_formatted || result.auto_refresh_hour + ':00 UTC'}`; + if (result.auto_refresh_next_run_formatted) { + statusText += ` (next: ${result.auto_refresh_next_run_formatted})`; + } + autoRefreshStatusElement.textContent = statusText; + autoRefreshInfoElement.classList.remove('d-none'); + } else { + autoRefreshInfoElement.classList.add('d-none'); + } + } + } + } catch (autoRefreshError) { + console.error('Error loading auto-refresh status:', autoRefreshError); + } } async function refreshActiveTabContent() { diff --git a/application/single_app/templates/control_center.html b/application/single_app/templates/control_center.html index 7a86d961..dbdce4d7 100644 --- a/application/single_app/templates/control_center.html +++ b/application/single_app/templates/control_center.html @@ -443,12 +443,18 @@

Control Center

Manage users and their workspaces, groups and their workspaces, and public workspaces.

-
+
Data last refreshed: Loading...
+
+ + + Auto-refresh scheduled + +