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:
parent
a5c3e36fce
commit
9db2407395
6 changed files with 589 additions and 2 deletions
|
|
@ -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.
|
||||
|
|
|
|||
41
remarkbox/templates/revision-history.j2
Normal file
41
remarkbox/templates/revision-history.j2
Normal 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>
|
||||
|
|
||||
{% if entry.revision.user %}{{ entry.revision.user.name }}{% else %}unknown{% endif %}
|
||||
|
|
||||
{{ entry.revision.created }}
|
||||
|
|
||||
<a href="/api/v1/revisions/{{ entry.revision.id }}" target="_blank">JSON</a>
|
||||
</div>
|
||||
{{ entry.diff_html | safe }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% endblock %}
|
||||
|
|
@ -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) }}
|
||||
|
|
|
|||
|
|
@ -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) }}
|
||||
|
|
@ -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 %}
|
||||
|
|
|
|||
478
remarkbox/tests/test_session_work.py
Normal file
478
remarkbox/tests/test_session_work.py
Normal 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)
|
||||
52
remarkbox/views/revisions.py
Normal file
52
remarkbox/views/revisions.py
Normal 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),
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue