diff --git a/Makefile b/Makefile index 56ff7476a..3dd4584c4 100644 --- a/Makefile +++ b/Makefile @@ -222,6 +222,7 @@ $(POT): securedrop_client @echo "updating catalog template: $@" @mkdir -p ${LOCALE_DIR} @pybabel extract \ + -F babel.cfg \ --charset=utf-8 \ --output=${POT} \ --project="SecureDrop Client" \ diff --git a/babel.cfg b/babel.cfg new file mode 100644 index 000000000..5a6102da9 --- /dev/null +++ b/babel.cfg @@ -0,0 +1,8 @@ +# Extract from Python source files and Jinja templates. + +[python: **.py] + +[jinja2: **.jinja] +# Warn on Jinja syntax errors +# (https://jinja.palletsprojects.com/en/3.0.x/integration/#babel). +silent=False diff --git a/requirements/build-requirements.txt b/requirements/build-requirements.txt index 54e1c2321..7a28efcb2 100644 --- a/requirements/build-requirements.txt +++ b/requirements/build-requirements.txt @@ -3,6 +3,7 @@ arrow==0.12.1 --hash=sha256:e2742eb33011f7aff1d5f27501d802680b6627939f20ff2ef56f certifi==2022.12.7 --hash=sha256:7f205a1a4f02f4970fb5d0e16457964bb30d6b678a766515278bc56e6eeb645f charset-normalizer==2.0.4 --hash=sha256:cd9a4492eef4e5276c07f9c0dc1338e7be3e95f2a536bf2c5b620b1f27d03d74 idna==3.2 --hash=sha256:691d9fc304505c65ea9ceb8eb7385d63988e344c065cacbbd2156ff9bdfcf0c1 +jinja2==3.0.2 --hash=sha256:d8075dbbb594058c565a74b6ca2b6a1822c9cdd949400b747f87ec004edca036 mako==1.2.2 --hash=sha256:f61384bcc80318821d1116891a82bb0ff18a9a4035c7c4eff72aced45ab590b5 markupsafe==2.0.1 --hash=sha256:bb3e541812095075336bcd935bb58941aedc0a7cba3c73d301dfdfd4d66a4eec --hash=sha256:7b12b29ae39060c29ed0d8cb1052fa1672832b5096f859fd35e896ca3b04ddd3 --hash=sha256:9a055a175f351a559937fb80ebb2885d005283577a016c0139817e261fb759eb pathlib2==2.3.2 --hash=sha256:90173e12465846173da76c62892b238c14a2a0e17aae580933041004fc01b713 diff --git a/requirements/dev-bookworm-requirements.txt b/requirements/dev-bookworm-requirements.txt index f793e81c6..dd9446301 100644 --- a/requirements/dev-bookworm-requirements.txt +++ b/requirements/dev-bookworm-requirements.txt @@ -187,6 +187,10 @@ isort==5.10.1 \ --hash=sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7 \ --hash=sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951 # via -r requirements/dev-sdw-requirements.in +jinja2==3.0.2 \ + --hash=sha256:827a0e32839ab1600d4eb1c4c33ec5a8edfbc5cb42dafa13b81f182f97784b45 \ + --hash=sha256:8569982d3f0889eed11dd620c706d39b60c36d6d25843961f33f77fb6bc6b20c + # via -r requirements/requirements.in jsonschema==4.17.0 \ --hash=sha256:5bfcf2bca16a087ade17e02b282d34af7ccd749ef76241e7f9bd7c0cb8a9424d \ --hash=sha256:f660066c3966db7d6daeaea8a75e0b68237a48e51cf49882087757bb59916248 @@ -241,6 +245,7 @@ markupsafe==2.1.1 \ # via # -r requirements/dev-sdw-requirements.in # -r requirements/requirements.in + # jinja2 # mako mccabe==0.7.0 \ --hash=sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325 \ diff --git a/requirements/dev-bullseye-requirements.txt b/requirements/dev-bullseye-requirements.txt index 665d615ae..0358cb769 100644 --- a/requirements/dev-bullseye-requirements.txt +++ b/requirements/dev-bullseye-requirements.txt @@ -187,6 +187,10 @@ isort==5.10.1 \ --hash=sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7 \ --hash=sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951 # via -r requirements/dev-sdw-requirements.in +jinja2==3.0.2 \ + --hash=sha256:827a0e32839ab1600d4eb1c4c33ec5a8edfbc5cb42dafa13b81f182f97784b45 \ + --hash=sha256:8569982d3f0889eed11dd620c706d39b60c36d6d25843961f33f77fb6bc6b20c + # via -r requirements/requirements.in jsonschema==4.17.0 \ --hash=sha256:5bfcf2bca16a087ade17e02b282d34af7ccd749ef76241e7f9bd7c0cb8a9424d \ --hash=sha256:f660066c3966db7d6daeaea8a75e0b68237a48e51cf49882087757bb59916248 @@ -241,6 +245,7 @@ markupsafe==2.1.1 \ # via # -r requirements/dev-sdw-requirements.in # -r requirements/requirements.in + # jinja2 # mako mccabe==0.7.0 \ --hash=sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325 \ diff --git a/requirements/dev-sdw-requirements.txt b/requirements/dev-sdw-requirements.txt index 115588f94..b42ea542c 100644 --- a/requirements/dev-sdw-requirements.txt +++ b/requirements/dev-sdw-requirements.txt @@ -187,6 +187,10 @@ isort==5.10.1 \ --hash=sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7 \ --hash=sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951 # via -r requirements/dev-sdw-requirements.in +jinja2==3.0.2 \ + --hash=sha256:827a0e32839ab1600d4eb1c4c33ec5a8edfbc5cb42dafa13b81f182f97784b45 \ + --hash=sha256:8569982d3f0889eed11dd620c706d39b60c36d6d25843961f33f77fb6bc6b20c + # via -r requirements/requirements.in jsonschema==4.17.0 \ --hash=sha256:5bfcf2bca16a087ade17e02b282d34af7ccd749ef76241e7f9bd7c0cb8a9424d \ --hash=sha256:f660066c3966db7d6daeaea8a75e0b68237a48e51cf49882087757bb59916248 @@ -241,6 +245,7 @@ markupsafe==2.1.1 \ # via # -r requirements/dev-sdw-requirements.in # -r requirements/requirements.in + # jinja2 # mako mccabe==0.7.0 \ --hash=sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325 \ diff --git a/requirements/requirements.in b/requirements/requirements.in index a66a8b6c0..d843971a1 100644 --- a/requirements/requirements.in +++ b/requirements/requirements.in @@ -13,3 +13,4 @@ securedrop-sdk==0.4.0 six==1.11.0 sqlalchemy==1.3.3 urllib3>=1.26.5 +jinja2==3.0.2 # per freedomofpress/securedrop#4829953 diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 55005922e..c00d69ebd 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -28,6 +28,10 @@ idna==3.2 \ # via # -r requirements/requirements.in # requests +jinja2==3.0.2 \ + --hash=sha256:827a0e32839ab1600d4eb1c4c33ec5a8edfbc5cb42dafa13b81f182f97784b45 \ + --hash=sha256:8569982d3f0889eed11dd620c706d39b60c36d6d25843961f33f77fb6bc6b20c + # via -r requirements/requirements.in mako==1.2.2 \ --hash=sha256:3724869b363ba630a272a5f89f68c070352137b8fd1757650017b7e06fda163f \ --hash=sha256:8efcb8004681b5f71d09c983ad5a9e6f5c40601a6ec469148753292abc0da534 @@ -91,6 +95,7 @@ markupsafe==2.0.1 \ --hash=sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872 # via # -r requirements/requirements.in + # jinja2 # mako pathlib2==2.3.2 \ --hash=sha256:8eb170f8d0d61825e09a95b38be068299ddeda82f35e96c3301a8a5e7604cb83 \ diff --git a/securedrop_client/conversation/__init__.py b/securedrop_client/conversation/__init__.py new file mode 100644 index 000000000..9fff1e925 --- /dev/null +++ b/securedrop_client/conversation/__init__.py @@ -0,0 +1 @@ +from .transcript import Transcript # noqa: F401 diff --git a/securedrop_client/conversation/transcript/__init__.py b/securedrop_client/conversation/transcript/__init__.py new file mode 100644 index 000000000..9fff1e925 --- /dev/null +++ b/securedrop_client/conversation/transcript/__init__.py @@ -0,0 +1 @@ +from .transcript import Transcript # noqa: F401 diff --git a/securedrop_client/conversation/transcript/items/__init__.py b/securedrop_client/conversation/transcript/items/__init__.py new file mode 100644 index 000000000..94c5de118 --- /dev/null +++ b/securedrop_client/conversation/transcript/items/__init__.py @@ -0,0 +1,2 @@ +from .factory import transcribe # noqa: F401 +from .item import Item # noqa: F401 diff --git a/securedrop_client/conversation/transcript/items/factory.py b/securedrop_client/conversation/transcript/items/factory.py new file mode 100644 index 000000000..723d68293 --- /dev/null +++ b/securedrop_client/conversation/transcript/items/factory.py @@ -0,0 +1,16 @@ +from typing import Optional + +from securedrop_client import db as database + +from .file import File +from .item import Item +from .message import Message + + +def transcribe(record: database.Base) -> Optional[Item]: + if isinstance(record, database.Message) or isinstance(record, database.Reply): + return Message(record) + if isinstance(record, database.File): + return File(record) + else: + return None diff --git a/securedrop_client/conversation/transcript/items/file.py b/securedrop_client/conversation/transcript/items/file.py new file mode 100644 index 000000000..261655a82 --- /dev/null +++ b/securedrop_client/conversation/transcript/items/file.py @@ -0,0 +1,13 @@ +from securedrop_client import db as database + +from .item import Item + + +class File(Item): + type = "file" + + def __init__(self, record: database.File): + super().__init__() + + self.filename = record.filename + self.sender = record.source.journalist_designation diff --git a/securedrop_client/conversation/transcript/items/item.py b/securedrop_client/conversation/transcript/items/item.py new file mode 100644 index 000000000..d78082164 --- /dev/null +++ b/securedrop_client/conversation/transcript/items/item.py @@ -0,0 +1,11 @@ +from typing import Optional + + +class Item: + """ + A transcript item. + + Transcript items must define their `type` to be rendered by the transcript template. + """ + + type: Optional[str] = None diff --git a/securedrop_client/conversation/transcript/items/message.py b/securedrop_client/conversation/transcript/items/message.py new file mode 100644 index 000000000..6bb83cc5a --- /dev/null +++ b/securedrop_client/conversation/transcript/items/message.py @@ -0,0 +1,19 @@ +from typing import Union + +from securedrop_client import db as database + +from .item import Item + + +class Message(Item): + type = "message" + + def __init__(self, record: Union[database.Message, database.Reply]): + super().__init__() + + self.content = record.content + + if isinstance(record, database.Message): + self.sender = record.source.journalist_designation + else: + self.sender = record.journalist.username diff --git a/securedrop_client/conversation/transcript/templates/transcript.txt.jinja b/securedrop_client/conversation/transcript/templates/transcript.txt.jinja new file mode 100644 index 000000000..9953a4e58 --- /dev/null +++ b/securedrop_client/conversation/transcript/templates/transcript.txt.jinja @@ -0,0 +1,16 @@ +{% if items|length <= 0 %}{% trans %}No messages.{% endtrans %}{% else %} + {% for item in items %} + {% if item.type == "message" %} + {% if loop.changed(item.sender) %} +{% trans sender=item.sender %}{{ sender }} wrote:{% endtrans +%} + {% endif %} +{{ item.content }} + {% elif item.type == "file" %} +{% trans sender=item.sender %}{{ sender }} sent:{% endtrans +%} +{% trans filename=item.filename %}File: {{ filename }}{% endtrans +%} + {% endif %} + {% if not loop.last %} +------ + {% endif %} + {% endfor %} +{% endif %} diff --git a/securedrop_client/conversation/transcript/transcript.py b/securedrop_client/conversation/transcript/transcript.py new file mode 100644 index 000000000..7ea5f8058 --- /dev/null +++ b/securedrop_client/conversation/transcript/transcript.py @@ -0,0 +1,37 @@ +import gettext +from typing import Optional + +from jinja2 import Environment, PackageLoader, select_autoescape + +from securedrop_client import db as database + +from .items import Item +from .items import transcribe as transcribe_item + +env = Environment( + loader=PackageLoader("securedrop_client.conversation.transcript"), + autoescape=select_autoescape(), + extensions=["jinja2.ext.i18n"], + # Since our plain-text templates have literal whitespace: + lstrip_blocks=True, + trim_blocks=True, +) +env.install_gettext_translations(gettext) # type: ignore [attr-defined] + + +def transcribe(record: database.Base) -> Optional[Item]: + return transcribe_item(record) + + +class Transcript: + def __init__(self, conversation: database.Source) -> None: + self._items = list( + filter( + lambda record: record is not None and record.type is not None, + [transcribe(record) for record in conversation.collection], + ) + ) + self._template = env.get_template("transcript.txt.jinja") + + def __str__(self) -> str: + return self._template.render(items=self._items) diff --git a/securedrop_client/locale/messages.pot b/securedrop_client/locale/messages.pot index b59d83948..f2bd680f7 100644 --- a/securedrop_client/locale/messages.pot +++ b/securedrop_client/locale/messages.pot @@ -61,6 +61,21 @@ msgstr "" msgid "Failed to delete source at server" msgstr "" +msgid "No messages." +msgstr "" + +#, python-format +msgid "%(sender)s wrote:" +msgstr "" + +#, python-format +msgid "%(sender)s sent:" +msgstr "" + +#, python-format +msgid "File: %(filename)s" +msgstr "" + msgid "Download All Files" msgstr "" diff --git a/tests/test_conversation.py b/tests/test_conversation.py new file mode 100644 index 000000000..d03daed14 --- /dev/null +++ b/tests/test_conversation.py @@ -0,0 +1,127 @@ +import unittest +from datetime import datetime +from textwrap import dedent + +from securedrop_client import conversation +from securedrop_client import db as database + + +class TestConversationTranscript(unittest.TestCase): + def setUp(self): + + source = database.Source( + journalist_designation="happy-bird", + ) + files = [ + database.File( + filename="4-memo.pdf.gpg", + is_downloaded=True, + ), + database.File( + filename="9-memo.zip.gpg", + is_downloaded=True, + ), + ] + messages = [ + database.Message( + filename="1-message.gpg", + is_downloaded=True, + content="Hello! I think this is newsworthy: ...", + ), + database.Message( + filename="6-message.gpg", + is_downloaded=True, + content="I can send you more if you're interested.", + ), + database.Message( + filename="5-message.gpg", + is_downloaded=True, + content="Here is a document with details!", + ), + database.Message( + filename="8-message.gpg", + is_downloaded=True, + content="Sure.", + ), + ] + interested_journalist = database.User(username="interested-journalist") + other_journalist = database.User(username="other-journalist") + replies = [ + database.Reply( + journalist=interested_journalist, + filename="2-reply.gpg", + is_downloaded=True, + content=dedent( + """\ + Thank you for the tip! + + Can you tell me more about... ? + """ + ), + ), + database.Reply( + journalist=interested_journalist, + filename="3-reply.gpg", + is_downloaded=True, + content=dedent( + """\ + Do you have proof of...? + """ + ), + ), + database.Reply( + journalist=other_journalist, + filename="7-reply.gpg", + is_downloaded=True, + content=dedent("Yes, the document you sent was useful, I'd love to see more."), + ), + ] + draft_reply = database.DraftReply( + content="Let me think...", + file_counter=2, + timestamp=datetime.now(), + ) + source.files = files + source.messages = messages + source.replies = replies + source.draftreplies = [draft_reply] + + self._source = source + + def test_indicates_explicitly_absence_of_messages(self): + source = database.Source() + assert str(conversation.Transcript(source)) == "No messages." + + def test_renders_all_messages(self): + assert str(conversation.Transcript(self._source)) == dedent( + """\ + happy-bird wrote: + Hello! I think this is newsworthy: ... + ------ + interested-journalist wrote: + Thank you for the tip! + + Can you tell me more about... ? + + ------ + Do you have proof of...? + + ------ + happy-bird sent: + File: 4-memo.pdf.gpg + ------ + happy-bird wrote: + Here is a document with details! + ------ + I can send you more if you're interested. + ------ + other-journalist wrote: + Yes, the document you sent was useful, I'd love to see more. + ------ + happy-bird wrote: + Sure. + ------ + happy-bird sent: + File: 9-memo.zip.gpg + """ + )