Commits

Thomas Waldmann committed df86cc3 Merge

merged with main repo, 1 test f failing still

  • Participants
  • Parent commits a2de9d5, c5af501

Comments (0)

Files changed (50)

 include README.txt LICENSE.txt
-include quickinstall quickinstall.bat
+include quickinstall.py
 include wikiconfig.py
 include Makefile
 
 global-exclude *.rej
 
 prune docs/_build
-prune env
-prune env-pypy
-prune env-py26
-prune env-py27
 
 prune wiki/data/content
 prune wiki/data/userprofiles

MoinMoin/_tests/test_user.py

 from flask import g as flaskg
 
 from MoinMoin import user
+from MoinMoin.items import Item
+from MoinMoin.constants.keys import (ITEMID, NAME, NAMEPREFIX, NAMERE, NAMESPACE, TAGS)
 
 
 class TestSimple(object):
 
     # Subscriptions ---------------------------------------------------
 
-    def testSubscriptionSubscribedPage(self):
-        """ user: tests is_subscribed_to  """
-        pagename = u'HelpMiscellaneous'
-        name = u'__Jürgen Herman__'
+    def test_subscriptions(self):
+        pagename = u"Foo:foo 123"
+        tagname = u"xxx"
+        regexp = r"\d+"
+        item = Item.create(pagename)
+        item._save({NAMESPACE: u"", TAGS: [tagname]})
+        item = Item.create(pagename)
+        meta = item.meta
+
+        name = u'bar'
         password = name
-        self.createUser(name, password)
-        # Login - this should replace the old password in the user file
-        theUser = user.User(name=name, password=password)
-        theUser.subscribe(pagename)
-        assert theUser.is_subscribed_to([pagename])  # list(!) of pages to check
+        email = "bar@example.org"
+        user.create_user(name, password, email)
+        the_user = user.User(name=name, password=password)
+        assert not the_user.is_subscribed_to(item)
+        the_user.subscribe(NAME, u"SomeOtherPageName", u"")
+        result = the_user.unsubscribe(NAME, u"OneMorePageName", u"")
+        assert result is False
 
-    def testSubscriptionSubPage(self):
-        """ user: tests is_subscribed_to on a subpage """
-        pagename = u'HelpMiscellaneous'
-        testPagename = u'HelpMiscellaneous/FrequentlyAskedQuestions'
-        name = u'__Jürgen Herman__'
-        password = name
-        self.createUser(name, password)
-        # Login - this should replace the old password in the user file
-        theUser = user.User(name=name, password=password)
-        theUser.subscribe(pagename)
-        assert not theUser.is_subscribed_to([testPagename])  # list(!) of pages to check
+        subscriptions = [(ITEMID, meta[ITEMID], None),
+                         (NAME, pagename, meta[NAMESPACE]),
+                         (TAGS, tagname, meta[NAMESPACE]),
+                         (NAMEPREFIX, pagename[:4], meta[NAMESPACE]),
+                         (NAMERE, regexp, meta[NAMESPACE])]
+        for subscription in subscriptions:
+            keyword, value, namespace = subscription
+            the_user.subscribe(keyword, value, namespace)
+            assert the_user.is_subscribed_to(item)
+            the_user.unsubscribe(keyword, value, namespace, item)
+            assert not the_user.is_subscribed_to(item)
 
     # Bookmarks -------------------------------------------------------
 

MoinMoin/apps/frontend/_tests/test_frontend.py

         self._test_view('frontend.quicklink_item', status='302 FOUND', viewopts=dict(item_name='DoesntExist'), data=['<!DOCTYPE HTML'])
 
     def test_subscribe_item(self):
-        self._test_view('frontend.subscribe_item', status='302 FOUND', viewopts=dict(item_name='DoesntExist'), data=['<!DOCTYPE HTML'])
+        self._test_view('frontend.subscribe_item', status='404 NOT FOUND', viewopts=dict(item_name='DoesntExist'))
 
     def test_register(self):
         self._test_view('frontend.register')

MoinMoin/apps/frontend/views.py

 from MoinMoin.i18n import _, L_, N_
 from MoinMoin.themes import render_template, contenttype_to_class
 from MoinMoin.apps.frontend import frontend
-from MoinMoin.forms import (OptionalText, RequiredText, URL, YourOpenID, YourEmail, RequiredPassword, Checkbox,
-                            InlineCheckbox, Select, Names, Tags, Natural, Hidden, MultiSelect, Enum, validate_name,
-                            NameNotValidError)
+from MoinMoin.forms import (OptionalText, RequiredText, URL, YourOpenID, YourEmail,
+                            RequiredPassword, Checkbox, InlineCheckbox, Select, Names,
+                            Tags, Natural, Hidden, MultiSelect, Enum, Subscriptions,
+                            validate_name, NameNotValidError)
 from MoinMoin.items import BaseChangeForm, Item, NonExistent, NameNotUniqueError, FieldNotUniqueError
 from MoinMoin.items.content import content_registry
 from MoinMoin import user, util
 @frontend.route('/+content/<itemname:item_name>', defaults=dict(rev=CURRENT))
 def content_item(item_name, rev):
     """ same as show_item, but we only show the content """
-    item_displayed.send(app._get_current_object(),
-                        item_name=item_name)
+    item_displayed.send(app, item_name=item_name)
     try:
         item = Item.create(item_name, rev_id=rev)
     except AccessDenied:
         item = Item.create(item_name)
         revid, size = item.modify({}, data, contenttype_guessed=contenttype)
         item_modified.send(app._get_current_object(),
-                           item_name=item_name)
+                           item_name=item_name, action=ACTION_SAVE)
         return jsonify(name=subitem_name,
                        size=size,
                        url=url_for('.show_item', item_name=item_name, rev=revid),
     :type item_name: unicode
     :returns: a page with all the items which link or transclude item_name
     """
+    try:
+        item = Item.create(item_name)
+    except AccessDenied:
+        abort(403)
     refs_here = _backrefs(item_name)
     return render_template('link_list_item_panel.html',
+                           item=item,
                            item_name=item_name,
                            fqname=split_fqname(item_name),
                            headline=_(u"Items which refer to '%(item_name)s'", item_name=item_name),
     u = flaskg.user
     cfg = app.cfg
     msg = None
+    try:
+        item = Item.create(item_name)
+    except AccessDenied:
+        abort(403)
+    if isinstance(item, NonExistent):
+        abort(404)
     if not u.valid:
         msg = _("You must login to use this action: %(action)s.", action="subscribe/unsubscribe"), "error"
     elif not u.may.read(item_name):
         msg = _("You are not allowed to subscribe to an item you may not read."), "error"
-    elif u.is_subscribed_to([item_name]):
+    elif u.is_subscribed_to(item):
         # Try to unsubscribe
-        if not u.unsubscribe(item_name):
-            msg = _("Can't remove regular expression subscription!") + u' ' + \
-                _("Edit the subscription regular expressions in your settings."), "error"
+        if not u.unsubscribe(ITEMID, item.meta[ITEMID]):
+            msg = _(
+                "Can't remove the subscription! You are subscribed to this page in some other way.") + u' ' + _(
+                "Please edit the subscription in your settings."), "error"
     else:
         # Try to subscribe
-        if not u.subscribe(item_name):
+        if not u.subscribe(ITEMID, item.meta[ITEMID]):
             msg = _('You could not get subscribed to this item.'), "error"
     if msg:
         flash(*msg)
     submit_label = L_('Save')
 
 
+class UserSettingsSubscriptionsForm(Form):
+    name = 'usersettings_subscriptions'
+    subscriptions = Subscriptions
+    submit_label = L_('Save')
+
+
 @frontend.route('/+usersettings', methods=['GET', 'POST'])
 def usersettings():
     # TODO use ?next=next_location check if target is in the wiki and not outside domain
         ui=UserSettingsUIForm,
         navigation=UserSettingsNavigationForm,
         options=UserSettingsOptionsForm,
+        subscriptions=UserSettingsSubscriptionsForm,
     )
     forms = dict()
 
     """
     list similar item names
     """
+    try:
+        item = Item.create(item_name)
+    except AccessDenied:
+        abort(403)
     fq_name = split_fqname(item_name)
     start, end, matches = findMatches(fq_name)
     keys = sorted(matches.keys())
                 fq_names.append(fqname)
     return render_template("link_list_item_panel.html",
                            headline=_("Items with similar names to '%(item_name)s'", item_name=item_name),
+                           item=item,
                            item_name=item_name,  # XXX no item
                            fqname=split_fqname(item_name),
                            fq_names=fq_names)

MoinMoin/config/default.py

             DISABLED: False,
             BOOKMARKS: {},
             QUICKLINKS: [],
-            SUBSCRIBED_ITEMS: [],
+            SUBSCRIPTIONS: [],
             EMAIL_SUBSCRIBED_EVENTS: [
                 # XXX PageChangedEvent.__name__
                 # XXX PageRenamedEvent.__name__

MoinMoin/constants/keys.py

 # needs more precise name / use case:
 SOMEDICT = u"somedict"
 
+# TODO review plural constants
 CONTENTTYPE = u"contenttype"
 ITEMTYPE = u"itemtype"
 SIZE = u"size"
 LOCALE = u"locale"
 TIMEZONE = u"timezone"
 ENC_PASSWORD = u"enc_password"
-SUBSCRIBED_ITEMS = u"subscribed_items"
+SUBSCRIPTIONS = u"subscriptions"
+SUBSCRIPTION_IDS = u"subscription_ids"
+SUBSCRIPTION_PATTERNS = u"subscription_patterns"
 BOOKMARKS = u"bookmarks"
 QUICKLINKS = u"quicklinks"
 SESSION_KEY = u"session_key"
 EMAIL_SUBSCRIBED_EVENTS = u"email_subscribed_events"
 DISABLED = u"disabled"
 EMAIL_UNVALIDATED = u"email_unvalidated"
+NAMERE = u"namere"
+NAMEPREFIX = u"nameprefix"
 
 # in which backend is some revision stored?
 BACKENDNAME = u"backendname"
     # User objects proxy these attributes of the UserProfile objects:
     NAME, DISABLED, ITEMID, DISPLAY_NAME, ENC_PASSWORD, EMAIL, OPENID,
     MAILTO_AUTHOR, SHOW_COMMENTS, RESULTS_PER_PAGE, EDIT_ON_DOUBLECLICK, SCROLL_PAGE_AFTER_EDIT,
-    EDIT_ROWS, THEME_NAME, LOCALE, TIMEZONE, SUBSCRIBED_ITEMS, QUICKLINKS,
-    CSS_URL,
+    EDIT_ROWS, THEME_NAME, LOCALE, TIMEZONE, SUBSCRIPTIONS, QUICKLINKS, CSS_URL,
 ]
 
 # keys for blog homepages
 LATEST_REVS = 'latest_revs'
 ALL_REVS = 'all_revs'
 
+# values for ACTION key
+ACTION_SAVE = u"SAVE"
+ACTION_REVERT = u"REVERT"
+ACTION_TRASH = u"TRASH"
+ACTION_COPY = u"COPY"
+ACTION_RENAME = u"RENAME"
+
+# defaul LOCALE key value
+DEFAULT_LOCALE = u"en"
+
 # key for composite name
 FQNAME = u'fqname'
 # Values that FIELD can take in the composite name: [NAMESPACE/][@FIELD/]NAME

MoinMoin/forms.py

 from MoinMoin.util.forms import FileStorage
 from MoinMoin.storage.middleware.validation import uuid_validator
 
+COLS = 60
+ROWS = 10
+
 
 class Enum(BaseEnum):
     """
     def u(self):
         return self.separator.join(child.u for child in self)
 
+
+class SubscriptionsJoinedString(JoinedString):
+    """ A JoinedString that offers the list of children as value property and also
+    appends the name of the item to the end of ITEMID subscriptions.
+    """
+    @property
+    def value(self):
+        subscriptions = []
+        for child in self:
+            if child.value.startswith(ITEMID):
+                value = re.sub(r"\(.*\)", "", child.value)
+            else:
+                value = child.value
+            subscriptions.append(value)
+        return subscriptions
+
+    @property
+    def u(self):
+        subscriptions = []
+        for child in self:
+            if child.u.startswith(ITEMID):
+                value = re.sub(r"\(.*\)", "", child.u)
+                item = flaskg.storage.document(**{ITEMID: value.split(":")[1]})
+                try:
+                    name_ = item.meta['name'][0]
+                except IndexError:
+                    name_ = "This item doesn't exist"
+                value = "{0} ({1})".format(value, name_)
+            else:
+                value = child.u
+            subscriptions.append(value)
+        return self.separator.join(subscriptions)
+
+
 Tags = MyJoinedString.of(String).with_properties(widget=WIDGET_TEXT).using(
     label=L_('Tags'), optional=True, separator=', ', separator_regex=re.compile(r'\s*,\s*'))
 
 Names = MyJoinedString.of(String).with_properties(widget=WIDGET_TEXT).using(
     label=L_('Names'), optional=True, separator=', ', separator_regex=re.compile(r'\s*,\s*')).validated_by(ValidName())
 
+Subscriptions = SubscriptionsJoinedString.of(String).with_properties(
+    widget=WIDGET_MULTILINE_TEXT, rows=ROWS, cols=COLS).using(
+    label=L_('Subscriptions'), optional=True, separator='\n',
+    separator_regex=re.compile(r'[\r\n]+'))
+
 Search = Text.using(default=u'', optional=True).with_properties(widget=WIDGET_SEARCH, placeholder=L_("Search Query"))
 
 _Integer = Integer.validated_by(Converted())

MoinMoin/i18n/__init__.py

 
 
 from babel import Locale
+from contextlib import contextmanager
 
-from flask import current_app, request
+from flask import current_app, request, _request_ctx_stack
 from flask import g as flaskg
 from flask.ext.babel import Babel, gettext, ngettext, lazy_gettext
 
     u = getattr(flaskg, 'user', None)
     if u and u.timezone is not None:
         return u.timezone
+
+
+# Original source is a patch to Flask Babel
+# https://github.com/lalinsky/flask-babel/commit/09ee1702c7129598bb202aa40a0e2e19f5414c24
+@contextmanager
+def force_locale(locale):
+    """Temporarily overrides the currently selected locale. Sometimes
+    it is useful to switch the current locale to different one, do
+    some tasks and then revert back to the original one. For example,
+    if the user uses German on the web site, but you want to send
+    them an email in English, you can use this function as a context
+    manager::
+
+        with force_locale('en_US'):
+            send_email(gettext('Hello!'), ...)
+    """
+    ctx = _request_ctx_stack.top
+    if ctx is None:
+        yield
+        return
+    babel = ctx.app.extensions['babel']
+    orig_locale_selector_func = babel.locale_selector_func
+    orig_attrs = {}
+    for key in ('babel_translations', 'babel_locale'):
+        orig_attrs[key] = getattr(ctx, key, None)
+    try:
+        babel.locale_selector_func = lambda: locale
+        for key in orig_attrs:
+            setattr(ctx, key, None)
+        yield
+    finally:
+        babel.locale_selector_func = orig_locale_selector_func
+        for key, value in orig_attrs.iteritems():
+            setattr(ctx, key, value)

MoinMoin/i18n/_tests/test_i18n.py

 Test for i18n
 """
 
-from MoinMoin.i18n import get_locale, get_timezone
+import pytest
 
+from flask import Flask
+from flask.ext import babel
+
+from MoinMoin.i18n import get_locale, get_timezone, force_locale
 from MoinMoin.i18n import _, L_, N_
 
 
     assert result1 == 'text1'
     result2 = N_('text1', 'text2', 2)
     assert result2 == 'text2'
+
+
+def test_force_locale():
+    pytest.skip("This test needs to be run with --assert=reinterp or --assert=plain flag")
+    app = Flask(__name__)
+    b = babel.Babel(app)
+
+    @b.localeselector
+    def select_locale():
+        return 'de_DE'
+
+    with app.test_request_context():
+        assert str(babel.get_locale()) == 'de_DE'
+        with force_locale('en_US'):
+            assert str(babel.get_locale()) == 'en_US'
+        assert str(babel.get_locale()) == 'de_DE'

MoinMoin/items/__init__.py

     CONTENTTYPE, SIZE, ACTION, ADDRESS, HOSTNAME, USERID, COMMENT,
     HASH_ALGORITHM, ITEMID, REVID, DATAID, CURRENT, PARENTID, NAMESPACE,
     UFIELDS_TYPELIST, UFIELDS, TRASH,
+    ACTION_SAVE, ACTION_REVERT, ACTION_TRASH, ACTION_RENAME
 )
 from MoinMoin.constants.namespaces import NAMESPACE_ALL
 from MoinMoin.constants.contenttypes import CHARSET, CONTENTTYPE_NONEXISTENT
 from MoinMoin.constants.itemtypes import (
     ITEMTYPE_NONEXISTENT, ITEMTYPE_USERPROFILE, ITEMTYPE_DEFAULT,
 )
+from MoinMoin.util.notifications import DESTROY_REV, DESTROY_ALL
 
 from .content import content_registry, Content, NonExistentContent, Draw
 
         fqname = CompositeName(self.fqname.namespace, self.fqname.field, name)
         if flaskg.storage.get_item(**fqname.query):
             raise NameNotUniqueError(L_("An item named %s already exists in the namespace %s." % (name, fqname.namespace)))
-        return self._rename(name, comment, action=u'RENAME')
+        return self._rename(name, comment, action=ACTION_RENAME)
 
     def delete(self, comment=u''):
         """
         delete this item (remove current name from NAME list)
         """
-        return self._rename(None, comment, action=u'TRASH', delete=True)
+        return self._rename(None, comment, action=ACTION_TRASH, delete=True)
 
     def revert(self, comment=u''):
-        return self._save(self.meta, self.content.data, action=u'REVERT', comment=comment)
+        return self._save(self.meta, self.content.data, action=ACTION_REVERT, comment=comment)
 
     def destroy(self, comment=u'', destroy_item=False):
+        action = DESTROY_ALL if destroy_item else DESTROY_REV
+        item_modified.send(app, item_name=self.name, action=action, meta=self.meta,
+                           content=self.rev.data, comment=comment)
         # called from destroy UI/POST
         if destroy_item:
             # destroy complete item with all revisions, metadata, etc.
         """
         raise NotImplementedError
 
-    def _save(self, meta, data=None, name=None, action=u'SAVE', contenttype_guessed=None, comment=None,
+    def _save(self, meta, data=None, name=None, action=ACTION_SAVE, contenttype_guessed=None, comment=None,
               overwrite=False, delete=False):
         backend = flaskg.storage
         storage_item = backend.get_item(**self.fqname.query)
                                              contenttype_guessed=contenttype_guessed,
                                              return_rev=True,
                                              )
-        item_modified.send(app._get_current_object(), item_name=name)
+        # XXX TODO name might be None here (we have a failing unit test)
+        # maybe this needs to be changed so a fqname is used instead of
+        # a simple name
+        assert name is not None  # fail early
+        item_modified.send(app, item_name=name, action=action)
         return newrev.revid, newrev.meta[SIZE]
 
     @property
         rev_ids = []
         item_templates = self.content.get_templates(self.contenttype)
         return render_template('modify_select_template.html',
+                               item=self,
                                item_name=self.name,
                                fqname=self.fqname,
                                itemtype=self.itemtype,
         return render_template(self.modify_template,
                                fqname=self.fqname,
                                item_name=self.name,
+                               item=self,
                                rows_meta=str(ROWS_META), cols=str(COLS),
                                form=form,
                                search_form=None,
 
     def _select_itemtype(self):
         return render_template('modify_select_itemtype.html',
+                               item=self,
                                item_name=self.name,
                                fqname=self.fqname,
                                itemtypes=item_registry.shown_entries,

MoinMoin/items/_tests/test_Content.py

 """
 
 import pytest
+from io import BytesIO
 
 from flask import Markup
 
         item = Item.create(item_name)
         contenttype = u'text/plain;charset=utf-8'
         meta = {CONTENTTYPE: contenttype}
-        item._save(meta)
+        data1 = "old_data"
+        item._save(meta, data1)
         item1 = Item.create(item_name)
-        data = 'test_data'
+        data2 = 'new_data'
         comment = u'next revision'
-        item1._save(meta, data, comment=comment)
+        item1._save(meta, data2, comment=comment)
         item2 = Item.create(item_name)
         result = Text._render_data_diff_text(item1.content, item1.rev, item2.rev)
-        expected = u'- \n+ test_data'
+        expected = u'- old_data\n+ new_data'
         assert result == expected
         assert item2.content.data == ''
 
         assert u'<pre class="highlight">test_data\n' in result
         assert item2.content.data == ''
 
+    def test__get_data_diff_text(self):
+        item_name = u'Text_Item'
+        item = Item.create(item_name)
+        contenttypes = dict(texttypes=[u'text/plain;charset=utf-8',
+                                       u'text/x-markdown;charset=utf-8', ],
+                            othertypes=[u'image/png', u'audio/wave',
+                                        u'video/ogg',
+                                        u'application/x-svgdraw',
+                                        u'application/octet-stream', ])
+        for key in contenttypes:
+            for contenttype in contenttypes[key]:
+                meta = {CONTENTTYPE: contenttype}
+                item._save(meta)
+                item_ = Item.create(item_name)
+                oldfile = BytesIO("x")
+                newfile = BytesIO("xx")
+                difflines = item_.content._get_data_diff_text(oldfile, newfile)
+                if key == 'texttypes':
+                    assert difflines == ['- x', '+ xx']
+                else:
+                    assert difflines == []
+
+    def test__get_data_diff_html(self):
+        item_name = u"Test_Item"
+        item = Item.create(item_name)
+        contenttype = u'text/plain;charset=utf-8'
+        meta = {CONTENTTYPE: contenttype}
+        item._save(meta)
+        item_ = Item.create(item_name)
+        oldfile = BytesIO("")
+        newfile = BytesIO("x")
+        difflines = item_.content._get_data_diff_html(oldfile, newfile)
+        assert difflines == [(1, Markup(u''), 1, Markup(u'<span>x</span>'))]
+
 
 coverage_modules = ['MoinMoin.items.content']

MoinMoin/items/_tests/test_Item.py

 from MoinMoin._tests import become_trusted, update_item
 from MoinMoin.items import Item, NonExistent, IndexEntry, MixedIndexEntry
 from MoinMoin.util.interwiki import CompositeName
-from MoinMoin.constants.keys import ITEMTYPE, CONTENTTYPE, NAME, NAME_OLD, COMMENT, ACTION, ADDRESS, TRASH, ITEMID, NAME_EXACT
+from MoinMoin.constants.keys import (ITEMTYPE, CONTENTTYPE, NAME, NAME_OLD, COMMENT,
+                                     ADDRESS, TRASH, ITEMID, NAME_EXACT,
+                                     ACTION, ACTION_REVERT)
 from MoinMoin.constants.namespaces import NAMESPACE_DEFAULT
 from MoinMoin.constants.contenttypes import CONTENTTYPE_NONEXISTENT
 from MoinMoin.constants.itemtypes import ITEMTYPE_NONEXISTENT
         item = Item.create(name)
         item.revert(u'revert')
         item = Item.create(name)
-        assert item.meta[ACTION] == u'REVERT'
+        assert item.meta[ACTION] == ACTION_REVERT
 
     def test_modify(self):
         name = u'Test_Item'

MoinMoin/items/content.py

 from MoinMoin.util.mime import Type, type_moin_document
 from MoinMoin.util.tree import moin_page, html, xlink, docbook
 from MoinMoin.util.iri import Iri
+from MoinMoin.util.diff_text import diff as text_diff
+from MoinMoin.util.diff_html import diff as html_diff
 from MoinMoin.util.crypto import cache_key
 from MoinMoin.util.clock import timed
 from MoinMoin.forms import File
     GROUP_MARKUP_TEXT, GROUP_OTHER_TEXT, GROUP_IMAGE, GROUP_AUDIO, GROUP_VIDEO,
     GROUP_DRAWING, GROUP_OTHER, CONTENTTYPE_NONEXISTENT, CHARSET
 )
-from MoinMoin.constants.keys import NAME_EXACT, WIKINAME, CONTENTTYPE, SIZE, TAGS, HASH_ALGORITHM
+from MoinMoin.constants.keys import (NAME_EXACT, WIKINAME, CONTENTTYPE, SIZE, TAGS,
+                                     HASH_ALGORITHM, ACTION_SAVE)
 
 
 COLS = 80
         # override this in child classes
         return ''
 
+    def _get_data_diff_text(self, oldfile, newfile):
+        """ Get the text diff of 2 versions of file contents
+
+        :param oldfile: file that contains old content data (bytes)
+        :param newfile: file that contains new content data (bytes)
+        :return: list of diff lines in a unified format without trailing linefeeds
+        """
+        return []
+
     def get_templates(self, contenttype=None):
         """ create a list of templates (for some specific contenttype) """
         terms = [Term(WIKINAME, app.cfg.interwikiname), Term(TAGS, u'template')]
             # everything we expected has been added to the tar file, save the container as revision
             meta = {CONTENTTYPE: self.contenttype}
             data = open(temp_fname, 'rb')
-            self.item._save(meta, data, name=self.name, action=u'SAVE', comment='')
+            self.item._save(meta, data, name=self.name, action=ACTION_SAVE, comment='')
             data.close()
             os.remove(temp_fname)
 
         """ convert data from storage format to memory format """
         return data.decode(CHARSET).replace(u'\r\n', u'\n')
 
-    def _get_data_diff_html(self, oldrev, newrev, template):
-        from MoinMoin.util.diff_html import diff
-        old_text = self.data_storage_to_internal(oldrev.data.read())
-        new_text = self.data_storage_to_internal(newrev.data.read())
-        storage_item = flaskg.storage[self.name]
-        diffs = [(d[0], Markup(d[1]), d[2], Markup(d[3])) for d in diff(old_text, new_text)]
+    def _render_data_diff_html(self, oldrev, newrev, template):
+        """ Render HTML formatted meta and content diff of 2 revisions
+
+        :param oldrev: old revision object
+        :param newrev: new revision object
+        :param template: name of the template to be rendered
+        :return: HTML data with meta and content diff
+        """
+        diffs = self._get_data_diff_html(oldrev.data, newrev.data)
         return render_template(template,
                                item_name=self.name,
                                oldrev=oldrev,
                                diffs=diffs,
                                )
 
+    def _get_data_diff_html(self, oldfile, newfile):
+        """ Get the HTML diff of 2 versions of file contents
+
+        :param oldfile: file that contains old content data (bytes)
+        :param newfile: file that contains new content data (bytes)
+        :return: list of tuples of the format (left lineno, deleted Markup content,
+                 right lineno, added Markup content)
+        """
+        old_text = self.data_storage_to_internal(oldfile.read())
+        new_text = self.data_storage_to_internal(newfile.read())
+        return [(d[0], Markup(d[1]), d[2], Markup(d[3])) for d in html_diff(old_text, new_text)]
+
+    def _get_data_diff_text(self, oldfile, newfile):
+        """ Get the text diff of 2 versions of file contents
+
+        :param oldfile: file that contains old content data (bytes)
+        :param newfile: file that contains new content data (bytes)
+        :return: list of diff lines in a unified format without trailing linefeeds
+        """
+        old_text = self.data_storage_to_internal(oldfile.read())
+        new_text = self.data_storage_to_internal(newfile.read())
+        return text_diff(old_text.splitlines(), new_text.splitlines())
+
     def _render_data_diff_atom(self, oldrev, newrev):
         """ renders diff in HTML for atom feed """
-        return self._get_data_diff_html(oldrev, newrev, 'diff_text_atom.html')
+        return self._render_data_diff_html(oldrev, newrev, 'diff_text_atom.html')
 
     def _render_data_diff(self, oldrev, newrev):
-        return self._get_data_diff_html(oldrev, newrev, 'diff_text.html')
+        return self._render_data_diff_html(oldrev, newrev, 'diff_text.html')
 
     def _render_data_diff_text(self, oldrev, newrev):
-        from MoinMoin.util import diff_text
-        oldlines = self.data_storage_to_internal(oldrev.data.read()).split('\n')
-        newlines = self.data_storage_to_internal(newrev.data.read()).split('\n')
-        difflines = diff_text.diff(oldlines, newlines)
+        """ Render text diff of 2 revisions' contents
+
+        :param oldrev: old revision object
+        :param newrev: new revision object
+        :return: text data of a content diff
+        """
+        difflines = self._get_data_diff_text(oldrev.data, newrev.data)
         return '\n'.join(difflines)
 
     _render_data_diff_raw = _render_data_diff

MoinMoin/items/ticket.py

 from MoinMoin.forms import (Form, OptionalText, OptionalMultilineText, SmallNatural, Tags,
                             Reference, BackReference, SelectSubmit)
 from MoinMoin.storage.middleware.protecting import AccessDenied
-from MoinMoin.constants.keys import ITEMTYPE, CONTENTTYPE, ITEMID, CURRENT, SUPERSEDED_BY, DEPENDS_ON, SUBSCRIBED_ITEMS
+from MoinMoin.constants.keys import (ITEMTYPE, CONTENTTYPE, ITEMID, CURRENT,
+                                     SUPERSEDED_BY, SUBSCRIPTIONS, DEPENDS_ON)
 from MoinMoin.constants.contenttypes import CONTENTTYPE_USER
 from MoinMoin.items import Item, Contentful, register, BaseModifyForm
 from MoinMoin.items.content import NonExistentContent
         id_ = item.meta[ITEMID]
         self['supersedes'].set(Term(SUPERSEDED_BY, id_))
         self['required_by'].set(Term(DEPENDS_ON, id_))
-        self['subscribers'].set(Term(SUBSCRIBED_ITEMS, id_))
+        self['subscribers'].set(Term(SUBSCRIPTIONS, id_))
 
 
 class TicketForm(BaseModifyForm):
 # Copyright: 2008 MoinMoin:ThomasWaldmann
 # Copyright: 2007 MoinMoin:JohannesBerg
 # License: GNU GPL v2 (or any later version), see LICENSE.txt for details.
-
 """
     MoinMoin - init "logging" system
 
 keys=root
 
 [handlers]
-keys=stderr
+keys=stderr,email
 
 [formatters]
 keys=default
 
 [logger_root]
 level=%(loglevel)s
-handlers=stderr
+handlers=stderr,email
 
 [handler_stderr]
 class=StreamHandler
 formatter=default
 args=(sys.stderr, )
 
+[handler_email]
+class=MoinMoin.log.EmailHandler
+level=ERROR
+formatter=default
+args=()
+
 [formatter_default]
 format=%(asctime)s %(levelname)s %(name)s:%(lineno)d %(message)s
 datefmt=
 
 configured = False
 fallback_config = False
+in_email_handler = False
 
 import warnings
 
         if isinstance(levelnumber, int):  # that list has also the reverse mapping...
             setattr(logger, levelname, levelnumber)
     return logger
+
+
+class EmailHandler(logging.Handler):
+    """ A custom handler class which sends email for each logging event using
+    wiki mail configuration
+    """
+    def __init__(self, toaddrs=[], subject=u''):
+        """ Initialize the handler
+
+        @param toaddrs: address or a list of email addresses whom to send email
+        @param subject: unicode email's subject
+        """
+        logging.Handler.__init__(self)
+        if isinstance(toaddrs, basestring):
+            toaddrs = [toaddrs]
+        self.toaddrs = toaddrs
+        self.subject = subject
+
+    def emit(self, record):
+        """ Emit a record.
+
+        Send the record to the specified addresses
+        """
+        # the app config is accessible after logging is initialized, so set the
+        # arguments and make the decision to send mail or not here
+        from flask import current_app as app
+        if not app.cfg.email_tracebacks:
+            return
+
+        global in_email_handler
+        if in_email_handler:
+            return
+        in_email_handler = True
+        toaddrs = self.toaddrs if self.toaddrs else app.cfg.admin_emails
+        log_level = logging.getLevelName(self.level)
+        subject = self.subject if self.subject else u'[{0}][{1}] Log message'.format(
+            app.cfg.sitename, log_level)
+        msg = self.format(record)
+        from MoinMoin.mail.sendmail import sendmail
+        sendmail(subject, msg, to=toaddrs)
+        in_email_handler = False

MoinMoin/mail/sendmail.py

         return str(address)
 
 
-def sendmail(subject, text, to=None, cc=None, bcc=None, mail_from=None):
+def sendmail(subject, text, to=None, cc=None, bcc=None, mail_from=None, html=None):
     """ Create and send a text/plain message
 
     Return a tuple of success or error indicator and message.
     :type bcc: list
     :param mail_from: override default mail_from
     :type mail_from: unicode
+    :param html: html email body text
+    :type html: unicode
 
     :rtype: tuple
     :returns: (is_ok, Description of error or OK message)
     import smtplib
     import socket
     from email.message import Message
+    from email.mime.multipart import MIMEMultipart
+    from email.mime.text import MIMEText
     from email.charset import Charset, QP
     from email.utils import formatdate, make_msgid
 
     # Create a message using CHARSET and quoted printable
     # encoding, which should be supported better by mail clients.
     # TODO: check if its really works better for major mail clients
-    msg = Message()
+    text_msg = Message()
     charset = Charset(CHARSET)
     charset.header_encoding = QP
     charset.body_encoding = QP
-    msg.set_charset(charset)
+    text_msg.set_charset(charset)
 
     # work around a bug in python 2.4.3 and above:
-    msg.set_payload('=')
-    if msg.as_string().endswith('='):
+    text_msg.set_payload('=')
+    if text_msg.as_string().endswith('='):
         text = charset.body_encode(text)
 
-    msg.set_payload(text)
+    text_msg.set_payload(text)
+
+    if html:
+        msg = MIMEMultipart('alternative')
+        msg.attach(text_msg)
+        html = html.encode(CHARSET)
+        html_msg = MIMEText(html, 'html')
+        html_msg.set_charset(charset)
+        msg.attach(html_msg)
+    else:
+        msg = text_msg
 
     address = encodeAddress(mail_from, charset)
     msg['From'] = address

MoinMoin/script/account/disable.py

             u.name = u"{0}-{1}".format(u.name, u.id)
             if u.email:
                 u.email = u"{0}-{1}".format(u.email, u.id)
-            u.subscribed_items = []  # avoid using email
+            u.subscriptions = []
             u.save()
             print "- disabled."
         else:

MoinMoin/script/account/resetpw.py

 from flask import current_app as app
 from flask.ext.script import Command, Option
 
-from MoinMoin.constants.keys import ITEMID, NAME, NAME_EXACT, EMAIL
+from MoinMoin.constants.keys import (
+    ITEMID, NAME, NAME_EXACT, EMAIL, EMAIL_UNVALIDATED,
+)
 from MoinMoin import user
 from MoinMoin.app import before_wiki
 
             return
         u.set_password(password)
         u.save()
-        if not u.email:
-            raise UserHasNoEMail('User profile does not have an E-Mail address (name: %r id: %r)!' % (u.name, u.id))
         if notify and not u.disabled:
+            if not u.email:
+                raise UserHasNoEMail('Notification was requested, but User profile does not have a validated E-Mail address (name: %r id: %r)!' % (u.name, u.itemid))
             mailok, msg = u.mail_password_recovery(subject=subject, text=text)
             if not mailok:
                 raise MailFailed(msg)
         total = len(uids_metas)
         for nr, (uid, meta) in enumerate(uids_metas, start=1):
             name = meta[NAME]
-            email = meta[EMAIL]
+            email = meta.get(EMAIL)
+            if email is None:
+                email = meta.get(EMAIL_UNVALIDATED)
+                if email is None:
+                    raise ValueError("neither EMAIL nor EMAIL_UNVALIDATED key is present in user profile metadata of uid %r name %r" % (uid, name))
+                else:
+                    email += '[email_unvalidated]'
             try:
                 set_password(uid, password, notify=notify, skip_invalid=skip_invalid,
                              subject=subject, text=text)

MoinMoin/script/migration/moin19/import19.py

                     editlog_data = {  # make something up
                         NAME: [item.name],
                         MTIME: int(os.path.getmtime(path)),
-                        ACTION: u'SAVE',
+                        ACTION: ACTION_SAVE,
                     }
             meta, data = split_body(content)
         meta.update(editlog_data)
         except KeyError:
             meta = {  # make something up
                 MTIME: int(os.path.getmtime(attpath)),
-                ACTION: u'SAVE',
+                ACTION: ACTION_SAVE,
             }
         meta[NAME] = [u'{0}/{1}'.format(item_name, attach_name)]
         if acl is not None:
                 if extra:
                     result[NAME_OLD] = extra
                 del result[EXTRA]
-                result[ACTION] = u'RENAME'
+                result[ACTION] = ACTION_RENAME
             elif action == 'SAVE/REVERT':
                 if extra:
                     result[REVERTED_TO] = int(extra)
                 del result[EXTRA]
-                result[ACTION] = u'REVERT'
+                result[ACTION] = ACTION_REVERT
         userid = result[USERID]
         #TODO
         #if userid:
         meta = dict([(k, v) for k, v in meta.items() if v])  # remove keys with empty values
         if meta.get(ACTION) == u'SAVENEW':
             # replace SAVENEW with just SAVE
-            meta[ACTION] = u'SAVE'
+            meta[ACTION] = ACTION_SAVE
         return meta
 
     def find_attach(self, attachname):
             raise KeyError
         del meta['__rev']
         del meta[EXTRA]  # we have full name in NAME
-        meta[ACTION] = u'SAVE'
+        meta[ACTION] = ACTION_SAVE
         meta = dict([(k, v) for k, v in meta.items() if v])  # remove keys with empty values
         return meta
 
         meta[ITEMID] = make_uuid()
         meta[REVID] = make_uuid()
         meta[SIZE] = 0
-        meta[ACTION] = u'SAVE'
+        meta[ACTION] = ACTION_SAVE
         self.meta = meta
         self.data = StringIO('')
 
         # rename aliasname to display_name:
         metadata[DISPLAY_NAME] = metadata.get('aliasname')
 
-        # rename subscribed_pages to subscribed_items
-        metadata[SUBSCRIBED_ITEMS] = metadata.get('subscribed_pages', [])
+        # transfer subscribed_pages to subscription_patterns
+        metadata[SUBSCRIPTIONS] = migrate_subscriptions(metadata.get('subscribed_pages', []))
 
         # convert bookmarks from usecs (and str) to secs (int)
         metadata[BOOKMARKS] = [(interwiki, int(long(bookmark) / 1000000))
     else:
         raise ValueError("unsupported content object: {0!r}".format(content))
     return size, HASH_ALGORITHM, unicode(hash.hexdigest())
+
+
+def migrate_subscriptions(subscribed_items):
+    """ Transfer subscribed_items meta to subscriptions meta
+
+    :param subscribed_items: a list of moin19-format subscribed_items
+    :return: subscriptions
+    """
+    subscriptions = []
+    for subscribed_item in subscribed_items:
+        # TODO: try to determine if pagename is not a regexp and create
+        # a subscription_id with a NAME keyword
+        # TODO: support interwiki wikiname
+        wikiname, pagename = subscribed_item.split(":", 1)
+        subscriptions.append("{0}::{2}".format(NAMERE, pagename))
+    return subscriptions

MoinMoin/signalling/log.py

 
 
 from .signals import *
+from flask import got_request_exception
 
 from MoinMoin import log
 logging = log.getLogger(__name__)
 
 
 @item_modified.connect_via(ANY)
-def log_item_modified(app, item_name):
+def log_item_modified(app, item_name, **kwargs):
     wiki_name = app.cfg.interwikiname
     logging.info(u"item {0}:{1} modified".format(wiki_name, item_name))
+
+
+@got_request_exception.connect_via(ANY)
+def log_exception(sender, exception, **extra):
+    logging.exception(exception)

MoinMoin/storage/middleware/_tests/test_indexing.py

 from flask import g as flaskg
 
 from MoinMoin.constants.keys import (NAME, SIZE, ITEMID, REVID, DATAID, HASH_ALGORITHM, CONTENT, COMMENT,
-                                     LATEST_REVS, ALL_REVS, NAMESPACE)
+                                     LATEST_REVS, ALL_REVS, NAMESPACE, NAMERE, NAMEPREFIX)
 from MoinMoin.constants.namespaces import NAMESPACE_USERPROFILES
 
 from MoinMoin.util.interwiki import split_fqname
         assert expected_revid == doc[REVID]
         assert unicode(data) == doc[CONTENT]
 
+    def test_indexing_subscriptions(self):
+        item_name = u"foo"
+        meta = dict(name=[item_name, ], subscriptions=[u"{0}::foo".format(NAME),
+                                                       u"{0}::.*".format(NAMERE)])
+        item = self.imw[item_name]
+        item.store_revision(meta, StringIO(str(item_name)))
+        doc1 = self.imw.document(subscription_ids=u"{0}::foo".format(NAME))
+        doc2 = self.imw.document(subscription_patterns=u"{0}::.*".format(NAMERE))
+        assert doc1 is not None
+        assert doc2 is not None
+        doc3 = self.imw.document(subscription_ids=u"{0}::.*".format(NAMERE))
+        doc4 = self.imw.document(subscription_patterns=u"{0}::foo".format(NAMEPREFIX))
+        assert doc3 is None
+        assert doc4 is None
+
     def test_namespaces(self):
         item_name_n = u'normal'
         item = self.imw[item_name_n]

MoinMoin/storage/middleware/_tests/test_validation.py

 
         state = {'trusted': False,  # True for loading a serialized representation or other trusted sources
                  keys.NAME: u'somename',  # name we decoded from URL path
-                 keys.ACTION: u'SAVE',
+                 keys.ACTION: keys.ACTION_SAVE,
                  keys.HOSTNAME: u'localhost',
                  keys.ADDRESS: u'127.0.0.1',
                  keys.USERID: make_uuid(),
             keys.NAME: [u"user name", ],
             keys.NAMESPACE: u"userprofiles",
             keys.EMAIL: u"foo@example.org",
+            keys.SUBSCRIPTIONS: [u"{0}:{1}".format(keys.ITEMID, make_uuid()),
+                                 u"{0}::foo".format(keys.NAME),
+                                 u"{0}::bar".format(keys.TAGS),
+                                 u"{0}::".format(keys.NAMERE),
+                                 u"{0}:userprofiles:a".format(keys.NAMEPREFIX),
+                                 ]
+        }
+
+        invalid_meta = {
+            keys.SUBSCRIPTIONS: [u"", u"unknown_tag:123",
+                                 u"{0}:123".format(keys.ITEMID),
+                                 u"{0}:foo".format(keys.NAME),
+                                 ]
         }
 
         state = {'trusted': False,  # True for loading a serialized representation or other trusted sources
                  keys.NAME: u'somename',  # name we decoded from URL path
-                 keys.ACTION: u'SAVE',
+                 keys.ACTION: keys.ACTION_SAVE,
                  keys.HOSTNAME: u'localhost',
                  keys.ADDRESS: u'127.0.0.1',
                  keys.WIKINAME: u'ThisWiki',
                 print e.valid, e
             print m.valid, m
         assert valid
+
+        m = UserMetaSchema(invalid_meta)
+        valid = m.validate(state)
+        assert not valid
+        for e in m.children:
+            if e.name in (keys.SUBSCRIPTIONS,):
+                for value in e:
+                    assert not value.valid

MoinMoin/storage/middleware/indexing.py

     doc = dict([(key, value)
                 for key, value in meta.items()
                 if key in schema])
+    if SUBSCRIPTION_IDS in schema and SUBSCRIPTIONS in meta:
+        doc[SUBSCRIPTION_IDS], doc[SUBSCRIPTION_PATTERNS] = backend_subscriptions_to_index(meta[SUBSCRIPTIONS])
     for key in [MTIME, PTIME]:
         if key in doc:
             # we have UNIX UTC timestamp (int), whoosh wants datetime
     return doc
 
 
+def backend_subscriptions_to_index(subscriptions):
+    """ Split subscriptions list to subscription_ids and subscription_patterns lists
+    which match the fields of the whoosh schema
+
+    :param subscriptions: user subscriptions meta
+    :return: tuple containing a list of subscription_ids and a list of
+             subscription_patterns
+    """
+    subscription_ids = []
+    subscription_patterns = []
+    for subscription in subscriptions:
+        keyword = subscription.split(':')[0]
+        if keyword in (ITEMID, NAME, TAGS, ):
+            subscription_ids.append(subscription)
+        elif keyword in (NAMERE, NAMEPREFIX, ):
+            subscription_patterns.append(subscription)
+    return subscription_ids, subscription_patterns
+
+
 from MoinMoin.util.mime import Type, type_moin_document
 from MoinMoin.util.tree import moin_page
 from MoinMoin.converter import default_registry
             EMAIL: ID(stored=True),
             OPENID: ID(stored=True),
             DISABLED: BOOLEAN(stored=True),
+            LOCALE: ID(stored=True),
+            SUBSCRIPTION_IDS: ID(),
+            SUBSCRIPTION_PATTERNS: ID(),
         }
         latest_revs_fields.update(**userprofile_fields)
 
     def store_revision(self, meta, data, overwrite=False,
                        trusted=False,  # True for loading a serialized representation or other trusted sources
                        name=None,  # TODO name we decoded from URL path
-                       action=u'SAVE',
+                       action=ACTION_SAVE,
                        remote_addr=None,
                        userid=None,
                        wikiname=None,

MoinMoin/storage/middleware/validation.py

 from __future__ import absolute_import, division
 
 import time
+import re
 
 from flatland import Dict, List, Unset, Boolean, Integer, String
 
     v = element.value
     if not isinstance(v, unicode):
         return False
-    if v not in [u'SAVE', u'REVERT', u'TRASH', u'COPY', u'RENAME', ]:
+    if v not in [keys.ACTION_SAVE, keys.ACTION_REVERT, keys.ACTION_TRASH,
+                 keys.ACTION_COPY, keys.ACTION_RENAME]:
         return False
     return True
 
     except ValueError:
         return False
 
+
+def subscription_validator(element, state):
+    """
+    a subscription
+    """
+    try:
+        keyword, value = element.value.split(":", 1)
+    except ValueError:
+        element.add_error("Subscription must contain colon delimiters.")
+        return False
+
+    if keyword in (keys.ITEMID, ):
+        value_element = String(value)
+        valid = uuid_validator(value_element, state)
+    elif keyword in (keys.NAME, keys.TAGS, keys.NAMERE, keys.NAMEPREFIX, ):
+        try:
+            namespace, value = value.split(":", 1)
+        except ValueError:
+            element.add_error("Subscription must contain 2 colon delimiters.")
+            return False
+        namespace_element = String(namespace)
+        if not namespace_validator(namespace_element, state):
+            element.add_error("Not a valid namespace value.")
+            return False
+    else:
+        element.add_error(
+            "Subscription must start with one of the keywords: "
+            "'{0}', '{1}', '{2}', '{3}' or '{4}'.".format(keys.ITEMID,
+                                                          keys.NAME, keys.TAGS,
+                                                          keys.NAMERE,
+                                                          keys.NAMEPREFIX))
+        return False
+
+    value_element = String(value)
+    if keyword == keys.TAGS:
+        valid = tag_validator(value_element, state)
+    elif keyword in (keys.NAME, keys.NAMEPREFIX, ):
+        valid = name_validator(value_element, state)
+    elif keyword == keys.NAMERE:
+        try:
+            re.compile(value, re.U)
+            valid = True
+        except re.error:
+            valid = False
+    if not valid:
+        element.add_error("Subscription has invalid value.")
+    return valid
+
+
 common_meta = (
     String.named(keys.ITEMID).validated_by(itemid_validator),
     String.named(keys.REVID).validated_by(revid_validator),
     Boolean.named(keys.SCROLL_PAGE_AFTER_EDIT).using(optional=True),
     Boolean.named(keys.MAILTO_AUTHOR).using(optional=True),
     List.named(keys.QUICKLINKS).of(String.named('quicklinks')).using(optional=True),
-    List.named(keys.SUBSCRIBED_ITEMS).of(String.named('subscribed_item')).using(optional=True),
+    List.named(keys.SUBSCRIPTIONS).of(String.named('subscription').validated_by(subscription_validator)).using(optional=True),
     List.named(keys.EMAIL_SUBSCRIBED_EVENTS).of(String.named('email_subscribed_event')).using(optional=True),
     #TODO: DuckDict.named('bookmarks').using(optional=True),
     *common_meta

MoinMoin/templates/base.html

     {% block icons_stylesheet %}
     <link rel="stylesheet" href="{{ url_for('serve.files', name='font_awesome', filename='css/font-awesome.css') }}" />
     {% endblock %}
-    
+
     <link rel="shortcut icon" href="{{ url_for('static', filename='logos/favicon.ico') }}" />
 
     {% block theme_stylesheets %}
 </div>
 
 {% block body_scripts %} {# js before </body> reduces IE8 js errors related to svgweb #}
-    <!--[if IE 8]>
-        <script src="{{ url_for('serve.files', name='svgweb', filename='svg.js') }}"></script>
-    <![endif]-->
     <script src="{{ url_for('serve.files', name='jquery', filename='jquery.min.js') }}"></script>
     <script src="{{ url_for('serve.files', name='bootstrap', filename='js/bootstrap.min.js') }}"></script>
     <script src="{{ url_for('frontend.template', filename='common.js') }}"></script>
         {# TODO: use a local copy later #}
         <script src="http://html5shiv.googlecode.com/svn/trunk/html5.js"></script>
     <![endif]-->
-    <!--[if lt IE 8]>
-        {# required to save user settings with IE7 and earlier #}
-        <script src="{{ url_for('serve.files', name='json_js', filename='json2.js') }}"></script>
-    <![endif]-->
 {% endblock %}
 
 </body>

MoinMoin/templates/itemviews.html

             {%- if endpoint == 'frontend.subscribe_item' and user.valid %}
                 <li>
                     <a href="{{ url_for(endpoint, item_name=fqname) }}" title="{{ title }}" rel="nofollow">
-                        {%- if user.is_subscribed_to([fqname]) %}
+                        {%- if user.is_subscribed_to(item) %}
                             {{ _('Unsubscribe') }}
                         {%- else %}
                             {{ _('Subscribe') }}

MoinMoin/templates/mail/content_diff.html

+{% macro content_diff(diff) %}
+<table border="0" cellpadding="0" cellspacing="0" style="margin:0; padding:1em;
+        width:100%; font-family:monospace; background-color:#F9F9F9;">
+{% for line in diff -%}
+{% if line[:2] == "+ " -%}
+    <tr style="color:#00B000; height:1.2em;">
+{% elif line[:2] == "- " -%}
+    <tr style="color:#991111; height:1.2em;">
+{% elif line[:2] == "@@" -%}
+    <tr style="color:#440088; height:1.2em;">
+{% else -%}
+    <tr style="color:#000000; height:1.2em;">
+{%- endif %}
+    {% if line[:2] == "+ " %}
+        <td style="font-family:monospace; width:1.2em; vertical-align:top;">{{ line[0] }}</td>
+        <td style="font-family:monospace;"><ins style="text-decoration:none;">{{ line[2:] }}</ins></td>
+    {% elif line[:2] == "- " -%}
+        <td style="font-family:monospace; width:1.2em; vertical-align:top;">{{ line[0] }}</td>
+        <td style="font-family:monospace;"><del style="text-decoration:none;">{{ line[2:] }}</del></td>
+    {% elif line[:2] == "@@" -%}
+        <td style="font-family:monospace; width:1.2em;"></td>
+        <td style="font-family:monospace;">{{ line }}</td>
+    {% else -%}
+        <td style="font-family:monospace; width:1.2em;"></td>
+        <td style="font-family:monospace;">{{ line[2:] }}</td>
+    {%- endif %}
+    </tr>
+{%- endfor %}
+</table>
+{% endmacro %}

MoinMoin/templates/mail/html_base.html

+{# HTML mail templates are based on htmlemailboilerplate authored by Sean Powell, The Engage Group, MIT licensed #}
+{# For more information visit: http://htmlemailboilerplate.com/ #}
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+    <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
+    <title>{% block title %}{% endblock %}</title>
+    <style type="text/css">
+        #outlook a {padding:0;}
+        #backgroundTable {margin:0; padding:0; width:100% !important; line-height:100% !important;}
+        .ExternalClass {width:100%;}
+        .ExternalClass, .ExternalClass p, .ExternalClass span, .ExternalClass font,
+        .ExternalClass td, .ExternalClass div {line-height:100%;}
+        p {margin:1em 0;}
+        h1, h2, h3, h4, h5, h6 {color:black !important;}
+        table td {border-collapse:collapse; padding:0;}
+    </style>
+</head>
+<body style="width:100% !important; -webkit-text-size-adjust:100%; -ms-text-size-adjust:100%; margin:0; padding:0;">
+<table border="0" cellpadding="0" cellspacing="0" width="100%" id="backgroundTable">
+    <tr>
+        <td>
+            {% block content %}{% endblock %}
+        </td>
+    </tr>
+</table>
+</body>
+</html>

MoinMoin/templates/mail/meta_diff.html

+{% macro meta_diff(diff) %}
+<table border="0" cellpadding="0" cellspacing="0" style="margin:0; padding:10px;
+        width:100%; font-family:monospace; background-color:#F9F9F9">
+{% for change in diff -%}
+{% if change[0] == "insert" -%}
+    <tr style="color:#00B000; height:1.2em;">
+    {% set marker = '+' -%}
+{% elif change[0] == "delete" -%}
+    <tr style="color:#991111; height:1.2em;">
+    {% set marker = '-' -%}
+{%- endif %}
+        <td style="font-family:monospace; width:1.2em; vertical-align:top;">{{ marker }}</td>
+        <td style="font-family:monospace;">
+        {% if change[0] == "insert" -%}
+            <ins style="text-decoration:none;">
+                {{ change[1] | join(".") }}{% if change[1] -%}:{%- endif %}
+                {{ change[2] }}
+            </ins>
+        {% elif change[0] == "delete" -%}
+            <del style="text-decoration:none;">
+                {{ change[1] | join(".") }}{% if change[1] -%}:{%- endif %}
+                {{ change[2] }}
+            </del>
+        {% endif %}
+        </td>
+    </tr>
+{%- endfor %}
+</table>
+{% endmacro %}

MoinMoin/templates/mail/notification.txt

+{{ _("Dear Wiki user,") }}
+
+{{ notification_sentence }}
+{{ diff_url }}
+
+{% if comment -%}
+{{ _("Comment:") }}
+{{ comment }}
+{%- endif %}
+
+{% if content_diff_ -%}
+{{ _("Data changes:") }}
+{%- for line in content_diff_ %}
+{{ line }}
+{%- endfor -%}
+{%- endif %}
+
+{% if meta_diff_ -%}
+{{ _("Metadata changes:") }}
+{%- for line in meta_diff_ %}
+{{ line }}
+{%- endfor -%}
+{%- endif %}
+
+{{ _("You are receiving this because you have subscribed to a wiki item on
+'%(wiki_name)s' for change notifications.", wiki_name=wiki_name) }}
+{{ _("Item link: %(item_url)s", item_url=item_url) }}
+{{ _("To unsubscribe use: %(unsubscribe_url)s",
+unsubscribe_url=unsubscribe_url) }}

MoinMoin/templates/mail/notification_main.html

+{% extends theme("mail/html_base.html") %}
+{% from "mail/content_diff.html" import content_diff %}
+{% from "mail/meta_diff.html" import meta_diff %}
+
+{% block title %}Wiki Notification{% endblock %}
+
+{% block content -%}
+    <div>
+        <p style="text-align:center; margin-bottom:0.6em;">{{ _("Dear Wiki user,") }}</p>
+        <p style="text-align:center; margin-top:0.4em; margin-bottom:0;">
+            {{ notification_sentence }}
+        </p>
+        <p style="text-align:center; margin-top:0.4em; margin-bottom:0;">
+            {{ _("Item link:") }}
+            <a href="{{ item_url }}"
+               style="text-decoration:none; color:#3CA7DD;">{{ item_url }}
+            </a>
+        </p>
+        <p style="text-align:center; margin-top:0.4em;">
+            <a href="{{ diff_url }}"
+                style="text-decoration:none; color:#3CA7DD;">{{ diff_url }}
+            </a>
+        </p>
+    </div>
+    {% if comment -%}
+    <h3 style="margin:0 1.3em 0.5em;">{{ _("Comment") }}</h3>
+    <p>{{ comment }}</p>
+    {%- endif %}
+    {% if content_diff_ -%}
+    <h3 style="margin:1em 1.3em 0.5em;">{{ _("Content") }}</h3>
+    {{ content_diff(content_diff_) }}
+    {%- endif %}
+    {% if meta_diff_ -%}
+    <h3 style="margin:1em 1.3em 0.5em;">{{ _("Metadata") }}</h3>
+    {{ meta_diff(meta_diff_) }}
+    {%- endif %}
+    <div style="font-size:0.7em; margin-top:2em;">
+        <p style="text-align:center; color:#A6A6A6; margin-bottom:0;">
+            {{ _("You are receiving this because you have subscribed to a wiki
+        item on %(wiki_name)s for change notifications.", wiki_name=wiki_name) }}</p>
+        <p style="text-align:center; margin-top:0;"><strong><a
+                href="{{ unsubscribe_url }}"
+            style="text-decoration:none; color:#3CA7DD;">{{ _("Unsubscribe from
+            this item notifications") }}</a></strong></p>
+    </div>
+{%- endblock %}

MoinMoin/templates/usersettings_ajax.html

     {{ user_forms.navigation(form) }}
 {% elif part == 'options' %}
     {{ user_forms.options(form) }}
+{% elif part == 'subscriptions' %}
+    {{ user_forms.subscriptions(form) }}
 {% endif %}

MoinMoin/templates/usersettings_forms.html

 {{ gen.form.close() }}
 {% endmacro %}
 
+{% macro subscriptions(form) %}
+{{ gen.form.open(form, method="post", action=url_for('frontend.usersettings')) }}
+{{ forms.render_errors(form) }}
+<dl>
+    {{ forms.render(form['subscriptions']) }}
+</dl>
+{{ forms.render_hidden('part', 'subscriptions') }}
+{{ forms.render_submit(form) }}
+{{ gen.form.close() }}
+{% endmacro %}
+
 {# javascript functions within common.js are dependent upon the structure, classes and ids defined here #}
 {% macro all_usersettings_forms(form_objs) %}
 <div id="moin-usersettings">
         <h2 class="moin-settings-head"><a href="#options">{{ _("Options") }}</a></h2>
         {{ options(form_objs.options) }}
     </div>
+    <div id="subscriptions" class="moin-tab-body moin-form">
+        <h2 class="moin-settings-head"><a href="#subscriptions">{{ _("Subscriptions") }}</a></h2>
+        {{ subscriptions(form_objs.subscriptions) }}
+    </div>
 </div>
 {% endmacro %}
 from MoinMoin.mail import sendmail
 from MoinMoin.util.interwiki import getInterwikiHome, getInterwikiName, is_local_wiki
 from MoinMoin.util.crypto import generate_token, valid_token, make_uuid
+from MoinMoin.util.subscriptions import get_matched_subscription_patterns
 from MoinMoin.storage.error import NoSuchItemError, ItemAlreadyExistsError, NoSuchRevisionError
 
 
-def create_user(username, password, email, openid=None, validate=True, is_encrypted=False, is_disabled=False):
-    """ create a user """
-    # Create user profile
+def create_user(username, password, email, validate=True, is_encrypted=False, **meta):
+    """
+    Create a new user
+
+    :param username: unique user name
+    :param password: user's password - see also is_encrypted param
+    :param email: unique email address
+    :param validate: if True (default) will validate username, password, email
+                        and the uniqueness of the user created
+    :param is_encrypted: if False (default) defines that the password is in
+                        plaintext, when True - password was already encrypted
+    :param meta: a dictionary of key-value pairs that represent user metadata and
+                    will be stored into user profile metadata
+    """
     theuser = User(auth_method="new-user")
 
     # Don't allow creating users with invalid names
         theuser.profile[EMAIL] = email
 
     # Openid should be unique
+    openid = meta.get(OPENID)
     if validate and openid and search_users(openid=openid):
         return _('This OpenID already belongs to somebody else.')
 
-    theuser.profile[OPENID] = openid
+    theuser.profile[DISABLED] = meta.get("is_disabled", False)
 
-    theuser.profile[DISABLED] = is_disabled
-
-    # save data
+    # TODO requires validation (preferably using flatland)
+    for key, value in meta.items():
+        theuser.profile[key] = value
     theuser.save()
 
 
     return (name == normalized) and not wikiutil.isGroupItem(name)
 
 
+def assemble_subscription(keyword, value, namespace=None):
+    """ Create a valid subscription string
+
+    :param keyword: the keyword (itemid, name, tags, nameprefix, namere) by which
+                    the type of the subscription is determined
+    :param value: the subscription value (itemid, name, tag, regexp or nameprefix value)
+    :param namespace: the namespace of the subscription
+    :return: subscription string
+    """
+    if keyword == ITEMID:
+        subscription = "{0}:{1}".format(ITEMID, value)
+    elif keyword in [NAME, TAGS, NAMERE, NAMEPREFIX, ]:
+        if namespace is not None:
+            subscription = "{0}:{1}:{2}".format(keyword, namespace, value)
+        else:
+            raise ValueError("The subscription by {0} keyword requires a namespace".format(keyword))
+    else:
+        raise ValueError("Invalid keyword string: {0}".format(keyword))
+    return subscription
+
+
 class UserProfile(object):
     """ A User Profile"""
 
 
     # Subscribed Items -------------------------------------------------------
 
-    def is_subscribed_to(self, pagelist):
-        """ Check if user subscription matches any page in pagelist.
+    def is_subscribed_to(self, item):
+        """ Check if user is subscribed to the following item
 
-        The subscription contains interwiki page names. e.g 'WikiName:Page_Name'
-
-        TODO: check if it's fast enough when getting called for many
-              users from page.getSubscribersList()
-
-        :param pagelist: list of pages to check for subscription
+        :param item: Item object
         :rtype: bool
-        :returns: if user is subscribed any page in pagelist
+        :returns: if user is subscribed to the item
         """
         if not self.valid:
             return False
 
-        # Create a new list with interwiki names.
-        pages = [getInterwikiName(pagename) for pagename in pagelist]
-        # Create text for regular expression search
-        text = '\n'.join(pages)
+        meta = item.meta
+        try:
+            item_namespace = meta[NAMESPACE]
+        except KeyError:
+            return False
+        subscriptions = {"{0}:{1}".format(ITEMID, meta[ITEMID])}
+        subscriptions.update("{0}:{1}:{2}".format(NAME, item_namespace, name)
+                             for name in meta[NAME])
+        subscriptions.update("{0}:{1}:{2}".format(TAGS, item_namespace, tag)
+                             for tag in meta[TAGS])
+        if subscriptions & set(self.subscriptions):
+            return True
 
-        for pattern in self.subscribed_items:
-            # Try simple match first
-            if pattern in pages:
-                return True
-            # Try regular expression search, skipping bad patterns
-            try:
-                pattern = re.compile(r'^{0}$'.format(pattern), re.M)
-            except re.error:
-                continue
-            if pattern.search(text):
-                return True
-
-        return False
-
-    def subscribe(self, pagename):
-        """ Subscribe to a wiki page.
-
-        Page names are saved as interwiki names.
-
-        :param pagename: name of the page to subscribe
-        :type pagename: unicode
-        :rtype: bool
-        :returns: if page was subscribed
-        """
-        pagename = getInterwikiName(pagename)
-        subscribed_items = self.subscribed_items
-        if pagename not in subscribed_items:
-            subscribed_items.append(pagename)
-            self.save(force=True)
-            # XXX SubscribedToPageEvent
+        if get_matched_subscription_patterns(self.subscriptions, **meta):
             return True
         return False
 
-    def unsubscribe(self, pagename):
-        """ Unsubscribe a wiki page.
+    def subscribe(self, keyword, value, namespace=None):
+        """ Subscribe to a wiki page.
 
-        Try to unsubscribe by removing interwiki name from the subscription
-        list.
+        The user can subscribe in 5 different ways:
+        * by itemid - ITEMID:<itemid value>
+        * by item name - NAME:<namespace>:<name value>
+        * by a tagname - TAGS:<namespace>:<tag value>
+        * by a prefix name - NAMEPREFIX:<namespace>:<name prefix>
+        * by a regular expression - NAMERE:<namespace>:<name regexp>
 
-        Its possible that the user will be subscribed to a page by more
-        than one pattern. It can be both interwiki name and a regex pattern that
-        both match the page. Therefore, we must check if the user is
-        still subscribed to the page after we try to remove names from the list.
+:       :param keyword: the keyword (itemid, name, tags, nameprefix, namere) by which
+                        the type of the subscription is determined
+        :param value: the subscription value (itemid, name, tag, regexp or nameprefix value)
+        :param namespace: the namespace of the subscription; itemid keyword doesn't
+                            require a namespace
+        :rtype: bool
+        :returns: if user was subscribed
+        """
+        subscription = assemble_subscription(keyword, value, namespace)
+        subscriptions = self.subscriptions
+        if subscription not in subscriptions:
+            subscriptions.append(subscription)
+            self.save(force=True)
+            return True
+        return False
 
-        :param pagename: name of the page to subscribe
-        :type pagename: unicode
+    def unsubscribe(self, keyword, value, namespace=None, item=None):
+        """ Unsubscribe from a wiki page.
+
+        Same as for subscribing, user can also unsubscribe in 5 ways.
+        The unsubscribe action doesn't guarantee that user will not receive any
+        notification for this item, since user can be subscribed by some other
+        patterns that match current item.
+
+        :param keyword: the keyword (itemid, name, tags, nameprefix, namere) by which
+                        the type of the subscription is determined
+        :param value: the subscription value (itemid, name, tag, regexp or nameprefix value)
+        :param namespace: the namespace of the subscription; itemid keyword doesn't
+                            require a namespace
+        :param item: Item object to check if the user is still subscribed
         :rtype: bool
-        :returns: if unsubscribe was successful. If the user has a
-            regular expression that matches, unsubscribe will always fail.
+        :returns: if user was unsubscribed
         """
-        interWikiName = getInterwikiName(pagename)
-        subscribed_items = self.profile[SUBSCRIBED_ITEMS]
-        if interWikiName and interWikiName in subscribed_items:
-            subscribed_items.remove(interWikiName)
+        subscription = assemble_subscription(keyword, value, namespace)
+        subscriptions = self.subscriptions
+        if subscription in subscriptions:
+            subscriptions.remove(subscription)
             self.save(force=True)
-        return not self.is_subscribed_to([pagename])
+            return not self.is_subscribed_to(item) if item else True
+        return False
 
     # Quicklinks -------------------------------------------------------------
 

MoinMoin/util/_tests/test_diff_datastruct.py

+# Copyright: 2013 MoinMoin:AnaBalica
+# License: GNU GPL v2 (or any later version), see LICENSE.txt for details.
+
+"""
+    MoinMoin - MoinMoin.util.diff_datastruct Tests
+"""
+
+import pytest
+
+from MoinMoin.util.diff_datastruct import diff, make_text_diff, Undefined, INSERT, DELETE
+
+
+class TestDiffDatastruct(object):
+
+    def _test_make_text_diff(self, tests):
+        for changes, expected in tests:
+            for got in make_text_diff(changes):
+                assert got == expected
+
+    def test_diff_no_change(self):
+        datastruct = [None, True, 42, u"value", [1, 2, 3], dict(one=1, two=2)]
+        for d in datastruct:
+            assert diff(d, d) == []
+
+    def test_diff_none(self):
+        tests = [(None, None, []),
+                 (Undefined, None, [(INSERT, [], None)]),
+                 (None, Undefined, [(DELETE, [], None)])]
+        for d1, d2, expected in tests:
+            assert diff(d1, d2) == expected
+
+    def test_diff_bool(self):
+        tests = [(True, True, []),
+                 (Undefined, True, [(INSERT, [], True)]),
+                 (True, Undefined, [(DELETE, [], True)]),
+                 (True, False, [(DELETE, [], True), (INSERT, [], False)])]
+        for d1, d2, expected in tests:
+            assert diff(d1, d2) == expected
+
+    def test_diff_int(self):
+        tests = [(1, 1, []),
+                 (Undefined, 2, [(INSERT, [], 2)]),
+                 (2, Undefined, [(DELETE, [], 2)]),
+                 (3, 4, [(DELETE, [], 3), (INSERT, [], 4)])]
+        for d1, d2, expected in tests:
+            assert diff(d1, d2) == expected
+
+    def test_diff_float(self):
+        tests = [(1.1, 1.1, []),
+                 (Undefined, 2.2, [(INSERT, [], 2.2)]),
+                 (2.2, Undefined, [(DELETE, [], 2.2)]),
+                 (3.3, 4.4, [(DELETE, [], 3.3), (INSERT, [], 4.4)])]
+        for d1, d2, expected in tests:
+            assert diff(d1, d2) == expected
+
+    def test_diff_unicode(self):
+        tests = [(u"same", u"same", []),
+                 (Undefined, u"new", [(INSERT, [], u"new")]),
+                 (u"old", Undefined, [(DELETE, [], u"old")]),
+                 (u"some value", u"some other value",
+                  [(DELETE, [], u"some value"), (INSERT, [], u"some other value")])]
+        for d1, d2, expected in tests:
+            assert diff(d1, d2) == expected
+
+    def test_diff_list(self):
+        tests = [([1], [1], []),
+                 (Undefined, [2], [(INSERT, [], [2])]),
+                 ([2], Undefined, [(DELETE, [], [2])]),
+                 ([1, 2], [2, 3], [(DELETE, [], [1]), (INSERT, [], [3])]),
+                 ([9, 8], [8, 7, 6, 5], [(DELETE, [], [9]), (INSERT, [], [7, 6, 5])])]
+        for d1, d2, expected in tests:
+            assert diff(d1, d2) == expected
+
+    def test_diff_dict(self):
+        tests = [(dict(same=1), dict(same=1), []),
+                 (Undefined, dict(new=1), [(INSERT, ["new"], 1)]),
+                 (dict(old=1), Undefined, [(DELETE, ["old"], 1)]),
+                 (dict(same=1, old=2), dict(same=1, new1=3, new2=4),
+                  [(INSERT, ["new1"], 3), (INSERT, ["new2"], 4),
+                   (DELETE, ["old"], 2)])]
+        for d1, d2, expected in tests:
+            assert diff(d1, d2) == expected
+
+    def test_diff_nested_dict(self):
+        tests = [(dict(key=dict(same=None)), dict(key=dict(same=None)), []),
+                 (dict(key=dict()), dict(key=dict(added=None)), [(INSERT, ["key", "added"], None)]),
+                 (dict(key=dict(removed=None)), dict(key=dict()), [(DELETE, ["key", "removed"], None)])]
+        for d1, d2, expected in tests:
+            assert diff(d1, d2) == expected
+
+    def test_diff_str_unicode_keys(self):
+        d1 = {"old": u"old", u"same1": u"same1", "same2": u"same2"}
+        d2 = {u"new": u"new", "same1": u"same1", u"same2": u"same2"}
+        assert diff(d1, d2) == [(INSERT, ["new"], u"new"),
+                                (DELETE, ["old"], u"old")]
+
+    def test_diff_errors(self):
+        tests = [(u"foo", True),
+                 ((1, 2, ), (3, 4, )),
+                 (dict(key=(1, 2, )), dict()),
+                 (None, [1, 2, ])]
+        for d1, d2 in tests:
+            with pytest.raises(TypeError):
+                diff(d1, d2)
+
+    def test_make_text_diff_empty(self):
+        for got in make_text_diff([]):
+            assert got == u""
+
+    def test_make_text_diff_none(self):
+        tests = [([(INSERT, [], None)], u"+ None"),
+                 ([(DELETE, [], None)], u"- None")]
+        self._test_make_text_diff(tests)
+
+    def test_make_text_diff_bool(self):
+        tests = [([(INSERT, [], True)], u"+ True"),
+                 ([(DELETE, [], False)], u"- False")]
+        self._test_make_text_diff(tests)
+
+    def test_make_text_diff_int(self):
+        tests = [([(INSERT, [], 123)], u"+ 123"),
+                 ([(DELETE, [], 321)], u"- 321")]
+        self._test_make_text_diff(tests)
+
+    def test_make_text_diff_float(self):
+        tests = [([(INSERT, [], 1.2)], u"+ 1.2"),
+                 ([(DELETE, [], 3.4)], u"- 3.4")]
+        self._test_make_text_diff(tests)
+
+    def test_make_text_diff_unicode(self):
+        tests = [([(INSERT, [], u"new value")], u"+ new value"),
+                 ([(DELETE, [], u"old value")], u"- old value")]