diff --git a/taiga/mdrender/processors/__init__.py b/taiga/mdrender/extensions/__init__.py similarity index 100% rename from taiga/mdrender/processors/__init__.py rename to taiga/mdrender/extensions/__init__.py diff --git a/taiga/mdrender/gfm/autolink.py b/taiga/mdrender/extensions/autolink.py similarity index 100% rename from taiga/mdrender/gfm/autolink.py rename to taiga/mdrender/extensions/autolink.py diff --git a/taiga/mdrender/gfm/automail.py b/taiga/mdrender/extensions/automail.py similarity index 100% rename from taiga/mdrender/gfm/automail.py rename to taiga/mdrender/extensions/automail.py diff --git a/taiga/mdrender/gfm/emojify.py b/taiga/mdrender/extensions/emojify.py similarity index 99% rename from taiga/mdrender/gfm/emojify.py rename to taiga/mdrender/extensions/emojify.py index 93321fb5..620cac02 100644 --- a/taiga/mdrender/gfm/emojify.py +++ b/taiga/mdrender/extensions/emojify.py @@ -3,6 +3,7 @@ # Tested on Markdown 2.3.1 # # Copyright (c) 2014, Esteban Castro Borsani +# Copyright (c) 2014, Jesús Espino García # The MIT License (MIT) # # Permission is hereby granted, free of charge, to any person obtaining a copy @@ -171,7 +172,7 @@ class EmojifyPreprocessor(Preprocessor): url = emojis_path + emoji + u'.png' - return u's)' % {'emoji': emoji, 'url': url} + return ''.format(emoji=emoji, url=url) for line in lines: if line.strip(): diff --git a/taiga/mdrender/gfm/hidden_hilite.py b/taiga/mdrender/extensions/hidden_hilite.py similarity index 100% rename from taiga/mdrender/gfm/hidden_hilite.py rename to taiga/mdrender/extensions/hidden_hilite.py diff --git a/taiga/mdrender/gfm/mentions.py b/taiga/mdrender/extensions/mentions.py similarity index 57% rename from taiga/mdrender/gfm/mentions.py rename to taiga/mdrender/extensions/mentions.py index 1b8e445d..7bb8b29c 100644 --- a/taiga/mdrender/gfm/mentions.py +++ b/taiga/mdrender/extensions/mentions.py @@ -28,41 +28,44 @@ import re import os from markdown.extensions import Extension -from markdown.preprocessors import Preprocessor +from markdown.inlinepatterns import Pattern +from markdown.util import etree + +from taiga.users.models import User class MentionsExtension(Extension): - def extendMarkdown(self, md, md_globals): - md.registerExtension(self) - md.preprocessors.add('emojify', - MentionsPreprocessor(md), - '_end') + MENTION_RE = r'(?<=^|(?<=[^a-zA-Z0-9-_\.]))@([A-Za-z]+[A-Za-z0-9-]+)' + mentionsPattern = MentionsPattern(MENTION_RE) + mentionsPattern.md = md + md.inlinePatterns.add('mentions', + mentionsPattern, + '_begin') -class MentionsPreprocessor(Preprocessor): +class MentionsPattern(Pattern): + def handleMatch(self, m): + if m.group(2).strip(): + username = m.group(2) - def run(self, lines): - new_lines = [] - pattern = re.compile('(?<=^|(?<=[^a-zA-Z0-9-_\.]))@([A-Za-z]+[A-Za-z0-9]+)') + try: + user = User.objects.get(username=username) + except User.DoesNotExist: + return "@{}".format(username) - def make_mention_link(m): - name = m.group(1) + url = "/#/profile/{}".format(username) - if not User.objects.filter(username=name): - return "@{name}".format(name=name) - - tpl = ('[@{name}](/#/profile/{name} "@{name}")') - return tpl.format(name=name) - - for line in lines: - if line.strip(): - line = pattern.sub(make_mention_link, line) - - new_lines.append(line) - - return new_lines + link_text = "@{}".format(username) + a = etree.Element('a') + a.text = link_text + a.set('href', url) + a.set('alt', user.get_full_name()) + a.set('title', user.get_full_name()) + a.set('class', "mention") + return a + return '' def makeExtension(configs=None): return MentionsExtension(configs=configs) diff --git a/taiga/mdrender/extensions/references.py b/taiga/mdrender/extensions/references.py new file mode 100644 index 00000000..861ddd68 --- /dev/null +++ b/taiga/mdrender/extensions/references.py @@ -0,0 +1,96 @@ +#-*- coding: utf-8 -*- + +# Tested on Markdown 2.3.1 +# +# Copyright (c) 2014, Esteban Castro Borsani +# The MIT License (MIT) +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + + +import re +import os + +from markdown.extensions import Extension +from markdown.inlinepatterns import Pattern +from markdown.util import etree + +from taiga.projects.references.services import get_instance_by_ref + + +class TaigaReferencesExtension(Extension): + def __init__(self, project, *args, **kwargs): + self.project = project + return super().__init__(*args, **kwargs) + + def extendMarkdown(self, md, md_globals): + TAIGA_REFERENCE_RE = r'(?<=^|(?<=[^a-zA-Z0-9-\[]))#(\d+)' + referencesPattern = TaigaReferencesPattern(TAIGA_REFERENCE_RE, self.project) + referencesPattern.md = md + md.inlinePatterns.add('taiga-references', + referencesPattern, + '_begin') + +class TaigaReferencesPattern(Pattern): + def __init__(self, pattern, project): + self.project = project + super().__init__(pattern) + + def handleMatch(self, m): + if m.group(2).strip(): + obj_ref = m.group(2) + + instance = get_instance_by_ref(self.project.id, obj_ref) + if instance is None: + return "#{}".format(obj_ref) + + subject = instance.content_object.subject + + if instance.content_type.model == "userstory": + obj_section = "user-story" + html_classes = "reference user-story" + elif instance.content_type.model == "task": + obj_section = "tasks" + html_classes = "reference task" + elif instance.content_type.model == "issue": + obj_section = "issues" + html_classes = "reference issue" + else: + return "#{}".format(obj_ref) + + + url = "/#/project/{}/{}/{}".format( + self.project.slug, + obj_section, + obj_ref + ) + link_text = "#{}".format(obj_ref) + + a = etree.Element('a') + a.text = link_text + a.set('href', url) + a.set('alt', subject) + a.set('title', subject) + a.set('class', html_classes) + return a + return '' + + +def makeExtension(configs=None): + return TaigaReferencesExtension(configs=configs) diff --git a/taiga/mdrender/gfm/semi_sane_lists.py b/taiga/mdrender/extensions/semi_sane_lists.py similarity index 100% rename from taiga/mdrender/gfm/semi_sane_lists.py rename to taiga/mdrender/extensions/semi_sane_lists.py diff --git a/taiga/mdrender/gfm/spaced_link.py b/taiga/mdrender/extensions/spaced_link.py similarity index 100% rename from taiga/mdrender/gfm/spaced_link.py rename to taiga/mdrender/extensions/spaced_link.py diff --git a/taiga/mdrender/gfm/strikethrough.py b/taiga/mdrender/extensions/strikethrough.py similarity index 100% rename from taiga/mdrender/gfm/strikethrough.py rename to taiga/mdrender/extensions/strikethrough.py diff --git a/taiga/mdrender/gfm/wikilinks.py b/taiga/mdrender/extensions/wikilinks.py similarity index 100% rename from taiga/mdrender/gfm/wikilinks.py rename to taiga/mdrender/extensions/wikilinks.py diff --git a/taiga/mdrender/gfm/__init__.py b/taiga/mdrender/gfm/__init__.py deleted file mode 100644 index a5aa0ac1..00000000 --- a/taiga/mdrender/gfm/__init__.py +++ /dev/null @@ -1,23 +0,0 @@ -# Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file -# for details. All rights reserved. Use of this source code is governed by a -# BSD-style license that can be found in the LICENSE file. - -from . import autolink -from . import automail -from . import hidden_hilite -from . import semi_sane_lists -from . import spaced_link -from . import strikethrough -from . import wikilinks -from . import emojify -from . import mentions - -AutolinkExtension = autolink.AutolinkExtension -AutomailExtension = automail.AutomailExtension -HiddenHiliteExtension = hidden_hilite.HiddenHiliteExtension -SemiSaneListExtension = semi_sane_lists.SemiSaneListExtension -SpacedLinkExtension = spaced_link.SpacedLinkExtension -StrikethroughExtension = strikethrough.StrikethroughExtension -WikiLinkExtension = wikilinks.WikiLinkExtension -EmojifyExtension = emojify.EmojifyExtension -MentionsExtension = mentions.MentionsExtension diff --git a/taiga/mdrender/processors/references.py b/taiga/mdrender/processors/references.py deleted file mode 100644 index 820be9e9..00000000 --- a/taiga/mdrender/processors/references.py +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -# -# Copyright (c) 2012, lepture.com -# Copyright (c) 2014, taiga.io -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions -# are met: -# -# * Redistributions of source code must retain the above copyright -# notice, this list of conditions and the following disclaimer. -# * Redistributions in binary form must reproduce the above -# copyright notice, this list of conditions and the following -# disclaimer in the documentation and/or other materials provided -# with the distribution. -# * Neither the name of the author nor the names of its contributors -# may be used to endorse or promote products derived from this -# software without specific prior written permission. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -# HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -import re - -from taiga.projects.userstories.models import UserStory -from taiga.projects.issues.models import Issue -from taiga.projects.tasks.models import Task - - -def references(project, text): - pattern = re.compile('(?<=^|(?<=[^a-zA-Z0-9-]))#(us|issue|task)(\d+)') - - def make_reference_link(m): - obj_type = m.group(1) - obj_ref = m.group(2) - - if obj_type == "us": - model = UserStory - obj_section = "user-story" - elif obj_type == "issue": - model = Issue - obj_section = "issues" - elif obj_type == "task": - model = Task - obj_section = "tasks" - - instances = model.objects.filter(project_id=project.id, ref=obj_ref) - if not instances: - return "#{type}{ref}".format(type=obj_type, ref=obj_ref) - - subject = instances[0].subject - - return '[#{type}{ref}](/#/project/{project_slug}/{section}/{ref} "{subject}")'.format( - type=obj_type, - section=obj_section, - ref=obj_ref, - project_slug=project.slug, - subject=subject - ) - - text = pattern.sub(make_reference_link, text) - return text - -__all__ = ['references'] diff --git a/taiga/mdrender/service.py b/taiga/mdrender/service.py index 9f13bc18..8f44e8f8 100644 --- a/taiga/mdrender/service.py +++ b/taiga/mdrender/service.py @@ -7,20 +7,19 @@ from django.utils.encoding import force_bytes from markdown import markdown from fn import F -from .gfm import AutolinkExtension -from .gfm import AutomailExtension -from .gfm import HiddenHiliteExtension -from .gfm import SemiSaneListExtension -from .gfm import SpacedLinkExtension -from .gfm import StrikethroughExtension -from .gfm import WikiLinkExtension -from .gfm import EmojifyExtension -from .gfm import MentionsExtension - -from .processors.references import references +from .extensions.autolink import AutolinkExtension +from .extensions.automail import AutomailExtension +from .extensions.hidden_hilite import HiddenHiliteExtension +from .extensions.semi_sane_lists import SemiSaneListExtension +from .extensions.spaced_link import SpacedLinkExtension +from .extensions.strikethrough import StrikethroughExtension +from .extensions.wikilinks import WikiLinkExtension +from .extensions.emojify import EmojifyExtension +from .extensions.mentions import MentionsExtension +from .extensions.references import TaigaReferencesExtension -def _make_extensions_list(wikilinks_config=None): +def _make_extensions_list(wikilinks_config=None, project=None): return [AutolinkExtension(), AutomailExtension(), SemiSaneListExtension(), @@ -29,6 +28,7 @@ def _make_extensions_list(wikilinks_config=None): WikiLinkExtension(wikilinks_config), EmojifyExtension(), MentionsExtension(), + TaigaReferencesExtension(project), "extra", "codehilite"] @@ -54,22 +54,13 @@ def cache_by_sha(func): return _decorator -def _render_markdown(project, text): - wikilinks_config = {"base_url": "#/project/{}/wiki/".format(project.slug), - "end_url": ""} - extensions = _make_extensions_list(wikilinks_config=wikilinks_config) - return markdown(text, extensions=extensions) - - -def _preprocessors(project, text): - pre = F() >> F(references, project) - return pre(text) - - #@cache_by_sha def render(project, text): - renderer = F() >> F(_preprocessors, project) >> F(_render_markdown, project) - return renderer(text) + wikilinks_config = {"base_url": "#/project/{}/wiki/".format(project.slug), + "end_url": ""} + extensions = _make_extensions_list(wikilinks_config=wikilinks_config, project=project) + return markdown(text, extensions=extensions) + class DiffMatchPatch(diff_match_patch.diff_match_patch): def diff_pretty_html(self, diffs): diff --git a/taiga/projects/references/services.py b/taiga/projects/references/services.py new file mode 100644 index 00000000..197c0111 --- /dev/null +++ b/taiga/projects/references/services.py @@ -0,0 +1,9 @@ +from .models import Reference + +def get_instance_by_ref(project_id, obj_ref): + try: + instance = Reference.objects.get(project_id=project_id, ref=obj_ref) + except Reference.DoesNotExist: + instance = None + + return instance diff --git a/tests/unit/test_mdrender.py b/tests/unit/test_mdrender.py index 0ec6a55e..3d4faede 100644 --- a/tests/unit/test_mdrender.py +++ b/tests/unit/test_mdrender.py @@ -1,17 +1,17 @@ -from unittest import mock +from unittest.mock import patch, MagicMock import pytest import taiga.base -from taiga.mdrender.gfm import mentions -from taiga.mdrender.gfm import emojify -from taiga.mdrender.processors import references +from taiga.mdrender.extensions import mentions +from taiga.mdrender.extensions import emojify from taiga.mdrender.service import render -class DummyClass: - pass +from taiga.projects.references import services -dummy_project = DummyClass() +from taiga.users.models import User + +dummy_project = MagicMock() dummy_project.id = 1 dummy_project.slug = "test" @@ -24,119 +24,52 @@ def test_proccessor_invalid_emoji(): assert result == ["**:notvalidemoji:**"] def test_proccessor_valid_user_mention(): - DummyModel = DummyClass() - DummyModel.objects = DummyClass() - DummyModel.objects.filter = lambda username: ["test"] - - mentions.User = DummyModel - - result = mentions.MentionsPreprocessor().run(["**@user1**"]) - assert result == ["**[@user1](/#/profile/user1 \"@user1\")**"] + with patch("taiga.mdrender.extensions.mentions.User") as mock: + instance = mock.objects.get.return_value + instance.get_full_name.return_value = "test name" + result = render(dummy_project, "**@user1**") + expected_result = "
" + assert result == expected_result def test_proccessor_invalid_user_mention(): - DummyModel = DummyClass() - DummyModel.objects = DummyClass() - DummyModel.objects.filter = lambda username: [] - - mentions.User = DummyModel - - result = mentions.MentionsPreprocessor().run(["**@notvaliduser**"]) - assert result == ['**@notvaliduser**'] - + with patch("taiga.mdrender.extensions.mentions.User") as mock: + mock.DoesNotExist = User.DoesNotExist + mock.objects.get.side_effect = User.DoesNotExist + result = render(dummy_project, "**@notvaliduser**") + assert result == '@notvaliduser
' def test_proccessor_valid_us_reference(): - class MockModelWithInstance: - class objects: - def filter(*args, **kwargs): - dummy_instance = DummyClass() - dummy_instance.subject = "test-subject" - return [dummy_instance] - UserStoryBack = references.UserStory - references.UserStory = MockModelWithInstance - - result = references.references(dummy_project, "**#us1**") - assert result == '**[#us1](/#/project/test/user-story/1 "test-subject")**' - - references.UserStory = UserStoryBack - - -def test_proccessor_invalid_us_reference(): - class MockModelEmpty: - class objects: - def filter(*args, **kwargs): - return [] - - UserStoryBack = references.UserStory - references.UserStory = MockModelEmpty - - result = references.references(dummy_project, "**#us1**") - assert result == "**#us1**" - - references.UserStory = UserStoryBack + with patch("taiga.mdrender.extensions.references.get_instance_by_ref") as mock: + instance = mock.return_value + instance.content_type.model = "userstory" + instance.content_object.subject = "test" + result = render(dummy_project, "**#1**") + expected_result = '' + assert result == expected_result def test_proccessor_valid_issue_reference(): - class MockModelWithInstance: - class objects: - def filter(*args, **kwargs): - dummy_instance = DummyClass() - dummy_instance.subject = "test-subject" - return [dummy_instance] - IssueBack = references.Issue - references.Issue = MockModelWithInstance - - result = references.references(dummy_project, "**#issue1**") - assert result == '**[#issue1](/#/project/test/issues/1 "test-subject")**' - - references.Issue = IssueBack - - -def test_proccessor_invalid_issue_reference(): - class MockModelEmpty: - class objects: - def filter(*args, **kwargs): - return [] - - IssueBack = references.Issue - references.Issue = MockModelEmpty - - result = references.references(dummy_project, "**#issue1**") - assert result == "**#issue1**" - - references.Issue = IssueBack + with patch("taiga.mdrender.extensions.references.get_instance_by_ref") as mock: + instance = mock.return_value + instance.content_type.model = "issue" + instance.content_object.subject = "test" + result = render(dummy_project, "**#1**") + expected_result = '' + assert result == expected_result def test_proccessor_valid_task_reference(): - class MockModelWithInstance: - class objects: - def filter(*args, **kwargs): - dummy_instance = DummyClass() - dummy_instance.subject = "test-subject" - return [dummy_instance] - TaskBack = references.Task - references.Task = MockModelWithInstance + with patch("taiga.mdrender.extensions.references.get_instance_by_ref") as mock: + instance = mock.return_value + instance.content_type.model = "task" + instance.content_object.subject = "test" + result = render(dummy_project, "**#1**") + expected_result = '' + assert result == expected_result - result = references.references(dummy_project, "**#task1**") - assert result == '**[#task1](/#/project/test/tasks/1 "test-subject")**' - - references.Task = TaskBack - - -def test_proccessor_invalid_task_reference(): - class MockModelEmpty: - class objects: - def filter(*args, **kwargs): - return [] - - TaskBack = references.Task - references.Task = MockModelEmpty - - result = references.references(dummy_project, "**#task1**") - assert result == "**#task1**" - - references.Task = TaskBack - -def test_proccessor_invalid_type_reference(): - result = references.references(dummy_project, "**#invalid1**") - assert result == "**#invalid1**" +def test_proccessor_invalid_reference(): + with patch("taiga.mdrender.extensions.references.get_instance_by_ref") as mock: + mock.return_value = None + result = render(dummy_project, "**#1**") + assert result == "#1
" def test_render_wiki_strong(): assert render(dummy_project, "**test**") == "test
"