Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Record user deletions in a new DB table #8721

Merged
merged 1 commit into from
May 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 0 additions & 27 deletions h/cli/commands/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,30 +101,3 @@ def password(ctx, username, authority, password):
request.tm.commit()

click.echo(f"Password changed for {username}", err=True)


@user.command()
@click.argument("username")
@click.option("--authority")
@click.pass_context
def delete(ctx, username, authority):
"""
Delete a user with all their group memberships and annotations.

You must specify the username of a user to delete.
"""
request = ctx.obj["bootstrap"]()

if not authority:
authority = request.default_authority

user = models.User.get_by_username(request.db, username, authority)
if user is None:
raise click.ClickException(
f'no user with username "{username}" and authority "{authority}"'
)

request.find_service(name="user_delete").delete_user(user)
request.tm.commit()

click.echo(f"User {username} deleted.", err=True)
Comment on lines -104 to -130
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nobody uses this command so I'm just deleting it rather than updating it to write to (or skip) the new user deletions log.

2 changes: 2 additions & 0 deletions h/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
from h.models.subscriptions import Subscriptions
from h.models.token import Token
from h.models.user import User
from h.models.user_deletion import UserDeletion
from h.models.user_identity import UserIdentity

__all__ = (
Expand All @@ -64,5 +65,6 @@
"Subscriptions",
"Token",
"User",
"UserDeletion",
"UserIdentity",
)
4 changes: 4 additions & 0 deletions h/models/helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
def repr_(obj, attrs):
class_name = type(obj).__name__
attrs = {attrname: getattr(obj, attrname) for attrname in attrs}
return f"{class_name}({', '.join(f'{name}={value!r}' for name, value in attrs.items())})"
Comment on lines +1 to +4
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Used this code in a couple of places now so moved it to a helper function.

Longer-term I think we should move all h's models to SQLAlchemy's dataclasses support (which we use in Via) then we won't need this.

13 changes: 6 additions & 7 deletions h/models/job.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
from sqlalchemy.dialects.postgresql import JSONB

from h.db import Base
from h.models import helpers


class Job(Base):
Expand All @@ -67,10 +68,9 @@ class Job(Base):
)

def __repr__(self):
class_name = type(self).__name__
attrs = {
attrname: repr(getattr(self, attrname))
for attrname in [
return helpers.repr_(
self,
[
"id",
"name",
"enqueued_at",
Expand All @@ -79,6 +79,5 @@ def __repr__(self):
"priority",
"tag",
"kwargs",
]
}
return f"{class_name}({', '.join(f'{name}={value}' for name, value in attrs.items())})"
],
)
53 changes: 53 additions & 0 deletions h/models/user_deletion.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
from datetime import datetime

from sqlalchemy import func
from sqlalchemy.orm import Mapped, mapped_column

from h.db import Base
from h.models import helpers


class UserDeletion(Base):
"""A record of a user account that was deleted."""

__tablename__ = "user_deletion"

id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)

userid: Mapped[str]
"""The userid of the user who was deleted."""

requested_at: Mapped[datetime] = mapped_column(
server_default=func.now(), # pylint:disable=not-callable
)
"""The time at which the user deletion was requested."""

requested_by: Mapped[str]
"""The userid of the user who requested the deletion."""

tag: Mapped[str]
"""Just a string 'tag' field.

For example different views that delete users might put different tag
values here.
"""

registered_date: Mapped[datetime]
"""The time when the deleted user account was first registered."""

num_annotations: Mapped[int]
"""The number of annotations that the deleted user account had."""

def __repr__(self) -> str:
return helpers.repr_(
self,
[
"id",
"userid",
"requested_at",
"requested_by",
"tag",
"registered_date",
"num_annotations",
],
)
18 changes: 16 additions & 2 deletions h/services/user_delete.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import sqlalchemy as sa

from h.models import Annotation, Group, Token, User
from h.models import Annotation, Group, Token, User, UserDeletion
from h.services.annotation_delete import AnnotationDeleteService


Expand All @@ -13,7 +13,7 @@ def __init__(
self._db = db_session
self._annotation_delete_service = annotation_delete_service

def delete_user(self, user: User):
def delete_user(self, user: User, requested_by: User, tag: str):
"""
Delete a user with all their group memberships and annotations.

Expand Down Expand Up @@ -46,6 +46,20 @@ def delete_user(self, user: User):
else:
self._db.delete(group)

self._db.add(
UserDeletion(
userid=user.userid,
requested_by=requested_by.userid,
tag=tag,
registered_date=user.registered_date,
num_annotations=self._db.scalar(
sa.select(
sa.func.count(Annotation.id) # pylint:disable=not-callable
).where(Annotation.userid == user.userid)
),
Comment on lines +55 to +59
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Querying the annotation table here. Any chance we might want to move this to annotation_slim?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not yet (if you want to get the exact number), just created:

#8727 to finish that.

)
)

self._db.delete(user)


Expand Down
2 changes: 1 addition & 1 deletion h/views/admin/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ def users_delete(request):
user = _form_request_user(request)
svc = request.find_service(name="user_delete")

svc.delete_user(user)
svc.delete_user(user, requested_by=request.user, tag=request.matched_route.name)
request.session.flash(
f"Successfully deleted user {user.username} with authority {user.authority}"
"success",
Expand Down
1 change: 1 addition & 0 deletions tests/common/factories/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,5 @@
from tests.common.factories.subscriptions import Subscriptions
from tests.common.factories.token import DeveloperToken, OAuth2Token
from tests.common.factories.user import User
from tests.common.factories.user_deletion import UserDeletion
from tests.common.factories.user_identity import UserIdentity
19 changes: 19 additions & 0 deletions tests/common/factories/user_deletion.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from factory import Faker, LazyAttribute

from h import models

from .base import ModelFactory


class UserDeletion(ModelFactory):
class Meta:
model = models.UserDeletion
exclude = ("username", "requesting_username")

username = Faker("user_name")
userid = LazyAttribute(lambda o: f"acct:{o.username}@example.com")
requesting_username = Faker("user_name")
requested_by = LazyAttribute(lambda o: f"acct:{o.requesting_username}@example.com")
tag = "factory"
registered_date = Faker("date_time")
num_annotations = Faker("random_int")
40 changes: 0 additions & 40 deletions tests/unit/h/cli/commands/user_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,46 +195,6 @@ def user(self, factories):
return factories.User()


class TestDeleteUserCommand:
def test_it_deletes_user(self, invoke_cli, user, user_delete_service):
result = invoke_cli(user_cli.delete, [user.username])

assert not result.exit_code
user_delete_service.delete_user.assert_called_once_with(user)

def test_it_deletes_user_with_specific_authority(
self, invoke_cli, user, user_delete_service
):
user.authority = "partner.org"

result = invoke_cli(
user_cli.delete, ["--authority", "partner.org", user.username]
)

assert not result.exit_code
user_delete_service.delete_user.assert_called_once_with(user)

def test_it_errors_when_user_could_not_be_found(
self, invoke_cli, user_delete_service
):
result = invoke_cli(user_cli.delete, ["bogus_username"])

assert result.exit_code == 1
user_delete_service.delete_user.assert_not_called()

def test_it_errors_when_user_with_specific_authority_could_not_be_found(
self, invoke_cli, user, user_delete_service
):
result = invoke_cli(user_cli.delete, ["--authority", "foo.com", user.username])

assert result.exit_code == 1
user_delete_service.delete_user.assert_not_called()

@pytest.fixture
def user(self, factories):
return factories.User()


@pytest.fixture
def invoke_cli(cli, pyramid_request):
pyramid_request.tm = mock.Mock()
Expand Down
9 changes: 9 additions & 0 deletions tests/unit/h/models/helpers_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from unittest.mock import Mock

from h.models import helpers


def test_repr_():
obj = Mock(foo="FOO", bar="BAR")

assert helpers.repr_(obj, ["foo", "bar"]) == "Mock(foo='FOO', bar='BAR')"
64 changes: 25 additions & 39 deletions tests/unit/h/models/job_test.py
Original file line number Diff line number Diff line change
@@ -1,43 +1,29 @@
from datetime import datetime
import pytest

from h.models.job import Job

def test___repr__(factories, helpers):
job = factories.Job()
repr_ = repr(job)

class TestJob:
def test___repr__(self):
job = Job(
id=42,
name="job_name",
enqueued_at=datetime(
year=2024,
month=5,
day=8,
hour=11,
minute=51,
second=23,
),
scheduled_at=datetime(
year=2024,
month=6,
day=1,
hour=0,
minute=0,
second=0,
),
expires_at=datetime(
year=2025,
month=1,
day=1,
hour=0,
minute=0,
second=0,
),
priority=3,
tag="job_tag",
kwargs={"foo": "FOO", "bar": "BAR"},
)
helpers.repr_.assert_called_once_with(
job,
[
"id",
"name",
"enqueued_at",
"scheduled_at",
"expires_at",
"priority",
"tag",
"kwargs",
],
)
assert repr_ == helpers.repr_.return_value

assert (
repr(job)
== "Job(id=42, name='job_name', enqueued_at=datetime.datetime(2024, 5, 8, 11, 51, 23), scheduled_at=datetime.datetime(2024, 6, 1, 0, 0), expires_at=datetime.datetime(2025, 1, 1, 0, 0), priority=3, tag='job_tag', kwargs={'foo': 'FOO', 'bar': 'BAR'})"
)

@pytest.fixture(autouse=True)
def helpers(mocker):
helpers = mocker.patch("h.models.job.helpers")
# __repr__() needs to return a string or repr() raises.
helpers.repr_.return_value = "test_string_representation"
return helpers
28 changes: 28 additions & 0 deletions tests/unit/h/models/user_deletion_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import pytest


def test___repr__(factories, helpers):
user_deletion = factories.UserDeletion()
repr_ = repr(user_deletion)

helpers.repr_.assert_called_once_with(
user_deletion,
[
"id",
"userid",
"requested_at",
"requested_by",
"tag",
"registered_date",
"num_annotations",
],
)
assert repr_ == helpers.repr_.return_value


@pytest.fixture(autouse=True)
def helpers(mocker):
helpers = mocker.patch("h.models.user_deletion.helpers")
# __repr__() needs to return a string or repr() raises.
helpers.repr_.return_value = "test_string_representation"
return helpers
Loading
Loading