-
Notifications
You must be signed in to change notification settings - Fork 42
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
Support deletion of multiple sources #2252
Conversation
msgid "Select or de-select sources using Ctrl+click, Shift+click, or by dragging the mouse." | ||
msgstr "" | ||
|
||
msgid "Use the top toolbar to delete multiple sources at once." |
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.
(When/if we add more options, this text will change. I'm not attached to it)
f433abe
to
ddbb33a
Compare
be38181
to
e4bb9d9
Compare
Here are some screenshots of what I've implemented. If anyone wants to check out this branch and run it locally, you can do that too. I will have some followup comments as well but just sharing what it looks like at the moment. Logged in, no source selected, delete button visible: Deletion in progress (note animation for individual sources; the animation is why some of these look odd in the screenshot) Some other notes:
|
993dca9
to
7df8962
Compare
41f19bf
to
225cce8
Compare
Updates: I will write a longer comment explaining the latest changes (QStackedLayout decision), which I previewed to @cfm already. |
a53d706
to
d71951f
Compare
Status:
More context/info about layout elements: The QStackedLayout element that I was mentioning in the previous comment is an additional change that was not in the original work plan, here is a summary of the changes and why they were needed. Originally, there was one view, the EmptyConversationView, that was a vertical layout that contained multiple child layout items that would represent contextual states (No sources available, No source selected) and would be shown depending on the user and sourcelist behaviour. The way to switch between views following this approach was to show and hide individual layout elements, which gets messy, can make for undefined ui states (if you forget to show/hide an element), and is less well-encapsulated for testing. However, there was a further issue, which is that the previous expectation was that the ConversationView would not be regularly shown and hidden (once you select a source, your option is to keep selecting other sources and rendering their conversations in the righthand pane, or, to delete the source, rendering the "no sources selected" view). Originally I added a third state (multiple sources selected) to this layout, but I realized that showing and hiding the Conversation pane element was wiping out the layout widgets that were hidden and redrawing each time the user switched between selecting multiple sources and a single source. A better approach than showing and hiding individual gui components is using separate layouts in QStackedWidget. This is like a tab view, where the context can 'flip' between Empty, Nothing Selected, Multiple Selected, and Single Conversation Rendered (and anything else we want on that stack), and can be thought of as an array of layouts, one of which can be seen at any given time. This is what is now implemented. Another benefit of this approach is that we could deprecate (What would be needed for that approach is a clean/efficient way of searching through SourceConversationWrappers to see if the right one is present on the stack, and displaying it if so. It might be possible to do this in a more performant way with setting a custom object property in the QWidget that is the source uuid - I'm not sure, haven't looked into it. But either way I think it would be an improvement to the strategy we use now.) |
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.
@rocodes, I did an initial review today, which I'm posting WIP to maximize our back-and-forth this week. I'll both update this and comment further as I chip away at this checklist tomorrow and Friday. It's looking really good so far.
- Visual review: done except for:
-
securedrop_client.gui.widgets
-
tests/
-
- CI
- test scenarios below
- additional tests written for race condition regression testing
Basic testing
- See screenshot: text cropped (especially characters truncated vertically) in narrow window
Though this also happens on the Nothing to see yet
screen, so it may not be anything you've introduced here! Feel free to file a separate ticket for this if you feel it's out of scope.
- Ctrl+click selects multiple disjointed sources
- Shift+click selects range of sources
- Mouse drag on sourcelist selects range of sources
Yes, although I am weakly opposed to introducing this UX pattern, which feels like a non-native touch gesture.
- When more than one source is selected, the "Multiple sources selected" context view replaces the Source Conversation view
- When all but one of the selected sources
areis deselected, the correct conversation view appears - When another (non-selected) source is selected, the correct conversation view appears
- Selecting Delete Sources from the toolbar prompts a dialog that shows each source name confirming deletion
Yes, but I've just realized that the use of a set means they're not presented in either presentation order (from top to bottom) or selection order (the order in which I clicked them), which makes it hard to compare "what I think I selected" with what the dialog lists. I think this can be a follow-up improvement, but I wanted to flag it.
- Closing or canceling the dialog has no effect and sources are still selected
- Accepting the dialog shows individualized deletion animation for each target source
- Clicking into a source pending deletion shows the deleted-source view for that that source
- DeleteSources button is not visible when logged out. (Note than in addition there is a logical check that requires an authenticated session)
-
Delete Sources
button does not display hover state when disabled because nothing is selected - Source Menu (3-dots menu by a single source) functionality is unchanged, including deleting a single source from the source menu
- Basic usage testing: ensure that successive clicks on different sources in the sourcelist render the correct expanded conversation in the conversation pane (right side). (Easiest way to do this is to reply with the sources nickname so that you can readily match the conversation to the source)
- Toggling between views (nothing selected, multi selected, single source selected) renders correct view
There's no way to go directly back to "nothing selected", is there?
Race condition testing
- Tester (@cfm) can use some discretion here, but the goal is to ensure that when the sourcelist is reordered, the correct sources remain selected. One way to test this would be to select (programatically or manually) a number of sources while the client is loading lots of dev data, or to select a number of sources, and before any action has been taken, reorder the sourcelist and ensure the correct items remain selected.
- The best asessment though is via review, noting that when the Sources are identifiedis at the time of the mouseclick, not at the time the Delete Sources button is called, and the selection is based on a QListWidget item which is tied to a Source object.
client/securedrop_client/logic.py
Outdated
def get_selected_sources(self) -> set[db.Source] | None: | ||
return self._selected_sources |
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.
Since this is a read-only getter with no side effects or dependencies, why not simplify it to:
def get_selected_sources(self) -> set[db.Source] | None: | |
return self._selected_sources | |
@property | |
def selected_sources(self) -> set[db.Source]: | |
return self._selected_sources |
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.
Depends on below - pending your feedback I can either adjust to instantiate with the empty set, and always return a set, or leave as is.
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.
#2252 (comment) resolves the typing question. My other suggestion here was to go from a method called as .get_selected_sources()
to a @property
called as .selected_sources
, but feel free to disregard that too if you feel it's too out of pattern with the rest of the current codebase.
@cfm : Thank you for your testing, it's been very helpful and a few small changes coming presently. One question: What desktop environment /window manager /display server are you using for testing? Re the narrow window/cut off text, the client sets a minimum screen width and does not permit resizing below that width, and that's not a regression. (It's not a great solution, we should reflow/resize fluidly and support more screen widths, but that's not introduced here.) In my testing, I'm prevented from decreasing the width of the application below the mininum width we have specified. If you're on i3 we should definitely file a separate issue since there will be a significant number of changes to make to accommodate that. But that said, if you notice a difference in behaviour from our last tagged release, please let me know. Other comments:
|
Another update re cut-off text: Since the last release, the conversation view area has shrank a bit due to the top toolbar addition, so on smaller screen sizes the text can get cut off, as below. This isn't what you were reporting @cfm but it did cause me to find this, so thank you - will tack on a fix. |
b2f9ca3
to
7aa9d22
Compare
Good call: I'm doing development testing in i3. Disregard these observations, and if we do notice them in release QA in Xfce we'll know it's a regression. |
My triaging instinct:
My assumption here is: either source-list ordering or lexical ordering will be obvious and intuitive to most folks; selection ordering will be neither obvious nor intuitive nor worth it to implement. :-) |
Yup, set -> list refactor coming presently-- I was originally thinking it would be a feed of selection data (and therefore a set was more important), but since we're using the built-in selectedItems, I feel fine about using a list |
24be2b7
to
01e3fad
Compare
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.
@rocodes's guidance was for testing races was:
- Tester (@cfm) can use some discretion here, but the goal is to ensure that when the sourcelist is reordered, the correct sources remain selected. One way to test this would be to select (programatically or manually) a number of sources while the client is loading lots of dev data, or to select a number of sources, and before any action has been taken, reorder the sourcelist and ensure the correct items remain selected.
- The best asessment though is via review, noting that when the Sources are identifiedis at the time of the mouseclick, not at the time the Delete Sources button is called, and the selection is based on a QListWidget item which is tied to a Source object.
I came up with three scenarios. Scenarios (2) and (3) should be automatable entirely within the Client test suite, which I'll do tomorrow. Scenario (1) is not automatable without a way to trigger server-side actions (even just loaddata.py
) from the Client test suite, but I'll see how much of it I can simulate with Client-side operations.
Scenario 1: Addition
- Select one or more sources to delete.
- Add one or more new sources (e.g., via
loaddata.py
) and wait for the source list to sync. - The selected sources remain selected.
- Click Delete Sources.
- The list of sources is the set of sources selected in step (1).
- Exactly this list of sources is deleted.
Scenario 2: Deletion, pre-dialog
- Select one or more sources to delete.
- Delete one or more of the selected sources out of band (e.g., via the Journalist Interface) and wait for the source list to sync.
- The selected sources remain selected.
- Click Delete Sources.
- The list of sources is the set of sources selected in step (1).
- Exactly this list of sources is deleted.
Scenario 3: Deletion, in-dialog
- Select one or more sources to delete.
- Click Delete Sources.
- Delete one or more of the selected sources out of band (e.g., via the Journalist Interface) and wait for the source list to sync.
- The selected sources remain selected, except for the deleted sources, which disappear from the source list.
- Click Yes, Delete n Source Accounts. Yes, the list of sources will be out of date, but...
- The remaining sources are deleted.
(I think this level of resilience is adequate, and trying to add more responsiveness might actually reduce resilience [i.e., introduce new data races].)
However, the Client crashes when it attempts to create a DeleteSourceJob
for one of the deleted sources.
2024-10-24 16:28:49,778 - securedrop_client.queue:164(add_job) DEBUG: Added <securedrop_client.api_jobs.sources.DeleteSourceJob object at 0x76faadbfb370> to queue
2024-10-24 16:28:49,787 - securedrop_client.sdk:330(_send_json_request) DEBUG: Sending request to proxy: DELETE api/v1/sources/c70eea2a-1d54-4df3-8d4b-c76c3358020d (body=False, stream=False, timeout=60)
2024-10-24 16:28:49,802 - securedrop_client.queue:164(add_job) DEBUG: Added <securedrop_client.api_jobs.sources.DeleteSourceJob object at 0x76faadbfb130> to queue
2024-10-24 16:28:49,805 - securedrop_client.queue:164(add_job) DEBUG: Added <securedrop_client.api_jobs.sources.DeleteSourceJob object at 0x76faadbfb2e0> to queue
Traceback (most recent call last):
File "/home/user/securedrop-client/client/securedrop_client/gui/widgets.py", line 558, in <lambda>
dialog.accepted.connect(lambda: self.controller.delete_sources(targets))
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/user/securedrop-client/client/securedrop_client/logic.py", line 80, in decorated_function
return f(self, *args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/user/securedrop-client/client/securedrop_client/logic.py", line 1038, in delete_sources
job = DeleteSourceJob(source.uuid)
^^^^^^^^^^^
File "/home/user/.cache/pypoetry/virtualenvs/securedrop-client-KGGbhkP--py3.11/lib/python3.11/site-packages/sqlalchemy/orm/attributes.py", line 276, in __get__
2024-10-24 16:28:49,807 - root:66(excepthook) ERROR: Unrecoverable error
Traceback (most recent call last):
File "/home/user/securedrop-client/client/securedrop_client/gui/widgets.py", line 558, in <lambda>
dialog.accepted.connect(lambda: self.controller.delete_sources(targets))
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/user/securedrop-client/client/securedrop_client/logic.py", line 80, in decorated_function
return f(self, *args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/user/securedrop-client/client/securedrop_client/logic.py", line 1038, in delete_sources
job = DeleteSourceJob(source.uuid)
^^^^^^^^^^^
File "/home/user/.cache/pypoetry/virtualenvs/securedrop-client-KGGbhkP--py3.11/lib/python3.11/site-packages/sqlalchemy/orm/attributes.py", line 276, in __get__
return self.impl.get(instance_state(instance), dict_)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/user/.cache/pypoetry/virtualenvs/securedrop-client-KGGbhkP--py3.11/lib/python3.11/site-packages/sqlalchemy/orm/attributes.py", line 677, in get
value = state._load_expired(state, passive)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/user/.cache/pypoetry/virtualenvs/securedrop-client-KGGbhkP--py3.11/lib/python3.11/site-packages/sqlalchemy/orm/state.py", line 660, in _load_expired
self.manager.deferred_scalar_loader(self, toload)
File "/home/user/.cache/pypoetry/virtualenvs/securedrop-client-KGGbhkP--py3.11/lib/python3.11/site-packages/sqlalchemy/orm/loading.py", line 985, in load_scalar_attributes
raise orm_exc.ObjectDeletedError(state)
sqlalchemy.orm.exc.ObjectDeletedError: Instance '<Source at 0x76faadbb1950>' has been deleted, or its row is otherwise not present.
return self.impl.get(instance_state(instance), dict_)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/user/.cache/pypoetry/virtualenvs/securedrop-client-KGGbhkP--py3.11/lib/python3.11/site-packages/sqlalchemy/orm/attributes.py", line 677, in get
value = state._load_expired(state, passive)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/user/.cache/pypoetry/virtualenvs/securedrop-client-KGGbhkP--py3.11/lib/python3.11/site-packages/sqlalchemy/orm/state.py", line 660, in _load_expired
self.manager.deferred_scalar_loader(self, toload)
File "/home/user/.cache/pypoetry/virtualenvs/securedrop-client-KGGbhkP--py3.11/lib/python3.11/site-packages/sqlalchemy/orm/loading.py", line 985, in load_scalar_attributes
raise orm_exc.ObjectDeletedError(state)
sqlalchemy.orm.exc.ObjectDeletedError: Instance '<Source at 0x76faadbb1950>' has been deleted, or its row is otherwise not present.
What it should do in its iterating, and what the bulk DELETE
endpoint in freedomofpress/securedrop#7228 will support in its batching, is silently discard a DeleteSourceJob
for a Source
(i.e., UUID) that doesn't exist, but probably logger.warning()
on it.
MainView layout to QVBoxLayout and add inner horizontal container to accommodate inner top bar. Update strings
…bar to request and receive updated list of selected sources from sourcelist. Change list selection method of SourceList to allow for multi-select. Add support for multi-source selection. Add multi-source view pane in the conversationview. Add styled icon, adjust button and sourcelist selector color to match EmptyConversationView color. Use a QStackedLayout for the Conversation Pane views instead of hiding, showing, and deleting child widgets. Update functional and integration tests to reflect new MainView QStackedLayout. Improve delete sources explanation text and extract strings.
30b6544
to
ec25f22
Compare
Hey @cfm, thank you and good find. I have pushed the most limited-diff fix, which is to try-catch accessing Source properties (This for our future selves): I started down, then paused, the path of a more involved fix for the race condition issue, which I will describe now: avoid passing db models to GUI (and API) components, and instead pass a simplified ViewModel object (in this case for a Source) that is constructed on demand and is not dependent on the underlying db session. (The DeleteSourcesDialog doesn't need all Source attributes, only the journalist designator; the Controller / API don't need all source attributes, only the uuid; etc). This way, the business of whether or not the db object is valid is not part of the gui or api responsibility. But I think that's a larger-scoped idea so I am leaving it out for now. I have rebased, pushed changes that I hope will fix the new FileWidget functional test, addressed changes, and squashed and signed commits except for the latest. |
1425c28
to
4e20eee
Compare
…ntroller delete_sources. Ensure DeleteSourceDialog is shown even with no sources selected in widgets.py and improve comments. Fix new FileWidget functional test.
907ebcd
to
c267ce7
Compare
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.
Thanks for the quick fixes, @rocodes. Your 4e20eee resolves the crash in the "deletion, in-dialog" scenario, which now succeeds with the expected warning:
2024-10-25 12:25:16,498 - securedrop_client.logic:1071(delete_sources) WARNING: DeleteSourceJob requested but source already deleted
I've amended that as c267ce7 to fix up test_deleted_file_filewidget
.
Here's how I'd like to proceed: Because you've resolved this race (thanks!), and because testing for races thoroughly exercises this feature (and isn't completely automatable), I'm going to approve this now but not merge it. Instead, I'll push whatever tests I'm able to write this afternoon, so that next week the team can review just the tests and re-approve for merge on that basis.
d6b19cc
to
933de98
Compare
… of one or more sources This is a complete rewrite of this test, which now (a) distinguishes between single- and multi-select behavior and (b) checks that the individual sources selected for deletion are confirmed and deleted, not only the *number* of sources selected. This includes a workaround for #2273. Here we also change securedrop_client.gui.widgets.BatchActionToolbar.on_action_triggered() to call QDialog.open(), which is modal with QDialog.setModal(), rather than QDialog.exec(), which is modal *and* blocking, which blocks pytest timers as well as the main event loop.
933de98
to
f6bd2c9
Compare
f6bd2c9 rewrites |
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 have taken a look at the missing f6bd2c9 and it looks reasonable to me. The test was really clear I especially appreciate the fact that we're confirming that we're now checking that the source which gets deleted does it fact (at least visually) match the one we want.
Here's how I'd like to proceed: Because you've resolved this race (thanks!), and because testing for races thoroughly exercises this feature (and isn't completely automatable), I'm going to approve this now but not merge it. Instead, I'll push whatever tests I'm able to write this afternoon, so that next week the team can review just the tests and re-approve for merge on that basis.
Originally posted by cfm October 25, 2024
Based on the above, I'm approving it generally, piggybacking pn @cfm's approval.
As a side-note on this, I was able to reproduce the issue with |
Status
Ready for review and testing; test suite is passing but new tests have not yet been added.
Description
Ready for review.
Closes #2160. Closes #1569.
Test Plan
Test against a dev server with
NUM_SOURCES=50 make dev
(or some other large number.I suggest adding replies to various sources once logged in that indicate which source conversation is expected, for example, the source nickname in a reply. (I'm realizing this is also a feature improvement to
make dev
that we can add, will track that separately).Basic testing
** Race condition testing**
Checklist
If these changes modify code paths involving cryptography, the opening of files in VMs or network (via the RPC service) traffic, Qubes testing in the staging environment is required. For fine tuning of the graphical user interface, testing in any environment in Qubes is required. Please check as applicable:
If these changes add or remove files other than client code, the AppArmor profile may need to be updated. Please check as applicable:
If these changes modify the database schema, you should include a database migration. Please check as applicable:
main
and confirmed that the migration is self-contained and applies cleanlymain
and would like the reviewer to do so