From eedf48dc6ababec01dce9c4f6f14ee16e3caadf2 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Mon, 17 Nov 2025 16:06:33 +1100 Subject: [PATCH] Add ability to send a bug report from within the app --- CHANGELOG.md | 1 + README.md | 3 +- bouquin/bug_report_dialog.py | 131 +++++++++++++++++++++ bouquin/locales/en.json | 9 +- bouquin/main_window.py | 11 +- poetry.lock | 2 +- pyproject.toml | 1 + tests/test_bug_report_dialog.py | 195 ++++++++++++++++++++++++++++++++ tests/test_main_window.py | 17 +-- 9 files changed, 345 insertions(+), 25 deletions(-) create mode 100644 bouquin/bug_report_dialog.py create mode 100644 tests/test_bug_report_dialog.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c479bb..a50f185 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ * Add weekday letters on left axis of Statistics page * Add the ability to choose the database path at startup + * Add in-app bug report functionality # 0.3.1 diff --git a/README.md b/README.md index c8ffec2..2df84cf 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,8 @@ for SQLite3. This means that the underlying database for the notebook is encrypt To increase security, the SQLCipher key is requested when the app is opened, and is not written to disk unless the user configures it to be in the settings. -There is deliberately no network connectivity or syncing intended. +There is deliberately no network connectivity or syncing intended, other than the option to send a bug +report from within the app. ## Screenshots diff --git a/bouquin/bug_report_dialog.py b/bouquin/bug_report_dialog.py new file mode 100644 index 0000000..6285c77 --- /dev/null +++ b/bouquin/bug_report_dialog.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +import importlib.metadata +from pathlib import Path + +import requests + +from PySide6.QtWidgets import ( + QDialog, + QVBoxLayout, + QLabel, + QTextEdit, + QDialogButtonBox, + QMessageBox, +) + +from . import strings + + +BUG_REPORT_HOST = "https://nr.mig5.net" +ROUTE = "forms/bouquin/bugs" + + +class BugReportDialog(QDialog): + """ + Dialog to collect a bug report + """ + + MAX_CHARS = 5000 + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle(strings._("report_a_bug")) + + self._attachment_path: Path | None = None + + layout = QVBoxLayout(self) + + header = QLabel(strings._("bug_report_explanation")) + header.setWordWrap(True) + layout.addWidget(header) + + self.text_edit = QTextEdit() + self.text_edit.setPlaceholderText(strings._("bug_report_placeholder")) + layout.addWidget(self.text_edit) + + self.text_edit.textChanged.connect(self._enforce_max_length) + + # Buttons: Cancel / Send + button_box = QDialogButtonBox(QDialogButtonBox.Cancel) + self.send_button = button_box.addButton( + strings._("send"), QDialogButtonBox.AcceptRole + ) + button_box.accepted.connect(self._send) + button_box.rejected.connect(self.reject) + layout.addWidget(button_box) + + self.text_edit.setFocus() + + # ------------Helpers ------------ # + + def _enforce_max_length(self): + text = self.text_edit.toPlainText() + if len(text) <= self.MAX_CHARS: + return + + # Remember cursor position + cursor = self.text_edit.textCursor() + pos = cursor.position() + + # Trim and restore without re-entering this slot + self.text_edit.blockSignals(True) + self.text_edit.setPlainText(text[: self.MAX_CHARS]) + self.text_edit.blockSignals(False) + + # Clamp cursor position to end of text + if pos > self.MAX_CHARS: + pos = self.MAX_CHARS + + cursor.setPosition(pos) + self.text_edit.setTextCursor(cursor) + + def _send(self): + text = self.text_edit.toPlainText().strip() + if not text: + QMessageBox.warning( + self, + strings._("report_a_bug"), + strings._("bug_report_empty"), + ) + return + + # Get current app version + try: + version = importlib.metadata.version("bouquin") + except importlib.metadata.PackageNotFoundError: + version = "unknown" + + payload: dict[str, str] = { + "message": text, + "version": version, + } + + # POST as JSON + try: + resp = requests.post( + f"{BUG_REPORT_HOST}/{ROUTE}", + json=payload, + timeout=10, + ) + except Exception as e: + QMessageBox.critical( + self, + strings._("report_a_bug"), + strings._("bug_report_send_failed") + f"\n{e}", + ) + return + + if resp.status_code == 201: + QMessageBox.information( + self, + strings._("report_a_bug"), + strings._("bug_report_sent_ok"), + ) + self.accept() + else: + QMessageBox.critical( + self, + strings._("report_a_bug"), + strings._("bug_report_send_failed") + f" (HTTP {resp.status_code})", + ) diff --git a/bouquin/locales/en.json b/bouquin/locales/en.json index 69af68a..6cd9270 100644 --- a/bouquin/locales/en.json +++ b/bouquin/locales/en.json @@ -148,6 +148,11 @@ "stats_metric_words": "Words", "stats_metric_revisions": "Revisions", "stats_no_data": "No statistics available yet.", - "select_notebook": "Select notebook" - + "select_notebook": "Select notebook", + "bug_report_explanation": "Describe what went wrong, what you expected to happen, and any steps to reproduce.\n\nWe do not collect anything else except the Bouquin version number.\n\nIf you wish to be contacted, please leave contact information.\n\nYour request will be sent over HTTPS.", + "bug_report_placeholder": "Type your bug report here", + "bug_report_empty": "Please enter some details about the bug before sending.", + "bug_report_send_failed": "Could not send bug report.", + "bug_report_sent_ok": "Bug report sent. Thank you!", + "send": "Send" } diff --git a/bouquin/main_window.py b/bouquin/main_window.py index efdbfd8..da2339d 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -56,6 +56,7 @@ from .search import Search from .settings import APP_ORG, APP_NAME, load_db_config, save_db_config from .settings_dialog import SettingsDialog from .statistics_dialog import StatisticsDialog +from .bug_report_dialog import BugReportDialog from . import strings from .tags_widget import PageTagsWidget from .toolbar import ToolBar @@ -1292,14 +1293,8 @@ class MainWindow(QMainWindow): ) def _open_bugs(self): - url_str = "https://nr.mig5.net/forms/mig5/contact" - url = QUrl.fromUserInput(url_str) - if not QDesktopServices.openUrl(url): - QMessageBox.warning( - self, - strings._("report_a_bug"), - strings._("couldnt_open") + url.toDisplayString(), - ) + dlg = BugReportDialog(self) + dlg.exec() def _open_version(self): version = importlib.metadata.version("bouquin") diff --git a/poetry.lock b/poetry.lock index 6382937..5f6ec39 100644 --- a/poetry.lock +++ b/poetry.lock @@ -756,4 +756,4 @@ zstd = ["zstandard (>=0.18.0)"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<3.14" -content-hash = "8c65ccc55e84371f8695117dcd01ca9ad2d78b159327045eced824e5f425a7d0" +content-hash = "cdb45b4ed472b5fd77e4c5b6ccd88ad4402b4e5473279627177cccb960a29f27" diff --git a/pyproject.toml b/pyproject.toml index ebf80ef..631d122 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ include = ["bouquin/locales/*.json"] python = ">=3.9,<3.14" pyside6 = ">=6.8.1,<7.0.0" sqlcipher3-wheels = "^0.5.5.post0" +requests = "^2.32.5" [tool.poetry.scripts] bouquin = "bouquin.__main__:main" diff --git a/tests/test_bug_report_dialog.py b/tests/test_bug_report_dialog.py new file mode 100644 index 0000000..7488f32 --- /dev/null +++ b/tests/test_bug_report_dialog.py @@ -0,0 +1,195 @@ +import bouquin.bug_report_dialog as bugmod +from bouquin.bug_report_dialog import BugReportDialog + + +def test_bug_report_truncates_text_to_max_chars(qtbot): + dlg = BugReportDialog() + qtbot.addWidget(dlg) + dlg.show() + + max_chars = getattr(dlg, "MAX_CHARS", 5000) + + # Make a string longer than the allowed maximum + long_text = "x" * (max_chars + 50) + + # Setting the text should trigger textChanged -> _enforce_max_length + dlg.text_edit.setPlainText(long_text) + + # Let Qt process the signal/slot if needed + qtbot.wait(10) + + current = dlg.text_edit.toPlainText() + assert len(current) == max_chars + assert current == long_text[:max_chars] + + +def test_bug_report_allows_up_to_max_chars_unchanged(qtbot): + dlg = BugReportDialog() + qtbot.addWidget(dlg) + dlg.show() + + max_chars = getattr(dlg, "MAX_CHARS", 5000) + exact_text = "y" * max_chars + + dlg.text_edit.setPlainText(exact_text) + qtbot.wait(10) + + current = dlg.text_edit.toPlainText() + # Should not be trimmed if it's exactly the limit + assert len(current) == max_chars + assert current == exact_text + + +def test_bug_report_send_success_201_shows_info_and_accepts(qtbot, monkeypatch): + dlg = BugReportDialog() + qtbot.addWidget(dlg) + dlg.show() + + # Non-empty message so we don't hit the "empty" warning branch + dlg.text_edit.setPlainText("Hello, something broke.") + qtbot.wait(10) + + # Make version() deterministic + def fake_version(pkg_name): + assert pkg_name == "bouquin" + return "1.2.3" + + monkeypatch.setattr( + bugmod.importlib.metadata, "version", fake_version, raising=True + ) + + # Capture the POST call and fake a 201 Created response + calls = {} + + class DummyResp: + status_code = 201 + + def fake_post(url, json=None, timeout=None): + calls["url"] = url + calls["json"] = json + calls["timeout"] = timeout + return DummyResp() + + monkeypatch.setattr(bugmod.requests, "post", fake_post, raising=True) + + # Capture information / critical message boxes + info_called = {} + crit_called = {} + + def fake_info(parent, title, text, *a, **k): + info_called["title"] = title + info_called["text"] = str(text) + return 0 + + def fake_critical(parent, title, text, *a, **k): + crit_called["title"] = title + crit_called["text"] = str(text) + return 0 + + monkeypatch.setattr( + bugmod.QMessageBox, "information", staticmethod(fake_info), raising=True + ) + monkeypatch.setattr( + bugmod.QMessageBox, "critical", staticmethod(fake_critical), raising=True + ) + + # Don't actually close the dialog in the test; just record that accept() was called + accepted = {} + + def fake_accept(): + accepted["called"] = True + + dlg.accept = fake_accept + + # Call the send logic directly + dlg._send() + + # --- Assertions --------------------------------------------------------- + + # POST was called with the expected URL and JSON payload + assert calls["url"] == f"{bugmod.BUG_REPORT_HOST}/{bugmod.ROUTE}" + assert calls["json"]["message"] == "Hello, something broke." + assert calls["json"]["version"] == "1.2.3" + # No attachment fields expected any more + + # Success path: information dialog shown, critical not shown + assert "title" in info_called + assert "text" in info_called + assert crit_called == {} + + # Dialog accepted + assert accepted.get("called") is True + + +def test_bug_report_send_failure_non_201_shows_critical_and_not_accepted( + qtbot, monkeypatch +): + dlg = BugReportDialog() + qtbot.addWidget(dlg) + dlg.show() + + dlg.text_edit.setPlainText("Broken again.") + qtbot.wait(10) + + # Stub version() again + monkeypatch.setattr( + bugmod.importlib.metadata, + "version", + lambda name: "9.9.9", + raising=True, + ) + + # Fake a non-201 response (e.g. 500) + calls = {} + + class DummyResp: + status_code = 500 + + def fake_post(url, json=None, timeout=None): + calls["url"] = url + calls["json"] = json + calls["timeout"] = timeout + return DummyResp() + + monkeypatch.setattr(bugmod.requests, "post", fake_post, raising=True) + + info_called = {} + crit_called = {} + + def fake_info(parent, title, text, *a, **k): + info_called["title"] = title + info_called["text"] = str(text) + return 0 + + def fake_critical(parent, title, text, *a, **k): + crit_called["title"] = title + crit_called["text"] = str(text) + return 0 + + monkeypatch.setattr( + bugmod.QMessageBox, "information", staticmethod(fake_info), raising=True + ) + monkeypatch.setattr( + bugmod.QMessageBox, "critical", staticmethod(fake_critical), raising=True + ) + + accepted = {} + + def fake_accept(): + accepted["called"] = True + + dlg.accept = fake_accept + + dlg._send() + + # POST still called with JSON payload + assert calls["url"] == f"{bugmod.BUG_REPORT_HOST}/{bugmod.ROUTE}" + assert calls["json"]["message"] == "Broken again." + assert calls["json"]["version"] == "9.9.9" + + # Failure path: critical dialog shown, information not shown + assert crit_called # non-empty + assert info_called == {} + + # Dialog should NOT be accepted on failure + assert accepted.get("called") is not True diff --git a/tests/test_main_window.py b/tests/test_main_window.py index 75314d1..e7eaed3 100644 --- a/tests/test_main_window.py +++ b/tests/test_main_window.py @@ -80,7 +80,7 @@ def test_load_yesterday_todos_moves_items(qtbot, app, tmp_db_cfg, fresh_db): assert "carry me" not in y_txt or "- [ ]" not in y_txt -def test_open_docs_and_bugs_warning(qtbot, app, fresh_db, tmp_path, monkeypatch): +def test_open_docs_warning(qtbot, app, fresh_db, tmp_path, monkeypatch): themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) w = MainWindow(themes=themes) qtbot.addWidget(w) @@ -101,16 +101,12 @@ def test_open_docs_and_bugs_warning(qtbot, app, fresh_db, tmp_path, monkeypatch) t = str(text) if "wiki" in t: called["docs"] = True - if "forms/mig5/contact" in t or "contact" in t: - called["bugs"] = True return 0 monkeypatch.setattr(mwmod, "QMessageBox", DummyMB, raising=True) # capture warnings - # Trigger both actions w._open_docs() - w._open_bugs() - assert called["docs"] and called["bugs"] + assert called["docs"] def test_export_success_and_error(qtbot, app, fresh_db, tmp_path, monkeypatch): @@ -900,9 +896,7 @@ def test_backup_success_and_error(qtbot, tmp_db_cfg, app, monkeypatch, tmp_path) # ---- Help openers (1152-1169) ---- -def test_open_docs_and_bugs_show_warning_on_failure( - qtbot, tmp_db_cfg, app, monkeypatch -): +def test_open_docs_show_warning_on_failure(qtbot, tmp_db_cfg, app, monkeypatch): w = _make_main_window(tmp_db_cfg, app, monkeypatch) qtbot.addWidget(w) @@ -914,17 +908,14 @@ def test_open_docs_and_bugs_show_warning_on_failure( def warn(parent, title, text, *a, **k): if "documentation" in title.lower(): seen["docs"] = True - if "bug" in title.lower(): - seen["bugs"] = True return 0 monkeypatch.setattr( mwmod, "QMessageBox", type("MB", (), {"warning": staticmethod(warn)}) ) w._open_docs() - w._open_bugs() - assert seen["docs"] and seen["bugs"] + assert seen["docs"] def test_open_version(qtbot, tmp_db_cfg, app, monkeypatch):