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

Record user deletions in a new DB table #8721

merged 1 commit into from
May 23, 2024

Conversation

seanh
Copy link
Contributor

@seanh seanh commented May 22, 2024

Migration: #8722.

The code that actually queries this table is over in #8723.

Testing

Log in to http://localhost:5000/login as devdata_admin, go to http://localhost:5000/admin/users?username=devdata_user&authority=localhost and delete devdata_user. You should see a record like this in the user_deletion table:

id              | 1
userid          | acct:devdata_user@localhost
requested_at    | 2024-05-22 14:32:30.891754
requested_by    | acct:devdata_admin@localhost
tag             | admin.users_delete
created_at      | 2024-05-22 14:31:07.501916
num_annotations | 0

@seanh seanh requested a review from marcospri May 22, 2024 12:18
@seanh seanh force-pushed the record-user-deletions branch 3 times, most recently from 41b5c22 to 524bdcf Compare May 22, 2024 14:29
Comment on lines -104 to -130


@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)
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.

Comment on lines +1 to +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())})"
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.

Comment on lines 26 to 53
def __repr__(self) -> str:
return helpers.repr_(
self,
[
"id",
"userid",
"requested_at",
"requested_by",
"tag",
"created_at",
"num_annotations",
],
)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Composition over inheritance 👍

@seanh seanh force-pushed the record-user-deletions branch 2 times, most recently from cead5cf to d8ef46e Compare May 22, 2024 14:39
Comment on lines 15 to 38
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
userid: Mapped[str]
requested_at: Mapped[datetime] = mapped_column(
server_default=func.now(), # pylint:disable=not-callable
)
requested_by: Mapped[str]
tag: Mapped[str]
created_at: Mapped[datetime]
num_annotations: Mapped[int]
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Might as well use the modern SQLAlchemy way?

Note: this makes all these columns not-nullable.

Might want to add an index to match the query we'll be doing on this table?

                                           Table "public.user_deletion"
     Column      |            Type             | Collation | Nullable |                  Default                  
-----------------+-----------------------------+-----------+----------+-------------------------------------------
 id              | integer                     |           | not null | nextval('user_deletion_id_seq'::regclass)
 userid          | character varying           |           | not null | 
 requested_at    | timestamp without time zone |           | not null | now()
 requested_by    | character varying           |           | not null | 
 tag             | character varying           |           | not null | 
 created_at      | timestamp without time zone |           | not null | 
 num_annotations | integer                     |           | not null | 
Indexes:
    "pk__user_deletion" PRIMARY KEY, btree (id)

Copy link
Member

Choose a reason for hiding this comment

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

Might want to add an index to match the query we'll be doing on this table?

Sure, why not, but I don't think table is going to grow that fast, up to you.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm gonna leave the index for later

Comment on lines +55 to +59
num_annotations=self._db.scalar(
sa.select(
sa.func.count(Annotation.id) # pylint:disable=not-callable
).where(Annotation.userid == user.userid)
),
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.

Comment on lines 1 to 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
Copy link
Contributor Author

@seanh seanh May 22, 2024

Choose a reason for hiding this comment

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

We can either have a boilerplatey mock-based test like this, or we could use # pragma: nocover, or we could have a test that does not mock the helper (which sounds nicer but is actually a pain to write because the test would have to pass hard-coded values for the whole long list of model attrs, unless you want the test code to look almost the same as the repr_() helper code). I don't think it makes much difference 🤷‍♂️

Copy link
Member

Choose a reason for hiding this comment

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

Yeah, # pragma: nocover could have work here. This works as well of course.

@seanh seanh marked this pull request as ready for review May 22, 2024 15:11
)
requested_by: Mapped[str]
tag: Mapped[str]
created_at: Mapped[datetime]
Copy link
Member

Choose a reason for hiding this comment

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

created_at is "user.registered_at" 👍

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah. Actually maybe I should rename this to registered_at to match, and also add docsttings to these attrs...

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

Comment on lines +55 to +59
num_annotations=self._db.scalar(
sa.select(
sa.func.count(Annotation.id) # pylint:disable=not-callable
).where(Annotation.userid == user.userid)
),
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.

Comment on lines 1 to 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
Copy link
Member

Choose a reason for hiding this comment

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

Yeah, # pragma: nocover could have work here. This works as well of course.

Comment on lines 15 to 38
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
userid: Mapped[str]
requested_at: Mapped[datetime] = mapped_column(
server_default=func.now(), # pylint:disable=not-callable
)
requested_by: Mapped[str]
tag: Mapped[str]
created_at: Mapped[datetime]
num_annotations: Mapped[int]
Copy link
Member

Choose a reason for hiding this comment

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

Might want to add an index to match the query we'll be doing on this table?

Sure, why not, but I don't think table is going to grow that fast, up to you.

Copy link
Member

@marcospri marcospri left a comment

Choose a reason for hiding this comment

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

This PR target is no the migration branch, but we have to deploy the migration first.

Looks good 👍

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.
@seanh seanh merged commit 3ddb0f7 into main May 23, 2024
9 checks passed
@seanh seanh deleted the record-user-deletions branch May 23, 2024 11:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants