From 602d0ad4dcd60a5ce42e72e593720255ee684371 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Sat, 20 Dec 2025 22:36:10 -0500 Subject: [PATCH 01/48] added group status (active, locked, upload disabled, and inactive) Adds Azure Billing Plugin in Community Customizations --- .../single_app/route_backend_groups.py | 79 ++- .../templates/group_workspaces.html | 272 +++++++- .../single_app/templates/manage_group.html | 146 ++++- .../single_app/templates/my_groups.html | 47 +- docs/features/GROUP_STATUS_MANAGEMENT.md | 585 ++++++++++++++++++ docs/features/GROUP_STATUS_UI_VISIBILITY.md | 229 +++++++ docs/fixes/GROUP_STATUS_UI_VISIBILITY_FIX.md | 240 +++++++ .../test_group_status_ui_visibility.py | 265 ++++++++ 8 files changed, 1809 insertions(+), 54 deletions(-) create mode 100644 docs/features/GROUP_STATUS_MANAGEMENT.md create mode 100644 docs/features/GROUP_STATUS_UI_VISIBILITY.md create mode 100644 docs/fixes/GROUP_STATUS_UI_VISIBILITY_FIX.md create mode 100644 functional_tests/test_group_status_ui_visibility.py diff --git a/application/single_app/route_backend_groups.py b/application/single_app/route_backend_groups.py index b23ad7fba..e2efaa9d8 100644 --- a/application/single_app/route_backend_groups.py +++ b/application/single_app/route_backend_groups.py @@ -112,7 +112,8 @@ def api_list_groups(): "name": g.get("name", "Untitled Group"), # Provide default name "description": g.get("description", ""), "userRole": role, - "isActive": (g["id"] == db_active_group_id) + "isActive": (g["id"] == db_active_group_id), + "status": g.get("status", "active") # Include group status }) return jsonify({ @@ -384,6 +385,7 @@ def add_member_directly(group_id): """ user_info = get_current_user_info() user_id = user_info["userId"] + user_email = user_info.get("email", "unknown") group_doc = find_group_by_id(group_id) @@ -402,16 +404,56 @@ def add_member_directly(group_id): if get_user_role_in_group(group_doc, new_user_id): return jsonify({"error": "User is already a member"}), 400 + # Get role from request, default to 'user' + member_role = data.get("role", "user").lower() + + # Validate role + valid_roles = ['admin', 'document_manager', 'user'] + if member_role not in valid_roles: + return jsonify({"error": f"Invalid role. Must be: {', '.join(valid_roles)}"}), 400 + new_member_doc = { "userId": new_user_id, "email": data.get("email", ""), "displayName": data.get("displayName", "New User") } group_doc["users"].append(new_member_doc) + + # Add to appropriate role array + if member_role == 'admin': + if new_user_id not in group_doc.get('admins', []): + group_doc.setdefault('admins', []).append(new_user_id) + elif member_role == 'document_manager': + if new_user_id not in group_doc.get('documentManagers', []): + group_doc.setdefault('documentManagers', []).append(new_user_id) + group_doc["modifiedDate"] = datetime.utcnow().isoformat() cosmos_groups_container.upsert_item(group_doc) - return jsonify({"message": "Member added"}), 200 + + # Log activity for member addition + try: + activity_record = { + 'id': str(uuid.uuid4()), + 'activity_type': 'group_member_added', + 'action': 'add_member_directly', + 'timestamp': datetime.utcnow().isoformat(), + 'added_by_user_id': user_id, + 'added_by_email': user_email, + 'added_by_role': role, + 'group_id': group_id, + 'group_name': group_doc.get('name', 'Unknown'), + 'member_user_id': new_user_id, + 'member_email': new_member_doc.get('email', ''), + 'member_name': new_member_doc.get('displayName', ''), + 'member_role': member_role, + 'description': f"{role} {user_email} added member {new_member_doc.get('displayName', '')} ({new_member_doc.get('email', '')}) to group {group_doc.get('name', group_id)} as {member_role}" + } + cosmos_activity_logs_container.create_item(body=activity_record) + except Exception as log_error: + current_app.logger.error(f"Failed to log member addition activity: {log_error}") + + return jsonify({"message": "Member added", "success": True}), 200 @app.route("/api/groups//members/", methods=["DELETE"]) @swagger_route(security=get_auth_security()) @@ -505,6 +547,7 @@ def update_member_role(group_id, member_id): """ user_info = get_current_user_info() user_id = user_info["userId"] + user_email = user_info.get("email", "unknown") group_doc = find_group_by_id(group_id) @@ -524,6 +567,15 @@ def update_member_role(group_id, member_id): if not target_role: return jsonify({"error": "Member is not in the group"}), 404 + # Get member details for logging + member_name = "Unknown" + member_email = "unknown" + for u in group_doc.get("users", []): + if u.get("userId") == member_id: + member_name = u.get("displayName", "Unknown") + member_email = u.get("email", "unknown") + break + if member_id in group_doc.get("admins", []): group_doc["admins"].remove(member_id) if member_id in group_doc.get("documentManagers", []): @@ -539,6 +591,29 @@ def update_member_role(group_id, member_id): group_doc["modifiedDate"] = datetime.utcnow().isoformat() cosmos_groups_container.upsert_item(group_doc) + # Log activity for role change + try: + activity_record = { + 'id': str(uuid.uuid4()), + 'type': 'group_member_role_changed', + 'action': 'update_member_role', + 'timestamp': datetime.utcnow().isoformat(), + 'changed_by_user_id': user_id, + 'changed_by_email': user_email, + 'changed_by_role': current_role, + 'group_id': group_id, + 'group_name': group_doc.get('name', 'Unknown'), + 'member_user_id': member_id, + 'member_email': member_email, + 'member_name': member_name, + 'old_role': target_role, + 'new_role': new_role, + 'description': f"{current_role} {user_email} changed {member_name} ({member_email}) role from {target_role} to {new_role} in group {group_doc.get('name', group_id)}" + } + cosmos_activity_logs_container.create_item(body=activity_record) + except Exception as log_error: + current_app.logger.error(f"Failed to log role change activity: {log_error}") + return jsonify({"message": f"User {member_id} updated to {new_role}"}), 200 @app.route("/api/groups//members", methods=["GET"]) diff --git a/application/single_app/templates/group_workspaces.html b/application/single_app/templates/group_workspaces.html index 7f7910725..53208b590 100644 --- a/application/single_app/templates/group_workspaces.html +++ b/application/single_app/templates/group_workspaces.html @@ -145,6 +145,28 @@ #group-dropdown-button { text-align: left; } + + /* Group status badges */ + .group-status-badge { + display: inline-block; + padding: 0.2em 0.5em; + font-size: 0.75em; + font-weight: 600; + margin-left: 0.5rem; + border-radius: 0.25rem; + } + .group-status-locked { + background-color: #ffc107; + color: #000; + } + .group-status-upload-disabled { + background-color: #17a2b8; + color: #fff; + } + .group-status-inactive { + background-color: #dc3545; + color: #fff; + } {% endblock %} {% block content %}
@@ -189,6 +211,11 @@

Group Workspace

+ +
+ +
+ +
  • + + Approval Requests + +
  • {% if app_settings.enable_group_workspaces %}
  • diff --git a/application/single_app/templates/_top_nav.html b/application/single_app/templates/_top_nav.html index 4d0d4e024..ea8e5284b 100644 --- a/application/single_app/templates/_top_nav.html +++ b/application/single_app/templates/_top_nav.html @@ -149,6 +149,11 @@
  • +
  • + + Approval Requests + +
  • {% if app_settings.enable_group_workspaces %}
  • diff --git a/application/single_app/templates/approvals.html b/application/single_app/templates/approvals.html new file mode 100644 index 000000000..4a851ae68 --- /dev/null +++ b/application/single_app/templates/approvals.html @@ -0,0 +1,670 @@ +{% extends "base.html" %} + +{% block title %}Approval Requests - {{ app_settings.app_title }}{% endblock %} + +{% block content %} +
    +
    +
    +
    +

    + Approval Requests +

    + +
    + + +
    +
    +
    + + +
    +
    +
    + +
    +
    + +
    +
    + +
    +
    + + +
    +
    +
    + + + + + + + + + + + + + + + + +
    Request TypeGroup NameRequested ByCreatedStatusActions
    +
    + Loading... +
    +
    Loading approvals...
    +
    +
    + + +
    +
    + +
    + +
    +
    +
    +
    +
    +
    + + + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/application/single_app/templates/control_center.html b/application/single_app/templates/control_center.html index 5fc3803f0..0cea3a27f 100644 --- a/application/single_app/templates/control_center.html +++ b/application/single_app/templates/control_center.html @@ -1230,6 +1230,9 @@
    File Upload Control
    + + + @@ -1584,7 +1629,7 @@
    Member Management
    Add/remove members and assign roles
    - See detailed group activity timeline @@ -1966,6 +2011,70 @@
    + + + + + +
    @@ -2174,7 +2283,8 @@
    ${title}
    // Initialize table sorting when the page loads document.addEventListener('DOMContentLoaded', function() { - // Ensure GroupManager is available globally + // Ensure managers are available globally window.GroupManager = GroupManager; // Wait a bit for the control center to load diff --git a/application/single_app/templates/group_workspaces.html b/application/single_app/templates/group_workspaces.html index 53208b590..88d95ee9b 100644 --- a/application/single_app/templates/group_workspaces.html +++ b/application/single_app/templates/group_workspaces.html @@ -436,6 +436,26 @@
    Group Documents
    + + + @@ -444,17 +464,7 @@
    Group Documents
    - + @@ -1401,55 +1411,37 @@
    Currently Shared With:
    } function updateGroupBulkActionButtons() { + const bulkActionsBar = document.getElementById("groupBulkActionsBar"); + const selectedCountSpan = document.getElementById("groupSelectedCount"); const deleteBtn = document.getElementById("group-delete-selected-btn"); const removeBtn = document.getElementById("group-remove-selected-btn"); - const bulkActions = document.getElementById("group-bulk-actions"); if (groupSelectedDocuments.size > 0) { - // Show bulk actions container - if (bulkActions) bulkActions.style.display = "inline-block"; + // Show bulk actions bar with count + if (bulkActionsBar) { + bulkActionsBar.style.display = "block"; + } + if (selectedCountSpan) { + selectedCountSpan.textContent = groupSelectedDocuments.size; + } // Check if user can manage documents (delete permission) const canManage = ["Owner", "Admin", "DocumentManager"].includes(userRoleInActiveGroup); - // For group documents, we need to determine if any selected documents - // require different actions based on user permissions - let hasOwnedDocuments = false; - let hasNonOwnedDocuments = false; - - // Check ownership of selected documents - Array.from(groupSelectedDocuments).forEach(docId => { - const docRow = document.getElementById(`group-doc-row-${docId}`); - if (docRow && docRow.__docData) { - const doc = docRow.__docData; - // In group context, we check if user has management permissions - // rather than individual document ownership - if (canManage) { - hasOwnedDocuments = true; - } else { - hasNonOwnedDocuments = true; - } - } - }); - - // Delete button: Show only if user has management permissions for group documents + // Show/hide delete and remove buttons based on permissions if (deleteBtn) { - deleteBtn.style.display = (canManage && hasOwnedDocuments) ? "inline-block" : "none"; + deleteBtn.style.display = canManage ? "inline-block" : "none"; } - // Remove button: Show for non-managers or when user wants to "remove from group" - // In group context, this could mean removing the document from the group - // but keeping it in the system (if supported by backend) + // Remove button is available for group documents if (removeBtn) { - // For now, hide remove button as group documents are typically deleted, not removed - // This can be adjusted based on backend implementation - removeBtn.style.display = "none"; + removeBtn.style.display = "inline-block"; } } else { - // No documents selected - hide everything - if (deleteBtn) deleteBtn.style.display = "none"; - if (removeBtn) removeBtn.style.display = "none"; - if (bulkActions) bulkActions.style.display = "none"; + // Hide bulk actions bar + if (bulkActionsBar) { + bulkActionsBar.style.display = "none"; + } } } @@ -1457,6 +1449,7 @@
    Currently Shared With:
    const table = document.getElementById("group-documents-table"); const checkboxes = document.querySelectorAll('.document-checkbox'); const expandContainers = document.querySelectorAll('.expand-collapse-container'); + const bulkActionsBar = document.getElementById("groupBulkActionsBar"); groupSelectionMode = !groupSelectionMode; @@ -1472,12 +1465,6 @@
    Currently Shared With:
    expandContainers.forEach(container => { container.style.display = 'none'; }); - - // Show bulk actions - const bulkActions = document.getElementById("group-bulk-actions"); - if (bulkActions) { - bulkActions.style.display = 'inline-block'; - } } else { // Exit selection mode table.classList.remove("selection-mode"); @@ -1492,21 +1479,26 @@
    Currently Shared With:
    container.style.display = 'inline-block'; }); - // Hide bulk actions and buttons - const bulkActions = document.getElementById("group-bulk-actions"); - if (bulkActions) { - bulkActions.style.display = 'none'; + // Hide bulk actions bar + if (bulkActionsBar) { + bulkActionsBar.style.display = 'none'; } - const deleteBtn = document.getElementById("group-delete-selected-btn"); - const removeBtn = document.getElementById("group-remove-selected-btn"); - if (deleteBtn) deleteBtn.style.display = 'none'; - if (removeBtn) removeBtn.style.display = 'none'; // Clear selected documents groupSelectedDocuments.clear(); } } + // Clear group selection + function clearGroupSelection() { + const checkboxes = document.querySelectorAll('.document-checkbox'); + checkboxes.forEach(checkbox => { + checkbox.checked = false; + }); + groupSelectedDocuments.clear(); + updateGroupBulkActionButtons(); + } + function deleteGroupSelectedDocuments() { if (groupSelectedDocuments.size === 0) return; @@ -1662,6 +1654,7 @@
    Currently Shared With:
    // Add event listeners for bulk action buttons const groupDeleteSelectedBtn = document.getElementById("group-delete-selected-btn"); const groupRemoveSelectedBtn = document.getElementById("group-remove-selected-btn"); + const groupClearSelectionBtn = document.getElementById("group-clear-selection-btn"); if (groupDeleteSelectedBtn) { groupDeleteSelectedBtn.addEventListener("click", deleteGroupSelectedDocuments); @@ -1669,6 +1662,9 @@
    Currently Shared With:
    if (groupRemoveSelectedBtn) { groupRemoveSelectedBtn.addEventListener("click", removeGroupSelectedDocuments); } + if (groupClearSelectionBtn) { + groupClearSelectionBtn.addEventListener("click", clearGroupSelection); + } // Load initial data fetchUserGroups().then(() => { diff --git a/application/single_app/templates/manage_group.html b/application/single_app/templates/manage_group.html index 18dc6a13d..91e1f62b1 100644 --- a/application/single_app/templates/manage_group.html +++ b/application/single_app/templates/manage_group.html @@ -91,9 +91,32 @@
    Membership
    + + +
    File Name TitleActions - - Actions
    + @@ -303,6 +326,88 @@ + + + + + +
    + + Name Role Actions
    diff --git a/application/single_app/templates/workspace.html b/application/single_app/templates/workspace.html index f9d1e2025..ade4a6c6f 100644 --- a/application/single_app/templates/workspace.html +++ b/application/single_app/templates/workspace.html @@ -355,6 +355,23 @@
    Your Documents
    + + +
    @@ -364,17 +381,7 @@
    Your Documents
    - + diff --git a/docs/explanation/features/v0.229.058/PUBLIC_WORKSPACE_GOTO_BUTTON_ENHANCEMENT.md b/docs/explanation/features/v0.229.058/PUBLIC_WORKSPACE_GOTO_BUTTON_ENHANCEMENT.md index a7c88c725..73fee8c36 100644 --- a/docs/explanation/features/v0.229.058/PUBLIC_WORKSPACE_GOTO_BUTTON_ENHANCEMENT.md +++ b/docs/explanation/features/v0.229.058/PUBLIC_WORKSPACE_GOTO_BUTTON_ENHANCEMENT.md @@ -34,6 +34,7 @@ Added a "Go to Public Workspace" button to the Public Workspace Management page, **New Route Added:** ```python @app.route('/set_active_public_workspace', methods=['POST']) +@swagger_route(security=get_auth_security()) @login_required @user_required @enabled_required("enable_public_workspaces") diff --git a/docs/features/APPROVAL_WORKFLOW_SYSTEM.md b/docs/features/APPROVAL_WORKFLOW_SYSTEM.md new file mode 100644 index 000000000..9660b947e --- /dev/null +++ b/docs/features/APPROVAL_WORKFLOW_SYSTEM.md @@ -0,0 +1,531 @@ +# Approval Workflow System for Control Center + +## Overview +The Approval Workflow System adds a comprehensive approval process for sensitive administrative operations in the Control Center. This system ensures that high-impact actions require review and approval from authorized users before execution, providing an audit trail and preventing unauthorized or accidental changes. + +## Version Information +- **Implemented in**: Version 0.234.034 +- **Feature Type**: Security & Governance Enhancement +- **Scope**: Control Center Administrative Operations + +## Feature Description + +### Purpose +Enable a controlled approval process for sensitive group management operations, requiring approval from group owners or other administrators before execution. This provides: +- **Accountability**: Every sensitive action requires documented justification +- **Review Process**: Group owners or admins must review and approve changes +- **Audit Trail**: Complete history of approval requests, approvals, and denials +- **Auto-Expiration**: Requests automatically expire after 3 days to prevent stale approvals +- **Notification Integration**: Seamless integration with existing notification system + +### Scope of Protected Operations +The following Control Center operations now require approval: + +1. **Take Ownership** - Admin assumes ownership of a group +2. **Transfer Ownership** - Transfer group ownership to another user +3. **Delete Documents** - Delete all documents within a group +4. **Delete Group** - Permanently delete an entire group + +## Architecture + +### Database Schema + +#### Cosmos DB Container: `approvals` +- **Partition Key**: `/group_id` +- **TTL**: Enabled (3-day auto-expiration) +- **Document Structure**: + ```json + { + "id": "uuid", + "group_id": "group_id", + "group_name": "Group Name", + "action_type": "take_ownership|transfer_ownership|delete_documents|delete_group", + "requested_by": "user_id", + "reason": "Reason for request", + "status": "pending|approved|denied", + "created_at": "ISO timestamp", + "expires_at": "ISO timestamp (created_at + 3 days)", + "approved_by": "user_id (if approved)", + "approved_at": "ISO timestamp (if approved)", + "denied_by": "user_id (if denied)", + "denied_at": "ISO timestamp (if denied)", + "admin_comment": "Optional comment from approver", + "auto_denied": true|false, + "action_params": { + "newOwnerId": "user_id (for transfer_ownership)" + } + } + ``` + +### Backend Components + +#### 1. `functions_approvals.py` (NEW) +Core approval workflow management system. + +**Key Functions**: +- `create_approval_request(group_id, group_name, action_type, requested_by, reason, action_params=None)` + - Creates a new approval request with 3-day TTL + - Returns approval ID for tracking + - Sends notification to eligible approvers + +- `get_pending_approvals(user_id, filters=None)` + - Retrieves approval requests for current user + - Filters by status, action type, and search query + - Determines approval eligibility using `_can_user_approve()` + +- `approve_request(approval_id, approved_by, group_id, comment=None)` + - Approves request and executes the action + - Calls appropriate execution function + - Updates approval status and sends notifications + +- `deny_request(approval_id, denied_by, group_id, comment)` + - Denies request with required comment + - Updates approval status + - Sends notification to requester + +- `auto_deny_expired_approvals()` + - Background job function + - Finds approvals past expiration date + - Auto-denies with `auto_denied` flag + - Sends expiration notifications + +**Approval Eligibility Logic** (`_can_user_approve()`): +Users can approve a request if they are: +- The group owner, OR +- A ControlCenterAdmin, OR +- An admin (system administrator) + +AND they are NOT the person who created the request (cannot approve own requests). + +**Action Execution Functions**: +- `_execute_take_ownership(group_id, requested_by)` - Transfers ownership to requesting admin +- `_execute_transfer_ownership(group_id, new_owner_id)` - Transfers ownership to specified user +- `_execute_delete_documents(group_id, requested_by)` - Deletes all documents in group +- `_execute_delete_group(group_id)` - Permanently deletes the entire group + +#### 2. `route_backend_control_center.py` (MODIFIED) +Added new approval endpoints and modified existing group management endpoints. + +**New Endpoints**: +- `GET /api/admin/control-center/approvals` + - Lists approval requests with filtering and pagination + - Filters: status, action_type, search query + - Only returns approvals user is eligible to see/approve + +- `POST /api/admin/control-center/approvals//approve` + - Approves an approval request + - Executes the associated action + - Requires: groupId in body, optional comment + +- `POST /api/admin/control-center/approvals//deny` + - Denies an approval request + - Requires: groupId and comment in body + +**Modified Endpoints** (now create approval requests): +- `POST /api/admin/control-center/groups//take-ownership` + - Now requires `reason` parameter + - Creates approval request instead of immediate execution + - Returns `approval_id` instead of confirmation + +- `POST /api/admin/control-center/groups//transfer-ownership` + - Now requires `reason` and `newOwnerId` parameters + - Creates approval request instead of immediate execution + - Returns `approval_id` instead of confirmation + +- `DELETE /api/admin/control-center/groups/` + - Now requires `reason` in request body + - Creates approval request instead of immediate deletion + - Returns `approval_id` instead of confirmation + +- `POST /api/admin/control-center/groups//delete-documents` (NEW) + - Creates approval request for document deletion + - Requires `reason` parameter + - Returns `approval_id` + +#### 3. `app.py` (MODIFIED) +Added scheduled background job for auto-denying expired approvals. + +**Background Thread**: +```python +def check_expired_approvals(): + while True: + try: + time.sleep(21600) # Check every 6 hours + auto_deny_expired_approvals() + except Exception as e: + logging.error(f"Error in approval expiration check: {e}") +``` + +Runs as daemon thread, checks every 6 hours for expired approvals and auto-denies them. + +#### 4. `config.py` (MODIFIED) +Added Cosmos DB container configuration: +```python +cosmos_approvals_container = cosmos_database.create_container_if_not_exists( + id='approvals', + partition_key=PartitionKey(path='/group_id'), + default_ttl=-1 +) +``` + +### Frontend Components + +#### 1. Control Center UI - Approvals Tab +New dedicated tab in Control Center for managing approval requests. + +**Features**: +- Responsive table displaying all approval requests +- Filter by status (all, pending, approved, denied) +- Filter by action type (all, take_ownership, transfer_ownership, delete_documents, delete_group) +- Search by group name or reason +- Pagination support +- Real-time approval/denial actions +- Loading indicators and error handling + +**Table Columns**: +- Status (badge: Pending/Approved/Denied/Auto-Denied) +- Action Type (badge with icon) +- Group Name +- Reason +- Requested By +- Created At +- Actions (Approve/Deny buttons when eligible) + +#### 2. Approval Action Modal +Modal dialog for approving or denying requests. + +**Components**: +- Approval/Denial confirmation +- Comment field (optional for approval, required for denial) +- Approve/Deny action buttons +- Request context display + +#### 3. Group Management Modal (MODIFIED) +Updated group management modal to include reason input for ownership changes. + +**Changes**: +- Added `ownershipReasonGroup` textarea +- Shows/hides based on ownership dropdown selection +- Required for "Take Ownership" and "Transfer to Another User" options +- Integrated into `saveGroupChanges()` function + +#### 4. JavaScript Manager: `ApprovalManager` +New JavaScript object following the `GroupManager` pattern. + +**Key Functions**: +- `init()` - Initialize approval management and bind events +- `loadApprovals(page)` - Load approvals from API with filters and pagination +- `renderApprovalRow(approval)` - Render individual approval row +- `showApprovalModal(approvalId, groupId, action)` - Display approval/denial modal +- `handleApprove()` - Process approval request +- `handleDeny()` - Process denial request +- `updatePagination(totalCount, currentPage, pageSize)` - Update pagination controls +- `refreshApprovals()` - Reload current page + +**Event Handlers**: +- Search input filtering +- Status filter dropdown +- Action type filter dropdown +- Approve/Deny button clicks +- Pagination controls + +#### 5. Modified JavaScript Functions +Updated existing JavaScript functions to handle approval workflow: + +**`saveGroupChanges()`**: +- Validates reason field when ownership change selected +- Includes reason in API request body +- Shows approval request confirmation instead of immediate success +- Displays approval ID in response message + +**`deleteDocuments()`**: +- Prompts for reason using browser prompt +- Validates reason input +- Calls POST /delete-documents endpoint +- Shows approval request confirmation + +**`deleteGroup()`**: +- Prompts for reason using browser prompt +- Validates reason input +- Includes reason in DELETE request body +- Shows approval request confirmation + +## User Workflows + +### Creating an Approval Request + +1. **Navigate to Control Center** → Groups tab +2. **Open Group Management Modal** for target group +3. **Select Sensitive Action**: + - Take Ownership (dropdown) + - Transfer Ownership (dropdown + user selection) + - Delete Documents (button) + - Delete Group (button) +4. **Provide Reason** (required) +5. **Confirm Action** → Approval request created +6. **Notification Sent** to eligible approvers (group owner + admins) + +### Approving a Request + +1. **Navigate to Control Center** → Approvals tab +2. **View Pending Requests** (filtered automatically) +3. **Click "Approve"** button on desired request +4. **Add Optional Comment** (recommended but not required) +5. **Confirm Approval** → Action executes immediately +6. **Notification Sent** to requester confirming approval and execution + +### Denying a Request + +1. **Navigate to Control Center** → Approvals tab +2. **View Pending Requests** +3. **Click "Deny"** button on desired request +4. **Provide Denial Reason** (required) +5. **Confirm Denial** +6. **Notification Sent** to requester with denial reason + +### Monitoring Approvals + +1. **Navigate to Control Center** → Approvals tab +2. **Use Filters**: + - Status: All, Pending, Approved, Denied + - Action Type: All, specific action types + - Search: Group name or reason keywords +3. **View Request Details** in table +4. **Check Expiration Time** for pending requests (shows hours remaining) +5. **Review Historical Approvals** (approved/denied remain visible) + +## Auto-Denial Process + +### Trigger Conditions +Approval requests are automatically denied when: +- Request is still in `pending` status +- Current time exceeds `expires_at` timestamp (3 days from creation) +- Background job detects expired request + +### Auto-Denial Workflow +1. **Background Job** runs every 6 hours (`app.py`) +2. **Queries** for pending approvals past expiration +3. **Updates Status** to `denied` with `auto_denied: true` flag +4. **Sends Notification** to requester explaining auto-denial +5. **Logs Event** for audit trail + +### Backup Expiration +Cosmos DB TTL provides secondary expiration mechanism: +- Document is automatically deleted 3 days after creation +- Acts as cleanup for expired approvals +- Ensures no indefinite pending requests + +## Notification Integration + +### Notification Events + +#### 1. Approval Request Created +- **Recipients**: All eligible approvers (group owner + admins) +- **Message**: "[User] has requested [action] for group [group_name]. Reason: [reason]. Expires in 3 days." +- **Action Link**: Link to Approvals tab with filter + +#### 2. Request Approved +- **Recipients**: Original requester +- **Message**: "Your request for [action] on group [group_name] has been APPROVED by [approver]. Comment: [comment]" +- **Action Link**: Link to group or approvals tab + +#### 3. Request Denied +- **Recipients**: Original requester +- **Message**: "Your request for [action] on group [group_name] has been DENIED by [denier]. Reason: [comment]" +- **Action Link**: Link to approvals tab + +#### 4. Request Auto-Denied +- **Recipients**: Original requester +- **Message**: "Your request for [action] on group [group_name] has expired and was automatically denied after 3 days." +- **Action Link**: Link to create new request + +## Security Considerations + +### Authorization +- **Request Creation**: Any admin can create approval requests +- **Request Approval**: Only eligible users can approve (owner OR admin, but NOT requester) +- **Request Denial**: Only eligible users can deny (same as approval) +- **View Permissions**: Users only see requests they created or can approve + +### Audit Trail +All approval actions are logged with: +- Requester ID and timestamp +- Approver/Denier ID and timestamp +- Reason for request +- Admin comment (for approval/denial) +- Auto-denial flag (for expired requests) + +### Isolation +- Approval requests partitioned by `group_id` +- Users cannot approve their own requests +- Approvals tied to specific groups for access control validation + +## Configuration + +### Required Environment Variables +No new environment variables required. Uses existing: +- Azure Cosmos DB connection settings +- Notification system settings + +### Cosmos DB Configuration +Container created automatically on startup: +```python +cosmos_approvals_container = cosmos_database.create_container_if_not_exists( + id='approvals', + partition_key=PartitionKey(path='/group_id'), + default_ttl=-1 # Enable TTL, set per-document +) +``` + +### Approval Expiration Settings +**Expiration Period**: 3 days (72 hours) +- Set at request creation: `expires_at = created_at + 3 days` +- Checked by background job every 6 hours +- Cosmos DB TTL cleanup ensures document deletion + +**Configurable in**: `functions_approvals.py` +```python +expires_at = datetime.now(timezone.utc) + timedelta(days=3) +``` + +## Testing + +### Manual Testing Checklist + +#### Backend Approval Creation +- [ ] Create take_ownership approval request +- [ ] Create transfer_ownership approval request +- [ ] Create delete_documents approval request +- [ ] Create delete_group approval request +- [ ] Verify approval ID returned in response +- [ ] Verify notification sent to eligible approvers + +#### Backend Approval Actions +- [ ] Approve take_ownership request +- [ ] Approve transfer_ownership request +- [ ] Approve delete_documents request +- [ ] Approve delete_group request +- [ ] Deny each request type with comment +- [ ] Verify action execution after approval +- [ ] Verify notification sent after approval/denial + +#### Frontend Approvals Tab +- [ ] Navigate to Approvals tab +- [ ] Verify pending approvals displayed +- [ ] Filter by status (pending/approved/denied) +- [ ] Filter by action type +- [ ] Search by group name +- [ ] Verify pagination works correctly +- [ ] Verify "Approve" button shown when eligible +- [ ] Verify "Cannot approve own request" message shown + +#### Frontend Approval Actions +- [ ] Click "Approve" button → modal opens +- [ ] Submit approval with comment +- [ ] Click "Deny" button → modal opens +- [ ] Submit denial with comment +- [ ] Verify approval/denial success messages +- [ ] Verify approvals list refreshes after action + +#### Group Management Integration +- [ ] Select "Take Ownership" → reason field appears +- [ ] Select "Transfer to Another User" → reason field appears +- [ ] Submit ownership change with reason +- [ ] Verify approval request created message +- [ ] Click "Delete Documents" → reason prompt appears +- [ ] Submit delete documents with reason +- [ ] Click "Delete Group" → reason prompt appears +- [ ] Submit delete group with reason + +#### Auto-Denial Testing +- [ ] Create approval request +- [ ] Fast-forward system time by 3 days (OR wait) +- [ ] Trigger background job manually +- [ ] Verify request auto-denied +- [ ] Verify auto_denied flag set +- [ ] Verify notification sent to requester + +#### Authorization Testing +- [ ] Verify group owner can approve requests +- [ ] Verify admin can approve requests +- [ ] Verify requester cannot approve own request +- [ ] Verify non-eligible users cannot see request +- [ ] Verify users only see relevant approvals + +### Functional Test Files +No dedicated functional test file created yet. Recommended test file: +`functional_tests/test_approval_workflow_system.py` + +## Performance Considerations + +### Database Queries +- **List Approvals**: Partitioned by group_id, uses pagination +- **Approval Lookup**: Direct ID lookup within group partition +- **Expiration Check**: Query for pending + expired_at < now (runs every 6 hours) + +### Optimization Strategies +- Partition by group_id ensures efficient queries +- TTL cleanup reduces database size over time +- Background job runs every 6 hours (not continuous) +- Pagination limits frontend data transfer + +## Known Limitations + +1. **Bulk Approvals**: No bulk approve/deny functionality yet +2. **Approval History**: Limited visibility into approval history per group +3. **Cancellation**: Requester cannot cancel pending requests +4. **Re-request**: No automatic retry after denial (must manually create new request) +5. **Mobile UI**: Approvals tab not optimized for mobile devices yet + +## Future Enhancements + +### Potential Improvements +1. **Approval Delegation**: Allow owners to delegate approval rights to specific users +2. **Escalation**: Auto-escalate to higher admin levels after certain time period +3. **Batch Operations**: Bulk approve/deny multiple requests +4. **Enhanced Audit**: Detailed activity timeline for each approval +5. **Email Notifications**: Send email alerts for approval requests (in addition to in-app) +6. **Request Cancellation**: Allow requesters to cancel pending requests +7. **Approval Templates**: Pre-defined reason templates for common scenarios +8. **Analytics Dashboard**: Metrics on approval patterns, average approval time, denial rates +9. **Custom Expiration**: Allow admins to configure expiration period per action type +10. **Approval Comments**: Threaded comments/discussion on approval requests + +## Related Documentation +- [Control Center Overview](../admin_configuration.md) +- [Notification System](../features/NOTIFICATION_SYSTEM.md) (if exists) +- [Group Management](../features/GROUP_MANAGEMENT.md) (if exists) + +## Support & Troubleshooting + +### Common Issues + +**Issue**: Approval requests not appearing in Approvals tab +- **Cause**: User not eligible to approve, or filter set incorrectly +- **Solution**: Check status filter is set to "Pending", verify user is group owner or admin + +**Issue**: Cannot approve own request +- **Cause**: System prevents self-approval for security +- **Solution**: Ask another admin or the group owner to approve + +**Issue**: Approval request expired +- **Cause**: Request was created more than 3 days ago +- **Solution**: Create a new approval request with updated reason + +**Issue**: Action not executed after approval +- **Cause**: Approval succeeded but execution failed +- **Solution**: Check server logs for execution errors, verify group still exists + +**Issue**: Notifications not sent +- **Cause**: Notification system error or user preferences +- **Solution**: Check notification system logs, verify user notification preferences + +### Debug Logging +Enable approval workflow logging in `functions_approvals.py`: +```python +logging.debug(f"Creating approval request: {action_type} for group {group_id}") +logging.debug(f"Approval eligibility check: user={user_id}, group_owner={group_owner}") +``` + +## Conclusion +The Approval Workflow System provides a robust, secure, and user-friendly mechanism for managing sensitive Control Center operations. By requiring documented justification and multi-party approval, it ensures accountability while maintaining operational flexibility through auto-expiration and comprehensive notification integration. From 7ed89a1d32f6c7e8bff60e5fe355dc188f6eb634 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Tue, 23 Dec 2025 15:50:45 -0500 Subject: [PATCH 12/48] Updated approval system --- application/single_app/functions_approvals.py | 756 ++++++++++++++++++ 1 file changed, 756 insertions(+) create mode 100644 application/single_app/functions_approvals.py diff --git a/application/single_app/functions_approvals.py b/application/single_app/functions_approvals.py new file mode 100644 index 000000000..d99de9922 --- /dev/null +++ b/application/single_app/functions_approvals.py @@ -0,0 +1,756 @@ +# functions_approvals.py + +""" +Approval workflow functions for Control Center administrative operations. +Handles approval requests for sensitive operations like ownership transfers, +group deletions, and document deletions. +""" + +import uuid +import logging +from datetime import datetime, timedelta +from typing import Optional, List, Dict, Any +from config import cosmos_approvals_container, cosmos_groups_container +from functions_appinsights import log_event +from functions_notifications import create_notification +from functions_group import find_group_by_id +from functions_debug import debug_print + +# Approval request statuses +STATUS_PENDING = "pending" +STATUS_APPROVED = "approved" +STATUS_DENIED = "denied" +STATUS_AUTO_DENIED = "auto_denied" +STATUS_EXECUTED = "executed" +STATUS_FAILED = "failed" + +# Approval request types +TYPE_TAKE_OWNERSHIP = "take_ownership" +TYPE_TRANSFER_OWNERSHIP = "transfer_ownership" +TYPE_DELETE_DOCUMENTS = "delete_documents" +TYPE_DELETE_GROUP = "delete_group" +TYPE_DELETE_USER_DOCUMENTS = "delete_user_documents" + +# TTL settings +TTL_AUTO_DENY_DAYS = 3 +TTL_AUTO_DENY_SECONDS = TTL_AUTO_DENY_DAYS * 24 * 60 * 60 # 3 days in seconds + + +def create_approval_request( + request_type: str, + group_id: str, + requester_id: str, + requester_email: str, + requester_name: str, + reason: str, + metadata: Optional[Dict[str, Any]] = None +) -> Dict[str, Any]: + """ + Create a new approval request for a sensitive Control Center operation. + + Args: + request_type: Type of request (take_ownership, transfer_ownership, delete_documents, delete_group, delete_user_documents) + group_id: ID of the group being affected (or user_id for user-related requests) + requester_id: User ID of the person requesting the action + requester_email: Email of the requester + requester_name: Display name of the requester + reason: Explanation/justification for the request + metadata: Additional request-specific data (e.g., new_owner_id for transfers, user_name for user documents) + + Returns: + Created approval request document + """ + try: + # For user document deletion requests, use metadata for display info + if request_type == TYPE_DELETE_USER_DOCUMENTS: + # For user document deletions, group_id is actually the user_id (partition key) + group_name = metadata.get('user_name', 'Unknown User') + group_owner = {} + else: + # Get group details for group-based approvals + group = find_group_by_id(group_id) + if not group: + raise ValueError(f"Group {group_id} not found") + + group_name = group.get('name', 'Unknown Group') + group_owner = group.get('owner', {}) + + # Create approval request document + approval_id = str(uuid.uuid4()) + now = datetime.utcnow() + + approval_request = { + 'id': approval_id, + 'group_id': group_id, # Partition key + 'request_type': request_type, + 'status': STATUS_PENDING, + 'group_name': group_name, + 'requester_id': requester_id, + 'requester_email': requester_email, + 'requester_name': requester_name, + 'reason': reason, + 'group_owner_id': group_owner.get('id'), + 'group_owner_email': group_owner.get('email'), + 'group_owner_name': group_owner.get('displayName', group_owner.get('email')), + 'created_at': now.isoformat(), + 'expires_at': (now + timedelta(days=TTL_AUTO_DENY_DAYS)).isoformat(), + 'ttl': TTL_AUTO_DENY_SECONDS, # Auto-deny after 3 days + 'approved_by_id': None, + 'approved_by_email': None, + 'approved_by_name': None, + 'approved_at': None, + 'approval_comment': None, + 'executed_at': None, + 'execution_result': None, + 'metadata': metadata or {} + } + + # Save to Cosmos DB + cosmos_approvals_container.create_item(body=approval_request) + + # Log event + log_event("[Approvals] Created approval request", { + 'approval_id': approval_id, + 'request_type': request_type, + 'group_id': group_id, + 'group_name': group_name, + 'requester': requester_email, + 'reason': reason + }) + debug_print(f"Created approval request: {approval_request}") + + # Create notifications for eligible approvers + _create_approval_notifications(approval_request, group if request_type != TYPE_DELETE_USER_DOCUMENTS else None) + + return approval_request + + except Exception as e: + log_event("[Approvals] Error creating approval request", { + 'error': str(e), + 'request_type': request_type, + 'group_id': group_id, + 'requester': requester_email + }, level=logging.ERROR) + debug_print(f"Error creating approval request: {e}") + raise + + +def get_pending_approvals( + user_id: str, + user_roles: List[str], + page: int = 1, + per_page: int = 20, + include_completed: bool = False, + request_type_filter: Optional[str] = None +) -> Dict[str, Any]: + """ + Get approval requests that the user is eligible to approve. + + Args: + user_id: Current user ID + user_roles: List of roles the user has (e.g., ['admin', 'ControlCenterAdmin']) + page: Page number for pagination + per_page: Items per page + include_completed: Include approved/denied/executed requests + request_type_filter: Filter by request type + + Returns: + Dictionary with approvals list, total count, and pagination info + """ + try: + # Build query based on filters + query_parts = ["SELECT * FROM c WHERE 1=1"] + parameters = [] + + # Status filter + if not include_completed: + query_parts.append("AND c.status = @status") + parameters.append({"name": "@status", "value": STATUS_PENDING}) + + # Request type filter + if request_type_filter: + query_parts.append("AND c.request_type = @request_type") + parameters.append({"name": "@request_type", "value": request_type_filter}) + + # Order by created date descending + query_parts.append("ORDER BY c.created_at DESC") + + query = " ".join(query_parts) + + # Execute cross-partition query (we need to see all groups) + items = list(cosmos_approvals_container.query_items( + query=query, + parameters=parameters, + enable_cross_partition_query=True + )) + + # Filter by user eligibility (can't do in Cosmos query due to complex logic) + eligible_approvals = [] + for approval in items: + if _can_user_approve(approval, user_id, user_roles): + eligible_approvals.append(approval) + + # Paginate + total_count = len(eligible_approvals) + start_idx = (page - 1) * per_page + end_idx = start_idx + per_page + paginated_approvals = eligible_approvals[start_idx:end_idx] + + debug_print(f"User {user_id} fetched pending approvals: page {page}, per_page {per_page}, total {total_count}") + + return { + 'approvals': paginated_approvals, + 'total': total_count, + 'page': page, + 'per_page': per_page, + 'total_pages': (total_count + per_page - 1) // per_page + } + + except Exception as e: + log_event("[Approvals] Error fetching pending approvals", { + 'error': str(e), + 'user_id': user_id, + 'user_roles': user_roles + }) + debug_print(f"Error fetching pending approvals: {e}") + raise + + +def approve_request( + approval_id: str, + group_id: str, + approver_id: str, + approver_email: str, + approver_name: str, + comment: Optional[str] = None +) -> Dict[str, Any]: + """ + Approve an approval request. + + Args: + approval_id: ID of the approval request + group_id: Group ID (partition key) + approver_id: User ID of approver + approver_email: Email of approver + approver_name: Display name of approver + comment: Optional comment from approver + + Returns: + Updated approval request document + """ + try: + # Get the approval request + approval = cosmos_approvals_container.read_item( + item=approval_id, + partition_key=group_id + ) + + # Validate status + if approval['status'] != STATUS_PENDING: + debug_print(f"Cannot approve request with status: {approval['status']}") + raise ValueError(f"Cannot approve request with status: {approval['status']}") + + # Update approval status + approval['status'] = STATUS_APPROVED + approval['approved_by_id'] = approver_id + approval['approved_by_email'] = approver_email + approval['approved_by_name'] = approver_name + approval['approved_at'] = datetime.utcnow().isoformat() + approval['approval_comment'] = comment + approval['ttl'] = -1 # Remove TTL so it doesn't auto-delete + + # Save updated approval + cosmos_approvals_container.upsert_item(approval) + + # Log event + log_event("[Approvals] Request approved", { + 'approval_id': approval_id, + 'request_type': approval['request_type'], + 'group_id': group_id, + 'approver': approver_email, + 'comment': comment + }) + debug_print(f"Approved request: {approval}") + + # Create notification for requester + create_notification( + user_id=approval['requester_id'], + notification_type='approval_request_approved', + title=f"Request Approved: {_format_request_type(approval['request_type'])}", + message=f"Your request for {approval['group_name']} has been approved by {approver_name}.", + link_url='/approvals', + link_context={ + 'approval_id': approval_id + }, + metadata={ + 'approval_id': approval_id, + 'request_type': approval['request_type'], + 'group_id': group_id, + 'approver_email': approver_email, + 'comment': comment + } + ) + + return approval + + except Exception as e: + log_event("[Approvals] Error approving request", { + 'error': str(e), + 'approval_id': approval_id, + 'group_id': group_id, + 'approver': approver_email + }) + debug_print(f"Error approving request: {e}") + raise + + +def deny_request( + approval_id: str, + group_id: str, + denier_id: str, + denier_email: str, + denier_name: str, + comment: str, + auto_denied: bool = False +) -> Dict[str, Any]: + """ + Deny an approval request. + + Args: + approval_id: ID of the approval request + group_id: Group ID (partition key) + denier_id: User ID of person denying (or 'system' for auto-deny) + denier_email: Email of denier + denier_name: Display name of denier + comment: Reason for denial + auto_denied: Whether this is an automatic denial + + Returns: + Updated approval request document + """ + try: + # Get the approval request + approval = cosmos_approvals_container.read_item( + item=approval_id, + partition_key=group_id + ) + + # Validate status (allow denying pending requests) + if approval['status'] not in [STATUS_PENDING]: + debug_print(f"Cannot deny request with status: {approval['status']}") + raise ValueError(f"Cannot deny request with status: {approval['status']}") + + # Update approval status + approval['status'] = STATUS_AUTO_DENIED if auto_denied else STATUS_DENIED + approval['approved_by_id'] = denier_id + approval['approved_by_email'] = denier_email + approval['approved_by_name'] = denier_name + approval['approved_at'] = datetime.utcnow().isoformat() + approval['approval_comment'] = comment + approval['ttl'] = -1 # Remove TTL + + # Save updated approval + cosmos_approvals_container.upsert_item(approval) + + # Log event + log_event("[Approvals] Request denied", { + 'approval_id': approval_id, + 'request_type': approval['request_type'], + 'group_id': group_id, + 'denier': denier_email, + 'auto_denied': auto_denied, + 'comment': comment + }) + debug_print(f"Request denied: {approval_id}") + + # Create notification for requester (only if not auto-denied) + if not auto_denied: + create_notification( + user_id=approval['requester_id'], + notification_type='approval_request_denied', + title=f"Request Denied: {_format_request_type(approval['request_type'])}", + message=f"Your request for {approval['group_name']} was denied by {denier_name}.", + link_url='/approvals', + link_context={ + 'approval_id': approval_id + }, + metadata={ + 'approval_id': approval_id, + 'request_type': approval['request_type'], + 'group_id': group_id, + 'denier_email': denier_email, + 'comment': comment + } + ) + + return approval + + except Exception as e: + log_event("[Approvals] Error denying request", { + 'error': str(e), + 'approval_id': approval_id, + 'group_id': group_id, + 'denier_id': denier_id, + 'comment': comment, + 'auto_denied': auto_denied + }) + debug_print(f"Error denying request: {e}") + raise + + +def mark_approval_executed( + approval_id: str, + group_id: str, + success: bool, + result_message: str +) -> Dict[str, Any]: + """ + Mark an approved request as executed (or failed). + + Args: + approval_id: ID of the approval request + group_id: Group ID (partition key) + success: Whether execution was successful + result_message: Result message or error + + Returns: + Updated approval request document + """ + try: + # Get the approval request + approval = cosmos_approvals_container.read_item( + item=approval_id, + partition_key=group_id + ) + + # Update execution status + approval['status'] = STATUS_EXECUTED if success else STATUS_FAILED + approval['executed_at'] = datetime.utcnow().isoformat() + approval['execution_result'] = result_message + + # Save updated approval + cosmos_approvals_container.upsert_item(approval) + + # Log event + log_event("[Approvals] Request executed", { + 'approval_id': approval_id, + 'request_type': approval['request_type'], + 'group_id': group_id, + 'success': success, + 'result': result_message + }) + debug_print(f"Marked approval as executed: {approval_id}, success: {success}") + + return approval + + except Exception as e: + log_event("[Approvals] Error marking request as executed", { + 'error': str(e), + 'approval_id': approval_id, + 'group_id': group_id, + 'success': success, + 'result': result_message + }) + debug_print(f"Error marking approval as executed: {e}") + raise + + +def get_approval_by_id(approval_id: str, group_id: str) -> Optional[Dict[str, Any]]: + """ + Get a specific approval request by ID. + + Args: + approval_id: ID of the approval request + group_id: Group ID (partition key) + + Returns: + Approval request document or None if not found + """ + try: + return cosmos_approvals_container.read_item( + item=approval_id, + partition_key=group_id + ) + except Exception: + log_event("[Approvals] Approval not found", { + 'approval_id': approval_id, + 'group_id': group_id + }) + debug_print(f"Approval not found: {approval_id}") + return None + + +def auto_deny_expired_approvals() -> int: + """ + Auto-deny approval requests that have expired (older than 3 days). + This function should be called by a scheduled job. + + Returns: + Number of approvals auto-denied + """ + try: + # Query for pending approvals + query = "SELECT * FROM c WHERE c.status = @status" + parameters = [{"name": "@status", "value": STATUS_PENDING}] + + pending_approvals = list(cosmos_approvals_container.query_items( + query=query, + parameters=parameters, + enable_cross_partition_query=True + )) + + now = datetime.utcnow() + denied_count = 0 + + for approval in pending_approvals: + expires_at = datetime.fromisoformat(approval['expires_at']) + + # Check if expired + if now >= expires_at: + try: + deny_request( + approval_id=approval['id'], + group_id=approval['group_id'], + denier_id='system', + denier_email='system@simplechat', + denier_name='System Auto-Deny', + comment='Request automatically denied after 3 days without approval.', + auto_denied=True + ) + denied_count += 1 + except Exception as e: + log_event("[Approvals] Error auto-denying expired approval", { + 'approval_id': approval['id'], + 'error': str(e) + }) + debug_print(f"Error auto-denying approval {approval['id']}: {e}") + + if denied_count > 0: + log_event("[Approvals] Auto-denied expired approvals", { + 'denied_count': denied_count + }) + debug_print(f"Auto-denied {denied_count} expired approvals") + + return denied_count + + except Exception as e: + log_event("[Approvals] Error in auto_deny_expired_approvals", { + 'error': str(e) + }) + debug_print(f"Error in auto_deny_expired_approvals: {e}") + return 0 + + +def _can_user_approve( + approval: Dict[str, Any], + user_id: str, + user_roles: List[str] +) -> bool: + """ + Check if a user is eligible to approve a specific request. + + Eligibility rules: + - User must be the group owner (for group operations), OR + - User must be the personal workspace owner (for user document operations), OR + - User must have 'ControlCenterAdmin' role, OR + - User must have 'Admin' role + - User cannot be the requester (unless they're the only eligible approver) + + Args: + approval: Approval request document + user_id: User ID to check + user_roles: List of roles the user has + + Returns: + True if user can approve, False otherwise + """ + # Check if user is the group owner (for group-based approvals) + is_group_owner = approval.get('group_owner_id') == user_id + + # Check if user is the personal workspace owner (for user document deletion) + is_personal_workspace_owner = False + if approval.get('request_type') == TYPE_DELETE_USER_DOCUMENTS: + # For user document deletion, check if user owns the documents + target_user_id = approval.get('metadata', {}).get('user_id') + is_personal_workspace_owner = target_user_id == user_id + + # Check if user has admin roles (check both capitalized and lowercase) + has_control_center_admin = 'ControlCenterAdmin' in user_roles + has_admin = 'Admin' in user_roles or 'admin' in user_roles + + # User must have at least one eligibility criterion + if not (is_group_owner or is_personal_workspace_owner or has_control_center_admin or has_admin): + return False + + # Special case: If user is the requester, they can still approve if they're the only eligible approver + # This handles the case where there's only one admin in the system + if approval.get('requester_id') == user_id: + # Allow same-user approval (with documentation through the approval system) + return True + + return True + + +def _create_approval_notifications( + approval: Dict[str, Any], + group: Optional[Dict[str, Any]] +) -> None: + """ + Create notifications for all users who can approve the request using assignment-based targeting. + Notifications target users by roles (Admin, ControlCenterAdmin) and/or ownership IDs. + + For user management (delete_user_documents): + - Notifies: Control Center Admins, Admins, and the affected user + For group management (transfer_ownership, delete_documents, delete_group, take_ownership): + - Notifies: Control Center Admins, Admins, and the group owner + + Args: + approval: Approval request document + group: Group document (None for user-related approvals) + """ + try: + log_event("[Approvals] Creating assignment-based approval notifications", { + 'approval_id': approval['id'], + 'group_id': approval['group_id'], + 'request_type': approval['request_type'] + }) + debug_print(f"Creating assignment-based approval notifications for approval: {approval['id']}") + + # Build assignment criteria based on request type + assignment = { + 'roles': ['Admin', 'ControlCenterAdmin'] # Always include admin roles + } + + # Add ownership-based targeting + if approval['request_type'] == TYPE_DELETE_USER_DOCUMENTS: + # For user document deletion: notify the user whose documents are being deleted + user_id = approval.get('metadata', {}).get('user_id') + if user_id: + assignment['personal_workspace_owner_id'] = user_id + log_event("[Approvals] Targeting user for document deletion", { + 'user_id': user_id, + 'approval_id': approval['id'] + }) + debug_print(f"Added personal workspace owner {user_id} to notification assignment") + else: + # For group operations: notify the group owner + if group: + group_owner_id = group.get('owner', {}).get('id') + if group_owner_id: + assignment['group_owner_id'] = group_owner_id + log_event("[Approvals] Targeting group owner", { + 'group_owner_id': group_owner_id, + 'approval_id': approval['id'] + }) + debug_print(f"Added group owner {group_owner_id} to notification assignment") + else: + log_event("[Approvals] No group provided for group-based approval", { + 'approval_id': approval['id'], + 'request_type': approval['request_type'] + }, level=logging.WARNING) + + log_event("[Approvals] Notification assignment", { + 'approval_id': approval['id'], + 'assignment': assignment + }) + debug_print(f"Notification assignment for approval {approval['id']}: {assignment}") + + # For transfer ownership requests, also notify the new owner (informational) + if approval['request_type'] == TYPE_TRANSFER_OWNERSHIP: + new_owner_id = approval.get('metadata', {}).get('new_owner_id') + if new_owner_id and new_owner_id != approval['requester_id']: + # Create informational notification for new owner + try: + log_event("[Approvals] Notifying new owner", { + 'user_id': new_owner_id, + 'approval_id': approval['id'] + }) + debug_print(f"Notifying new owner {new_owner_id} about transfer request") + create_notification( + group_id=approval['group_id'], + notification_type='approval_request_pending', + title=f"Ownership Transfer Pending", + message=f"{approval['requester_name']} has requested to transfer ownership of {approval['group_name']} to you. Awaiting approval.", + link_url='/approvals', + link_context={ + 'approval_id': approval['id'] + }, + metadata={ + 'approval_id': approval['id'], + 'request_type': approval['request_type'], + 'group_id': approval['group_id'], + 'requester_email': approval['requester_email'] + }, + assignment={ + 'personal_workspace_owner_id': new_owner_id # Only new owner sees this + } + ) + debug_print(f"Successfully notified new owner {new_owner_id}") + except Exception as notify_error: + log_event("[Approvals] Error notifying new owner", { + 'error': str(notify_error), + 'user_id': new_owner_id, + 'approval_id': approval['id'] + }) + debug_print(f"Error notifying new owner {new_owner_id}: {str(notify_error)}") + + # Create single notification with assignment - visible to all eligible approvers + try: + log_event("[Approvals] Creating approval notification with assignment", { + 'approval_id': approval['id'], + 'assignment': assignment + }) + debug_print(f"Creating approval notification with assignment for approval {approval['id']}") + create_notification( + group_id=approval['group_id'], + notification_type='approval_request_pending', + title=f"Approval Required: {_format_request_type(approval['request_type'])}", + message=f"{approval['requester_name']} requests {_format_request_type(approval['request_type'])} for {approval['group_name']}. Reason: {approval.get('reason', 'Not provided')}", + link_url='/approvals', + link_context={ + 'approval_id': approval['id'] + }, + metadata={ + 'approval_id': approval['id'], + 'request_type': approval['request_type'], + 'group_id': approval['group_id'], + 'requester_email': approval['requester_email'], + 'reason': approval['reason'] + }, + assignment=assignment + ) + debug_print(f"Successfully created approval notification with assignment for approval {approval['id']}") + except Exception as notify_error: + log_event("[Approvals] Error creating approval notification", { + 'error': str(notify_error), + 'approval_id': approval['id'] + }) + debug_print(f"Error creating approval notification for approval {approval['id']}: {str(notify_error)}") + + except Exception as e: + log_event("[Approvals] Error notifying users about approval request", { + 'error': str(e), + 'approval_id': approval['id'] + }) + debug_print(f"Error notifying users about approval request {approval['id']}: {str(e)}") + # Don't raise - notifications are non-critical + + +def _format_request_type(request_type: str) -> str: + """ + Format request type for display. + + Args: + request_type: Request type constant + + Returns: + Human-readable request type string + """ + type_labels = { + TYPE_TAKE_OWNERSHIP: "Take Ownership", + TYPE_TRANSFER_OWNERSHIP: "Transfer Ownership", + TYPE_DELETE_DOCUMENTS: "Delete All Documents", + TYPE_DELETE_GROUP: "Delete Group", + TYPE_DELETE_USER_DOCUMENTS: "Delete All User Documents" + } + return type_labels.get(request_type, request_type) From 6cfa92503a46a33f6da12a1d70ee2442c362c6ae Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Wed, 24 Dec 2025 14:17:56 -0500 Subject: [PATCH 13/48] updated approval workflow --- application/single_app/config.py | 2 +- application/single_app/functions_approvals.py | 78 +++- application/single_app/functions_documents.py | 14 +- .../single_app/functions_group_actions.py | 10 +- .../single_app/functions_group_agents.py | 10 +- .../single_app/functions_personal_actions.py | 25 +- .../single_app/functions_personal_agents.py | 25 +- .../single_app/route_backend_agents.py | 7 +- .../route_backend_control_center.py | 406 +++++++++++------- .../single_app/route_backend_groups.py | 5 +- .../single_app/route_backend_notifications.py | 13 +- .../single_app/route_backend_plugins.py | 10 +- .../route_frontend_authentication.py | 2 +- .../route_frontend_control_center.py | 23 +- application/single_app/route_openapi.py | 14 +- .../single_app/static/js/control-center.js | 99 ++++- .../static/js/group/manage_group.js | 37 +- .../single_app/static/js/notifications.js | 22 +- .../single_app/templates/approvals.html | 9 +- .../single_app/templates/control_center.html | 18 +- .../CONTROL_CENTER_METRICS_CACHING.md | 6 +- docs/features/ENHANCED_USER_MANAGEMENT.md | 2 +- .../GROUP_NOTIFICATION_CONTEXT_ENHANCEMENT.md | 183 ++++++++ .../test_group_notification_context.py | 189 ++++++++ 24 files changed, 929 insertions(+), 280 deletions(-) create mode 100644 docs/fixes/GROUP_NOTIFICATION_CONTEXT_ENHANCEMENT.md create mode 100644 functional_tests/test_group_notification_context.py diff --git a/application/single_app/config.py b/application/single_app/config.py index c352f33ec..f74cb1c2b 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.234.049" +VERSION = "0.234.066" SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') diff --git a/application/single_app/functions_approvals.py b/application/single_app/functions_approvals.py index d99de9922..112d3c375 100644 --- a/application/single_app/functions_approvals.py +++ b/application/single_app/functions_approvals.py @@ -141,7 +141,8 @@ def get_pending_approvals( page: int = 1, per_page: int = 20, include_completed: bool = False, - request_type_filter: Optional[str] = None + request_type_filter: Optional[str] = None, + status_filter: str = 'pending' ) -> Dict[str, Any]: """ Get approval requests that the user is eligible to approve. @@ -153,6 +154,7 @@ def get_pending_approvals( per_page: Items per page include_completed: Include approved/denied/executed requests request_type_filter: Filter by request type + status_filter: Filter by specific status ('pending', 'approved', 'denied', 'executed', 'all') Returns: Dictionary with approvals list, total count, and pagination info @@ -163,9 +165,11 @@ def get_pending_approvals( parameters = [] # Status filter - if not include_completed: + if status_filter != 'all': + # If specific status requested (pending, approved, denied, executed) query_parts.append("AND c.status = @status") - parameters.append({"name": "@status", "value": STATUS_PENDING}) + parameters.append({"name": "@status", "value": status_filter}) + # else: 'all' means no status filter # Request type filter if request_type_filter: @@ -177,6 +181,10 @@ def get_pending_approvals( query = " ".join(query_parts) + debug_print(f"📋 [GET_APPROVALS] Query: {query}") + debug_print(f"📋 [GET_APPROVALS] Parameters: {parameters}") + debug_print(f"📋 [GET_APPROVALS] status_filter: {status_filter}") + # Execute cross-partition query (we need to see all groups) items = list(cosmos_approvals_container.query_items( query=query, @@ -184,11 +192,23 @@ def get_pending_approvals( enable_cross_partition_query=True )) - # Filter by user eligibility (can't do in Cosmos query due to complex logic) + debug_print(f"📋 [GET_APPROVALS] Found {len(items)} total items from query") + + # Filter by user eligibility + # For pending requests: check if user can approve + # For completed requests: check if user has visibility (was involved or is admin/owner) eligible_approvals = [] for approval in items: - if _can_user_approve(approval, user_id, user_roles): - eligible_approvals.append(approval) + if status_filter == 'pending': + # For pending requests, check if user can approve + if _can_user_approve(approval, user_id, user_roles): + eligible_approvals.append(approval) + else: + # For completed requests, check if user has visibility + if _can_user_view(approval, user_id, user_roles): + eligible_approvals.append(approval) + + debug_print(f"📋 [GET_APPROVALS] After eligibility filter: {len(eligible_approvals)} approvals") # Paginate total_count = len(eligible_approvals) @@ -541,6 +561,52 @@ def auto_deny_expired_approvals() -> int: return 0 +def _can_user_view( + approval: Dict[str, Any], + user_id: str, + user_roles: List[str] +) -> bool: + """ + Check if a user can view a specific approval request (including completed ones). + + Visibility rules (more permissive than approval rights): + - User is the requester, OR + - User is the approver, OR + - User is the group owner, OR + - User is the personal workspace owner (for user document operations), OR + - User has 'ControlCenterAdmin' role, OR + - User has 'Admin' role + + Args: + approval: Approval request document + user_id: User ID to check + user_roles: List of roles the user has + + Returns: + True if user can view, False otherwise + """ + # Check if user was involved in the request + is_requester = approval.get('requester_id') == user_id + is_approver = approval.get('approved_by_id') == user_id + + # Check if user is the group owner + is_group_owner = approval.get('group_owner_id') == user_id + + # Check if user is the personal workspace owner (for user document deletion) + is_personal_workspace_owner = False + if approval.get('request_type') == TYPE_DELETE_USER_DOCUMENTS: + target_user_id = approval.get('metadata', {}).get('user_id') + is_personal_workspace_owner = target_user_id == user_id + + # Check if user has admin roles + has_control_center_admin = 'ControlCenterAdmin' in user_roles + has_admin = 'Admin' in user_roles or 'admin' in user_roles + + # User can view if they meet any of these criteria + return (is_requester or is_approver or is_group_owner or + is_personal_workspace_owner or has_control_center_admin or has_admin) + + def _can_user_approve( approval: Dict[str, Any], user_id: str, diff --git a/application/single_app/functions_documents.py b/application/single_app/functions_documents.py index 42cf74068..12c0ebdb2 100644 --- a/application/single_app/functions_documents.py +++ b/application/single_app/functions_documents.py @@ -5261,12 +5261,16 @@ def update_doc_callback(**kwargs): print(f"📢 Created notification for public workspace {public_workspace_id}") elif group_id: - # Notification for all group members + # Notification for all group members - get group name + from functions_group import find_group_by_id + group = find_group_by_id(group_id) + group_name = group.get('name', 'Unknown Group') if group else 'Unknown Group' + create_group_notification( group_id=group_id, notification_type='document_processing_complete', title=notification_title, - message=notification_message, + message=f"Document uploaded to {group_name} has been processed successfully with {total_chunks_saved} chunks.", link_url='/group_workspaces', link_context={ 'workspace_type': 'group', @@ -5276,10 +5280,12 @@ def update_doc_callback(**kwargs): metadata={ 'document_id': document_id, 'file_name': original_filename, - 'chunks': total_chunks_saved + 'chunks': total_chunks_saved, + 'group_name': group_name, + 'group_id': group_id } ) - print(f"📢 Created notification for group {group_id}") + print(f"📢 Created notification for group {group_id} ({group_name})") else: # Personal notification for the uploader diff --git a/application/single_app/functions_group_actions.py b/application/single_app/functions_group_actions.py index 0dc0c3ddc..bc6aa4ea5 100644 --- a/application/single_app/functions_group_actions.py +++ b/application/single_app/functions_group_actions.py @@ -6,7 +6,7 @@ import uuid from datetime import datetime from typing import Any, Dict, List, Optional - +from functions_debug import debug_print from azure.cosmos import exceptions from flask import current_app @@ -42,7 +42,7 @@ def get_group_actions( except exceptions.CosmosResourceNotFoundError: return [] except Exception as exc: - current_app.logger.error( + debug_print( "Error fetching group actions for %s: %s", group_id, exc ) return [] @@ -74,7 +74,7 @@ def get_group_action( return None action = actions[0] except Exception as exc: - current_app.logger.error( + debug_print( "Error fetching group action %s for %s: %s", action_id, group_id, exc ) return None @@ -113,7 +113,7 @@ def save_group_action(group_id: str, action_data: Dict[str, Any]) -> Dict[str, A stored = cosmos_group_actions_container.upsert_item(body=payload) return _clean_action(stored, group_id, SecretReturnType.TRIGGER) except Exception as exc: - current_app.logger.error( + debug_print( "Error saving group action %s for %s: %s", action_id, group_id, exc ) raise @@ -137,7 +137,7 @@ def delete_group_action(group_id: str, action_id: str) -> bool: ) return True except Exception as exc: - current_app.logger.error( + debug_print( "Error deleting group action %s for %s: %s", action_id, group_id, exc ) raise diff --git a/application/single_app/functions_group_agents.py b/application/single_app/functions_group_agents.py index e8d34df45..764480982 100644 --- a/application/single_app/functions_group_agents.py +++ b/application/single_app/functions_group_agents.py @@ -6,7 +6,7 @@ import uuid from datetime import datetime from typing import Any, Dict, List, Optional - +from functions_debug import debug_print from azure.cosmos import exceptions from flask import current_app @@ -39,7 +39,7 @@ def get_group_agents(group_id: str) -> List[Dict[str, Any]]: except exceptions.CosmosResourceNotFoundError: return [] except Exception as exc: - current_app.logger.error( + debug_print( "Error fetching group agents for %s: %s", group_id, exc ) return [] @@ -56,7 +56,7 @@ def get_group_agent(group_id: str, agent_id: str) -> Optional[Dict[str, Any]]: except exceptions.CosmosResourceNotFoundError: return None except Exception as exc: - current_app.logger.error( + debug_print( "Error fetching group agent %s for %s: %s", agent_id, group_id, exc ) return None @@ -111,7 +111,7 @@ def save_group_agent(group_id: str, agent_data: Dict[str, Any]) -> Dict[str, Any stored = cosmos_group_agents_container.upsert_item(body=payload) return _clean_agent(stored) except Exception as exc: - current_app.logger.error( + debug_print( "Error saving group agent %s for %s: %s", agent_id, group_id, exc ) raise @@ -135,7 +135,7 @@ def delete_group_agent(group_id: str, agent_id: str) -> bool: ) return True except Exception as exc: - current_app.logger.error( + debug_print( "Error deleting group agent %s for %s: %s", agent_id, group_id, exc ) raise diff --git a/application/single_app/functions_personal_actions.py b/application/single_app/functions_personal_actions.py index 108d31512..6345438ea 100644 --- a/application/single_app/functions_personal_actions.py +++ b/application/single_app/functions_personal_actions.py @@ -13,6 +13,7 @@ from flask import current_app from functions_keyvault import keyvault_plugin_save_helper, keyvault_plugin_get_helper, keyvault_plugin_delete_helper, SecretReturnType from functions_settings import get_user_settings, update_user_settings +from functions_debug import debug_print from config import cosmos_personal_actions_container import logging @@ -47,7 +48,7 @@ def get_personal_actions(user_id, return_type=SecretReturnType.TRIGGER): except exceptions.CosmosResourceNotFoundError: return [] except Exception as e: - current_app.logger.error(f"Error fetching personal actions for user {user_id}: {e}") + debug_print(f"Error fetching personal actions for user {user_id}: {e}") return [] def get_personal_action(user_id, action_id, return_type=SecretReturnType.TRIGGER): @@ -91,7 +92,7 @@ def get_personal_action(user_id, action_id, return_type=SecretReturnType.TRIGGER return cleaned_action except Exception as e: - current_app.logger.error(f"Error fetching action {action_id} for user {user_id}: {e}") + debug_print(f"Error fetching action {action_id} for user {user_id}: {e}") return None def save_personal_action(user_id, action_data): @@ -151,7 +152,7 @@ def save_personal_action(user_id, action_data): return cleaned_result except Exception as e: - current_app.logger.error(f"Error saving action for user {user_id}: {e}") + debug_print(f"Error saving action for user {user_id}: {e}") raise def delete_personal_action(user_id, action_id): @@ -182,7 +183,7 @@ def delete_personal_action(user_id, action_id): except exceptions.CosmosResourceNotFoundError: return False except Exception as e: - current_app.logger.error(f"Error deleting action {action_id} for user {user_id}: {e}") + debug_print(f"Error deleting action {action_id} for user {user_id}: {e}") raise def ensure_migration_complete(user_id): @@ -213,13 +214,13 @@ def ensure_migration_complete(user_id): settings_to_update = user_settings.get('settings', {}) settings_to_update['plugins'] = [] # Set to empty array instead of removing update_user_settings(user_id, settings_to_update) - current_app.logger.info(f"Cleaned up legacy plugin data for user {user_id} (already migrated)") + debug_print(f"Cleaned up legacy plugin data for user {user_id} (already migrated)") return 0 return 0 except Exception as e: - current_app.logger.error(f"Error ensuring action migration complete for user {user_id}: {e}") + debug_print(f"Error ensuring action migration complete for user {user_id}: {e}") return 0 def migrate_actions_from_user_settings(user_id): @@ -245,7 +246,7 @@ def migrate_actions_from_user_settings(user_id): try: # Skip if plugin already exists in personal container if plugin.get('name') in existing_action_names: - current_app.logger.info(f"Skipping migration of plugin '{plugin.get('name')}' - already exists") + debug_print(f"Skipping migration of plugin '{plugin.get('name')}' - already exists") continue # Ensure plugin has an ID (generate GUID if missing) if 'id' not in plugin or not plugin['id']: @@ -255,18 +256,18 @@ def migrate_actions_from_user_settings(user_id): save_personal_action(user_id, plugin) migrated_count += 1 except Exception as e: - current_app.logger.error(f"Error migrating plugin {plugin.get('name', 'unknown')} for user {user_id}: {e}") + debug_print(f"Error migrating plugin {plugin.get('name', 'unknown')} for user {user_id}: {e}") # Always remove plugins from user settings after processing (even if no new ones migrated) settings_to_update = user_settings.get('settings', {}) settings_to_update['plugins'] = [] # Set to empty array instead of removing update_user_settings(user_id, settings_to_update) - current_app.logger.info(f"Migrated {migrated_count} new actions for user {user_id}, cleaned up legacy data") + debug_print(f"Migrated {migrated_count} new actions for user {user_id}, cleaned up legacy data") return migrated_count except Exception as e: - current_app.logger.error(f"Error during action migration for user {user_id}: {e}") + debug_print(f"Error during action migration for user {user_id}: {e}") return 0 def get_actions_by_names(user_id, action_names, return_type=SecretReturnType.TRIGGER): @@ -308,7 +309,7 @@ def get_actions_by_names(user_id, action_names, return_type=SecretReturnType.TRI return cleaned_actions except Exception as e: - current_app.logger.error(f"Error fetching actions by names for user {user_id}: {e}") + debug_print(f"Error fetching actions by names for user {user_id}: {e}") return [] def get_actions_by_type(user_id, action_type, return_type=SecretReturnType.TRIGGER): @@ -345,5 +346,5 @@ def get_actions_by_type(user_id, action_type, return_type=SecretReturnType.TRIGG return cleaned_actions except Exception as e: - current_app.logger.error(f"Error fetching actions by type {action_type} for user {user_id}: {e}") + debug_print(f"Error fetching actions by type {action_type} for user {user_id}: {e}") return [] diff --git a/application/single_app/functions_personal_agents.py b/application/single_app/functions_personal_agents.py index 3f2cc6eac..7462d1b40 100644 --- a/application/single_app/functions_personal_agents.py +++ b/application/single_app/functions_personal_agents.py @@ -18,6 +18,7 @@ from config import cosmos_personal_agents_container from functions_settings import get_settings, get_user_settings, update_user_settings from functions_keyvault import keyvault_agent_save_helper, keyvault_agent_get_helper, keyvault_agent_delete_helper +from functions_debug import debug_print def get_personal_agents(user_id): """ @@ -58,7 +59,7 @@ def get_personal_agents(user_id): except exceptions.CosmosResourceNotFoundError: return [] except Exception as e: - current_app.logger.error(f"Error fetching personal agents for user {user_id}: {e}") + debug_print(f"Error fetching personal agents for user {user_id}: {e}") return [] def get_personal_agent(user_id, agent_id): @@ -92,10 +93,10 @@ def get_personal_agent(user_id, agent_id): cleaned_agent.pop('reasoning_effort', None) return cleaned_agent except exceptions.CosmosResourceNotFoundError: - current_app.logger.warning(f"Agent {agent_id} not found for user {user_id}") + debug_print(f"Agent {agent_id} not found for user {user_id}") return None except Exception as e: - current_app.logger.error(f"Error fetching agent {agent_id} for user {user_id}: {e}") + debug_print(f"Error fetching agent {agent_id} for user {user_id}: {e}") return None def save_personal_agent(user_id, agent_data): @@ -153,7 +154,7 @@ def save_personal_agent(user_id, agent_data): return cleaned_result except Exception as e: - current_app.logger.error(f"Error saving agent for user {user_id}: {e}") + debug_print(f"Error saving agent for user {user_id}: {e}") raise def delete_personal_agent(user_id, agent_id): @@ -185,10 +186,10 @@ def delete_personal_agent(user_id, agent_id): ) return True except exceptions.CosmosResourceNotFoundError: - current_app.logger.warning(f"Agent {agent_id} not found for user {user_id}") + debug_print(f"Agent {agent_id} not found for user {user_id}") return False except Exception as e: - current_app.logger.error(f"Error deleting agent {agent_id} for user {user_id}: {e}") + debug_print(f"Error deleting agent {agent_id} for user {user_id}: {e}") raise def ensure_migration_complete(user_id): @@ -219,13 +220,13 @@ def ensure_migration_complete(user_id): settings_to_update = user_settings.get('settings', {}) settings_to_update['agents'] = [] # Set to empty array instead of removing update_user_settings(user_id, settings_to_update) - current_app.logger.info(f"Cleaned up legacy agent data for user {user_id} (already migrated)") + debug_print(f"Cleaned up legacy agent data for user {user_id} (already migrated)") return 0 return 0 except Exception as e: - current_app.logger.error(f"Error ensuring agent migration complete for user {user_id}: {e}") + debug_print(f"Error ensuring agent migration complete for user {user_id}: {e}") return 0 def migrate_agents_from_user_settings(user_id): @@ -249,7 +250,7 @@ def migrate_agents_from_user_settings(user_id): try: # Skip if agent already exists in personal container if agent.get('name') in existing_agent_names: - current_app.logger.info(f"Skipping migration of agent '{agent.get('name')}' - already exists") + debug_print(f"Skipping migration of agent '{agent.get('name')}' - already exists") continue # Ensure agent has an ID if 'id' not in agent: @@ -257,14 +258,14 @@ def migrate_agents_from_user_settings(user_id): save_personal_agent(user_id, agent) migrated_count += 1 except Exception as e: - current_app.logger.error(f"Error migrating agent {agent.get('name', 'unknown')} for user {user_id}: {e}") + debug_print(f"Error migrating agent {agent.get('name', 'unknown')} for user {user_id}: {e}") # Always remove agents from user settings after processing (even if no new ones migrated) settings_to_update = user_settings.get('settings', {}) settings_to_update['agents'] = [] # Set to empty array instead of removing update_user_settings(user_id, settings_to_update) - current_app.logger.info(f"Migrated {migrated_count} new agents for user {user_id}, cleaned up legacy data") + debug_print(f"Migrated {migrated_count} new agents for user {user_id}, cleaned up legacy data") return migrated_count except Exception as e: - current_app.logger.error(f"Error during agent migration for user {user_id}: {e}") + debug_print(f"Error during agent migration for user {user_id}: {e}") return 0 diff --git a/application/single_app/route_backend_agents.py b/application/single_app/route_backend_agents.py index d2e812b1a..5032ebec1 100644 --- a/application/single_app/route_backend_agents.py +++ b/application/single_app/route_backend_agents.py @@ -17,6 +17,7 @@ delete_group_agent, validate_group_agent_payload, ) +from functions_debug import debug_print from functions_authentication import * from functions_appinsights import log_event from json_schema_validation import validate_agent @@ -266,7 +267,7 @@ def create_group_agent_route(): try: saved = save_group_agent(active_group, payload) except Exception as exc: - current_app.logger.error('Failed to save group agent: %s', exc) + debug_print('Failed to save group agent: %s', exc) return jsonify({'error': 'Unable to save agent'}), 500 return jsonify(saved), 201 @@ -314,7 +315,7 @@ def update_group_agent_route(agent_id): try: saved = save_group_agent(active_group, merged) except Exception as exc: - current_app.logger.error('Failed to update group agent %s: %s', agent_id, exc) + debug_print('Failed to update group agent %s: %s', agent_id, exc) return jsonify({'error': 'Unable to update agent'}), 500 return jsonify(saved), 200 @@ -340,7 +341,7 @@ def delete_group_agent_route(agent_id): try: removed = delete_group_agent(active_group, agent_id) except Exception as exc: - current_app.logger.error('Failed to delete group agent %s: %s', agent_id, exc) + debug_print('Failed to delete group agent %s: %s', agent_id, exc) return jsonify({'error': 'Unable to delete agent'}), 500 if not removed: diff --git a/application/single_app/route_backend_control_center.py b/application/single_app/route_backend_control_center.py index cf94ee0a9..b40d8c04e 100644 --- a/application/single_app/route_backend_control_center.py +++ b/application/single_app/route_backend_control_center.py @@ -6,7 +6,9 @@ from functions_logging import * from functions_activity_logging import * from functions_approvals import * -from functions_documents import update_document +from functions_documents import update_document, delete_document, delete_document_chunks +from functions_group import delete_group +from utils_cache import invalidate_group_search_cache from swagger_wrapper import swagger_route, get_auth_security from datetime import datetime, timedelta, timezone import json @@ -99,7 +101,7 @@ def enhance_user_with_activity(user, force_refresh=False): cached_metrics = user.get('settings', {}).get('metrics') if cached_metrics and cached_metrics.get('calculated_at'): try: - current_app.logger.debug(f"Using cached metrics for user {user.get('id')}") + debug_print(f"Using cached metrics for user {user.get('id')}") # Use cached data regardless of age when not forcing refresh if 'login_metrics' in cached_metrics: enhanced['activity']['login_metrics'] = cached_metrics['login_metrics'] @@ -113,14 +115,14 @@ def enhance_user_with_activity(user, force_refresh=False): enhanced['activity']['document_metrics'] = cached_doc_metrics return enhanced except Exception as cache_e: - current_app.logger.debug(f"Error using cached metrics for user {user.get('id')}: {cache_e}") + debug_print(f"Error using cached metrics for user {user.get('id')}: {cache_e}") # If no cached metrics and not forcing refresh, return with default/empty metrics # Do NOT include enhanced_citation_enabled in user data - frontend gets it from app settings - current_app.logger.debug(f"No cached metrics for user {user.get('id')}, returning default values (use refresh button to calculate)") + debug_print(f"No cached metrics for user {user.get('id')}, returning default values (use refresh button to calculate)") return enhanced - current_app.logger.debug(f"Force refresh requested - calculating fresh metrics for user {user.get('id')}") + debug_print(f"Force refresh requested - calculating fresh metrics for user {user.get('id')}") # Try to get comprehensive conversation metrics @@ -215,10 +217,10 @@ def enhance_user_with_activity(user, force_refresh=False): batch_size = size_result[0] if size_result else 0 total_message_size += batch_size or 0 - current_app.logger.debug(f"Messages batch {i//batch_size + 1}: {batch_messages} messages, {batch_size or 0} bytes") + debug_print(f"Messages batch {i//batch_size + 1}: {batch_messages} messages, {batch_size or 0} bytes") except Exception as msg_e: - current_app.logger.error(f"Could not query message sizes for batch {i//batch_size + 1}: {msg_e}") + debug_print(f"Could not query message sizes for batch {i//batch_size + 1}: {msg_e}") # Try individual conversation queries as fallback for conv_id in batch_ids: try: @@ -251,15 +253,15 @@ def enhance_user_with_activity(user, force_refresh=False): total_message_size += size_result[0] if size_result and size_result[0] else 0 except Exception as individual_e: - current_app.logger.debug(f"Could not query individual conversation {conv_id}: {individual_e}") + debug_print(f"Could not query individual conversation {conv_id}: {individual_e}") continue enhanced['activity']['chat_metrics']['total_messages'] = total_messages enhanced['activity']['chat_metrics']['total_message_size'] = total_message_size - current_app.logger.debug(f"Final chat metrics for user {user.get('id')}: {total_messages} messages, {total_message_size} bytes") + debug_print(f"Final chat metrics for user {user.get('id')}: {total_messages} messages, {total_message_size} bytes") except Exception as e: - current_app.logger.debug(f"Could not get chat metrics for user {user.get('id')}: {e}") + debug_print(f"Could not get chat metrics for user {user.get('id')}: {e}") # Try to get comprehensive login metrics try: @@ -292,7 +294,7 @@ def enhance_user_with_activity(user, force_refresh=False): enhanced['activity']['login_metrics']['last_login'] = login_record.get('timestamp') or login_record.get('created_at') except Exception as e: - current_app.logger.debug(f"Could not get login metrics for user {user.get('id')}: {e}") + debug_print(f"Could not get login metrics for user {user.get('id')}: {e}") # Try to get comprehensive document metrics try: @@ -353,14 +355,14 @@ def enhance_user_with_activity(user, force_refresh=False): total_storage_size += blob.size blob_count += 1 debug_print(f"💾 [STORAGE DEBUG] Blob {blob.name}: {blob.size} bytes") - current_app.logger.debug(f"Storage blob {blob.name}: {blob.size} bytes") + debug_print(f"Storage blob {blob.name}: {blob.size} bytes") debug_print(f"💾 [STORAGE DEBUG] Found {blob_count} blobs, total size: {total_storage_size} bytes") enhanced['activity']['document_metrics']['storage_account_size'] = total_storage_size - current_app.logger.debug(f"Total storage size for user {user.get('id')}: {total_storage_size} bytes") + debug_print(f"Total storage size for user {user.get('id')}: {total_storage_size} bytes") else: debug_print(f"💾 [STORAGE DEBUG] Storage client NOT available for user {user.get('id')}") - current_app.logger.debug(f"Storage client not available for user {user.get('id')}") + debug_print(f"Storage client not available for user {user.get('id')}") # Fallback to estimation if storage client not available storage_size_query = """ SELECT c.file_name, c.number_of_pages FROM c @@ -395,16 +397,16 @@ def enhance_user_with_activity(user, force_refresh=False): enhanced['activity']['document_metrics']['storage_account_size'] = total_storage_size debug_print(f"💾 [STORAGE DEBUG] Fallback estimation complete: {total_storage_size} bytes") - current_app.logger.debug(f"Estimated storage size for user {user.get('id')}: {total_storage_size} bytes") + debug_print(f"Estimated storage size for user {user.get('id')}: {total_storage_size} bytes") except Exception as storage_e: debug_print(f"❌ [STORAGE DEBUG] Storage calculation failed for user {user.get('id')}: {storage_e}") - current_app.logger.debug(f"Could not calculate storage size for user {user.get('id')}: {storage_e}") + debug_print(f"Could not calculate storage size for user {user.get('id')}: {storage_e}") # Set to 0 if we can't calculate enhanced['activity']['document_metrics']['storage_account_size'] = 0 except Exception as e: - current_app.logger.debug(f"Could not get document metrics for user {user.get('id')}: {e}") + debug_print(f"Could not get document metrics for user {user.get('id')}: {e}") # Save calculated metrics to user settings for caching (only if we calculated fresh data) if force_refresh or not user.get('settings', {}).get('metrics', {}).get('calculated_at'): @@ -429,17 +431,17 @@ def enhance_user_with_activity(user, force_refresh=False): update_success = update_user_settings(user.get('id'), settings_update) if update_success: - current_app.logger.debug(f"Successfully cached metrics for user {user.get('id')}") + debug_print(f"Successfully cached metrics for user {user.get('id')}") else: - current_app.logger.debug(f"Failed to cache metrics for user {user.get('id')}") + debug_print(f"Failed to cache metrics for user {user.get('id')}") except Exception as cache_save_e: - current_app.logger.debug(f"Error saving metrics cache for user {user.get('id')}: {cache_save_e}") + debug_print(f"Error saving metrics cache for user {user.get('id')}: {cache_save_e}") return enhanced except Exception as e: - current_app.logger.error(f"Error enhancing user data: {e}") + debug_print(f"Error enhancing user data: {e}") return user # Return original user data if enhancement fails def enhance_public_workspace_with_activity(workspace, force_refresh=False): @@ -703,7 +705,7 @@ def enhance_public_workspace_with_activity(workspace, force_refresh=False): return enhanced except Exception as e: - current_app.logger.error(f"Error enhancing public workspace data: {e}") + debug_print(f"Error enhancing public workspace data: {e}") return workspace # Return original workspace data if enhancement fails def enhance_group_with_activity(group, force_refresh=False): @@ -967,15 +969,15 @@ def enhance_group_with_activity(group, force_refresh=False): total_storage_size += blob.size blob_count += 1 debug_print(f"💾 [GROUP STORAGE DEBUG] Blob {blob.name}: {blob.size} bytes") - current_app.logger.debug(f"Group storage blob {blob.name}: {blob.size} bytes") + debug_print(f"Group storage blob {blob.name}: {blob.size} bytes") debug_print(f"💾 [GROUP STORAGE DEBUG] Found {blob_count} blobs, total size: {total_storage_size} bytes") enhanced['activity']['document_metrics']['storage_account_size'] = total_storage_size enhanced['storage_size'] = total_storage_size # Update flat field - current_app.logger.debug(f"Total storage size for group {group_id}: {total_storage_size} bytes") + debug_print(f"Total storage size for group {group_id}: {total_storage_size} bytes") else: debug_print(f"💾 [GROUP STORAGE DEBUG] Storage client NOT available for group {group_id}") - current_app.logger.debug(f"Storage client not available for group {group_id}") + debug_print(f"Storage client not available for group {group_id}") # Fallback to estimation if storage client not available storage_size_query = """ SELECT c.file_name, c.number_of_pages FROM c @@ -1011,11 +1013,11 @@ def enhance_group_with_activity(group, force_refresh=False): enhanced['activity']['document_metrics']['storage_account_size'] = total_storage_size enhanced['storage_size'] = total_storage_size # Update flat field debug_print(f"💾 [GROUP STORAGE DEBUG] Fallback estimation complete: {total_storage_size} bytes") - current_app.logger.debug(f"Estimated storage size for group {group_id}: {total_storage_size} bytes") + debug_print(f"Estimated storage size for group {group_id}: {total_storage_size} bytes") except Exception as storage_e: debug_print(f"❌ [GROUP STORAGE DEBUG] Storage calculation failed for group {group_id}: {storage_e}") - current_app.logger.debug(f"Could not calculate storage size for group {group_id}: {storage_e}") + debug_print(f"Could not calculate storage size for group {group_id}: {storage_e}") # Set to 0 if we can't calculate enhanced['activity']['document_metrics']['storage_account_size'] = 0 enhanced['storage_size'] = 0 @@ -1039,7 +1041,7 @@ def enhance_group_with_activity(group, force_refresh=False): return enhanced except Exception as e: - current_app.logger.error(f"Error enhancing group data: {e}") + debug_print(f"Error enhancing group data: {e}") return group # Return original group data if enhancement fails def get_activity_trends_data(start_date, end_date): @@ -1122,10 +1124,10 @@ def get_activity_trends_data(start_date, end_date): if date_key in daily_data: daily_data[date_key]['chats'] += 1 except Exception as e: - current_app.logger.debug(f"Could not parse conversation timestamp {timestamp}: {e}") + debug_print(f"Could not parse conversation timestamp {timestamp}: {e}") except Exception as e: - current_app.logger.warning(f"Could not query conversation activity logs: {e}") + debug_print(f"Could not query conversation activity logs: {e}") print(f"❌ [ACTIVITY TRENDS DEBUG] Error querying chats: {e}") # Query 2: Get document activity from activity_logs container (document_creation activity_type) @@ -1175,12 +1177,12 @@ def get_activity_trends_data(start_date, end_date): # Keep total for backward compatibility daily_data[date_key]['documents'] += 1 except Exception as e: - current_app.logger.debug(f"Could not parse document timestamp {timestamp}: {e}") + debug_print(f"Could not parse document timestamp {timestamp}: {e}") debug_print(f"🔍 [ACTIVITY TRENDS DEBUG] Total documents found: {len(docs)}") except Exception as e: - current_app.logger.warning(f"Could not query document activity logs: {e}") + debug_print(f"Could not query document activity logs: {e}") print(f"❌ [ACTIVITY TRENDS DEBUG] Error querying documents: {e}") # Query 3: Get login activity from activity_logs container @@ -1235,10 +1237,10 @@ def get_activity_trends_data(start_date, end_date): if date_key in daily_data: daily_data[date_key]['logins'] += 1 except Exception as e: - current_app.logger.debug(f"Could not parse login timestamp {timestamp}: {e}") + debug_print(f"Could not parse login timestamp {timestamp}: {e}") except Exception as e: - current_app.logger.warning(f"Could not query activity logs for login data: {e}") + debug_print(f"Could not query activity logs for login data: {e}") print(f"❌ [ACTIVITY TRENDS DEBUG] Error querying logins: {e}") # Query 4: Get token usage from activity_logs (token_usage activity_type) @@ -1288,12 +1290,12 @@ def get_activity_trends_data(start_date, end_date): if date_key in token_daily_data: token_daily_data[date_key][token_type] += token_count except Exception as e: - current_app.logger.debug(f"Could not parse token timestamp {timestamp}: {e}") + debug_print(f"Could not parse token timestamp {timestamp}: {e}") debug_print(f"🔍 [ACTIVITY TRENDS DEBUG] Token daily data: {token_daily_data}") except Exception as e: - current_app.logger.warning(f"Could not query activity logs for token usage: {e}") + debug_print(f"Could not query activity logs for token usage: {e}") print(f"❌ [ACTIVITY TRENDS DEBUG] Error querying tokens: {e}") # Initialize empty token data on error token_daily_data = {} @@ -1335,7 +1337,7 @@ def get_activity_trends_data(start_date, end_date): return result except Exception as e: - current_app.logger.error(f"Error getting activity trends data: {e}") + debug_print(f"Error getting activity trends data: {e}") print(f"❌ [ACTIVITY TRENDS DEBUG] Fatal error: {e}") return { 'chats': {}, @@ -1936,7 +1938,7 @@ def get_document_storage_size(doc, cosmos_container, container_name, folder_pref return result except Exception as e: - current_app.logger.error(f"Error getting raw activity trends data: {e}") + debug_print(f"Error getting raw activity trends data: {e}") debug_print(f"❌ [RAW ACTIVITY DEBUG] Fatal error: {e}") return {} @@ -2050,7 +2052,7 @@ def api_get_all_users(): }), 200 except Exception as e: - current_app.logger.error(f"Error getting users: {e}") + debug_print(f"Error getting users: {e}") return jsonify({'error': 'Failed to retrieve users'}), 500 @app.route('/api/admin/control-center/users//access', methods=['PATCH']) @@ -2107,7 +2109,7 @@ def api_update_user_access(user_id): return jsonify({'error': 'Failed to update user access'}), 500 except Exception as e: - current_app.logger.error(f"Error updating user access: {e}") + debug_print(f"Error updating user access: {e}") return jsonify({'error': 'Failed to update user access'}), 500 @app.route('/api/admin/control-center/users//file-uploads', methods=['PATCH']) @@ -2164,7 +2166,7 @@ def api_update_user_file_uploads(user_id): return jsonify({'error': 'Failed to update user file upload permissions'}), 500 except Exception as e: - current_app.logger.error(f"Error updating user file uploads: {e}") + debug_print(f"Error updating user file uploads: {e}") return jsonify({'error': 'Failed to update user file upload permissions'}), 500 @app.route('/api/admin/control-center/users//delete-documents', methods=['POST']) @@ -2235,7 +2237,7 @@ def api_delete_user_documents_admin(user_id): }), 200 except Exception as e: - current_app.logger.error(f"Error creating user document deletion request: {e}") + debug_print(f"Error creating user document deletion request: {e}") log_event("[ControlCenter] Delete User Documents Request Failed", { "error": str(e), "user_id": user_id @@ -2299,7 +2301,7 @@ def api_bulk_user_action(): else: failed_users.append(user_id) except Exception as e: - current_app.logger.error(f"Error updating user {user_id}: {e}") + debug_print(f"Error updating user {user_id}: {e}") failed_users.append(user_id) # Log admin action @@ -2325,7 +2327,7 @@ def api_bulk_user_action(): return jsonify(result), 200 except Exception as e: - current_app.logger.error(f"Error performing bulk user action: {e}") + debug_print(f"Error performing bulk user action: {e}") return jsonify({'error': 'Failed to perform bulk action'}), 500 # Group Management APIs @@ -2434,7 +2436,7 @@ def api_get_all_groups(): }), 200 except Exception as e: - current_app.logger.error(f"Error getting groups: {e}") + debug_print(f"Error getting groups: {e}") return jsonify({'error': 'Failed to retrieve groups'}), 500 @app.route('/api/admin/control-center/groups//status', methods=['PUT']) @@ -2534,7 +2536,7 @@ def api_update_group_status(group_id): }), 200 except Exception as e: - current_app.logger.error(f"Error updating group status: {e}") + debug_print(f"Error updating group status: {e}") return jsonify({'error': 'Failed to update group status'}), 500 @app.route('/api/admin/control-center/groups/', methods=['GET']) @@ -2559,7 +2561,7 @@ def api_get_group_details_admin(group_id): return jsonify(enhanced_group), 200 except Exception as e: - current_app.logger.error(f"Error getting group details: {e}") + debug_print(f"Error getting group details: {e}") return jsonify({'error': 'Failed to retrieve group details'}), 500 @app.route('/api/admin/control-center/groups/', methods=['DELETE']) @@ -2625,7 +2627,7 @@ def api_delete_group_admin(group_id): }), 200 except Exception as e: - current_app.logger.error(f"Error creating group deletion request: {e}") + debug_print(f"Error creating group deletion request: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/admin/control-center/groups//delete-documents', methods=['POST']) @@ -2689,7 +2691,7 @@ def api_delete_group_documents_admin(group_id): }), 200 except Exception as e: - current_app.logger.error(f"Error creating document deletion request: {e}") + debug_print(f"Error creating document deletion request: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/admin/control-center/groups//members', methods=['GET']) @@ -2724,7 +2726,7 @@ def api_get_group_members_admin(group_id): return jsonify({'members': members}), 200 except Exception as e: - current_app.logger.error(f"Error getting group members: {e}") + debug_print(f"Error getting group members: {e}") return jsonify({'error': 'Failed to retrieve group members'}), 500 @app.route('/api/admin/control-center/groups//take-ownership', methods=['POST']) @@ -2793,7 +2795,7 @@ def api_admin_take_group_ownership(group_id): }), 200 except Exception as e: - current_app.logger.error(f"Error creating take ownership request: {e}") + debug_print(f"Error creating take ownership request: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/admin/control-center/groups//transfer-ownership', methods=['POST']) @@ -2877,7 +2879,7 @@ def api_admin_transfer_group_ownership(group_id): }), 200 except Exception as e: - current_app.logger.error(f"Error creating transfer ownership request: {e}") + debug_print(f"Error creating transfer ownership request: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/admin/control-center/groups//add-member', methods=['POST']) @@ -2985,7 +2987,7 @@ def api_admin_add_group_member(group_id): }), 200 except Exception as e: - current_app.logger.error(f"Error adding group member: {e}") + debug_print(f"Error adding group member: {e}") return jsonify({'error': 'Failed to add member'}), 500 @app.route('/api/admin/control-center/groups//activity', methods=['GET']) @@ -3056,9 +3058,9 @@ def api_admin_get_group_activity(group_id): """ # Log the queries for debugging - current_app.logger.info(f"[Group Activity] Querying for group: {group_id}, days: {days}") - current_app.logger.debug(f"[Group Activity] Query 1: {query1}") - current_app.logger.debug(f"[Group Activity] Query 2: {query2}") + debug_print(f"[Group Activity] Querying for group: {group_id}, days: {days}") + debug_print(f"[Group Activity] Query 1: {query1}") + debug_print(f"[Group Activity] Query 2: {query2}") parameters = [ {"name": "@group_id", "value": group_id} @@ -3067,7 +3069,7 @@ def api_admin_get_group_activity(group_id): if cutoff_date: parameters.append({"name": "@cutoff_date", "value": cutoff_date}) - current_app.logger.debug(f"[Group Activity] Parameters: {parameters}") + debug_print(f"[Group Activity] Parameters: {parameters}") # Execute both queries activities = [] @@ -3079,10 +3081,10 @@ def api_admin_get_group_activity(group_id): parameters=parameters, enable_cross_partition_query=True )) - current_app.logger.debug(f"[Group Activity] Query 1 returned {len(activities1)} activities") + debug_print(f"[Group Activity] Query 1 returned {len(activities1)} activities") activities.extend(activities1) except Exception as e: - current_app.logger.warning(f"[Group Activity] Query 1 failed: {e}") + debug_print(f"[Group Activity] Query 1 failed: {e}") try: # Query 2: Document activities @@ -3091,10 +3093,10 @@ def api_admin_get_group_activity(group_id): parameters=parameters, enable_cross_partition_query=True )) - current_app.logger.debug(f"[Group Activity] Query 2 returned {len(activities2)} activities") + debug_print(f"[Group Activity] Query 2 returned {len(activities2)} activities") activities.extend(activities2) except Exception as e: - current_app.logger.warning(f"[Group Activity] Query 2 failed: {e}") + debug_print(f"[Group Activity] Query 2 failed: {e}") # Sort combined results by timestamp descending activities.sort(key=lambda x: x.get('timestamp', ''), reverse=True) @@ -3193,7 +3195,7 @@ def api_admin_get_group_activity(group_id): }), 200 except Exception as e: - current_app.logger.error(f"Error fetching group activity: {e}") + debug_print(f"Error fetching group activity: {e}") import traceback traceback.print_exc() return jsonify({'error': f'Failed to fetch group activity: {str(e)}'}), 500 @@ -3267,7 +3269,7 @@ def api_control_center_public_workspaces(): enhanced_workspace = enhance_public_workspace_with_activity(workspace, force_refresh=force_refresh) enhanced_workspaces.append(enhanced_workspace) except Exception as enhance_e: - current_app.logger.error(f"Error enhancing workspace {workspace.get('id', 'unknown')}: {enhance_e}") + debug_print(f"Error enhancing workspace {workspace.get('id', 'unknown')}: {enhance_e}") # Include the original workspace if enhancement fails enhanced_workspaces.append(workspace) @@ -3302,7 +3304,7 @@ def api_control_center_public_workspaces(): }) except Exception as e: - current_app.logger.error(f"Error getting public workspaces for control center: {e}") + debug_print(f"Error getting public workspaces for control center: {e}") return jsonify({'error': 'Failed to retrieve public workspaces'}), 500 # Activity Trends API @@ -3352,7 +3354,7 @@ def api_get_activity_trends(): }) except Exception as e: - current_app.logger.error(f"Error getting activity trends: {e}") + debug_print(f"Error getting activity trends: {e}") print(f"❌ [Activity Trends API] Error: {e}") return jsonify({'error': 'Failed to retrieve activity trends'}), 500 @@ -3545,7 +3547,7 @@ def api_export_activity_trends(): return response except Exception as e: - current_app.logger.error(f"Error exporting activity trends: {e}") + debug_print(f"Error exporting activity trends: {e}") return jsonify({'error': 'Failed to export data'}), 500 @app.route('/api/admin/control-center/activity-trends/chat', methods=['POST']) @@ -3701,7 +3703,7 @@ def api_chat_activity_trends(): }), 200 except Exception as e: - current_app.logger.error(f"Error creating activity trends chat: {e}") + debug_print(f"Error creating activity trends chat: {e}") return jsonify({'error': 'Failed to create chat conversation'}), 500 # Data Refresh API @@ -3717,7 +3719,7 @@ def api_refresh_control_center_data(): """ try: debug_print("🔄 [REFRESH DEBUG] Starting Control Center data refresh...") - current_app.logger.info("Starting Control Center data refresh...") + debug_print("Starting Control Center data refresh...") # Check if request has specific user_id from flask import request @@ -3756,14 +3758,14 @@ def api_refresh_control_center_data(): refreshed_count += 1 debug_print(f"✅ [REFRESH DEBUG] Successfully refreshed user {user_id}") - current_app.logger.debug(f"Refreshed metrics for user {user_id}") + debug_print(f"Refreshed metrics for user {user_id}") except Exception as user_error: failed_count += 1 debug_print(f"❌ [REFRESH DEBUG] Failed to refresh user {user.get('id')}: {user_error}") debug_print(f"❌ [REFRESH DEBUG] User error traceback:") import traceback debug_print(traceback.format_exc()) - current_app.logger.error(f"Failed to refresh metrics for user {user.get('id')}: {user_error}") + debug_print(f"Failed to refresh metrics for user {user.get('id')}: {user_error}") debug_print(f"🔄 [REFRESH DEBUG] User refresh loop completed. Refreshed: {refreshed_count}, Failed: {failed_count}") @@ -3791,18 +3793,18 @@ def api_refresh_control_center_data(): groups_refreshed_count += 1 debug_print(f"✅ [REFRESH DEBUG] Successfully refreshed group {group_id}") - current_app.logger.debug(f"Refreshed metrics for group {group_id}") + debug_print(f"Refreshed metrics for group {group_id}") except Exception as group_error: groups_failed_count += 1 debug_print(f"❌ [REFRESH DEBUG] Failed to refresh group {group.get('id')}: {group_error}") debug_print(f"❌ [REFRESH DEBUG] Group error traceback:") import traceback debug_print(traceback.format_exc()) - current_app.logger.error(f"Failed to refresh metrics for group {group.get('id')}: {group_error}") + debug_print(f"Failed to refresh metrics for group {group.get('id')}: {group_error}") except Exception as groups_error: debug_print(f"❌ [REFRESH DEBUG] Error querying groups: {groups_error}") - current_app.logger.error(f"Error querying groups for refresh: {groups_error}") + debug_print(f"Error querying groups for refresh: {groups_error}") debug_print(f"🔄 [REFRESH DEBUG] Group refresh loop completed. Refreshed: {groups_refreshed_count}, Failed: {groups_failed_count}") @@ -3818,19 +3820,19 @@ def api_refresh_control_center_data(): if not update_success: debug_print("⚠️ [REFRESH DEBUG] Failed to update admin settings") - current_app.logger.warning("Failed to update admin settings with refresh timestamp") + debug_print("Failed to update admin settings with refresh timestamp") else: debug_print("✅ [REFRESH DEBUG] Admin settings updated successfully") - current_app.logger.info("Updated admin settings with refresh timestamp") + debug_print("Updated admin settings with refresh timestamp") else: debug_print("⚠️ [REFRESH DEBUG] Could not get admin settings") except Exception as admin_error: debug_print(f"❌ [REFRESH DEBUG] Admin settings update failed: {admin_error}") - current_app.logger.error(f"Error updating admin settings: {admin_error}") + debug_print(f"Error updating admin settings: {admin_error}") debug_print(f"🎉 [REFRESH DEBUG] Refresh completed! Users - Refreshed: {refreshed_count}, Failed: {failed_count}. Groups - Refreshed: {groups_refreshed_count}, Failed: {groups_failed_count}") - current_app.logger.info(f"Control Center data refresh completed. Users: {refreshed_count} refreshed, {failed_count} failed. Groups: {groups_refreshed_count} refreshed, {groups_failed_count} failed") + debug_print(f"Control Center data refresh completed. Users: {refreshed_count} refreshed, {failed_count} failed. Groups: {groups_refreshed_count} refreshed, {groups_failed_count} failed") return jsonify({ 'success': True, @@ -3847,7 +3849,7 @@ def api_refresh_control_center_data(): debug_print("💥 [REFRESH DEBUG] Full traceback:") import traceback debug_print(traceback.format_exc()) - current_app.logger.error(f"Error refreshing Control Center data: {e}") + debug_print(f"Error refreshing Control Center data: {e}") return jsonify({'error': 'Failed to refresh data'}), 500 # Get refresh status API @@ -3872,7 +3874,7 @@ def api_get_refresh_status(): }), 200 except Exception as e: - current_app.logger.error(f"Error getting refresh status: {e}") + debug_print(f"Error getting refresh status: {e}") return jsonify({'error': 'Failed to get refresh status'}), 500 # Activity Log Migration APIs @@ -3910,7 +3912,7 @@ def api_get_migration_status(): )) migration_status['conversations_without_logs'] = conversations_result[0] if conversations_result else 0 except Exception as e: - current_app.logger.warning(f"Error checking conversations migration status: {e}") + debug_print(f"Error checking conversations migration status: {e}") # Check personal documents without the flag try: @@ -3925,7 +3927,7 @@ def api_get_migration_status(): )) migration_status['personal_documents_without_logs'] = personal_docs_result[0] if personal_docs_result else 0 except Exception as e: - current_app.logger.warning(f"Error checking personal documents migration status: {e}") + debug_print(f"Error checking personal documents migration status: {e}") # Check group documents without the flag try: @@ -3940,7 +3942,7 @@ def api_get_migration_status(): )) migration_status['group_documents_without_logs'] = group_docs_result[0] if group_docs_result else 0 except Exception as e: - current_app.logger.warning(f"Error checking group documents migration status: {e}") + debug_print(f"Error checking group documents migration status: {e}") # Check public documents without the flag try: @@ -3955,7 +3957,7 @@ def api_get_migration_status(): )) migration_status['public_documents_without_logs'] = public_docs_result[0] if public_docs_result else 0 except Exception as e: - current_app.logger.warning(f"Error checking public documents migration status: {e}") + debug_print(f"Error checking public documents migration status: {e}") # Calculate totals migration_status['total_documents_without_logs'] = ( @@ -3974,7 +3976,7 @@ def api_get_migration_status(): return jsonify(migration_status), 200 except Exception as e: - current_app.logger.error(f"Error getting migration status: {e}") + debug_print(f"Error getting migration status: {e}") return jsonify({'error': 'Failed to get migration status'}), 500 @app.route('/api/admin/control-center/migrate/all', methods=['POST']) @@ -4008,7 +4010,7 @@ def api_migrate_to_activity_logs(): } # Migrate conversations - current_app.logger.info("Starting conversation migration...") + debug_print("Starting conversation migration...") try: conversations_query = """ SELECT * @@ -4020,7 +4022,7 @@ def api_migrate_to_activity_logs(): enable_cross_partition_query=True )) - current_app.logger.info(f"Found {len(conversations)} conversations to migrate") + debug_print(f"Found {len(conversations)} conversations to migrate") for conv in conversations: try: @@ -4053,16 +4055,16 @@ def api_migrate_to_activity_logs(): except Exception as conv_error: results['conversations_failed'] += 1 error_msg = f"Failed to migrate conversation {conv.get('id')}: {str(conv_error)}" - current_app.logger.error(error_msg) + debug_print(error_msg) results['errors'].append(error_msg) except Exception as e: error_msg = f"Error during conversation migration: {str(e)}" - current_app.logger.error(error_msg) + debug_print(error_msg) results['errors'].append(error_msg) # Migrate personal documents - current_app.logger.info("Starting personal documents migration...") + debug_print("Starting personal documents migration...") try: personal_docs_query = """ SELECT * @@ -4119,16 +4121,16 @@ def api_migrate_to_activity_logs(): except Exception as doc_error: results['personal_documents_failed'] += 1 error_msg = f"Failed to migrate personal document {doc.get('id')}: {str(doc_error)}" - current_app.logger.error(error_msg) + debug_print(error_msg) results['errors'].append(error_msg) except Exception as e: error_msg = f"Error during personal documents migration: {str(e)}" - current_app.logger.error(error_msg) + debug_print(error_msg) results['errors'].append(error_msg) # Migrate group documents - current_app.logger.info("Starting group documents migration...") + debug_print("Starting group documents migration...") try: group_docs_query = """ SELECT * @@ -4187,16 +4189,16 @@ def api_migrate_to_activity_logs(): except Exception as doc_error: results['group_documents_failed'] += 1 error_msg = f"Failed to migrate group document {doc.get('id')}: {str(doc_error)}" - current_app.logger.error(error_msg) + debug_print(error_msg) results['errors'].append(error_msg) except Exception as e: error_msg = f"Error during group documents migration: {str(e)}" - current_app.logger.error(error_msg) + debug_print(error_msg) results['errors'].append(error_msg) # Migrate public documents - current_app.logger.info("Starting public documents migration...") + debug_print("Starting public documents migration...") try: public_docs_query = """ SELECT * @@ -4255,12 +4257,12 @@ def api_migrate_to_activity_logs(): except Exception as doc_error: results['public_documents_failed'] += 1 error_msg = f"Failed to migrate public document {doc.get('id')}: {str(doc_error)}" - current_app.logger.error(error_msg) + debug_print(error_msg) results['errors'].append(error_msg) except Exception as e: error_msg = f"Error during public documents migration: {str(e)}" - current_app.logger.error(error_msg) + debug_print(error_msg) results['errors'].append(error_msg) # Calculate totals @@ -4278,12 +4280,12 @@ def api_migrate_to_activity_logs(): results['public_documents_failed'] ) - current_app.logger.info(f"Migration complete: {results['total_migrated']} migrated, {results['total_failed']} failed") + debug_print(f"Migration complete: {results['total_migrated']} migrated, {results['total_failed']} failed") return jsonify(results), 200 except Exception as e: - current_app.logger.error(f"Error during migration: {e}") + debug_print(f"Error during migration: {e}") import traceback traceback.print_exc() return jsonify({'error': f'Migration failed: {str(e)}'}), 500 @@ -4337,8 +4339,8 @@ def api_get_activity_logs(): OFFSET {offset} LIMIT {per_page} """ - current_app.logger.info(f"Activity logs query: {logs_query}") - current_app.logger.info(f"Query parameters: {parameters}") + debug_print(f"Activity logs query: {logs_query}") + debug_print(f"Query parameters: {parameters}") logs = list(cosmos_activity_logs_container.query_items( query=logs_query, @@ -4405,7 +4407,7 @@ def api_get_activity_logs(): }), 200 except Exception as e: - current_app.logger.error(f"Error getting activity logs: {e}") + debug_print(f"Error getting activity logs: {e}") import traceback traceback.print_exc() return jsonify({'error': 'Failed to fetch activity logs'}), 500 @@ -4479,9 +4481,9 @@ def api_admin_get_approvals(): }), 200 except Exception as e: - current_app.logger.error(f"Error fetching approvals: {e}") + debug_print(f"Error fetching approvals: {e}") import traceback - current_app.logger.error(traceback.format_exc()) + debug_print(traceback.format_exc()) return jsonify({'error': 'Failed to fetch approvals', 'details': str(e)}), 500 @app.route('/api/admin/control-center/approvals/', methods=['GET']) @@ -4516,9 +4518,9 @@ def api_admin_get_approval_by_id(approval_id): return jsonify(approval), 200 except Exception as e: - current_app.logger.error(f"Error fetching approval {approval_id}: {e}") + debug_print(f"Error fetching approval {approval_id}: {e}") import traceback - current_app.logger.error(traceback.format_exc()) + debug_print(traceback.format_exc()) return jsonify({'error': 'Failed to fetch approval', 'details': str(e)}), 500 @app.route('/api/admin/control-center/approvals//approve', methods=['POST']) @@ -4568,7 +4570,7 @@ def api_admin_approve_request(approval_id): }), 200 except Exception as e: - current_app.logger.error(f"Error approving request: {e}") + debug_print(f"Error approving request: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/admin/control-center/approvals//deny', methods=['POST']) @@ -4618,7 +4620,7 @@ def api_admin_deny_request(approval_id): }), 200 except Exception as e: - current_app.logger.error(f"Error denying request: {e}") + debug_print(f"Error denying request: {e}") return jsonify({'error': str(e)}), 500 # New standalone approvals API endpoints (accessible to all users with permissions) @@ -4647,8 +4649,13 @@ def api_get_approvals(): action_type_filter = request.args.get('action_type', 'all') search_query = request.args.get('search', '') + debug_print(f"📋 [APPROVALS API] Fetching approvals - status_filter: {status_filter}, action_type: {action_type_filter}") + # Determine include_completed based on status filter - include_completed = (status_filter == 'all' or status_filter in ['approved', 'denied']) + # 'all' means show everything, specific statuses mean show only those + include_completed = (status_filter in ['all', 'approved', 'denied', 'executed']) + + debug_print(f"📋 [APPROVALS API] include_completed: {include_completed}") # Map action_type to request_type_filter request_type_filter = None if action_type_filter == 'all' else action_type_filter @@ -4660,7 +4667,8 @@ def api_get_approvals(): page=page, per_page=page_size, include_completed=include_completed, - request_type_filter=request_type_filter + request_type_filter=request_type_filter, + status_filter=status_filter ) # Add can_approve field to each approval @@ -4681,9 +4689,9 @@ def api_get_approvals(): }), 200 except Exception as e: - current_app.logger.error(f"Error fetching approvals: {e}") + debug_print(f"Error fetching approvals: {e}") import traceback - current_app.logger.error(traceback.format_exc()) + debug_print(traceback.format_exc()) return jsonify({'error': 'Failed to fetch approvals', 'details': str(e)}), 500 @app.route('/api/approvals/', methods=['GET']) @@ -4716,9 +4724,9 @@ def api_get_approval_by_id(approval_id): return jsonify(approval), 200 except Exception as e: - current_app.logger.error(f"Error fetching approval {approval_id}: {e}") + debug_print(f"Error fetching approval {approval_id}: {e}") import traceback - current_app.logger.error(traceback.format_exc()) + debug_print(traceback.format_exc()) return jsonify({'error': 'Failed to fetch approval', 'details': str(e)}), 500 @app.route('/api/approvals//approve', methods=['POST']) @@ -4766,7 +4774,7 @@ def api_approve_request(approval_id): }), 200 except Exception as e: - current_app.logger.error(f"Error approving request: {e}") + debug_print(f"Error approving request: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/approvals//deny', methods=['POST']) @@ -4814,7 +4822,7 @@ def api_deny_request(approval_id): }), 200 except Exception as e: - current_app.logger.error(f"Error denying request: {e}") + debug_print(f"Error denying request: {e}") return jsonify({'error': str(e)}), 500 def _execute_approved_action(approval, executor_id, executor_email, executor_name): @@ -5050,40 +5058,82 @@ def _execute_delete_documents(approval, executor_id, executor_email, executor_na try: group_id = approval['group_id'] - # Query all documents for this group - query = "SELECT * FROM c WHERE c.group_id = @group_id" + debug_print(f"🔍 [DELETE_GROUP_DOCS] Starting deletion for group_id: {group_id}") + + # Query all document metadata for this group + query = "SELECT * FROM c WHERE c.group_id = @group_id AND c.type = 'document_metadata'" parameters = [{"name": "@group_id", "value": group_id}] + debug_print(f"🔍 [DELETE_GROUP_DOCS] Query: {query}") + debug_print(f"🔍 [DELETE_GROUP_DOCS] Parameters: {parameters}") + debug_print(f"🔍 [DELETE_GROUP_DOCS] Using partition_key: {group_id}") + + # Query with partition key for better performance documents = list(cosmos_group_documents_container.query_items( query=query, parameters=parameters, - enable_cross_partition_query=True + partition_key=group_id )) + debug_print(f"📊 [DELETE_GROUP_DOCS] Found {len(documents)} documents with partition key query") + + # If no documents found with partition key, try cross-partition query + if len(documents) == 0: + debug_print(f"⚠️ [DELETE_GROUP_DOCS] No documents found with partition key, trying cross-partition query") + documents = list(cosmos_group_documents_container.query_items( + query=query, + parameters=parameters, + enable_cross_partition_query=True + )) + debug_print(f"📊 [DELETE_GROUP_DOCS] Cross-partition query found {len(documents)} documents") + + # Log sample document for debugging + if len(documents) > 0: + sample_doc = documents[0] + debug_print(f"📄 [DELETE_GROUP_DOCS] Sample document structure: id={sample_doc.get('id')}, type={sample_doc.get('type')}, group_id={sample_doc.get('group_id')}") + deleted_count = 0 + # Use proper deletion APIs for each document for doc in documents: try: - # Delete blob from Azure Storage - blob_path = f"group-workspaces/{group_id}/{doc['id']}/{doc.get('file_name', '')}" - blob_client = blob_service_client.get_blob_client( - container=app.config.get('AZURE_STORAGE_CONTAINER_NAME', 'documents'), - blob=blob_path - ) - - if blob_client.exists(): - blob_client.delete_blob() + doc_id = doc['id'] + debug_print(f"🗑️ [DELETE_GROUP_DOCS] Deleting document {doc_id}") - # Delete document from Cosmos - cosmos_group_documents_container.delete_item( - item=doc['id'], - partition_key=group_id + # Use delete_document API which handles: + # - Blob storage deletion + # - AI Search index deletion + # - Cosmos DB metadata deletion + # Note: For group documents, we don't have a user_id, so we pass None + delete_result = delete_document( + user_id=None, + document_id=doc_id, + group_id=group_id ) - deleted_count += 1 + # Check if delete_result is valid and successful + if delete_result and delete_result.get('success'): + # Delete document chunks using proper API + delete_document_chunks( + document_id=doc_id, + group_id=group_id + ) + + deleted_count += 1 + debug_print(f"✅ [DELETE_GROUP_DOCS] Successfully deleted document {doc_id}") + else: + error_msg = delete_result.get('message') if delete_result else 'delete_document returned None' + debug_print(f"❌ [DELETE_GROUP_DOCS] Failed to delete document {doc_id}: {error_msg}") except Exception as doc_error: - current_app.logger.error(f"Error deleting document {doc['id']}: {doc_error}") + debug_print(f"❌ [DELETE_GROUP_DOCS] Error deleting document {doc.get('id')}: {doc_error}") + + # Invalidate group search cache after deletion + try: + invalidate_group_search_cache(group_id) + debug_print(f"🔄 [DELETE_GROUP_DOCS] Invalidated search cache for group {group_id}") + except Exception as cache_error: + debug_print(f"⚠️ [DELETE_GROUP_DOCS] Could not invalidate search cache: {cache_error}") # Log to activity logs activity_record = { @@ -5103,12 +5153,15 @@ def _execute_delete_documents(approval, executor_id, executor_email, executor_na } cosmos_activity_logs_container.create_item(body=activity_record) + debug_print(f"[ControlCenter] Group Documents Deleted (Approved) -- group_id: {group_id}, documents_deleted: {deleted_count}") + return { 'success': True, 'message': f'Deleted {deleted_count} documents' } except Exception as e: + debug_print(f"[DELETE_GROUP_DOCS] Fatal error: {e}") return {'success': False, 'message': f'Failed to delete documents: {str(e)}'} def _execute_delete_group(approval, executor_id, executor_email, executor_name): @@ -5136,7 +5189,7 @@ def _execute_delete_group(approval, executor_id, executor_email, executor_name): partition_key=group_id ) except Exception as conv_error: - current_app.logger.error(f"Error deleting conversations: {conv_error}") + debug_print(f"Error deleting conversations: {conv_error}") # Delete group messages (optional) try: @@ -5152,13 +5205,12 @@ def _execute_delete_group(approval, executor_id, executor_email, executor_name): partition_key=group_id ) except Exception as msg_error: - current_app.logger.error(f"Error deleting messages: {msg_error}") + debug_print(f"Error deleting messages: {msg_error}") - # Finally, delete the group itself - cosmos_groups_container.delete_item( - item=group_id, - partition_key=group_id - ) + # Finally, delete the group itself using proper API + debug_print(f"🗑️ [DELETE GROUP] Deleting group document using delete_group() API") + delete_group(group_id) + debug_print(f"✅ [DELETE GROUP] Group {group_id} successfully deleted") # Log to activity logs activity_record = { @@ -5188,6 +5240,9 @@ def _execute_delete_group(approval, executor_id, executor_email, executor_name): def _execute_delete_user_documents(approval, executor_id, executor_email, executor_name): """Execute delete all user documents action.""" try: + from functions_documents import delete_document, delete_document_chunks + from utils_cache import invalidate_personal_search_cache + user_id = approval['metadata'].get('user_id') user_email = approval['metadata'].get('user_email', 'unknown') user_name = approval['metadata'].get('user_name', user_email) @@ -5196,47 +5251,64 @@ def _execute_delete_user_documents(approval, executor_id, executor_email, execut return {'success': False, 'message': 'User ID not found in approval metadata'} # Query all personal documents for this user - query = "SELECT * FROM c WHERE c.user_id = @user_id AND (NOT IS_DEFINED(c.group_id) OR c.group_id = null)" + # Personal documents are stored in cosmos_user_documents_container with user_id as partition key + query = "SELECT * FROM c WHERE c.user_id = @user_id" parameters = [{"name": "@user_id", "value": user_id}] + debug_print(f"🔍 [DELETE_USER_DOCS] Querying for user_id: {user_id}") + debug_print(f"🔍 [DELETE_USER_DOCS] Query: {query}") + debug_print(f"🔍 [DELETE_USER_DOCS] Container: cosmos_user_documents_container") + documents = list(cosmos_user_documents_container.query_items( query=query, parameters=parameters, - enable_cross_partition_query=True + partition_key=user_id # Use partition key for efficient query )) + debug_print(f"📊 [DELETE_USER_DOCS] Found {len(documents)} documents with partition key query") + if len(documents) > 0: + debug_print(f"📄 [DELETE_USER_DOCS] First document sample: id={documents[0].get('id', 'no-id')}, file_name={documents[0].get('file_name', 'no-filename')}, type={documents[0].get('type', 'no-type')}") + else: + # Try a cross-partition query to see if documents exist elsewhere + debug_print(f"⚠️ [DELETE_USER_DOCS] No documents found with partition key, trying cross-partition query...") + documents = list(cosmos_user_documents_container.query_items( + query=query, + parameters=parameters, + enable_cross_partition_query=True + )) + debug_print(f"📊 [DELETE_USER_DOCS] Cross-partition query found {len(documents)} documents") + if len(documents) > 0: + sample_doc = documents[0] + debug_print(f"📄 [DELETE_USER_DOCS] Sample doc fields: {list(sample_doc.keys())}") + debug_print(f"📄 [DELETE_USER_DOCS] Sample doc: id={sample_doc.get('id')}, type={sample_doc.get('type')}, user_id={sample_doc.get('user_id')}, file_name={sample_doc.get('file_name')}") + deleted_count = 0 + # Use the existing delete_document function for proper cleanup for doc in documents: try: - # Delete blob from Azure Storage - blob_path = f"{user_id}/{doc['id']}/{doc.get('file_name', '')}" - blob_client = blob_service_client.get_blob_client( - container=app.config.get('AZURE_STORAGE_CONTAINER_NAME', 'documents'), - blob=blob_path - ) + document_id = doc['id'] + debug_print(f"🗑️ [DELETE_USER_DOCS] Deleting document {document_id}: {doc.get('file_name', 'unknown')}") - if blob_client.exists(): - blob_client.delete_blob() - - # Delete from AI Search index if enabled - if app.config.get('AZURE_SEARCH_ENDPOINT'): - try: - from functions_documents import delete_document_chunks - delete_document_chunks(doc['id']) - except Exception as search_error: - current_app.logger.error(f"Error deleting from AI Search: {search_error}") - - # Delete document from Cosmos - cosmos_user_documents_container.delete_item( - item=doc['id'], - partition_key=user_id - ) + # Use the proper delete_document function which handles: + # - Blob storage deletion + # - AI Search index deletion + # - Cosmos DB document deletion + delete_document(user_id, document_id) + delete_document_chunks(document_id) deleted_count += 1 + debug_print(f"✅ [DELETE_USER_DOCS] Successfully deleted document {document_id}") except Exception as doc_error: - current_app.logger.error(f"Error deleting user document {doc['id']}: {doc_error}") + debug_print(f"❌ [DELETE_USER_DOCS] Error deleting document {doc.get('id')}: {doc_error}") + + # Invalidate search cache for this user + try: + invalidate_personal_search_cache(user_id) + debug_print(f"🔄 [DELETE_USER_DOCS] Invalidated search cache for user {user_id}") + except Exception as cache_error: + debug_print(f"⚠️ [DELETE_USER_DOCS] Failed to invalidate search cache: {cache_error}") # Log to activity logs activity_record = { @@ -5272,7 +5344,7 @@ def _execute_delete_user_documents(approval, executor_id, executor_email, execut } except Exception as e: - current_app.logger.error(f"Error deleting user documents: {e}") + debug_print(f"Error deleting user documents: {e}") return {'success': False, 'message': f'Failed to delete user documents: {str(e)}'} return jsonify({'error': 'Failed to retrieve activity logs'}), 500 \ No newline at end of file diff --git a/application/single_app/route_backend_groups.py b/application/single_app/route_backend_groups.py index 051984396..f02c0a161 100644 --- a/application/single_app/route_backend_groups.py +++ b/application/single_app/route_backend_groups.py @@ -3,6 +3,7 @@ from config import * from functions_authentication import * from functions_group import * +from functions_debug import debug_print from swagger_wrapper import swagger_route, get_auth_security def register_route_backend_groups(app): @@ -450,7 +451,7 @@ def add_member_directly(group_id): } cosmos_activity_logs_container.create_item(body=activity_record) except Exception as log_error: - current_app.logger.error(f"Failed to log member addition activity: {log_error}") + debug_print(f"Failed to log member addition activity: {log_error}") return jsonify({"message": "Member added", "success": True}), 200 @@ -655,7 +656,7 @@ def update_member_role(group_id, member_id): } cosmos_activity_logs_container.create_item(body=activity_record) except Exception as log_error: - current_app.logger.error(f"Failed to log role change activity: {log_error}") + debug_print(f"Failed to log role change activity: {log_error}") return jsonify({"message": f"User {member_id} updated to {new_role}"}), 200 diff --git a/application/single_app/route_backend_notifications.py b/application/single_app/route_backend_notifications.py index 718338953..8fe8dd580 100644 --- a/application/single_app/route_backend_notifications.py +++ b/application/single_app/route_backend_notifications.py @@ -5,6 +5,7 @@ from functions_settings import * from functions_notifications import * from swagger_wrapper import swagger_route, get_auth_security +from functions_debug import debug_print def register_route_backend_notifications(app): @@ -52,7 +53,7 @@ def api_get_notifications(): }) except Exception as e: - current_app.logger.error(f"Error fetching notifications: {e}") + debug_print(f"Error fetching notifications: {e}") return jsonify({ 'success': False, 'error': 'Failed to fetch notifications' @@ -76,7 +77,7 @@ def api_get_notification_count(): }) except Exception as e: - current_app.logger.error(f"Error fetching notification count: {e}") + debug_print(f"Error fetching notification count: {e}") return jsonify({ 'success': False, 'count': 0 @@ -106,7 +107,7 @@ def api_mark_notification_read(notification_id): }), 400 except Exception as e: - current_app.logger.error(f"Error marking notification as read: {e}") + debug_print(f"Error marking notification as read: {e}") return jsonify({ 'success': False, 'error': 'Internal server error' @@ -136,7 +137,7 @@ def api_dismiss_notification(notification_id): }), 400 except Exception as e: - current_app.logger.error(f"Error dismissing notification: {e}") + debug_print(f"Error dismissing notification: {e}") return jsonify({ 'success': False, 'error': 'Internal server error' @@ -161,7 +162,7 @@ def api_mark_all_read(): }) except Exception as e: - current_app.logger.error(f"Error marking all notifications as read: {e}") + debug_print(f"Error marking all notifications as read: {e}") return jsonify({ 'success': False, 'error': 'Internal server error' @@ -202,7 +203,7 @@ def api_update_notification_settings(): }) except Exception as e: - current_app.logger.error(f"Error updating notification settings: {e}") + debug_print(f"Error updating notification settings: {e}") return jsonify({ 'success': False, 'error': 'Internal server error' diff --git a/application/single_app/route_backend_plugins.py b/application/single_app/route_backend_plugins.py index 51f0c6a04..edd53dbd0 100644 --- a/application/single_app/route_backend_plugins.py +++ b/application/single_app/route_backend_plugins.py @@ -11,7 +11,7 @@ from swagger_wrapper import swagger_route, get_auth_security import logging import os - +from functions_debug import debug_print import importlib.util from functions_plugins import get_merged_plugin_settings from semantic_kernel_plugins.base_plugin import BasePlugin @@ -342,7 +342,7 @@ def set_user_plugins(): delete_personal_action(user_id, plugin_name) except Exception as e: - current_app.logger.error(f"Error saving personal actions for user {user_id}: {e}") + debug_print(f"Error saving personal actions for user {user_id}: {e}") return jsonify({'error': 'Failed to save plugins'}), 500 log_event("User plugins updated", extra={"user_id": user_id, "plugins_count": len(filtered_plugins)}) return jsonify({'success': True}) @@ -460,7 +460,7 @@ def create_group_action_route(): try: saved = save_group_action(active_group, payload) except Exception as exc: - current_app.logger.error('Failed to save group action: %s', exc) + debug_print('Failed to save group action: %s', exc) return jsonify({'error': 'Unable to save action'}), 500 return jsonify(saved), 201 @@ -513,7 +513,7 @@ def update_group_action_route(action_id): try: saved = save_group_action(active_group, merged) except Exception as exc: - current_app.logger.error('Failed to update group action %s: %s', action_id, exc) + debug_print('Failed to update group action %s: %s', action_id, exc) return jsonify({'error': 'Unable to update action'}), 500 return jsonify(saved), 200 @@ -539,7 +539,7 @@ def delete_group_action_route(action_id): try: removed = delete_group_action(active_group, action_id) except Exception as exc: - current_app.logger.error('Failed to delete group action %s: %s', action_id, exc) + debug_print('Failed to delete group action %s: %s', action_id, exc) return jsonify({'error': 'Unable to delete action'}), 500 if not removed: diff --git a/application/single_app/route_frontend_authentication.py b/application/single_app/route_frontend_authentication.py index 4b8fa021f..022ecf846 100644 --- a/application/single_app/route_frontend_authentication.py +++ b/application/single_app/route_frontend_authentication.py @@ -134,7 +134,7 @@ def authorized(): if user_id: log_user_login(user_id, 'azure_ad') except Exception as e: - current_app.logger.warning(f"Could not log login activity: {e}") + debug_print(f"Could not log login activity: {e}") # Redirect to the originally intended page or home # You might want to store the original destination in the session during /login diff --git a/application/single_app/route_frontend_control_center.py b/application/single_app/route_frontend_control_center.py index acc3e9818..017ba10fe 100644 --- a/application/single_app/route_frontend_control_center.py +++ b/application/single_app/route_frontend_control_center.py @@ -7,6 +7,7 @@ from swagger_wrapper import swagger_route, get_auth_security from datetime import datetime, timedelta import json +from functions_debug import debug_print def register_route_frontend_control_center(app): @app.route('/admin/control-center', methods=['GET']) @@ -32,7 +33,7 @@ def control_center(): settings=public_settings, statistics=stats) except Exception as e: - current_app.logger.error(f"Error loading control center: {e}") + debug_print(f"Error loading control center: {e}") flash(f"Error loading control center: {str(e)}", "error") return redirect(url_for('admin_settings')) @@ -59,7 +60,7 @@ def approvals(): except Exception as e: import traceback error_trace = traceback.format_exc() - current_app.logger.error(f"Error loading approvals: {e}\n{error_trace}") + debug_print(f"Error loading approvals: {e}\n{error_trace}") print(f"ERROR IN APPROVALS ROUTE: {e}\n{error_trace}") flash(f"Error loading approvals: {str(e)}", "error") return redirect(url_for('index')) @@ -94,7 +95,7 @@ def get_control_center_statistics(): )) stats['total_users'] = user_result[0] if user_result else 0 except Exception as e: - current_app.logger.warning(f"Could not get user count: {e}") + debug_print(f"Could not get user count: {e}") # Get active users in last 30 days using lastUpdated try: @@ -111,7 +112,7 @@ def get_control_center_statistics(): )) stats['active_users_30_days'] = active_users_result[0] if active_users_result else 0 except Exception as e: - current_app.logger.warning(f"Could not get active users count: {e}") + debug_print(f"Could not get active users count: {e}") # Get total groups count try: @@ -122,7 +123,7 @@ def get_control_center_statistics(): )) stats['total_groups'] = groups_result[0] if groups_result else 0 except Exception as e: - current_app.logger.warning(f"Could not get groups count: {e}") + debug_print(f"Could not get groups count: {e}") # Get groups created in last 30 days using createdDate try: @@ -139,7 +140,7 @@ def get_control_center_statistics(): )) stats['locked_groups'] = new_groups_result[0] if new_groups_result else 0 except Exception as e: - current_app.logger.warning(f"Could not get new groups count: {e}") + debug_print(f"Could not get new groups count: {e}") # Get total public workspaces count try: @@ -150,7 +151,7 @@ def get_control_center_statistics(): )) stats['total_public_workspaces'] = workspaces_result[0] if workspaces_result else 0 except Exception as e: - current_app.logger.warning(f"Could not get public workspaces count: {e}") + debug_print(f"Could not get public workspaces count: {e}") # Get public workspaces created in last 30 days using createdDate try: @@ -167,7 +168,7 @@ def get_control_center_statistics(): )) stats['hidden_workspaces'] = new_workspaces_result[0] if new_workspaces_result else 0 except Exception as e: - current_app.logger.warning(f"Could not get new public workspaces count: {e}") + debug_print(f"Could not get new public workspaces count: {e}") # Get blocked users count try: @@ -181,7 +182,7 @@ def get_control_center_statistics(): )) stats['blocked_users'] = blocked_result[0] if blocked_result else 0 except Exception as e: - current_app.logger.warning(f"Could not get blocked users count: {e}") + debug_print(f"Could not get blocked users count: {e}") # Get recent activity (last 24 hours) try: @@ -228,7 +229,7 @@ def get_control_center_statistics(): stats['recent_activity_24h']['documents'] = recent_docs[0] if recent_docs else 0 except Exception as e: - current_app.logger.warning(f"Could not get recent activity: {e}") + debug_print(f"Could not get recent activity: {e}") # Add alerts for blocked users if stats['blocked_users'] > 0: @@ -241,7 +242,7 @@ def get_control_center_statistics(): return stats except Exception as e: - current_app.logger.error(f"Error getting control center statistics: {e}") + debug_print(f"Error getting control center statistics: {e}") return { 'total_users': 0, 'active_users_30_days': 0, diff --git a/application/single_app/route_openapi.py b/application/single_app/route_openapi.py index 8f6f282e0..238e9a4c7 100644 --- a/application/single_app/route_openapi.py +++ b/application/single_app/route_openapi.py @@ -15,7 +15,7 @@ from openapi_auth_analyzer import analyze_openapi_authentication, get_authentication_help_text from swagger_wrapper import swagger_route, get_auth_security from functions_security import is_valid_storage_name - +from functions_debug import debug_print def register_openapi_routes(app): """Register OpenAPI-related routes.""" @@ -130,7 +130,7 @@ def upload_openapi_spec(): os.unlink(temp_path) except Exception as e: - current_app.logger.error(f"Error uploading OpenAPI spec: {str(e)}") + debug_print(f"Error uploading OpenAPI spec: {str(e)}") return jsonify({ 'success': False, 'error': 'Internal server error during upload' @@ -228,7 +228,7 @@ def validate_openapi_url(): }) except Exception as e: - current_app.logger.error(f"Error validating OpenAPI URL: {str(e)}") + debug_print(f"Error validating OpenAPI URL: {str(e)}") return jsonify({ 'success': False, 'error': 'Internal server error during validation' @@ -338,7 +338,7 @@ def download_openapi_from_url(): }) except Exception as e: - current_app.logger.error(f"Error downloading OpenAPI spec from URL: {str(e)}") + debug_print(f"Error downloading OpenAPI spec from URL: {str(e)}") return jsonify({ 'success': False, 'error': 'Internal server error during download' @@ -386,7 +386,7 @@ def list_uploaded_specs(): 'last_modified': os.path.getmtime(file_path) }) except Exception as e: - current_app.logger.warning(f"Could not read spec file {filename}: {str(e)}") + debug_print(f"Could not read spec file {filename}: {str(e)}") continue return jsonify({ @@ -395,7 +395,7 @@ def list_uploaded_specs(): }) except Exception as e: - current_app.logger.error(f"Error listing OpenAPI specs: {str(e)}") + debug_print(f"Error listing OpenAPI specs: {str(e)}") return jsonify({ 'success': False, 'error': 'Internal server error while listing specifications' @@ -443,7 +443,7 @@ def analyze_openapi_auth(): }) except Exception as e: - current_app.logger.error(f"Error analyzing authentication: {str(e)}") + debug_print(f"Error analyzing authentication: {str(e)}") return jsonify({ 'success': False, 'error': 'Internal server error during authentication analysis' diff --git a/application/single_app/static/js/control-center.js b/application/single_app/static/js/control-center.js index 4279bda25..2352f3763 100644 --- a/application/single_app/static/js/control-center.js +++ b/application/single_app/static/js/control-center.js @@ -2086,8 +2086,19 @@ class ControlCenter { } tbody.innerHTML = logs.map(log => { - const user = userMap[log.user_id] || {}; - const userName = user.display_name || user.email || log.user_id; + // Handle user identification - some activities may not have user_id (system activities) + let userName = 'System'; + if (log.user_id) { + const user = userMap[log.user_id] || {}; + userName = user.display_name || user.email || log.user_id || 'Unknown User'; + } else if (log.admin_email) { + userName = log.admin_email; + } else if (log.requester_email) { + userName = log.requester_email; + } else if (log.added_by_email) { + userName = log.added_by_email; + } + const timestamp = new Date(log.timestamp).toLocaleString(); const activityType = this.formatActivityType(log.activity_type); const details = this.formatActivityDetails(log); @@ -2109,12 +2120,20 @@ class ControlCenter { const typeMap = { 'user_login': 'User Login', 'conversation_creation': 'Conversation Created', + 'conversation_deletion': 'Conversation Deleted', + 'conversation_archival': 'Conversation Archived', 'document_creation': 'Document Created', + 'document_deletion': 'Document Deleted', + 'document_metadata_update': 'Document Metadata Updated', 'token_usage': 'Token Usage', - 'conversation_deletion': 'Conversation Deleted', - 'conversation_archival': 'Conversation Archived' + 'group_status_change': 'Group Status Change', + 'group_member_deleted': 'Group Member Deleted', + 'add_member_directly': 'Add Member Directly', + 'admin_take_ownership_approved': 'Admin Take Ownership (Approved)', + 'delete_group_approved': 'Delete Group (Approved)', + 'delete_all_documents_approved': 'Delete All Documents (Approved)' }; - return typeMap[activityType] || activityType; + return typeMap[activityType] || activityType.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); } formatActivityDetails(log) { @@ -2129,26 +2148,73 @@ class ControlCenter { const convId = log.conversation?.conversation_id || 'N/A'; return `Title: ${this.escapeHtml(convTitle)}
    ID: ${convId}`; + case 'conversation_deletion': + const delTitle = log.conversation?.title || 'Untitled'; + const delId = log.conversation?.conversation_id || 'N/A'; + return `Deleted: ${this.escapeHtml(delTitle)}
    ID: ${delId}`; + + case 'conversation_archival': + const archTitle = log.conversation?.title || 'Untitled'; + const archId = log.conversation?.conversation_id || 'N/A'; + return `Archived: ${this.escapeHtml(archTitle)}
    ID: ${archId}`; + case 'document_creation': const fileName = log.document?.file_name || 'Unknown'; const fileType = log.document?.file_type || ''; return `File: ${this.escapeHtml(fileName)}
    Type: ${fileType}`; + case 'document_deletion': + const delFileName = log.document?.file_name || 'Unknown'; + const delFileType = log.document?.file_type || ''; + return `Deleted: ${this.escapeHtml(delFileName)}
    Type: ${delFileType}`; + + case 'document_metadata_update': + const updatedFileName = log.document?.file_name || 'Unknown'; + const updatedFields = Object.keys(log.updated_fields || {}).join(', ') || 'N/A'; + return `File: ${this.escapeHtml(updatedFileName)}
    Updated: ${updatedFields}`; + case 'token_usage': const tokenType = log.token_type || 'unknown'; const totalTokens = log.usage?.total_tokens || 0; const model = log.usage?.model || 'N/A'; return `Type: ${tokenType}
    Tokens: ${totalTokens.toLocaleString()}
    Model: ${model}`; - case 'conversation_deletion': - const delTitle = log.conversation?.title || 'Untitled'; - const delId = log.conversation?.conversation_id || 'N/A'; - return `Deleted: ${this.escapeHtml(delTitle)}
    ID: ${delId}`; + case 'group_status_change': + const groupName = log.group?.group_name || 'Unknown Group'; + const oldStatus = log.status_change?.old_status || 'N/A'; + const newStatus = log.status_change?.new_status || 'N/A'; + return `Group: ${this.escapeHtml(groupName)}
    Status: ${oldStatus} → ${newStatus}`; - case 'conversation_archival': - const archTitle = log.conversation?.title || 'Untitled'; - const archId = log.conversation?.conversation_id || 'N/A'; - return `Archived: ${this.escapeHtml(archTitle)}
    ID: ${archId}`; + case 'group_member_deleted': + const memberName = log.removed_member?.name || log.removed_member?.email || 'Unknown'; + const memberGroupName = log.group?.group_name || 'Unknown Group'; + return `Removed: ${this.escapeHtml(memberName)}
    From: ${this.escapeHtml(memberGroupName)}`; + + case 'add_member_directly': + const addedMemberName = log.member_name || log.member_email || 'Unknown'; + const addedToGroup = log.group_name || 'Unknown Group'; + const memberRole = log.member_role || 'user'; + return `Added: ${this.escapeHtml(addedMemberName)}
    To: ${this.escapeHtml(addedToGroup)} (${memberRole})`; + + case 'admin_take_ownership_approved': + const ownershipGroup = log.group_name || 'Unknown Group'; + const oldOwner = log.old_owner_email || 'Unknown'; + const newOwner = log.new_owner_email || 'Unknown'; + const approver = log.approver_email || 'N/A'; + return `Group: ${this.escapeHtml(ownershipGroup)}
    Old Owner: ${this.escapeHtml(oldOwner)}
    New Owner: ${this.escapeHtml(newOwner)}
    Approved by: ${this.escapeHtml(approver)}`; + + case 'delete_group_approved': + const deletedGroup = log.group_name || 'Unknown Group'; + const requester = log.requester_email || 'Unknown'; + const delApprover = log.approver_email || 'N/A'; + return `Group: ${this.escapeHtml(deletedGroup)}
    Requested by: ${this.escapeHtml(requester)}
    Approved by: ${this.escapeHtml(delApprover)}`; + + case 'delete_all_documents_approved': + const docsGroup = log.group_name || 'Unknown Group'; + const docsDeleted = log.documents_deleted !== undefined ? log.documents_deleted : 'N/A'; + const docsRequester = log.requester_email || 'Unknown'; + const docsApprover = log.approver_email || 'N/A'; + return `Group: ${this.escapeHtml(docsGroup)}
    Documents Deleted: ${docsDeleted}
    Requested by: ${this.escapeHtml(docsRequester)}
    Approved by: ${this.escapeHtml(docsApprover)}`; default: return 'N/A'; @@ -2360,6 +2426,13 @@ class ControlCenter { } escapeHtml(text) { + // Handle undefined, null, or non-string values + if (text === undefined || text === null) { + return ''; + } + // Convert to string if not already + text = String(text); + const map = { '&': '&', '<': '<', diff --git a/application/single_app/static/js/group/manage_group.js b/application/single_app/static/js/group/manage_group.js index 2e14401ea..a3e2d976a 100644 --- a/application/single_app/static/js/group/manage_group.js +++ b/application/single_app/static/js/group/manage_group.js @@ -2,6 +2,29 @@ let currentUserRole = null; +// Toast notification function +function showToast(message, variant = "danger") { + const container = document.getElementById("toast-container"); + if (!container) return; + + const id = "toast-" + Date.now(); + const toastHtml = ` + + `; + container.insertAdjacentHTML("beforeend", toastHtml); + + const toastEl = document.getElementById(id); + const bsToast = new bootstrap.Toast(toastEl, { delay: 5000 }); + bsToast.show(); +} + $(document).ready(function () { loadGroupInfo(function () { loadMembers(); @@ -152,7 +175,7 @@ $(document).ready(function () { e.preventDefault(); const newOwnerId = $("#newOwnerSelect").val(); if (!newOwnerId) { - alert("Please select a member."); + showToast("Please select a member.", "warning"); return; } @@ -162,15 +185,19 @@ $(document).ready(function () { contentType: "application/json", data: JSON.stringify({ newOwnerId }), success: function (resp) { - alert("Ownership transferred successfully."); - window.location.reload(); + $("#transferOwnershipModal").modal("hide"); + showToast("Ownership transferred successfully.", "success"); + setTimeout(function() { + window.location.reload(); + }, 1000); }, error: function (err) { console.error(err); + $("#transferOwnershipModal").modal("hide"); if (err.responseJSON && err.responseJSON.error) { - alert("Error: " + err.responseJSON.error); + showToast("Error: " + err.responseJSON.error, "danger"); } else { - alert("Failed to transfer ownership."); + showToast("Failed to transfer ownership.", "danger"); } }, }); diff --git a/application/single_app/static/js/notifications.js b/application/single_app/static/js/notifications.js index 5a7a80062..3728f7a22 100644 --- a/application/single_app/static/js/notifications.js +++ b/application/single_app/static/js/notifications.js @@ -388,12 +388,32 @@ /** * Handle notification click */ - function handleNotificationClick(notification) { + async function handleNotificationClick(notification) { // Mark as read if (!notification.is_read) { markNotificationRead(notification.id); } + // Check if this is a group notification - set active group before navigating + const groupId = notification.metadata?.group_id; + if (groupId && notification.link_url === '/group_workspaces') { + try { + const response = await fetch('/api/groups/setActive', { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ groupId: groupId }) + }); + + if (!response.ok) { + console.error('Failed to set active group:', await response.text()); + } + } catch (error) { + console.error('Error setting active group:', error); + } + } + // Navigate if link exists if (notification.link_url) { window.location.href = notification.link_url; diff --git a/application/single_app/templates/approvals.html b/application/single_app/templates/approvals.html index 4a851ae68..a3388f23c 100644 --- a/application/single_app/templates/approvals.html +++ b/application/single_app/templates/approvals.html @@ -277,13 +277,10 @@
    Request Details
    // Build query params const params = new URLSearchParams({ page: this.currentPage, - page_size: this.pageSize + page_size: this.pageSize, + status: this.currentFilters.status || 'pending' }); - if (this.currentFilters.status && this.currentFilters.status !== 'all') { - params.append('status', this.currentFilters.status); - } - if (this.currentFilters.actionType && this.currentFilters.actionType !== 'all') { params.append('action_type', this.currentFilters.actionType); } @@ -355,6 +352,8 @@
    Request Details
    } else { statusBadge = 'Denied'; } + } else if (approval.status === 'executed') { + statusBadge = 'Executed'; } // Request type badge diff --git a/application/single_app/templates/control_center.html b/application/single_app/templates/control_center.html index 0cea3a27f..03559ea68 100644 --- a/application/single_app/templates/control_center.html +++ b/application/single_app/templates/control_center.html @@ -1073,10 +1073,18 @@
    - - + + + + + + + + + +
    @@ -2893,7 +2901,7 @@
    - + +
    +
    + Retention Policy +
    +

    Automatically delete aged conversations and documents based on configurable retention periods. Users, group owners, and public workspace admins can set their own retention policies.

    + + + + +
    +
    +
    + + + +
    +
    +
    +
    + + + +
    +
    +
    +
    + + + +
    +
    +
    + + +
    + + + Retention policy will run once daily at this hour (UTC timezone). +
    + + +
    +
    + +
    + {% if settings.retention_policy_last_run %} + {{ settings.retention_policy_last_run }} + {% else %} + Never run + {% endif %} +
    +
    +
    + +
    + {% if settings.retention_policy_next_run %} + {{ settings.retention_policy_next_run }} + {% else %} + Not scheduled + {% endif %} +
    +
    +
    + + +
    + + + Trigger retention policy execution immediately for selected workspace types, bypassing the scheduled time. + +
    + + +
    @@ -2981,6 +3075,68 @@
    Security Considerat + + + {% include '_video_indexer_info.html' %} @@ -3401,6 +3557,106 @@
    Security Considerat updateTimerLimits('file'); }); + // Retention Policy Functions + function showManualExecutionModal() { + const modal = new bootstrap.Modal(document.getElementById('manualExecutionModal')); + + // Reset modal state + document.getElementById('execution-status').style.display = 'none'; + document.getElementById('execution-results').style.display = 'none'; + document.getElementById('manual_exec_personal').checked = false; + document.getElementById('manual_exec_group').checked = false; + document.getElementById('manual_exec_public').checked = false; + document.getElementById('execute-btn').disabled = false; + + modal.show(); + } + + function executeRetentionPolicy() { + const personal = document.getElementById('manual_exec_personal').checked; + const group = document.getElementById('manual_exec_group').checked; + const publicWs = document.getElementById('manual_exec_public').checked; + + const scopes = []; + if (personal) scopes.push('personal'); + if (group) scopes.push('group'); + if (publicWs) scopes.push('public'); + + if (scopes.length === 0) { + alert('Please select at least one workspace scope.'); + return; + } + + // Confirm before executing + if (!confirm(`Are you sure you want to execute retention policy for ${scopes.join(', ')} workspaces? This will delete aged items according to configured retention periods.`)) { + return; + } + + // Show processing status + document.getElementById('execution-status').style.display = 'block'; + document.getElementById('execution-status-text').textContent = 'Processing...'; + document.getElementById('execution-results').style.display = 'none'; + document.getElementById('execute-btn').disabled = true; + + // Execute via API + fetch('/api/admin/retention-policy/execute', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ scopes: scopes }) + }) + .then(response => response.json()) + .then(data => { + document.getElementById('execution-status').style.display = 'none'; + document.getElementById('execution-results').style.display = 'block'; + + if (data.success) { + const results = data.results; + let html = '
    Execution completed successfully!
    '; + + html += '
    File Name TitleActions - - Actions
    '; + html += ''; + html += ''; + + if (scopes.includes('personal')) { + html += ``; + } + if (scopes.includes('group')) { + html += ``; + } + if (scopes.includes('public')) { + html += ``; + } + + html += '
    Workspace TypeConversations DeletedDocuments DeletedWorkspaces/Users Affected
    Personal${results.personal.conversations}${results.personal.documents}${results.personal.users_affected} users
    Group${results.group.conversations}${results.group.documents}${results.group.workspaces_affected} groups
    Public${results.public.conversations}${results.public.documents}${results.public.workspaces_affected} workspaces
    '; + html += '

    Affected users/owners will receive notifications with details of deleted items.

    '; + + document.getElementById('results-content').innerHTML = html; + } else { + document.getElementById('results-content').innerHTML = ` +
    + + Execution failed: ${data.error || 'Unknown error'} +
    + `; + } + + document.getElementById('execute-btn').disabled = false; + }) + .catch(error => { + document.getElementById('execution-status').style.display = 'none'; + document.getElementById('execution-results').style.display = 'block'; + document.getElementById('results-content').innerHTML = ` +
    + + Error: ${error.message} +
    + `; + document.getElementById('execute-btn').disabled = false; + }); + } + {% endblock %} diff --git a/application/single_app/templates/control_center.html b/application/single_app/templates/control_center.html index 03559ea68..6ed3d9ae5 100644 --- a/application/single_app/templates/control_center.html +++ b/application/single_app/templates/control_center.html @@ -1648,6 +1648,49 @@
    Member Management
    + + {% if app_settings.enable_retention_policy_group %} +
    +
    +
    Retention Policy
    +
    +
    +
    + + Configure automatic deletion of aged conversations and documents. Set to "No automatic deletion" to keep items indefinitely. +
    +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    + {% endif %} +
    @@ -2552,6 +2595,25 @@