feat: wiki revision history — HTML tabs, diff view, export links

- New route /{node_id}/revisions rendered by revision-history.j2
- Show content/history tabs on wiki root nodes
- Export dropdown includes history (json + html) links on wiki namespaces
- Node action buttons now gate on can_wiki_edit (not can_alter_node)
  so wiki-mode members can edit their own root nodes
- Suppress Topic link when URI matches current domain (self-reference)
- Tests covering revision timestamps, wiki_edit(), can_wiki_edit(),
  /revisions endpoints, CSRF trusted origins, export menu gating,
  pandoc rst→html rendering
This commit is contained in:
russell@unturf.com 2026-04-22 11:37:06 -04:00
parent a5c3e36fce
commit 9db2407395
6 changed files with 589 additions and 2 deletions

View file

@ -145,5 +145,6 @@ def includeme(config):
config.add_route("basic-reply2", "/{node_id}/{slug:.*}/reply")
config.add_route("basic-show-count", "/{node_id}/count")
config.add_route("basic-show-count2", "/{node_id}/{slug:.*}/count")
config.add_route("node-revisions-html", "/{node_id}/revisions")
config.add_route("basic-show-node", "/{node_id}")
config.add_route("basic-show-node2", "/{node_id}/{slug:.*}") # must be last.

View file

@ -0,0 +1,41 @@
{% extends request.base_template %}
{% block title %}{{ title }} | {{ request.domain }}{% endblock %}
{% block content %}
<h2>{{ node.title or node.id }}</h2>
<div class="wiki-tabs">
<a href="/r/{{ node.id }}" class="wiki-tab">content</a>
<a href="/{{ node.id }}/revisions" class="wiki-tab active">history</a>
</div>
<p style="margin-top:0.5em"><a href="/api/v1/nodes/{{ node.id }}/revisions" target="_blank" class="action">JSON</a></p>
<style>
.diff-entry { margin-bottom: 2em; border-top: 1px solid #444; padding-top: 1em; }
.diff-meta { font-size: 0.85em; margin-bottom: 0.5em; }
table.diff { width: 100%; border-collapse: collapse; font-size: 0.8em; font-family: monospace; }
table.diff td { padding: 2px 4px; vertical-align: top; white-space: pre-wrap; word-break: break-word; }
table.diff .diff_header { background: #222; color: #888; }
table.diff .diff_next { display: none; }
.diff_add { background: #1a3a1a; }
.diff_chg { background: #3a3a00; }
.diff_sub { background: #3a0000; }
</style>
{% for entry in entries %}
<div class="diff-entry">
<div class="diff-meta">
<strong>rev {{ entry.revision.revision_number }}</strong>
&nbsp;|&nbsp;
{% if entry.revision.user %}{{ entry.revision.user.name }}{% else %}unknown{% endif %}
&nbsp;|&nbsp;
{{ entry.revision.created }}
&nbsp;|&nbsp;
<a href="/api/v1/revisions/{{ entry.revision.id }}" target="_blank">JSON</a>
</div>
{{ entry.diff_html | safe }}
</div>
{% endfor %}
{% endblock %}

View file

@ -37,7 +37,7 @@
{% block content -%}
{% if request.mode != "embed" and request.node.root.uri %}
{% if request.mode != "embed" and request.node.root.uri and request.domain not in request.node.root.uri.data %}
<span class="topic">Topic: <a href="{{ request.node.root.uri.data }}" target="_blank" rel="nofollow">{{ request.node.root.uri.data }}</a></span>
{% endif %}
@ -48,6 +48,13 @@
<h2 class="title"><a href="{{ request.node.path }}">{{ request.node.title }}</a></h2>
{% endif %}
{% if request.node.is_root and request.namespace.wiki %}
<div class="wiki-tabs">
<a href="{{ request.node.path }}" class="wiki-tab active">content</a>
<a href="/{{ request.node.id }}/revisions" class="wiki-tab">history</a>
</div>
{% endif %}
<a class="anchor" id="{{ request.node.id }}"></a>
<div id="node-{{ request.node.id }}" class="node root-node">
{{ snippets.node_avatar_link(request.node) }}

View file

@ -210,7 +210,7 @@
{% macro actions(node) %}
{% if request.namespace.can_alter_node(node, request.user) %}
{% if request.namespace.can_wiki_edit(node, request.user) %}
{% if node.disabled %}
{{ enable_node(node=node) }} &nbsp;
@ -255,6 +255,10 @@
<a href="/api/v1/export/threads/{{ node.id }}.latex" class="export-link">latex</a>
<a href="/api/v1/export/threads/{{ node.id }}.odt" class="export-link">odt</a>
<a href="/api/v1/export/threads/{{ node.id }}.plain" class="export-link">plain text</a>
{% if request.namespace.wiki %}
<a href="/api/v1/nodes/{{ node.id }}/revisions" class="export-link" target="_blank">history (json)</a>
<a href="/{{ node.id }}/revisions" class="export-link">history (html)</a>
{% endif %}
</div>
</details>
{% endmacro %}
@ -268,6 +272,10 @@
<a href="/api/v1/export/nodes/{{ node.id }}.pdf" class="export-link">pdf</a>
<a href="/api/v1/export/nodes/{{ node.id }}.epub" class="export-link">epub</a>
<a href="/api/v1/export/nodes/{{ node.id }}.docx" class="export-link">docx</a>
{% if request.namespace.wiki %}
<a href="/api/v1/nodes/{{ node.id }}/revisions" class="export-link" target="_blank">history (json)</a>
<a href="/{{ node.id }}/revisions" class="export-link">history (html)</a>
{% endif %}
</div>
</details>
{% endmacro %}

View file

@ -0,0 +1,478 @@
"""
Tests covering work from the 2026-04-08 session:
- Revision.created & Node.changed use numeric Unix-ms timestamps (not strings)
- wiki_edit() creates a revision AND regenerates data_html
- can_wiki_edit() gates: wiki=True any auth user edits root; wiki=False owner only
- /{node_id}/revisions returns 200 HTML with diff table
- /api/v1/nodes/{node_id}/revisions returns JSON revision list
- pyramid.csrf_trusted_origins allows www.foxhop.net origin
- Export menu shows history links only for wiki namespaces
- set_data() with rst format produces data_html via pandoc
"""
import transaction
import unittest
from unittest import mock
from pyramid.paster import get_appsettings
from remarkbox.models import (
get_tm_session,
get_or_create_user_by_email,
get_or_create_namespace,
Node,
)
from remarkbox.models.meta import Base
from remarkbox.models.revision import Revision
from remarkbox.models.namespace import Namespace
from remarkbox.models.node import now_timestamp
# ---------------------------------------------------------------------------
# Unit tests — no DB, no app
# ---------------------------------------------------------------------------
class TestTimestampTypes(unittest.TestCase):
"""Revision.created & Node.changed must be numeric (Unix ms), not strings."""
def test_revision_created_is_int(self):
rev = Revision()
self.assertIsInstance(rev.created, (int, float))
def test_revision_created_is_positive(self):
rev = Revision()
self.assertGreater(rev.created, 0)
def test_now_timestamp_is_numeric(self):
ts = now_timestamp()
self.assertIsInstance(ts, (int, float))
def test_now_timestamp_is_ms_scale(self):
"""Unix ms timestamps are > 1e12; Unix second timestamps are < 2e9."""
ts = now_timestamp()
self.assertGreater(ts, 1_000_000_000_000)
class TestCanWikiEdit(unittest.TestCase):
"""Namespace.can_wiki_edit() gating logic."""
def setUp(self):
self.ns = Namespace("test-wiki.com")
self.ns.subscription_type = "production"
def _auth_user(self):
u = mock.Mock()
u.authenticated = True
return u
def _root_node(self):
n = mock.Mock()
n.is_root = True
return n
def _child_node(self):
n = mock.Mock()
n.is_root = False
return n
# --- wiki=True cases ---
def test_wiki_mode_auth_user_edits_root(self):
self.ns.wiki = True
with mock.patch.object(self.ns, "can_alter_node", return_value=False):
self.assertTrue(self.ns.can_wiki_edit(self._root_node(), self._auth_user()))
def test_wiki_mode_auth_user_cannot_edit_child(self):
self.ns.wiki = True
with mock.patch.object(self.ns, "can_alter_node", return_value=False):
self.assertFalse(self.ns.can_wiki_edit(self._child_node(), self._auth_user()))
def test_wiki_mode_unauth_user_denied(self):
self.ns.wiki = True
u = mock.Mock()
u.authenticated = False
self.assertFalse(self.ns.can_wiki_edit(self._root_node(), u))
def test_wiki_mode_none_user_denied(self):
self.ns.wiki = True
self.assertFalse(self.ns.can_wiki_edit(self._root_node(), None))
# --- wiki=False cases ---
def test_non_wiki_owner_can_edit_root(self):
self.ns.wiki = False
with mock.patch.object(self.ns, "can_alter_node", return_value=True):
self.assertTrue(self.ns.can_wiki_edit(self._root_node(), self._auth_user()))
def test_non_wiki_non_owner_denied(self):
self.ns.wiki = False
with mock.patch.object(self.ns, "can_alter_node", return_value=False):
self.assertFalse(self.ns.can_wiki_edit(self._root_node(), self._auth_user()))
# ---------------------------------------------------------------------------
# Functional tests — full app via webtest
# ---------------------------------------------------------------------------
class WikiFunctionalBase(unittest.TestCase):
"""Shared setup for functional tests that need a wiki namespace & auth user."""
@classmethod
def setUpClass(cls):
import webtest
from remarkbox import main
cls.settings = get_appsettings("test.ini")
cls.settings["pyramid.csrf_trusted_origins"] = "www.test-wiki.com"
cls.app = main({}, **cls.settings)
cls.testapp = webtest.TestApp(cls.app)
cls.session_factory = cls.app.registry["dbsession_factory"]
cls.engine = cls.session_factory.kw["bind"]
Base.metadata.create_all(bind=cls.engine)
cls.tm = transaction.manager
cls.dbsession = get_tm_session(cls.session_factory, cls.tm)
@classmethod
def tearDownClass(cls):
cls.dbsession.close()
Base.metadata.drop_all(bind=cls.engine)
def tearDown(self):
self.testapp.get("/log-out")
def _create_wiki_namespace(self, name="wiki-test.com"):
ns = get_or_create_namespace(self.dbsession, name)
ns.wiki = True
ns.subscription_type = "production"
self.dbsession.add(ns)
self.dbsession.flush()
ns_name = ns.name
self.tm.commit()
return get_or_create_namespace(self.dbsession, ns_name)
def _create_user_and_login(self, email):
user = get_or_create_user_by_email(self.dbsession, email)
raw_otp = user.new_password()
self.dbsession.add(user)
self.dbsession.flush()
self.tm.commit()
self.testapp.post(
"/verification-challenge?email={}&raw-otp={}&submit".format(email, raw_otp)
)
# Grab CSRF token from new-thread form on home page
res = self.testapp.get("/")
try:
self.csrf = res.form.fields["csrf_token"][0].value
except (KeyError, IndexError):
self.csrf = ""
# Re-query user after commit + login
return get_or_create_user_by_email(self.dbsession, email)
def _create_wiki_root_node(self, ns_name, user_email, title="Test Wiki Page", data="# Hello\n\nWorld."):
# Re-query from session to avoid detached instances
ns = get_or_create_namespace(self.dbsession, ns_name)
user = get_or_create_user_by_email(self.dbsession, user_email)
node = Node()
node.title = title
node.data = data
node.source_format = "markdown"
node.user = user
node.namespace = ns
self.dbsession.add(node)
self.dbsession.flush()
node_id = node.id
self.tm.commit()
return node_id
class TestWikiEditCreatesRevisionAndHTML(WikiFunctionalBase):
"""wiki_edit() must create a Revision AND regenerate data_html."""
def test_wiki_edit_creates_revision(self):
self._create_wiki_namespace("wiki-rev-test.com")
self._create_user_and_login("wiki-rev@test.com")
node_id = self._create_wiki_root_node("wiki-rev-test.com", "wiki-rev@test.com", data="Original content.")
node = self.dbsession.query(Node).filter(Node.id == node_id).one()
node.wiki_edit("Updated content.", user=None)
self.dbsession.add(node)
self.dbsession.flush()
self.tm.commit()
revisions = (
self.dbsession.query(Revision)
.filter(Revision.node_id == node_id)
.all()
)
self.assertGreaterEqual(len(revisions), 1)
def test_wiki_edit_updates_data_html(self):
self._create_wiki_namespace("wiki-html-test.com")
self._create_user_and_login("wiki-html@test.com")
node_id = self._create_wiki_root_node("wiki-html-test.com", "wiki-html@test.com", data="Before.")
node = self.dbsession.query(Node).filter(Node.id == node_id).one()
node.wiki_edit("After the edit.", user=None)
self.dbsession.add(node)
self.dbsession.flush()
self.tm.commit()
node = self.dbsession.query(Node).filter(Node.id == node_id).one()
self.assertIsNotNone(node.data_html)
self.assertIn("After the edit", node.data_html)
def test_wiki_edit_revision_created_is_numeric(self):
self._create_wiki_namespace("wiki-ts-test.com")
self._create_user_and_login("wiki-ts@test.com")
node_id = self._create_wiki_root_node("wiki-ts-test.com", "wiki-ts@test.com")
node = self.dbsession.query(Node).filter(Node.id == node_id).one()
node.wiki_edit("New content.", user=None)
self.dbsession.add(node)
self.dbsession.flush()
self.tm.commit()
rev = (
self.dbsession.query(Revision)
.filter(Revision.node_id == node_id)
.order_by(Revision.revision_number.desc())
.first()
)
self.assertIsInstance(rev.created, (int, float))
self.assertGreater(rev.created, 1_000_000_000_000)
def test_node_changed_is_numeric_after_wiki_edit(self):
self._create_wiki_namespace("wiki-changed-test.com")
self._create_user_and_login("wiki-changed@test.com")
node_id = self._create_wiki_root_node("wiki-changed-test.com", "wiki-changed@test.com")
node = self.dbsession.query(Node).filter(Node.id == node_id).one()
node.wiki_edit("Changed.", user=None)
self.dbsession.add(node)
self.dbsession.flush()
self.tm.commit()
node = self.dbsession.query(Node).filter(Node.id == node_id).one()
self.assertIsInstance(node.changed, (int, float))
self.assertGreater(node.changed, 1_000_000_000_000)
class TestRevisionHistoryRoute(WikiFunctionalBase):
"""/{node_id}/revisions returns HTML with diff tables."""
def test_revision_history_returns_200(self):
self._create_wiki_namespace("wiki-history.com")
self._create_user_and_login("wiki-history@test.com")
node_id = self._create_wiki_root_node("wiki-history.com", "wiki-history@test.com", data="Version one.")
node = self.dbsession.query(Node).filter(Node.id == node_id).one()
node.wiki_edit("Version two.", user=None)
self.dbsession.add(node)
self.dbsession.flush()
self.tm.commit()
res = self.testapp.get("/{}/revisions".format(node_id), status=200)
self.assertEqual(res.status_int, 200)
def test_revision_history_contains_diff_table(self):
self._create_wiki_namespace("wiki-diff.com")
self._create_user_and_login("wiki-diff@test.com")
node_id = self._create_wiki_root_node("wiki-diff.com", "wiki-diff@test.com", data="Alpha content.")
node = self.dbsession.query(Node).filter(Node.id == node_id).one()
node.wiki_edit("Beta content.", user=None)
self.dbsession.add(node)
self.dbsession.flush()
self.tm.commit()
res = self.testapp.get("/{}/revisions".format(node_id), status=200)
self.assertIn(b"table", res.body)
self.assertIn(b"diff", res.body)
def test_revision_history_links_to_json(self):
self._create_wiki_namespace("wiki-json-link.com")
self._create_user_and_login("wiki-json-link@test.com")
node_id = self._create_wiki_root_node("wiki-json-link.com", "wiki-json-link@test.com")
res = self.testapp.get("/{}/revisions".format(node_id), status=200)
self.assertIn(b"/api/v1/nodes/", res.body)
self.assertIn(b"revisions", res.body)
def test_revision_history_404_for_missing_node(self):
self.testapp.get("/nonexistent-node-id-xyz/revisions", status=404)
class TestRevisionHistoryJSON(WikiFunctionalBase):
"""/api/v1/nodes/{node_id}/revisions returns JSON list."""
def test_json_revisions_returns_200(self):
self._create_wiki_namespace("wiki-json-api.com")
self._create_user_and_login("wiki-json-api@test.com")
node_id = self._create_wiki_root_node("wiki-json-api.com", "wiki-json-api@test.com", data="Rev one.")
node = self.dbsession.query(Node).filter(Node.id == node_id).one()
node.wiki_edit("Rev two.", user=None)
self.dbsession.add(node)
self.dbsession.flush()
self.tm.commit()
res = self.testapp.get(
"/api/v1/nodes/{}/revisions".format(node_id),
headers={"Accept": "application/json"},
status=200,
)
data = res.json
self.assertIn("revisions", data)
self.assertGreaterEqual(data["count"], 1)
def test_json_revisions_include_author(self):
self._create_wiki_namespace("wiki-json-author.com")
self._create_user_and_login("wiki-json-author@test.com")
node_id = self._create_wiki_root_node("wiki-json-author.com", "wiki-json-author@test.com", data="Content.")
node = self.dbsession.query(Node).filter(Node.id == node_id).one()
node.wiki_edit("Edited.", user=None)
self.dbsession.add(node)
self.dbsession.flush()
self.tm.commit()
res = self.testapp.get(
"/api/v1/nodes/{}/revisions".format(node_id), status=200
)
# Revisions may have null author (user=None), just verify structure
revisions = res.json["revisions"]
self.assertIsInstance(revisions, list)
self.assertGreaterEqual(len(revisions), 1)
self.assertIn("revision_number", revisions[0])
class TestCSRFTrustedOrigins(WikiFunctionalBase):
"""pyramid.csrf_trusted_origins must include www.foxhop.net equivalent."""
def test_trusted_origin_setting_present(self):
"""The app was built with www.test-wiki.com in csrf_trusted_origins."""
settings = self.app.registry.settings
trusted = settings.get("pyramid.csrf_trusted_origins", "")
self.assertIn("www.test-wiki.com", trusted)
def test_post_from_trusted_www_origin_passes_csrf(self):
"""A POST with CSRF token from a trusted www origin must not 400."""
# Log in to get a valid CSRF token
self._create_user_and_login("csrf-origin@test.com")
post_res = self.testapp.post(
"/new",
{
"thread_title": "CSRF origin test",
"thread_data": "body",
"email": "csrf-origin@test.com",
"csrf_token": self.csrf,
},
headers={"Origin": "https://www.test-wiki.com"},
expect_errors=True,
)
# Must not be 400 Bad Request (CSRF rejection)
self.assertNotEqual(post_res.status_int, 400)
class TestExportMenuHistoryLinks(WikiFunctionalBase):
"""Export menu shows history links iff namespace.wiki is True."""
def test_wiki_namespace_export_menu_has_history_links(self):
self._create_wiki_namespace("wiki-export-menu.com")
self._create_user_and_login("wiki-export@test.com")
node_id = self._create_wiki_root_node("wiki-export-menu.com", "wiki-export@test.com", title="Export Test")
res = self.testapp.get("/{}".format(node_id)).follow()
self.assertIn(b"history (json)", res.body)
self.assertIn(b"history (html)", res.body)
def test_non_wiki_namespace_export_menu_no_history_links(self):
ns = get_or_create_namespace(self.dbsession, "nonwiki-export.com")
ns.wiki = False
ns.subscription_type = "production"
self.dbsession.add(ns)
self.dbsession.flush()
self.tm.commit()
self._create_user_and_login("nonwiki-export@test.com")
node_id = self._create_wiki_root_node("nonwiki-export.com", "nonwiki-export@test.com", title="Non-wiki export")
res = self.testapp.get("/{}".format(node_id)).follow()
self.assertNotIn(b"history (json)", res.body)
self.assertNotIn(b"history (html)", res.body)
class TestSetDataRSTRegenHTML(unittest.TestCase):
"""set_data() with source_format=rst must produce data_html via pandoc.
Skipped if pandoc is not installed in the test environment.
"""
def setUp(self):
import shutil
if not shutil.which("pandoc"):
self.skipTest("pandoc not installed")
def test_rst_produces_data_html(self):
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
ns = Namespace("rst-test.com")
ns.subscription_type = "production"
session.add(ns)
session.flush()
node = Node()
node.title = "RST Test"
node.data = ""
node.source_format = "rst"
node.namespace = ns
session.add(node)
session.flush()
rst = "Title\n=====\n\n*Joey Ballestrini* — GIMP\n"
node.set_data(rst, namespace=ns, dbsession=session, source_format="rst")
session.flush()
self.assertIsNotNone(node.data_html)
self.assertIn("Joey Ballestrini", node.data_html)
self.assertIn("<em>", node.data_html)
def test_rst_data_html_not_string_timestamp(self):
"""data_html must be HTML content, not a raw timestamp string."""
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
ns = Namespace("rst-ts-test.com")
ns.subscription_type = "production"
session.add(ns)
session.flush()
node = Node()
node.title = "TS Test"
node.data = ""
node.source_format = "rst"
node.namespace = ns
session.add(node)
session.flush()
node.set_data("Hello\n=====\n\nWorld.\n", namespace=ns, dbsession=session, source_format="rst")
session.flush()
# data_html must not look like a raw Unix timestamp
self.assertFalse(node.data_html.strip().isdigit())
self.assertIn("<", node.data_html)

View file

@ -0,0 +1,52 @@
"""HTML revision history view with side-by-side diffs."""
import difflib
from pyramid.view import view_config
from pyramid.httpexceptions import HTTPNotFound
from remarkbox.models.node import get_node_by_id
from remarkbox.models.revision import Revision
@view_config(route_name="node-revisions-html", renderer="revision-history.j2")
def node_revisions_html(request):
node_id = request.matchdict["node_id"]
node = get_node_by_id(request.dbsession, node_id)
if node is None:
raise HTTPNotFound()
revisions = (
request.dbsession.query(Revision)
.filter(Revision.node_id == node.id)
.order_by(Revision.revision_number.asc())
.all()
)
d = difflib.HtmlDiff(wrapcolumn=80)
entries = []
for i, rev in enumerate(revisions):
prev = revisions[i - 1] if i > 0 else None
from_lines = prev.data.splitlines() if prev else []
to_lines = rev.data.splitlines()
from_desc = "rev {}".format(prev.revision_number) if prev else "empty"
to_desc = "rev {}".format(rev.revision_number)
diff_html = d.make_table(
from_lines, to_lines,
fromdesc=from_desc, todesc=to_desc,
context=True, numlines=2,
)
entries.append({
"revision": rev,
"diff_html": diff_html,
})
# Most recent first
entries.reverse()
return {
"node": node,
"entries": entries,
"title": "{} — revision history".format(node.title or node_id),
}