Skip to content

Commit

Permalink
Record user deletions in a new DB table
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
seanh committed May 22, 2024
1 parent a3821ee commit 524bdcf
Show file tree
Hide file tree
Showing 15 changed files with 182 additions and 121 deletions.
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)
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())})"
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())})"
],
)
38 changes: 38 additions & 0 deletions h/models/user_deletion.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
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] = mapped_column(nullable=False)
requested_at: Mapped[datetime] = mapped_column(
default=func.now(), # pylint:disable=not-callable
nullable=False,
)
requested_by: Mapped[str] = mapped_column(nullable=False)
tag: Mapped[str] = mapped_column(nullable=False)
created_at: Mapped[datetime] = mapped_column(nullable=False)
num_annotations: Mapped[int] = mapped_column(nullable=False)

def __repr__(self) -> str:
return helpers.repr_(
self,
[
"id",
"userid",
"requested_at",
"requested_by",
"tag",
"created_at",
"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,
created_at=user.registered_date,
num_annotations=self._db.scalar(
sa.select(
sa.func.count(Annotation.id) # pylint:disable=not-callable
).where(Annotation.userid == user.userid)
),
)
)

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
20 changes: 20 additions & 0 deletions tests/common/factories/user_deletion.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from factory import Faker, LazyAttribute, SubFactory

from h import models

from .base import ModelFactory
from .user import User


class UserDeletion(ModelFactory):
class Meta:
model = models.UserDeletion
exclude = ("user", "requesting_user")

user = SubFactory(User)
userid = LazyAttribute(lambda o: o.user.userid)
requesting_user = SubFactory(User)
requested_by = LazyAttribute(lambda o: o.requesting_user.userid)
tag = "factory"
created_at = LazyAttribute(lambda o: o.user.registered_date)
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",
"created_at",
"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

0 comments on commit 524bdcf

Please sign in to comment.