Skip to content

Commit 502fe6c

Browse files
committed
✨ Support delete tenant:concurrently delete all users
1 parent 3701eaa commit 502fe6c

9 files changed

Lines changed: 79 additions & 43 deletions

backend/apps/tenant_app.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ async def delete_tenant_endpoint(
250250
user_id, _ = get_current_user_id(authorization)
251251

252252
# Perform tenant deletion with all associated resources
253-
delete_tenant(tenant_id, deleted_by=user_id)
253+
await delete_tenant(tenant_id, deleted_by=user_id)
254254

255255
logger.info(f"Deleted tenant {tenant_id} and all associated resources by user {user_id}")
256256

backend/consts/const.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -303,4 +303,4 @@ class VectorDatabaseType(str, Enum):
303303
MODEL_ENGINE_ENABLED = os.getenv("MODEL_ENGINE_ENABLED")
304304

305305
# APP Version
306-
APP_VERSION = "v1.8.0"
306+
APP_VERSION = "v1.8.0.1"

backend/services/tenant_service.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""
22
Tenant service for managing tenant operations
33
"""
4+
import asyncio
45
import logging
56
import uuid
67
from typing import Any, Dict, List, Optional
@@ -14,6 +15,7 @@
1415
get_all_configs_by_tenant_id,
1516
)
1617
from database.user_tenant_db import get_users_by_tenant_id, soft_delete_users_by_tenant_id
18+
from services.user_service import delete_user_and_cleanup
1719
from database.group_db import add_group, query_groups_by_tenant, remove_group
1820
from database.model_management_db import get_model_records, delete_model_record
1921
from database.knowledge_db import get_knowledge_info_by_tenant_id, delete_knowledge_record
@@ -300,7 +302,7 @@ def update_tenant_info(tenant_id: str, tenant_name: str, updated_by: Optional[st
300302
return updated_tenant
301303

302304

303-
def delete_tenant(tenant_id: str, deleted_by: Optional[str] = None) -> bool:
305+
async def delete_tenant(tenant_id: str, deleted_by: Optional[str] = None) -> bool:
304306
"""
305307
Delete tenant and all associated resources
306308
@@ -333,9 +335,23 @@ def delete_tenant(tenant_id: str, deleted_by: Optional[str] = None) -> bool:
333335
logger.info(f"Starting cascade deletion for tenant {tenant_id} by {deleted_by}")
334336

335337
try:
336-
# 1. Delete all users in the tenant (soft delete)
337-
logger.info(f"Deleting users for tenant {tenant_id}")
338-
soft_delete_users_by_tenant_id(tenant_id, deleted_by or "system")
338+
# 1. Deactivate all users in the tenant (full cleanup including Supabase deletion)
339+
logger.info(f"Deactivating users for tenant {tenant_id}")
340+
users_result = get_users_by_tenant_id(tenant_id, page=1, page_size=10000)
341+
users = users_result.get("users", [])
342+
343+
if users:
344+
async def delete_single_user(user: Dict[str, Any]) -> None:
345+
user_id = user.get("user_id")
346+
if user_id:
347+
try:
348+
await delete_user_and_cleanup(user_id, tenant_id)
349+
logger.info(f"Deactivated user {user_id} for tenant {tenant_id}")
350+
except Exception as e:
351+
logger.warning(f"Failed to deactivate user {user_id}: {str(e)}")
352+
353+
# Concurrently delete all users
354+
await asyncio.gather(*[delete_single_user(user) for user in users])
339355

340356
# 2. Delete all groups in the tenant
341357
logger.info(f"Deleting groups for tenant {tenant_id}")

backend/services/user_service.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
import logging
55
from typing import Dict, Any, List
66

7-
from consts.exceptions import ValidationError
87
from database.user_tenant_db import (
98
get_users_by_tenant_id, update_user_tenant_role, get_user_tenant_by_user_id,
109
soft_delete_user_tenant_by_user_id
@@ -14,6 +13,7 @@
1413
from database.conversation_db import soft_delete_all_conversations_by_user
1514
from utils.auth_utils import get_supabase_admin_client
1615
from utils.memory_utils import build_memory_config
16+
1717
from nexent.memory.memory_service import clear_memory
1818

1919
logger = logging.getLogger(__name__)
@@ -73,8 +73,6 @@ async def update_user(user_id: str, update_data: Dict[str, Any], updated_by: str
7373
Raises:
7474
ValueError: When user not found or invalid data
7575
"""
76-
from database.user_tenant_db import update_user_tenant_role
77-
7876
try:
7977
# Validate role if provided
8078
if "role" in update_data:
File renamed without changes.
File renamed without changes.

docker/sql/v1.8.1_0301_add_authorization_token_to_mcp_record_t.sql renamed to docker/sql/v1.8.0.1_0226_add_authorization_token_to_mcp_record_t.sql

File renamed without changes.

test/backend/app/test_knowledge_summary_app.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ def __init__(self, *args, **kwargs): pass
8080
sys.modules['nexent.core.models.tts_model'].TTSConfig = MockTTSConfig
8181
sys.modules['nexent.core.models.tts_model'].TTSModel = MockTTSModel
8282
sys.modules['nexent.storage.storage_client_factory'] = MagicMock()
83+
sys.modules['nexent.memory.memory_service'] = MagicMock()
8384

8485
# Patch storage factory and MinIO config validation to avoid errors during initialization
8586
# These patches must be started before any imports that use MinioClient

test/backend/services/test_tenant_service.py

Lines changed: 55 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,7 @@ def test_get_tenants_paginated_custom_page_size(self, service_mocks):
265265
"""Test get_tenants_paginated with custom page and page_size"""
266266
# Setup
267267
tenant_ids = ["tenant1", "tenant2", "tenant3", "tenant4", "tenant5"]
268-
268+
269269
# Create a function that returns tenant info based on tenant_id
270270
def mock_get_tenant_info(tenant_id):
271271
idx = int(tenant_id.replace("tenant", ""))
@@ -291,7 +291,7 @@ def test_get_tenants_paginated_last_page(self, service_mocks):
291291
"""Test get_tenants_paginated on the last page with fewer items"""
292292
# Setup
293293
tenant_ids = ["tenant1", "tenant2", "tenant3", "tenant4", "tenant5"]
294-
294+
295295
# Create a function that returns tenant info based on tenant_id
296296
def mock_get_tenant_info(tenant_id):
297297
idx = int(tenant_id.replace("tenant", ""))
@@ -603,15 +603,17 @@ def test_update_tenant_info_name_already_exists(self, service_mocks):
603603
class TestDeleteTenant:
604604
"""Test cases for delete_tenant function"""
605605

606-
def test_delete_tenant_success(self):
606+
@pytest.mark.asyncio
607+
async def test_delete_tenant_success(self):
607608
"""Test successfully deleting a tenant and all associated resources"""
608609
# Setup
609610
tenant_id = "test_tenant"
610611
deleted_by = "admin_user"
611612

612613
# Mock dependencies
613614
with patch('backend.services.tenant_service.get_single_config_info') as mock_get_config, \
614-
patch('backend.services.tenant_service.soft_delete_users_by_tenant_id') as mock_soft_delete_users, \
615+
patch('backend.services.tenant_service.get_users_by_tenant_id') as mock_get_users, \
616+
patch('backend.services.tenant_service.delete_user_and_cleanup') as mock_delete_user, \
615617
patch('backend.services.tenant_service.query_groups_by_tenant') as mock_query_groups, \
616618
patch('backend.services.tenant_service.remove_group') as mock_remove_group, \
617619
patch('backend.services.tenant_service.get_model_records') as mock_get_models, \
@@ -632,6 +634,9 @@ def test_delete_tenant_success(self):
632634
# Configure mocks
633635
mock_get_config.return_value = {"tenant_config_id": 1, "config_value": "Test Tenant"}
634636

637+
# Empty user list
638+
mock_get_users.return_value = {"users": [], "total": 0}
639+
635640
# Empty lists for resources
636641
mock_query_groups.return_value = {"data": []}
637642
mock_get_models.return_value = []
@@ -647,18 +652,20 @@ def test_delete_tenant_success(self):
647652
]
648653

649654
# Execute
650-
result = delete_tenant(tenant_id, deleted_by)
655+
result = await delete_tenant(tenant_id, deleted_by)
651656

652657
# Assert
653658
assert result is True
654659

655-
# Verify soft delete of users was called
656-
mock_soft_delete_users.assert_called_once_with(tenant_id, deleted_by)
660+
# Verify user cleanup was called
661+
mock_get_users.assert_called_once_with(tenant_id, page=1, page_size=10000)
662+
mock_delete_user.assert_not_called()
657663

658664
# Verify configs deletion was called
659665
mock_delete_config.assert_called()
660666

661-
def test_delete_tenant_not_found(self):
667+
@pytest.mark.asyncio
668+
async def test_delete_tenant_not_found(self):
662669
"""Test delete_tenant when tenant doesn't exist"""
663670
# Setup
664671
tenant_id = "nonexistent_tenant"
@@ -670,32 +677,34 @@ def test_delete_tenant_not_found(self):
670677

671678
# Execute & Assert
672679
with pytest.raises(NotFoundException, match="does not exist"):
673-
delete_tenant(tenant_id, deleted_by)
680+
await delete_tenant(tenant_id, deleted_by)
674681

675-
def test_delete_tenant_validation_error(self):
682+
@pytest.mark.asyncio
683+
async def test_delete_tenant_validation_error(self):
676684
"""Test delete_tenant when validation fails"""
677685
# Setup
678686
tenant_id = "test_tenant"
679687
deleted_by = "admin_user"
680688

681689
# Mock dependencies to raise ValidationError during deletion
682690
with patch('backend.services.tenant_service.get_single_config_info') as mock_get_config, \
683-
patch('backend.services.tenant_service.soft_delete_users_by_tenant_id') as mock_soft_delete_users:
691+
patch('backend.services.tenant_service.get_users_by_tenant_id') as mock_get_users:
684692
mock_get_config.return_value = {"tenant_config_id": 1}
685-
mock_soft_delete_users.side_effect = ValidationError("Database error")
693+
mock_get_users.side_effect = ValidationError("Database error")
686694

687695
# Execute & Assert
688696
with pytest.raises(ValidationError, match="Failed to delete tenant"):
689-
delete_tenant(tenant_id, deleted_by)
697+
await delete_tenant(tenant_id, deleted_by)
690698

691-
def test_delete_tenant_with_groups(self):
699+
@pytest.mark.asyncio
700+
async def test_delete_tenant_with_groups(self):
692701
"""Test delete_tenant deletes all groups in the tenant"""
693702
# Setup
694703
tenant_id = "test_tenant"
695704
deleted_by = "admin_user"
696705

697706
with patch('backend.services.tenant_service.get_single_config_info') as mock_get_config, \
698-
patch('backend.services.tenant_service.soft_delete_users_by_tenant_id') as mock_soft_delete_users, \
707+
patch('backend.services.tenant_service.get_users_by_tenant_id') as mock_get_users, \
699708
patch('backend.services.tenant_service.query_groups_by_tenant') as mock_query_groups, \
700709
patch('backend.services.tenant_service.remove_group') as mock_remove_group, \
701710
patch('backend.services.tenant_service.get_model_records') as mock_get_models, \
@@ -708,6 +717,9 @@ def test_delete_tenant_with_groups(self):
708717

709718
mock_get_config.return_value = {"tenant_config_id": 1}
710719

720+
# Empty user list
721+
mock_get_users.return_value = {"users": [], "total": 0}
722+
711723
# Mock groups
712724
mock_query_groups.return_value = {
713725
"data": [
@@ -724,20 +736,21 @@ def test_delete_tenant_with_groups(self):
724736
mock_get_all_configs.return_value = []
725737

726738
# Execute
727-
result = delete_tenant(tenant_id, deleted_by)
739+
result = await delete_tenant(tenant_id, deleted_by)
728740

729741
# Assert
730742
assert result is True
731743
assert mock_remove_group.call_count == 2
732744

733-
def test_delete_tenant_with_group_deletion_error(self):
745+
@pytest.mark.asyncio
746+
async def test_delete_tenant_with_group_deletion_error(self):
734747
"""Test delete_tenant handles group deletion errors gracefully"""
735748
# Setup
736749
tenant_id = "test_tenant"
737750
deleted_by = "admin_user"
738751

739752
with patch('backend.services.tenant_service.get_single_config_info') as mock_get_config, \
740-
patch('backend.services.tenant_service.soft_delete_users_by_tenant_id') as mock_soft_delete_users, \
753+
patch('backend.services.tenant_service.get_users_by_tenant_id') as mock_get_users, \
741754
patch('backend.services.tenant_service.query_groups_by_tenant') as mock_query_groups, \
742755
patch('backend.services.tenant_service.remove_group') as mock_remove_group, \
743756
patch('backend.services.tenant_service.get_model_records') as mock_get_models, \
@@ -750,6 +763,9 @@ def test_delete_tenant_with_group_deletion_error(self):
750763

751764
mock_get_config.return_value = {"tenant_config_id": 1}
752765

766+
# Empty user list
767+
mock_get_users.return_value = {"users": [], "total": 0}
768+
753769
# Mock groups - one group
754770
mock_query_groups.return_value = {
755771
"data": [
@@ -768,21 +784,22 @@ def test_delete_tenant_with_group_deletion_error(self):
768784
mock_get_all_configs.return_value = []
769785

770786
# Execute - should not raise, should handle exception gracefully
771-
result = delete_tenant(tenant_id, deleted_by)
787+
result = await delete_tenant(tenant_id, deleted_by)
772788

773789
# Assert - deletion should still succeed despite group deletion error
774790
assert result is True
775791
# Verify remove_group was called and exception was caught
776792
mock_remove_group.assert_called_once()
777793

778-
def test_delete_tenant_with_models(self):
794+
@pytest.mark.asyncio
795+
async def test_delete_tenant_with_models(self):
779796
"""Test delete_tenant deletes all models in the tenant"""
780797
# Setup
781798
tenant_id = "test_tenant"
782799
deleted_by = "admin_user"
783800

784801
with patch('backend.services.tenant_service.get_single_config_info') as mock_get_config, \
785-
patch('backend.services.tenant_service.soft_delete_users_by_tenant_id') as mock_soft_delete_users, \
802+
patch('backend.services.tenant_service.get_users_by_tenant_id') as mock_get_users, \
786803
patch('backend.services.tenant_service.query_groups_by_tenant') as mock_query_groups, \
787804
patch('backend.services.tenant_service.remove_group') as mock_remove_group, \
788805
patch('backend.services.tenant_service.get_model_records') as mock_get_models, \
@@ -810,20 +827,21 @@ def test_delete_tenant_with_models(self):
810827
mock_get_all_configs.return_value = []
811828

812829
# Execute
813-
result = delete_tenant(tenant_id, deleted_by)
830+
result = await delete_tenant(tenant_id, deleted_by)
814831

815832
# Assert
816833
assert result is True
817834
assert mock_delete_model.call_count == 2
818835

819-
def test_delete_tenant_with_model_deletion_error(self):
836+
@pytest.mark.asyncio
837+
async def test_delete_tenant_with_model_deletion_error(self):
820838
"""Test delete_tenant handles model deletion errors gracefully"""
821839
# Setup
822840
tenant_id = "test_tenant"
823841
deleted_by = "admin_user"
824842

825843
with patch('backend.services.tenant_service.get_single_config_info') as mock_get_config, \
826-
patch('backend.services.tenant_service.soft_delete_users_by_tenant_id') as mock_soft_delete_users, \
844+
patch('backend.services.tenant_service.get_users_by_tenant_id') as mock_get_users, \
827845
patch('backend.services.tenant_service.query_groups_by_tenant') as mock_query_groups, \
828846
patch('backend.services.tenant_service.remove_group') as mock_remove_group, \
829847
patch('backend.services.tenant_service.get_model_records') as mock_get_models, \
@@ -851,19 +869,20 @@ def test_delete_tenant_with_model_deletion_error(self):
851869
mock_get_all_configs.return_value = []
852870

853871
# Execute
854-
result = delete_tenant(tenant_id, deleted_by)
872+
result = await delete_tenant(tenant_id, deleted_by)
855873

856874
# Assert - should succeed despite error
857875
assert result is True
858876

859-
def test_delete_tenant_with_agents(self):
877+
@pytest.mark.asyncio
878+
async def test_delete_tenant_with_agents(self):
860879
"""Test delete_tenant deletes all agents in the tenant"""
861880
# Setup
862881
tenant_id = "test_tenant"
863882
deleted_by = "admin_user"
864883

865884
with patch('backend.services.tenant_service.get_single_config_info') as mock_get_config, \
866-
patch('backend.services.tenant_service.soft_delete_users_by_tenant_id') as mock_soft_delete_users, \
885+
patch('backend.services.tenant_service.get_users_by_tenant_id') as mock_get_users, \
867886
patch('backend.services.tenant_service.query_groups_by_tenant') as mock_query_groups, \
868887
patch('backend.services.tenant_service.remove_group') as mock_remove_group, \
869888
patch('backend.services.tenant_service.get_model_records') as mock_get_models, \
@@ -892,7 +911,7 @@ def test_delete_tenant_with_agents(self):
892911
mock_get_all_configs.return_value = []
893912

894913
# Execute
895-
result = delete_tenant(tenant_id, deleted_by)
914+
result = await delete_tenant(tenant_id, deleted_by)
896915

897916
# Assert
898917
assert result is True
@@ -901,14 +920,15 @@ def test_delete_tenant_with_agents(self):
901920
mock_delete_rel.assert_called()
902921
mock_delete_agent.assert_called()
903922

904-
def test_delete_tenant_with_mcp_records(self):
923+
@pytest.mark.asyncio
924+
async def test_delete_tenant_with_mcp_records(self):
905925
"""Test delete_tenant deletes all MCP configurations in the tenant"""
906926
# Setup
907927
tenant_id = "test_tenant"
908928
deleted_by = "admin_user"
909929

910930
with patch('backend.services.tenant_service.get_single_config_info') as mock_get_config, \
911-
patch('backend.services.tenant_service.soft_delete_users_by_tenant_id') as mock_soft_delete_users, \
931+
patch('backend.services.tenant_service.get_users_by_tenant_id') as mock_get_users, \
912932
patch('backend.services.tenant_service.query_groups_by_tenant') as mock_query_groups, \
913933
patch('backend.services.tenant_service.remove_group') as mock_remove_group, \
914934
patch('backend.services.tenant_service.get_model_records') as mock_get_models, \
@@ -936,20 +956,21 @@ def test_delete_tenant_with_mcp_records(self):
936956
mock_get_all_configs.return_value = []
937957

938958
# Execute
939-
result = delete_tenant(tenant_id, deleted_by)
959+
result = await delete_tenant(tenant_id, deleted_by)
940960

941961
# Assert
942962
assert result is True
943963
assert mock_delete_mcp.call_count == 2
944964

945-
def test_delete_tenant_with_invitations(self):
965+
@pytest.mark.asyncio
966+
async def test_delete_tenant_with_invitations(self):
946967
"""Test delete_tenant deletes all invitations in the tenant"""
947968
# Setup
948969
tenant_id = "test_tenant"
949970
deleted_by = "admin_user"
950971

951972
with patch('backend.services.tenant_service.get_single_config_info') as mock_get_config, \
952-
patch('backend.services.tenant_service.soft_delete_users_by_tenant_id') as mock_soft_delete_users, \
973+
patch('backend.services.tenant_service.get_users_by_tenant_id') as mock_get_users, \
953974
patch('backend.services.tenant_service.query_groups_by_tenant') as mock_query_groups, \
954975
patch('backend.services.tenant_service.remove_group') as mock_remove_group, \
955976
patch('backend.services.tenant_service.get_model_records') as mock_get_models, \
@@ -977,7 +998,7 @@ def test_delete_tenant_with_invitations(self):
977998
mock_get_all_configs.return_value = []
978999

9791000
# Execute
980-
result = delete_tenant(tenant_id, deleted_by)
1001+
result = await delete_tenant(tenant_id, deleted_by)
9811002

9821003
# Assert
9831004
assert result is True

0 commit comments

Comments
 (0)