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

Add Seen/Unseen support at source level #1165

Merged
merged 8 commits into from Nov 25, 2020
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
11 changes: 5 additions & 6 deletions README.md
Expand Up @@ -325,26 +325,25 @@ To set up a local dev server and generate cassettes, follow these steps:

https://gist.github.com/creviera/8793d5ec4d28f034f2c1e8320a93866a

2. Start the server in a docker container by running:
2. Start the server in a docker container and add 5 sources with messages, files, and replies by running:

```bash
NUM_SOURCES=0 make dev
NUM_SOURCES=5 make dev
```

3. Create two new sources, each with one submission that contains both a file and a message. The message should be set to `this is the message`. The file should be called `hello.txt` and contain a single line of text: `hello`.
4. Delete the cassettes you wish to regenerate or just delete the entire directory by running:
3. Delete the cassettes you wish to regenerate or just delete the entire directory by running:

```bash
rm -r tests/functional/cassettes
```

5. Regenerate cassettes by running:
4. Regenerate cassettes by running:

```bash
make test-functional
```

Note: One of the functional tests deletes a source, so you may need to add it back in between test runs where you are generating new cassettes.
Note: One of the functional tests deletes a source, so you may need to add it back or restart the server in between test runs where you are generating new cassettes.

## Making a Release

Expand Down
26 changes: 26 additions & 0 deletions securedrop_client/api_jobs/seen.py
@@ -0,0 +1,26 @@
from typing import List

from sdclientapi import API
from sqlalchemy.orm.session import Session

from securedrop_client.api_jobs.base import ApiJob


class SeenJob(ApiJob):
def __init__(self, files: List[str], messages: List[str], replies: List[str]) -> None:
super().__init__()
self.files = files
self.messages = messages
self.replies = replies

def call_api(self, api_client: API, session: Session) -> None:
"""
Override ApiJob.

Mark files, messages, and replies as seen. Do not make the request if there are no items to
be marked as seen.
"""
if not self.files and not self.messages and not self.replies:
return

api_client.seen(self.files, self.messages, self.replies)
71 changes: 71 additions & 0 deletions securedrop_client/db.py
Expand Up @@ -95,6 +95,14 @@ def journalist_filename(self) -> str:
[c for c in self.journalist_designation.lower().replace(" ", "_") if c in valid_chars]
)

@property
def seen(self) -> bool:
for item in self.collection:
if not item.seen:
return False

return True


class Message(Base):

Expand Down Expand Up @@ -183,6 +191,24 @@ def location(self, data_dir: str) -> str:
)
)

@property
def seen(self) -> bool:
"""
If the submission has been downloaded or seen by any journalist, then the submssion is
considered seen.
"""
if self.seen_messages.count():
return True

return False

def seen_by(self, journalist_id: int) -> bool:
for seen_message in self.seen_messages:
if seen_message.journalist_id == journalist_id:
return True

return False


class File(Base):

Expand Down Expand Up @@ -264,6 +290,24 @@ def location(self, data_dir: str) -> str:
)
)

@property
def seen(self) -> bool:
"""
If the submission has been downloaded or seen by any journalist, then the submssion is
considered seen.
"""
if self.seen_files.count():
return True

return False

def seen_by(self, journalist_id: int) -> bool:
for seen_file in self.seen_files:
if seen_file.journalist_id == journalist_id:
return True

return False


class Reply(Base):

Expand Down Expand Up @@ -350,6 +394,20 @@ def location(self, data_dir: str) -> str:
)
)

@property
def seen(self) -> bool:
"""
A reply is always seen in a global inbox.
"""
return True

def seen_by(self, journalist_id: int) -> bool:
for seen_reply in self.seen_replies:
if seen_reply.journalist_id == journalist_id:
return True

return False


class DownloadErrorCodes(Enum):
"""
Expand Down Expand Up @@ -425,6 +483,19 @@ def __str__(self) -> str:
def __repr__(self) -> str:
return "<DraftReply {}>".format(self.uuid)

@property
sssoleileraaa marked this conversation as resolved.
Show resolved Hide resolved
def seen(self) -> bool:
"""
A draft reply is always seen in a global inbox.
"""
return True

def seen_by(self, journalist_id: int) -> bool:
"""
A draft reply is considered seen by everyone (we don't track who sees draft replies).
"""
return True


class ReplySendStatus(Base):

Expand Down
115 changes: 93 additions & 22 deletions securedrop_client/gui/widgets.py
Expand Up @@ -632,28 +632,34 @@ def show_sources(self, sources: List[Source]):

def on_source_changed(self):
"""
Show conversation for the currently-selected source if it hasn't been deleted. If the
current source no longer exists, clear the conversation for that source.
Show conversation for the selected source.
"""
source = self.source_list.get_selected_source()
try:
source = self.source_list.get_selected_source()
if not source:
return

if not source:
return
self.controller.session.refresh(source)

self.controller.session.refresh(source)
# Try to get the SourceConversationWrapper from the persistent dict,
# else we create it.
try:
logger.debug("Drawing source conversation for {}".format(source.uuid))
conversation_wrapper = self.source_conversations[source.uuid]
# Immediately show the selected source as seen in the UI and then make a request to mark
# source as seen.
self.source_list.source_selected.emit(source.uuid)
self.controller.mark_seen(source)

# Redraw the conversation view such that new messages, replies, files appear.
conversation_wrapper.conversation_view.update_conversation(source.collection)
except KeyError:
conversation_wrapper = SourceConversationWrapper(source, self.controller)
self.source_conversations[source.uuid] = conversation_wrapper
# Get or create the SourceConversationWrapper
if source.uuid in self.source_conversations:
conversation_wrapper = self.source_conversations[source.uuid]
conversation_wrapper.conversation_view.update_conversation(source.collection)
else:
conversation_wrapper = SourceConversationWrapper(source, self.controller)
self.source_conversations[source.uuid] = conversation_wrapper

self.set_conversation(conversation_wrapper)
self.set_conversation(conversation_wrapper)
logger.debug(
"Set conversation to the selected source with uuid: {}".format(source.uuid)
)
except sqlalchemy.exc.InvalidRequestError as e:
logger.debug(e)

def delete_conversation(self, source_uuid: str) -> None:
"""
Expand Down Expand Up @@ -795,6 +801,8 @@ class SourceList(QListWidget):

NUM_SOURCES_TO_ADD_AT_A_TIME = 32

source_selected = pyqtSignal(str)

def __init__(self):
super().__init__()

Expand Down Expand Up @@ -865,7 +873,9 @@ def update(self, sources: List[Source]) -> List[str]:

# Add widgets for new sources
for uuid in sources_to_add:
source_widget = SourceWidget(self.controller, sources_to_add[uuid])
source_widget = SourceWidget(
self.controller, sources_to_add[uuid], self.source_selected
)
source_item = SourceListWidgetItem(self)
source_item.setSizeHint(source_widget.sizeHint())
self.insertItem(0, source_item)
Expand Down Expand Up @@ -900,7 +910,7 @@ def schedule_source_management(slice_size=slice_size):
for source in sources_slice:
try:
source_uuid = source.uuid
source_widget = SourceWidget(self.controller, source)
source_widget = SourceWidget(self.controller, source, self.source_selected)
source_item = SourceListWidgetItem(self)
source_item.setSizeHint(source_widget.sizeHint())
self.insertItem(0, source_item)
Expand Down Expand Up @@ -994,17 +1004,24 @@ class SourceWidget(QWidget):
PREVIEW_WIDTH = 380
PREVIEW_HEIGHT = 60

def __init__(self, controller: Controller, source: Source):
SOURCE_NAME_CSS = load_css("source_name.css")
SOURCE_PREVIEW_CSS = load_css("source_preview.css")
SOURCE_TIMESTAMP_CSS = load_css("source_timestamp.css")

def __init__(self, controller: Controller, source: Source, source_selected_signal: pyqtSignal):
super().__init__()

self.controller = controller
self.controller.source_deleted.connect(self._on_source_deleted)
self.controller.source_deletion_failed.connect(self._on_source_deletion_failed)
self.controller.authentication_state.connect(self._on_authentication_changed)
source_selected_signal.connect(self._on_source_selected)

# Store source
self.source_uuid = source.uuid
self.last_updated = source.last_updated
self.source = source
self.seen = self.source.seen
self.source_uuid = self.source.uuid
self.last_updated = self.source.last_updated

# Set layout
layout = QHBoxLayout(self)
Expand Down Expand Up @@ -1100,9 +1117,14 @@ def update(self):
if self.source.document_count == 0:
self.paperclip.hide()
self.star.update(self.source.is_starred)

# When not authenticated we always show the source as having been seen
self.seen = True if not self.controller.is_authenticated else self.source.seen
self.update_styles()
except sqlalchemy.exc.InvalidRequestError as e:
logger.debug(f"Could not update SourceWidget for source {self.source_uuid}: {e}")

@pyqtSlot(str, str, str)
def set_snippet(self, source_uuid: str, collection_uuid: str = None, content: str = None):
"""
Update the preview snippet if the source_uuid matches our own.
Expand Down Expand Up @@ -1130,6 +1152,55 @@ def delete_source(self, event):
messagebox = DeleteSourceMessageBox(self.source, self.controller)
messagebox.launch()

def update_styles(self) -> None:
if self.seen:
self.name.setStyleSheet("")
self.name.setObjectName("SourceWidget_name")
self.name.setStyleSheet(self.SOURCE_NAME_CSS)

self.timestamp.setStyleSheet("")
self.timestamp.setObjectName("SourceWidget_timestamp")
self.timestamp.setStyleSheet(self.SOURCE_TIMESTAMP_CSS)

self.preview.setStyleSheet("")
self.preview.setObjectName("SourceWidget_preview")
self.preview.setStyleSheet(self.SOURCE_PREVIEW_CSS)
else:
self.name.setStyleSheet("")
self.name.setObjectName("SourceWidget_name_unread")
self.name.setStyleSheet(self.SOURCE_NAME_CSS)

self.timestamp.setStyleSheet("")
self.timestamp.setObjectName("SourceWidget_timestamp_unread")
self.timestamp.setStyleSheet(self.SOURCE_TIMESTAMP_CSS)

self.preview.setStyleSheet("")
self.preview.setObjectName("SourceWidget_preview_unread")
self.preview.setStyleSheet(self.SOURCE_PREVIEW_CSS)

@pyqtSlot(bool)
def _on_authentication_changed(self, authenticated: bool) -> None:
"""
When the user logs out, show source as seen.
"""
if not authenticated:
self.seen = True
self.update_styles()

@pyqtSlot(str)
def _on_source_selected(self, selected_source_uuid: str):
"""
Show widget as having been seen.
"""
if self.source_uuid != selected_source_uuid:
return

if self.seen:
return

self.seen = True
self.update_styles()

@pyqtSlot(str)
def _on_source_deleted(self, source_uuid: str):
if self.source_uuid == source_uuid:
Expand Down