Skip to content

Commit 3ddb0f7

Browse files
committed
Record user deletions in a new DB table
Remove the CLI command for deleting a user: this interacts awkwardly with recording deletions in a table because there's no authenticated user to record as the requester of the deletion. There could be various solutions to this but no one uses this CLI command anyway so let's just delete it.
1 parent 08bfc47 commit 3ddb0f7

File tree

15 files changed

+196
-121
lines changed

15 files changed

+196
-121
lines changed

h/cli/commands/user.py

Lines changed: 0 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -101,30 +101,3 @@ def password(ctx, username, authority, password):
101101
request.tm.commit()
102102

103103
click.echo(f"Password changed for {username}", err=True)
104-
105-
106-
@user.command()
107-
@click.argument("username")
108-
@click.option("--authority")
109-
@click.pass_context
110-
def delete(ctx, username, authority):
111-
"""
112-
Delete a user with all their group memberships and annotations.
113-
114-
You must specify the username of a user to delete.
115-
"""
116-
request = ctx.obj["bootstrap"]()
117-
118-
if not authority:
119-
authority = request.default_authority
120-
121-
user = models.User.get_by_username(request.db, username, authority)
122-
if user is None:
123-
raise click.ClickException(
124-
f'no user with username "{username}" and authority "{authority}"'
125-
)
126-
127-
request.find_service(name="user_delete").delete_user(user)
128-
request.tm.commit()
129-
130-
click.echo(f"User {username} deleted.", err=True)

h/models/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
from h.models.subscriptions import Subscriptions
3939
from h.models.token import Token
4040
from h.models.user import User
41+
from h.models.user_deletion import UserDeletion
4142
from h.models.user_identity import UserIdentity
4243

4344
__all__ = (
@@ -64,5 +65,6 @@
6465
"Subscriptions",
6566
"Token",
6667
"User",
68+
"UserDeletion",
6769
"UserIdentity",
6870
)

h/models/helpers.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
def repr_(obj, attrs):
2+
class_name = type(obj).__name__
3+
attrs = {attrname: getattr(obj, attrname) for attrname in attrs}
4+
return f"{class_name}({', '.join(f'{name}={value!r}' for name, value in attrs.items())})"

h/models/job.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
from sqlalchemy.dialects.postgresql import JSONB
4242

4343
from h.db import Base
44+
from h.models import helpers
4445

4546

4647
class Job(Base):
@@ -67,10 +68,9 @@ class Job(Base):
6768
)
6869

6970
def __repr__(self):
70-
class_name = type(self).__name__
71-
attrs = {
72-
attrname: repr(getattr(self, attrname))
73-
for attrname in [
71+
return helpers.repr_(
72+
self,
73+
[
7474
"id",
7575
"name",
7676
"enqueued_at",
@@ -79,6 +79,5 @@ def __repr__(self):
7979
"priority",
8080
"tag",
8181
"kwargs",
82-
]
83-
}
84-
return f"{class_name}({', '.join(f'{name}={value}' for name, value in attrs.items())})"
82+
],
83+
)

h/models/user_deletion.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
from datetime import datetime
2+
3+
from sqlalchemy import func
4+
from sqlalchemy.orm import Mapped, mapped_column
5+
6+
from h.db import Base
7+
from h.models import helpers
8+
9+
10+
class UserDeletion(Base):
11+
"""A record of a user account that was deleted."""
12+
13+
__tablename__ = "user_deletion"
14+
15+
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
16+
17+
userid: Mapped[str]
18+
"""The userid of the user who was deleted."""
19+
20+
requested_at: Mapped[datetime] = mapped_column(
21+
server_default=func.now(), # pylint:disable=not-callable
22+
)
23+
"""The time at which the user deletion was requested."""
24+
25+
requested_by: Mapped[str]
26+
"""The userid of the user who requested the deletion."""
27+
28+
tag: Mapped[str]
29+
"""Just a string 'tag' field.
30+
31+
For example different views that delete users might put different tag
32+
values here.
33+
"""
34+
35+
registered_date: Mapped[datetime]
36+
"""The time when the deleted user account was first registered."""
37+
38+
num_annotations: Mapped[int]
39+
"""The number of annotations that the deleted user account had."""
40+
41+
def __repr__(self) -> str:
42+
return helpers.repr_(
43+
self,
44+
[
45+
"id",
46+
"userid",
47+
"requested_at",
48+
"requested_by",
49+
"tag",
50+
"registered_date",
51+
"num_annotations",
52+
],
53+
)

h/services/user_delete.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import sqlalchemy as sa
22

3-
from h.models import Annotation, Group, Token, User
3+
from h.models import Annotation, Group, Token, User, UserDeletion
44
from h.services.annotation_delete import AnnotationDeleteService
55

66

@@ -13,7 +13,7 @@ def __init__(
1313
self._db = db_session
1414
self._annotation_delete_service = annotation_delete_service
1515

16-
def delete_user(self, user: User):
16+
def delete_user(self, user: User, requested_by: User, tag: str):
1717
"""
1818
Delete a user with all their group memberships and annotations.
1919
@@ -46,6 +46,20 @@ def delete_user(self, user: User):
4646
else:
4747
self._db.delete(group)
4848

49+
self._db.add(
50+
UserDeletion(
51+
userid=user.userid,
52+
requested_by=requested_by.userid,
53+
tag=tag,
54+
registered_date=user.registered_date,
55+
num_annotations=self._db.scalar(
56+
sa.select(
57+
sa.func.count(Annotation.id) # pylint:disable=not-callable
58+
).where(Annotation.userid == user.userid)
59+
),
60+
)
61+
)
62+
4963
self._db.delete(user)
5064

5165

h/views/admin/users.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ def users_delete(request):
134134
user = _form_request_user(request)
135135
svc = request.find_service(name="user_delete")
136136

137-
svc.delete_user(user)
137+
svc.delete_user(user, requested_by=request.user, tag=request.matched_route.name)
138138
request.session.flash(
139139
f"Successfully deleted user {user.username} with authority {user.authority}"
140140
"success",

tests/common/factories/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,5 @@
2020
from tests.common.factories.subscriptions import Subscriptions
2121
from tests.common.factories.token import DeveloperToken, OAuth2Token
2222
from tests.common.factories.user import User
23+
from tests.common.factories.user_deletion import UserDeletion
2324
from tests.common.factories.user_identity import UserIdentity
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from factory import Faker, LazyAttribute
2+
3+
from h import models
4+
5+
from .base import ModelFactory
6+
7+
8+
class UserDeletion(ModelFactory):
9+
class Meta:
10+
model = models.UserDeletion
11+
exclude = ("username", "requesting_username")
12+
13+
username = Faker("user_name")
14+
userid = LazyAttribute(lambda o: f"acct:{o.username}@example.com")
15+
requesting_username = Faker("user_name")
16+
requested_by = LazyAttribute(lambda o: f"acct:{o.requesting_username}@example.com")
17+
tag = "factory"
18+
registered_date = Faker("date_time")
19+
num_annotations = Faker("random_int")

tests/unit/h/cli/commands/user_test.py

Lines changed: 0 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -195,46 +195,6 @@ def user(self, factories):
195195
return factories.User()
196196

197197

198-
class TestDeleteUserCommand:
199-
def test_it_deletes_user(self, invoke_cli, user, user_delete_service):
200-
result = invoke_cli(user_cli.delete, [user.username])
201-
202-
assert not result.exit_code
203-
user_delete_service.delete_user.assert_called_once_with(user)
204-
205-
def test_it_deletes_user_with_specific_authority(
206-
self, invoke_cli, user, user_delete_service
207-
):
208-
user.authority = "partner.org"
209-
210-
result = invoke_cli(
211-
user_cli.delete, ["--authority", "partner.org", user.username]
212-
)
213-
214-
assert not result.exit_code
215-
user_delete_service.delete_user.assert_called_once_with(user)
216-
217-
def test_it_errors_when_user_could_not_be_found(
218-
self, invoke_cli, user_delete_service
219-
):
220-
result = invoke_cli(user_cli.delete, ["bogus_username"])
221-
222-
assert result.exit_code == 1
223-
user_delete_service.delete_user.assert_not_called()
224-
225-
def test_it_errors_when_user_with_specific_authority_could_not_be_found(
226-
self, invoke_cli, user, user_delete_service
227-
):
228-
result = invoke_cli(user_cli.delete, ["--authority", "foo.com", user.username])
229-
230-
assert result.exit_code == 1
231-
user_delete_service.delete_user.assert_not_called()
232-
233-
@pytest.fixture
234-
def user(self, factories):
235-
return factories.User()
236-
237-
238198
@pytest.fixture
239199
def invoke_cli(cli, pyramid_request):
240200
pyramid_request.tm = mock.Mock()

0 commit comments

Comments
 (0)