mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 05:37:20 +08:00
fix(backend): unable to delete an organization after inviting at least one member (#12993)
This commit is contained in:
@@ -51,7 +51,9 @@ class Org(Base): # type: ignore
|
||||
# Relationships
|
||||
org_members = relationship('OrgMember', back_populates='org')
|
||||
current_users = relationship('User', back_populates='current_org')
|
||||
invitations = relationship('OrgInvitation', back_populates='org')
|
||||
invitations = relationship(
|
||||
'OrgInvitation', back_populates='org', passive_deletes=True
|
||||
)
|
||||
billing_sessions = relationship('BillingSession', back_populates='org')
|
||||
stored_conversation_metadata_saas = relationship(
|
||||
'StoredConversationMetadataSaas', back_populates='org'
|
||||
|
||||
@@ -15,6 +15,7 @@ from storage.device_code import DeviceCode # noqa: F401
|
||||
from storage.feedback import Feedback
|
||||
from storage.github_app_installation import GithubAppInstallation
|
||||
from storage.org import Org
|
||||
from storage.org_invitation import OrgInvitation # noqa: F401
|
||||
from storage.org_member import OrgMember
|
||||
from storage.role import Role
|
||||
from storage.stored_conversation_metadata import StoredConversationMetadata
|
||||
|
||||
@@ -10,6 +10,7 @@ with patch('storage.database.engine', create=True), patch(
|
||||
'storage.database.a_engine', create=True
|
||||
):
|
||||
from storage.org import Org
|
||||
from storage.org_invitation import OrgInvitation
|
||||
from storage.org_member import OrgMember
|
||||
from storage.org_store import OrgStore
|
||||
from storage.role import Role
|
||||
@@ -806,3 +807,88 @@ def test_orphaned_user_error_contains_user_ids():
|
||||
assert error.user_ids == user_ids
|
||||
assert '2 user(s)' in str(error)
|
||||
assert 'no remaining organization' in str(error)
|
||||
|
||||
|
||||
def test_org_deletion_with_invitations_uses_passive_deletes(
|
||||
session_maker, mock_litellm_api
|
||||
):
|
||||
"""
|
||||
GIVEN: Organization has associated invitations with non-nullable org_id foreign key
|
||||
WHEN: Organization is deleted via SQLAlchemy session.delete()
|
||||
THEN: Deletion succeeds without NOT NULL constraint violation
|
||||
(passive_deletes=True defers to database CASCADE instead of setting org_id to NULL)
|
||||
|
||||
This test verifies the fix for the bug where SQLAlchemy would try to
|
||||
SET org_id=NULL on org_invitation before deleting the org, causing:
|
||||
"NOT NULL constraint failed: org_invitation.org_id"
|
||||
|
||||
With passive_deletes=True on the relationship, SQLAlchemy defers to the
|
||||
database's CASCADE constraint instead of trying to nullify the foreign key.
|
||||
|
||||
Note: SQLite doesn't enforce CASCADE by default, so we only verify that
|
||||
the deletion succeeds. In production (PostgreSQL), CASCADE handles cleanup.
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# Arrange
|
||||
org_id = uuid.uuid4()
|
||||
other_org_id = uuid.uuid4()
|
||||
user_id = uuid.uuid4()
|
||||
|
||||
with session_maker() as session:
|
||||
# Create role first (required for invitation)
|
||||
role = Role(id=1, name='owner', rank=1)
|
||||
session.add(role)
|
||||
session.flush()
|
||||
|
||||
# Create organization to be deleted
|
||||
org = Org(id=org_id, name='test-org-with-invitations')
|
||||
session.add(org)
|
||||
session.flush()
|
||||
|
||||
# Create a second org for the user's current_org_id
|
||||
# (to avoid the user.current_org_id constraint issue during deletion)
|
||||
other_org = Org(id=other_org_id, name='other-org')
|
||||
session.add(other_org)
|
||||
session.flush()
|
||||
|
||||
# Create user with current_org pointing to the OTHER org (not the one being deleted)
|
||||
user = User(id=user_id, current_org_id=other_org_id)
|
||||
session.add(user)
|
||||
session.flush()
|
||||
|
||||
# Create invitation associated with the organization to be deleted
|
||||
invitation = OrgInvitation(
|
||||
token='test-invitation-token-12345',
|
||||
org_id=org_id,
|
||||
email='invitee@example.com',
|
||||
role_id=1,
|
||||
inviter_id=user_id,
|
||||
status='pending',
|
||||
created_at=datetime.now(),
|
||||
expires_at=datetime.now() + timedelta(days=7),
|
||||
)
|
||||
session.add(invitation)
|
||||
session.commit()
|
||||
|
||||
# Verify invitation was created
|
||||
invitation_count = session.query(OrgInvitation).filter_by(org_id=org_id).count()
|
||||
assert invitation_count == 1
|
||||
|
||||
# Act - Delete organization via SQLAlchemy (this is what triggered the bug)
|
||||
# Without passive_deletes=True, SQLAlchemy would try to SET org_id=NULL
|
||||
# which violates the NOT NULL constraint on org_invitation.org_id
|
||||
with session_maker() as session:
|
||||
org = session.query(Org).filter(Org.id == org_id).first()
|
||||
assert org is not None
|
||||
|
||||
# This should NOT raise IntegrityError with passive_deletes=True
|
||||
# Previously this would fail with:
|
||||
# "NOT NULL constraint failed: org_invitation.org_id"
|
||||
session.delete(org)
|
||||
session.commit() # Success indicates passive_deletes=True is working
|
||||
|
||||
# Assert - Organization should be deleted
|
||||
with session_maker() as session:
|
||||
deleted_org = session.query(Org).filter(Org.id == org_id).first()
|
||||
assert deleted_org is None
|
||||
|
||||
Reference in New Issue
Block a user