-
Notifications
You must be signed in to change notification settings - Fork 426
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
Conversation
41b5c22
to
524bdcf
Compare
|
||
|
||
@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) |
There was a problem hiding this comment.
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.
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())})" |
There was a problem hiding this comment.
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.
h/models/user_deletion.py
Outdated
def __repr__(self) -> str: | ||
return helpers.repr_( | ||
self, | ||
[ | ||
"id", | ||
"userid", | ||
"requested_at", | ||
"requested_by", | ||
"tag", | ||
"created_at", | ||
"num_annotations", | ||
], | ||
) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Composition over inheritance 👍
cead5cf
to
d8ef46e
Compare
h/models/user_deletion.py
Outdated
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] |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
num_annotations=self._db.scalar( | ||
sa.select( | ||
sa.func.count(Annotation.id) # pylint:disable=not-callable | ||
).where(Annotation.userid == user.userid) | ||
), |
There was a problem hiding this comment.
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
?
There was a problem hiding this comment.
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.
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 |
There was a problem hiding this comment.
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 🤷♂️
There was a problem hiding this comment.
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.
d8ef46e
to
7ab0ba2
Compare
h/models/user_deletion.py
Outdated
) | ||
requested_by: Mapped[str] | ||
tag: Mapped[str] | ||
created_at: Mapped[datetime] |
There was a problem hiding this comment.
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" 👍
There was a problem hiding this comment.
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...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
num_annotations=self._db.scalar( | ||
sa.select( | ||
sa.func.count(Annotation.id) # pylint:disable=not-callable | ||
).where(Annotation.userid == user.userid) | ||
), |
There was a problem hiding this comment.
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.
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 |
There was a problem hiding this comment.
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.
h/models/user_deletion.py
Outdated
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] |
There was a problem hiding this comment.
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.
There was a problem hiding this 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.
7ab0ba2
to
b875bfd
Compare
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 deletedevdata_user
. You should see a record like this in theuser_deletion
table: