Commit abf5e2fb authored by Florent Cayré's avatar Florent Cayré
Browse files

Implement versioned wiki with online edit, history display, version diffs,

and revert to old revision features.
parent 43c321518272
Summary
-------
Version controlled wiki component for the CubicWeb framework
Currently, to use, you need the following steps:
1. The administrator must create a `Repository` entity and set its `path`
attribute to a valid path to a Mercurial repository (which must be created
beforehand).
2. The administrator must add write permissions for the `Repository`, via
the `localperms` cube (action available at the left of the screen).
3. The administrator must perform a "Refresh repository" action, also available
at the left of the screen.
4. Create a `VcWiki` entity and set `rst` as default extension.
5. You should be able to create your wiki homepage through the `wiki homepage`
action on the left.
note: The Wiki pages are available at an address which has the following
format: http://<cubicweb_instance:port>/wiki/<wiki_name>/<page_name>.
a.doesnotexist {
text-decoration: line-through;
}
/* Revision comparison form styling */
table.diff {
margin-top: 2em;
}
table.diff th, table.diff td {
padding: 0 10px;
}
table.diff th {
padding-bottom: 1em;
}
table.diff td div {
height: 2em;
}
table.diff td.rev1 div {
text-align: right;
}
table.diff td div input {
margin: 0 10px;
}
table.diff td div.current a {
font-weight: bold;
}
......@@ -17,23 +17,159 @@
"""cubicweb-vcwiki entity's classes"""
import mimetypes
mimetypes.add_type('text/rest', '.rst')
from logilab.common.decorators import cached, cachedproperty
from cubicweb.entities import AnyEntity
from cubicweb.predicates import is_instance, score_entity
from cubicweb.web.views.ibreadcrumbs import IBreadCrumbsAdapter
class VCWiki(AnyEntity):
__regid__ = 'VCWiki'
def content(self, path):
""" Return the VersionContent entity representing the content of the
file with given path if it exists in the last revision of the 'default'
branch of current wiki's repository, or None.
@property
def repository(self):
""" Short accessor to the wiki's repository. """
return self.content_repo[0]
@property
def urlpath(self):
""" Path part of the wiki's URL. """
return 'wiki/%s' % self._cw.url_quote(self.name)
@property
def url(self):
""" URL of the current wiki. """
return self._cw.build_url(self.urlpath)
def page_urlpath(self, path):
""" Path part of a wiki page's URL. """
if not path:
path = self.index_fname
return '%s/%s' % (self.urlpath, self._cw.url_quote(path, safe='/'))
def page_url(self, path, **kwargs):
""" Helper to build the URL of the wiki page's that has `path` path.
Use additional keyword args to specify other URL parameters, like "vid".
"""
return self._cw.build_url(self.page_urlpath(path), **kwargs)
@cachedproperty
def index_fname(self):
""" Name of the index file that contains a directory's content. """
return u'index.%s' % self.content_file_extension
def split_path(self, path):
""" Return a tuple (file directory, file name) deduced from the `path`
parameter. This method uses '/' as a separator between file directory
and path, and changes the file extension from 'html' into the vcwiki
defined extension if found at the end of the file name.
"""
repo = self.content_repo[0]
filedir, sep, name = path.rpartition('/')
if not name.endswith('.rst'):
name += '.rst'
return filedir, name
@cached
def file(self, path):
""" Return the VersionedFile entity representing the file with given
path in the repository. If such a file does not exist, consider `path`
as a folder and search an "index.html" content file.
If nothing was found, return None.
"""
repo = self.repository
filedir, name = self.split_path(path)
versioned_file = repo.versioned_file(filedir, name)
if versioned_file is None: #check if folder exist
versioned_file = repo.versioned_file(path, self.index_fname)
if (versioned_file is None and
not name.endswith('.%s' % self.content_file_extension)):
name += '.%s' % self.content_file_extension
versioned_file = repo.versioned_file(filedir, name)
return versioned_file
def content(self, path, revision=None, allow_deleted=False):
""" Return the VersionContent entity representing the content of the
file with given path if it exists in the last revision of the 'default'
branch of current wiki's repository.
If the `allow_deleted` parameter is True, a DeletedVersionContent can be
returned, if any. Otherwise, deleted content will lead to a None value.
If nothing was found, return None.
"""
versioned_file = self.file(path)
if versioned_file is not None:
version_content = versioned_file.version_content(None)
version_content = versioned_file.version_content(revision)
if (not allow_deleted
and str(version_content.e_schema) == 'DeletedVersionContent'):
return None
return version_content
def display_name(self, vcsfile):
"""Return a displayable name for a VersionedFile -normally part of the
wiki's content-, build by stripping the trailing vcwiki's extension,
if present."""
name = vcsfile.name
ext = '.' + self.content_file_extension
if name.endswith(ext):
name = name[:-len(ext)]
return name
@cachedproperty
def content_mimetype(self):
return mimetypes.types_map.get('.' + self.content_file_extension,
'text/plain')
def is_vcwiki_page(vcontent):
if vcontent.repository.reverse_content_repo:
return 1
class VCWikiBreadcrumbs(IBreadCrumbsAdapter):
""" Breadcrumbs adapter for VCWiki entities to expose the directory
structure of the wiki.
"""
__select__ = (IBreadCrumbsAdapter.__select__
& is_instance('VCWiki')
& score_entity(is_vcwiki_page))
@property
def vcwiki(self):
""" Short accessor to the wiki the present version content is for. """
return self.entity
@property
def vcfile(self):
""" Short accessor to the versioned file which present content
if the content for.
"""
return ('path' in self._cw.form
and self.vcwiki.file(self._cw.form['path']))
def breadcrumbs(self, view=None, recurs=None):
"""Hierarchy of wiki pages following it's repository's directory-like
structure. Return a link to the index page of the parent folders.
"""
path = []
if self.vcfile:
dirs = self.vcfile.path.split('/')[:-1]
name = self.vcfile.name
if name != self.vcwiki.index_fname:
if name.endswith('.%s'
% self.vcwiki.content_file_extension):
name = name[:-(len(self.vcwiki.content_file_extension) + 1)]
path.append(name)
else:
path.append('')
while dirs:
dirpath = u'/'.join(dirs)
dirname = dirs.pop()
index_path = '%s/%s' % (dirpath, self.vcwiki.index_fname)
vcontent = self.vcwiki.content(index_path)
if vcontent is not None:
path.append((self.vcwiki.page_url(index_path), dirname))
else:
path.append(dirname)
path.append((self.vcwiki.url, self.vcwiki.name))
path.reverse()
return path
......@@ -24,3 +24,7 @@ from yams.buildobjs import EntityType, SubjectRelation, String
class VCWiki(EntityType):
name = String(required=True, unique=True)
content_repo = SubjectRelation('Repository', cardinality='1?', inlined=True)
content_file_extension = String(required=True, maxsize=16,
description=_(
'extension of the names of the files in the vcwiki repository.'
' Examples: "rst", "html"'))
from docutils import nodes, utils
from docutils.parsers.rst.roles import register_canonical_role, set_classes
def wiki_page_reference_role(role, rawtext, text, lineno, inliner,
options={}, content=[]):
text = text.strip()
try:
wikipath, rest = text.split(u':', 1)
except:
wikipath, rest = text, text
context = inliner.document.settings.context # VersionContent instance
if not (hasattr(context, 'repository')
and context.repository.reverse_content_repo):
return [nodes.Text(rest)], []
vcwiki = context.repository.reverse_content_repo[0]
ref = vcwiki.page_url(wikipath)
set_classes(options)
if not vcwiki.content(wikipath):
options['classes'] = ['doesnotexist']
else:
options.pop('classes', None)
return [nodes.reference(rawtext, utils.unescape(rest), refuri=ref,
**options)], []
register_canonical_role('wiki', wiki_page_reference_role)
from mercurial import cmdutil, patch, scmutil, ui as uimod
from mercurial.error import RepoError
from cubicweb import Binary
from cubicweb.server import Service
from cubes.vcsfile import bridge
class RevisionDiffService(Service):
""" Return the diff between two revisions.
"""
__regid__ = 'vcwiki.export-rev-diff'
def call(self, repo_eid, path, rev1, rev2):
repo = self._cw.entity_from_eid(repo_eid)
ui = uimod.ui()
diffopts = patch.diffopts(ui, {'git': True, 'unified': 5})
hdrepo = bridge.repository_handler(repo)
try:
hgrepo = hdrepo.hgrepo()
except RepoError:
return None
output = Binary()
node1, node2 = scmutil.revpair(hgrepo, (rev1, rev2))
m = scmutil.matchfiles(hgrepo, (path.encode(repo.encoding),))
cmdutil.diffordiffstat(ui, hgrepo, diffopts, node1, node2, m, fp=output)
return output.getvalue()
# -*- coding: utf-8 -*-
# copyright 2013 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
# contact http://www.logilab.fr -- mailto:contact@logilab.fr
#
# This program is free software: you can redistribute it and/or modify it under
# the terms of the GNU Lesser General Public License as published by the Free
# Software Foundation, either version 2.1 of the License, or (at your option)
# any later version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
# details.
#
# You should have received a copy of the GNU Lesser General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
"""cubicweb-vcwiki actions' tests"""
from utils import VCWikiTC
class VCWikiActionsTC(VCWikiTC):
def wiki_actions(self, **form):
req = self.request()
req.form = form
rset = req.execute('VCWiki X')
return [a.__regid__
for a in rset.possible_actions()
if tuple(a.actual_actions())]
def simple_user_wiki_actions(self, **form):
self.create_user('toto')
with self.login('toto'):
return self.wiki_actions(**form)
def test_wiki_actions(self):
actions = self.simple_user_wiki_actions()
self.assertTrue('vcwiki.view_home' in actions)
def test_wiki_page_simple_user_actions(self):
actions = self.simple_user_wiki_actions(path=u'subject1/content1.rst')
self.assertFalse('edit' in actions)
self.assertFalse('delete' in actions)
self.assertFalse('vcwiki.view_history' in actions)
def test_wiki_existing_page_authorized_user_actions(self):
actions = self.wiki_actions(path=u'subject1/content1.rst')
self.assertTrue('edit' in actions)
self.assertTrue('delete' in actions)
self.assertTrue('vcwiki.view_history' in actions)
def test_wiki_non_existing_page_authorized_user_actions(self):
actions = self.wiki_actions(path=u'does_not_exist.rst')
self.assertFalse('edit' in actions)
self.assertFalse('delete' in actions)
self.assertFalse('vcwiki.view_history' in actions)
# -*- coding: utf-8 -*-
# copyright 2013 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
# contact http://www.logilab.fr -- mailto:contact@logilab.fr
#
# This program is free software: you can redistribute it and/or modify it under
# the terms of the GNU Lesser General Public License as published by the Free
# Software Foundation, either version 2.1 of the License, or (at your option)
# any later version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
# details.
#
# You should have received a copy of the GNU Lesser General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
"""cubicweb-vcwiki controllers' tests"""
from os import path as osp
from cubicweb.web import Redirect
from utils import VCWikiTC
class VCWikiViewControllerTC(VCWikiTC):
def publish_wiki_path(self, path, wikiid='vcwiki'):
req = self.request()
req.form = {'wikiid': wikiid, 'path': path}
return self.ctrl_publish(req, ctrl='wiki')
def test_wiki_does_not_exist(self):
self.assertTrue('no access to this view'
in self.publish_wiki_path('', wikiid='does_not_exist'))
def test_wiki_page(self):
self.assertTrue('content of subject2/content2.rst'
in self.publish_wiki_path('subject2/content2'))
def test_image(self):
repo_path = self.vcwiki.repository.path
fname = '120px-Crystal_Clear_app_kedit.png'
image = self.publish_wiki_path(fname)
with open(osp.join(repo_path, fname)) as image_file:
self.assertEqual(image, image_file.read())
class VCWikiEditControllerTC(VCWikiTC):
def setup_database(self):
super(VCWikiEditControllerTC, self).setup_database()
req = self.request()
rql = 'Any X WHERE X is CWGroup, X name "managers"'
managers = req.execute(rql).get_entity(0, 0)
req.create_entity('CWPermission',
name=u'write',
label=u'vcwiki write',
require_group=managers,
reverse_require_permission=self.vcwiki.repository)
self.commit()
def vcwiki_edit(self, path, content=u'new content'):
req = self.request()
req.form = {'vcwiki_eid': self.vcwiki.eid,
'path': path,
'content': content,
'message': u'my commit'}
with self.assertRaises(Redirect):
self.ctrl_publish(req, ctrl='vcwiki.edit_page')
if content:
vcontent = self.vcwiki.content(path)
self.assertEqual(content,
vcontent.data.getvalue())
def test_creation(self):
self.vcwiki_edit(u'newsubject/index.rst')
def test_edition(self):
self.vcwiki_edit(u'subject2/content2.rst')
def test_deletion(self):
self.vcwiki_edit(u'subject2/content2.rst', content=u'')
vcontent = self.vcwiki.content(u'subject2/content2.rst',
allow_deleted=True)
self.assertEqual(str(vcontent.e_schema), 'DeletedVersionContent')
# -*- coding: utf-8 -*-
# copyright 2013 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
# contact http://www.logilab.fr -- mailto:contact@logilab.fr
#
# This program is free software: you can redistribute it and/or modify it under
# the terms of the GNU Lesser General Public License as published by the Free
# Software Foundation, either version 2.1 of the License, or (at your option)
# any later version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
# details.
#
# You should have received a copy of the GNU Lesser General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
"""cubicweb-vcwiki entity tests"""
from utils import VCWikiTC
class VCWikiContentTC(VCWikiTC):
def test_simple(self):
self.assertEqual('VersionContent',
self.vcwiki.content('hello.rst').__regid__)
def test_folder(self):
content = self.vcwiki.content('subject1')
self.assertEqual(getattr(content, '__regid__', None), 'VersionContent')
self.assertEqual('index file of subject1 folder\n',
content.data.getvalue())
def test_root(self):
content = self.vcwiki.content('')
self.assertEqual(getattr(content, '__regid__', None), 'VersionContent')
self.assertEqual('index file of the root folder\n',
content.data.getvalue())
def test_does_not_exist(self):
content = self.vcwiki.content('does_not_exist')
self.assertIsNone(content)
def test_deleted(self):
content = self.vcwiki.content('deleted.rst')
self.assertIsNone(content)
content = self.vcwiki.content('deleted.rst', allow_deleted=True)
self.assertEqual(str(content.e_schema), 'DeletedVersionContent')
def test_index_if_no_path(self):
self.assertEqual(self.vcwiki.page_urlpath(''),
(u'wiki/%s/%s'
% (self.vcwiki.name, 'index.rst')))
class VCWikiBreadcrumbsTC(VCWikiTC):
def path_breadcrumbs(self, path):
req = self.request()
req.form = {'path': path}
vcwiki = req.entity_from_eid(self.vcwiki.eid)
return vcwiki.cw_adapt_to('IBreadCrumbs').breadcrumbs()
def test_root(self):
self.assertEqual([(self.vcwiki.url, u'vcwiki'),''],
self.path_breadcrumbs('index.rst'))
def test_simple(self):
self.assertEqual([(self.vcwiki.url, u'vcwiki'),
'hello'],
self.path_breadcrumbs('hello.rst'))
def test_subdir_index(self):
self.assertEqual([(self.vcwiki.url, u'vcwiki'),
(self.vcwiki.url + '/subject1/index.rst', u'subject1'), ''],
self.path_breadcrumbs('subject1/index.rst'))
def test_subdir_file(self):
self.assertEqual([(self.vcwiki.url, u'vcwiki'),
(self.vcwiki.url + '/subject1/index.rst', u'subject1'),
u'content1'],
self.path_breadcrumbs('subject1/content1.rst'))
def test_folder_has_no_index(self):
self.assertEqual([(self.vcwiki.url, u'vcwiki'),
'subject2',
'content2'],
self.path_breadcrumbs('subject2/content2.rst'))
def test_does_not_exist(self):
self.assertEqual([(self.vcwiki.url, u'vcwiki')],
self.path_breadcrumbs('does_not_exist.rst'))
# -*- coding: utf-8 -*-
# copyright 2013 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
# contact http://www.logilab.fr -- mailto:contact@logilab.fr
#
# This program is free software: you can redistribute it and/or modify it under
# the terms of the GNU Lesser General Public License as published by the Free
# Software Foundation, either version 2.1 of the License, or (at your option)
# any later version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
# details.
#
# You should have received a copy of the GNU Lesser General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
"""cubicweb-vcwiki restructured text rendering tests"""
from utils import VCWikiTC
class VCWikiViewTC(VCWikiTC):
base_url = u'http://testing.fr/cubicweb/wiki/vcwiki'
def wiki_view(self, path, **form):
req = self.request()
req.form = {'path': path}
req.form.update(form)
vcwiki = req.entity_from_eid(self.vcwiki.eid)
return self.view('vcwiki.view_page', vcwiki.as_rset(), req=req)
def assertEntitled(self, expected_title, html):
self.assertEqual([u'vcwiki - %s' % expected_title],
html.find_tag('title'))
def assertHasLink(self, text, html, url_path, klass):
attrs = {'class': klass, 'href': self.base_url + url_path}
links = tuple(html.matching_nodes('a', **attrs))
self.assertTrue(1, len(links))
self.assertTrue(text, links[0].text)
def test_existing_wiki_page(self):
html = self.wiki_view('subject2/content2')
self.assertEntitled('content2', html)
self.assertTrue('content of subject2/content2.rst' in html)
def test_existing_wiki_page_with_revision(self):
req = self.request()
# Check test prerequisite
vc0 = self.vcwiki.content('hello.rst', revision=0)
vc1 = self.vcwiki.content('hello.rst') # last revision
new_words = 'modified since its creation'
self.assertFalse(new_words in vc0.data.getvalue())
self.assertTrue(new_words in vc1.data.getvalue())
# Check new added content is not present in the view of the old revision
html = self.wiki_view('hello.rst', rev='0')
self.assertFalse(new_words in html)
def test_non_existing_wiki_page(self):
html = self.wiki_view('does_not_exist')
self.assertEntitled('New wiki page', html)
self.assertTrue('This wiki page does not exist.' in html)
self.assertTrue(html.has_link('Create this wiki page?'))
def test_link_to_existing_page(self):
html = self.wiki_view('with_links.rst')
self.assertHasLink('link to content1', html,
u'/subject1/content1.rst', u'reference')