Skip to content

Commit cab6bdd

Browse files
committed
Delete groups based on owners not creator
When deleting a user account we currently delete any groups that the user created and that don't contain any annotations by other users. This made sense when the creator was the only person who could edit the group (changing its name, description and type) or moderate annotations in the group: groups with no creator were kind of broken and may as well be deleted (as long as they didn't contain any annotations). But we've now added the concept of group membership roles and in particular a group can have multiple "owners" who all have full permissions to edit and moderate the group. Even if the group's creator no longer exists, the group may still have other owners who have full control of the group. So instead of deleting groups that the user created, change `UserDeleteService` to delete groups that the user is the only owner of (and that don't contain any annotations by other users).
1 parent 6345053 commit cab6bdd

File tree

4 files changed

+96
-53
lines changed

4 files changed

+96
-53
lines changed

h/models/group.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44

55
import slugify
66
import sqlalchemy as sa
7-
from sqlalchemy.orm import relationship
87
from sqlalchemy.dialects.postgresql import JSONB
8+
from sqlalchemy.orm import relationship
99

1010
from h import pubid
1111
from h.db import Base, mixins

h/services/user_delete.py

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -195,15 +195,15 @@ def delete_annotations(self, user):
195195

196196
def delete_groups(self, user):
197197
"""
198-
Delete groups created by `user` that have no annotations.
198+
Delete groups owned by `user` that have no annotations.
199199
200-
Delete groups that were created by `user` and that don't contain any
201-
non-deleted annotations.
200+
Delete groups that that `user` is the *only* owner of and that don't
201+
contain any non-deleted annotations.
202202
203203
If delete_annotations() (above) is called first then all of `user`'s
204204
own annotations will already have been deleted, so ultimately any
205-
groups created by `user` that don't contain any annotations by *other*
206-
users will get deleted.
205+
groups that `user` is the only owner of and that don't contain any
206+
annotations by *other* users will get deleted.
207207
208208
Known issue: if this method does not delete a group because it contains
209209
annotations by other users, and those annotations other users are later
@@ -215,22 +215,40 @@ def delete_groups(self, user):
215215
really large number of members this could take too long and cause a
216216
timeout.
217217
"""
218-
# pylint:disable=not-callable,use-implicit-booleaness-not-comparison-to-zero
219-
self.worker.delete(
220-
Group,
218+
# pylint:disable=not-callable
219+
# pylint:disable=use-implicit-booleaness-not-comparison-to-zero
220+
221+
# The IDs of all groups that have only one owner.
222+
groups_with_only_one_owner = (
223+
select(GroupMembership.group_id)
224+
.where(GroupMembership.roles.contains(["owner"]))
225+
.group_by(GroupMembership.group_id)
226+
.having(func.count(GroupMembership.group_id) == 1)
227+
)
228+
229+
# The IDs of all groups where `user` is the only owner.
230+
groups_where_user_is_only_owner = (
231+
select(GroupMembership.group_id)
232+
.where(GroupMembership.group_id.in_(groups_with_only_one_owner))
233+
.where(GroupMembership.roles.contains(["owner"]))
234+
.where(GroupMembership.user_id == user.id)
235+
)
236+
237+
# The IDs of all groups where `user` is the only owner *and* the group
238+
# doesn't contain any annotations by other users.
239+
groups_to_be_deleted = (
221240
select(Group.id)
222-
.where(Group.creator_id == user.id)
241+
.where(Group.id.in_(groups_where_user_is_only_owner))
223242
.outerjoin(
224243
Annotation,
225-
and_(
226-
Annotation.groupid == Group.pubid,
227-
Annotation.deleted.is_(False),
228-
),
244+
and_(Annotation.groupid == Group.pubid, Annotation.deleted.is_(False)),
229245
)
230246
.group_by(Group.id)
231-
.having(func.count(Annotation.id) == 0),
247+
.having(func.count(Annotation.id) == 0)
232248
)
233249

250+
self.worker.delete(Group, groups_to_be_deleted)
251+
234252
def delete_group_memberships(self, user):
235253
"""
236254
Delete `user`'s group memberships.

tests/unit/h/models/document/_uri_test.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ class TestDocumentURI:
1414
def test_it_normalizes_the_uri(self):
1515
document_uri = DocumentURI(uri="http://example.com/")
1616

17+
# pylint:disable=comparison-with-callable
1718
assert document_uri.uri_normalized == "httpx://example.com"
1819

1920
def test_type_defaults_to_empty_string(self, db_session, document_uri, factories):

tests/unit/h/services/user_delete_test.py

Lines changed: 62 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import pytest
66
from h_matchers import Any
7-
from sqlalchemy import Select, func, select
7+
from sqlalchemy import Select, func, inspect, select
88

99
from h.models import (
1010
Annotation,
@@ -13,6 +13,7 @@
1313
Flag,
1414
Group,
1515
GroupMembership,
16+
GroupMembershipRoles,
1617
Job,
1718
Token,
1819
User,
@@ -302,51 +303,74 @@ def test_delete_annotations(
302303
).only()
303304
)
304305

305-
def test_delete_groups(self, user, db_session, worker, factories, purger):
306-
factories.Group(creator=user)
307-
308-
purger.delete_groups(user)
306+
def test_delete_groups(self, user, worker, factories, purger):
307+
def make_group(name, owner=user):
308+
"""Create and return a group that `owner` is the only owner of."""
309+
return factories.Group(
310+
name=name,
311+
memberships=[
312+
GroupMembership(user=owner, roles=[GroupMembershipRoles.OWNER])
313+
],
314+
)
309315

310-
worker.delete.assert_called_once_with(Group, Any.instance_of(Select))
311-
assert (
312-
db_session.scalars(select(Group).where(Group.creator == user)).all() == []
313-
)
316+
# A list of groups that should be deleted when `user` is deleted.
317+
should_be_deleted = []
318+
319+
# A group that `user` is the only owner of, this group should be
320+
# deleted when `user` is deleted.
321+
should_be_deleted.append(make_group("only_owner"))
322+
323+
# A group that `user` is the only owner of but that has other non-owner
324+
# members. This group should still be deleted.
325+
group = make_group("other_members")
326+
for role in GroupMembershipRoles:
327+
if role == GroupMembershipRoles.OWNER:
328+
continue
329+
group.memberships.append(
330+
GroupMembership(user=factories.User(), roles=[role])
331+
)
332+
should_be_deleted.append(group)
314333

315-
def test_delete_groups_still_deletes_groups_if_they_have_deleted_annotations(
316-
self, user, db_session, factories, purger
317-
):
318-
group = factories.Group(creator=user)
319-
# An annotation in the group, but the annotation has already been
320-
# marked as deleted (meaning it'll soon be purged from the DB) so this
321-
# shouldn't prevent the group from being deleted.
334+
# A group that `user` is the only owner of but that contains a
335+
# deleted annotation by another user. This group should still be
336+
# deleted.
337+
group = make_group("deleted_annotation")
322338
factories.Annotation(group=group, deleted=True)
323-
324-
purger.delete_groups(user)
325-
326-
assert (
327-
db_session.scalars(select(Group).where(Group.creator == user)).all() == []
339+
should_be_deleted.append(group)
340+
341+
# A list of groups that should *not* be deleted when `user` is deleted.
342+
should_not_be_deleted = []
343+
344+
# A group that has one owner, but `user` isn't a member of this group.
345+
should_not_be_deleted.append(make_group("other_owner", owner=factories.User()))
346+
347+
# Some groups that have one owner but `user` is a non-owner member.
348+
for role in GroupMembershipRoles:
349+
if role == GroupMembershipRoles.OWNER:
350+
continue
351+
group = make_group(role, owner=factories.User())
352+
group.memberships.append(GroupMembership(user=user, roles=[role]))
353+
should_not_be_deleted.append(group)
354+
355+
# A group that `user` is an owner of but that also has another owner.
356+
group = make_group("another_owner")
357+
group.memberships.append(
358+
GroupMembership(user=factories.User(), roles=[GroupMembershipRoles.OWNER])
328359
)
360+
should_not_be_deleted.append(group)
329361

330-
def test_delete_groups_doesnt_delete_groups_created_by_other_users(
331-
self, user, db_session, factories, purger
332-
):
333-
group = factories.Group()
334-
335-
purger.delete_groups(user)
336-
337-
assert group in db_session.scalars(select(Group)).all()
338-
339-
def test_delete_groups_doesnt_delete_groups_with_annotations(
340-
self, user, db_session, factories, purger
341-
):
342-
group = factories.Group(creator=user)
343-
# The group contains an annotation by another user.
344-
# This should prevent the group from being deleted.
345-
factories.Annotation(group=group)
362+
# A group that `user` is the only owner of but that contains an annotation from another user.
363+
group = make_group("annotation")
364+
factories.Annotation(group=group, deleted=False)
365+
should_not_be_deleted.append(group)
346366

347367
purger.delete_groups(user)
348368

349-
assert group in db_session.scalars(select(Group)).all()
369+
worker.delete.assert_called_once_with(Group, Any.instance_of(Select))
370+
for group in should_not_be_deleted:
371+
assert not inspect(group).deleted
372+
for group in should_be_deleted:
373+
assert inspect(group).deleted
350374

351375
def test_delete_group_memberships(
352376
self, user, factories, purger, worker, db_session

0 commit comments

Comments
 (0)