From abbf0697c25bb11086851d29fb024171f9eb7b26 Mon Sep 17 00:00:00 2001 From: Christina Lin <44586776+chrstinalin@users.noreply.github.com> Date: Fri, 20 Dec 2024 16:05:11 -0500 Subject: [PATCH 01/11] Support Markdown in Add-on Listing Fields --- requirements/prod.txt | 3 ++ src/olympia/addons/models.py | 12 ++++--- .../devhub/addons/edit/describe.html | 4 +-- .../devhub/addons/edit/technical.html | 4 +-- .../devhub/addons/submit/describe.html | 6 ++-- .../templates/devhub/includes/macros.html | 11 ++++++ .../devhub/includes/policy_form.html | 6 ++-- src/olympia/translations/fields.py | 4 +++ src/olympia/translations/models.py | 35 +++++++++++++++---- 9 files changed, 64 insertions(+), 21 deletions(-) diff --git a/requirements/prod.txt b/requirements/prod.txt index 844b006bd1f8..234c3a954e63 100644 --- a/requirements/prod.txt +++ b/requirements/prod.txt @@ -1208,3 +1208,6 @@ watchdog[watchmedo]==3.0.0 \ django-node-assets==0.9.14 \ --hash=sha256:80cbe3d10521808309712b2aa5ef6d69799bbcafef844cf7f223d3c93f405768 \ --hash=sha256:d5b5c472136084d533268f52ab77897327863a102e25c81f484aae85eb806987 +Markdown==3.7 \ + --hash=sha256:2ae2471477cfd02dbbf038d5d9bc226d40def84b4fe2986e49b59b6b472bbed2 \ + --hash=sha256:7eb6df5690b81a1d7942992c97fad2938e956e79df20cbc6186e9c3a77b1c803 diff --git a/src/olympia/addons/models.py b/src/olympia/addons/models.py index 8125e9ce7678..b9dc5fdb8672 100644 --- a/src/olympia/addons/models.py +++ b/src/olympia/addons/models.py @@ -65,7 +65,7 @@ from olympia.tags.models import Tag from olympia.translations.fields import ( NoURLsField, - PurifiedField, + PurifiedMarkdownField, TranslatedField, save_signal, ) @@ -501,12 +501,14 @@ class Addon(OnChangeMixin, ModelBase): homepage = TranslatedField(max_length=255) support_email = TranslatedField(db_column='supportemail', max_length=100) support_url = TranslatedField(db_column='supporturl', max_length=255) - description = PurifiedField(short=False, max_length=15000) + description = PurifiedMarkdownField(short=False, max_length=15000) summary = NoURLsField(max_length=250) - developer_comments = PurifiedField(db_column='developercomments', max_length=3000) - eula = PurifiedField(max_length=350000) - privacy_policy = PurifiedField(db_column='privacypolicy', max_length=150000) + developer_comments = PurifiedMarkdownField( + db_column='developercomments', max_length=3000 + ) + eula = PurifiedMarkdownField(max_length=350000) + privacy_policy = PurifiedMarkdownField(db_column='privacypolicy', max_length=150000) average_rating = models.FloatField( max_length=255, default=0, null=True, db_column='averagerating' diff --git a/src/olympia/devhub/templates/devhub/addons/edit/describe.html b/src/olympia/devhub/templates/devhub/addons/edit/describe.html index 284c4e3f3872..7a795d9b3f96 100644 --- a/src/olympia/devhub/templates/devhub/addons/edit/describe.html +++ b/src/olympia/devhub/templates/devhub/addons/edit/describe.html @@ -1,4 +1,4 @@ -{% from "devhub/includes/macros.html" import tip, empty_unless, flags, select_cats, some_html_tip, trans_readonly %} +{% from "devhub/includes/macros.html" import tip, empty_unless, flags, select_cats, markdown_tip, trans_readonly %}
- {{ some_html_tip() }} + {{ markdown_tip() }} {% else %} {% call empty_unless(addon.description) %}
diff --git a/src/olympia/devhub/templates/devhub/addons/edit/technical.html b/src/olympia/devhub/templates/devhub/addons/edit/technical.html index 887b97eaac3e..9c16717cbd4c 100644 --- a/src/olympia/devhub/templates/devhub/addons/edit/technical.html +++ b/src/olympia/devhub/templates/devhub/addons/edit/technical.html @@ -1,4 +1,4 @@ -{% from "devhub/includes/macros.html" import tip, some_html_tip, empty_unless, flags %} +{% from "devhub/includes/macros.html" import tip, markdown_tip, empty_unless, flags %} @@ -32,7 +32,7 @@

{% if editable %} {{ main_form.developer_comments }} {{ main_form.developer_comments.errors }} - {{ some_html_tip() }} + {{ markdown_tip() }} {% else %} {% call empty_unless(addon.developer_comments) %}
{{ addon|all_locales('developer_comments') }}
diff --git a/src/olympia/devhub/templates/devhub/addons/submit/describe.html b/src/olympia/devhub/templates/devhub/addons/submit/describe.html index 969280bb0e92..ba80a5b51454 100644 --- a/src/olympia/devhub/templates/devhub/addons/submit/describe.html +++ b/src/olympia/devhub/templates/devhub/addons/submit/describe.html @@ -1,4 +1,4 @@ -{% from "devhub/includes/macros.html" import some_html_tip, select_cats %} +{% from "devhub/includes/macros.html" import markdown_tip, select_cats %} {% from "includes/forms.html" import tip %} {% extends "devhub/addons/submit/base.html" %} @@ -113,7 +113,7 @@

{{ _('Describe Add-on') }}

data-for-startswith="{{ describe_form.description.auto_id }}_" data-minlength="{{ describe_form.description.field.min_length }}">
- {{ some_html_tip() }} + {{ markdown_tip() }} {% endif %} {% if addon.type != amo.ADDON_STATICTHEME %} @@ -180,7 +180,7 @@

{{ _('Describe Add-on') }}

{{ license_form.text.errors }} {{ license_form.text.label_tag() }} {{ license_form.text }} - {{ some_html_tip() }} + {{ markdown_tip() }} {% endif %} diff --git a/src/olympia/devhub/templates/devhub/includes/macros.html b/src/olympia/devhub/templates/devhub/includes/macros.html index 3ce176200905..50f117da4307 100644 --- a/src/olympia/devhub/templates/devhub/includes/macros.html +++ b/src/olympia/devhub/templates/devhub/includes/macros.html @@ -10,6 +10,17 @@

{% endmacro %} +{% macro markdown_tip(title=None) %} +

+{# L10n: %s is a list of HTML tags. #} +{{ _('Some Markdown supported.') }} +

+{% endmacro %} + + {% macro empty_unless(truthy) %} {% if truthy %} {{ caller() }} diff --git a/src/olympia/devhub/templates/devhub/includes/policy_form.html b/src/olympia/devhub/templates/devhub/includes/policy_form.html index 6018405858c2..96dc841b321a 100644 --- a/src/olympia/devhub/templates/devhub/includes/policy_form.html +++ b/src/olympia/devhub/templates/devhub/includes/policy_form.html @@ -1,4 +1,4 @@ -{% from "devhub/includes/macros.html" import tip, some_html_tip %} +{% from "devhub/includes/macros.html" import tip, markdown_tip %} {{ tip(_('End-User License Agreement'), _('Please note that a EULA is not ' @@ -13,7 +13,7 @@ {{ policy_form.eula.label }} {{ policy_form.eula }} - {{ some_html_tip() }} + {{ markdown_tip() }} @@ -31,7 +31,7 @@ {{ policy_form.privacy_policy.label }} {{ policy_form.privacy_policy }} - {{ some_html_tip() }} + {{ markdown_tip() }} diff --git a/src/olympia/translations/fields.py b/src/olympia/translations/fields.py index 447f31301d64..93a1cbc58bab 100644 --- a/src/olympia/translations/fields.py +++ b/src/olympia/translations/fields.py @@ -196,6 +196,10 @@ class PurifiedField(TranslatedField): to = 'translations.PurifiedTranslation' +class PurifiedMarkdownField(TranslatedField): + to = 'translations.PurifiedMarkdownTranslation' + + class LinkifiedField(TranslatedField): to = 'translations.LinkifiedTranslation' diff --git a/src/olympia/translations/models.py b/src/olympia/translations/models.py index 2c8a03782cab..492ac7df57f3 100644 --- a/src/olympia/translations/models.py +++ b/src/olympia/translations/models.py @@ -4,6 +4,7 @@ from django.db.models.deletion import Collector import bleach +import markdown as md import olympia.core.logger from olympia.amo.fields import PositiveAutoField @@ -187,6 +188,12 @@ class PurifiedTranslation(Translation): 'acronym': ['title'], } + # All links (text and markup) are normalized. + linkify_filter = partial( + bleach.linkifier.LinkifyFilter, + callbacks=[linkify_bounce_url_callback, bleach.callbacks.nofollow], + ) + class Meta: proxy = True @@ -209,21 +216,37 @@ def clean(self): self.localized_string_clean = clean_nl(cleaned).strip() def clean_localized_string(self): - # All links (text and markup) are normalized. - linkify_filter = partial( - bleach.linkifier.LinkifyFilter, - callbacks=[linkify_bounce_url_callback, bleach.callbacks.nofollow], - ) # Keep only the allowed tags and attributes, escape the rest. cleaner = bleach.Cleaner( tags=self.allowed_tags, attributes=self.allowed_attributes, - filters=[linkify_filter], + filters=[self.linkify_filter], ) return cleaner.clean(str(self.localized_string)) +class PurifiedMarkdownTranslation(PurifiedTranslation): + class Meta: + proxy = True + + def clean_localized_string(self): + # bleach user-inputted html + cleaned = bleach.clean(self.localized_string, tags=[], attributes={}) + # hack; cleaning breaks blockquotes + markdown = md.markdown(cleaned.replace('>', '>')) + + # Keep only the allowed tags and attributes, strip the rest. + cleaner = bleach.Cleaner( + tags=self.allowed_tags, + attributes=self.allowed_attributes, + filters=[self.linkify_filter], + strip=True, + ) + + return cleaner.clean(markdown) + + class LinkifiedTranslation(PurifiedTranslation): """Run the string through bleach to get a linkified version.""" From f863faa223157e25ffe5fae7855e4a9136799afb Mon Sep 17 00:00:00 2001 From: Christina Lin <44586776+chrstinalin@users.noreply.github.com> Date: Mon, 23 Dec 2024 09:39:42 -0500 Subject: [PATCH 02/11] clean --- .../devhub/templates/devhub/addons/edit/technical.html | 2 +- src/olympia/devhub/templates/devhub/includes/macros.html | 9 ++++----- src/olympia/translations/models.py | 2 +- static/css/zamboni/developers.css | 2 +- static/css/zamboni/zamboni.css | 8 ++++---- 5 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/olympia/devhub/templates/devhub/addons/edit/technical.html b/src/olympia/devhub/templates/devhub/addons/edit/technical.html index 9c16717cbd4c..8873ba535a74 100644 --- a/src/olympia/devhub/templates/devhub/addons/edit/technical.html +++ b/src/olympia/devhub/templates/devhub/addons/edit/technical.html @@ -35,7 +35,7 @@

{{ markdown_tip() }} {% else %} {% call empty_unless(addon.developer_comments) %} -
{{ addon|all_locales('developer_comments') }}
+
{{ addon|all_locales('developer_comments', nl2br=True) }}
{% endcall %} {% endif %} diff --git a/src/olympia/devhub/templates/devhub/includes/macros.html b/src/olympia/devhub/templates/devhub/includes/macros.html index 50f117da4307..7493132106b7 100644 --- a/src/olympia/devhub/templates/devhub/includes/macros.html +++ b/src/olympia/devhub/templates/devhub/includes/macros.html @@ -1,7 +1,7 @@ {% extends "includes/forms.html" %} {% macro some_html_tip(title=None) %} -

+

{# L10n: %s is a list of HTML tags. #} -{# L10n: %s is a list of HTML tags. #} +

+{# L10n: %s is a list of markdown syntax. #} {{ _('Some Markdown supported.') }}

{% endmacro %} diff --git a/src/olympia/translations/models.py b/src/olympia/translations/models.py index 492ac7df57f3..55cbdddd418c 100644 --- a/src/olympia/translations/models.py +++ b/src/olympia/translations/models.py @@ -234,7 +234,7 @@ def clean_localized_string(self): # bleach user-inputted html cleaned = bleach.clean(self.localized_string, tags=[], attributes={}) # hack; cleaning breaks blockquotes - markdown = md.markdown(cleaned.replace('>', '>')) + markdown = md.markdown(cleaned.replace('>', '>'), extensions=['abbr']) # Keep only the allowed tags and attributes, strip the rest. cleaner = bleach.Cleaner( diff --git a/static/css/zamboni/developers.css b/static/css/zamboni/developers.css index f4704fa7612d..4873b567c669 100644 --- a/static/css/zamboni/developers.css +++ b/static/css/zamboni/developers.css @@ -147,7 +147,7 @@ form .char-count { font-size: 0.9em; } form .edit-addon-details .char-count, -form .edit-addon-details .html-support { +form .edit-addon-details .syntax-support { font-size: 1em; } diff --git a/static/css/zamboni/zamboni.css b/static/css/zamboni/zamboni.css index 4e776cdfedea..4e884bd74e27 100644 --- a/static/css/zamboni/zamboni.css +++ b/static/css/zamboni/zamboni.css @@ -3197,18 +3197,18 @@ input.ui-autocomplete-loading { } /* end l10n */ -/** "Some HTML Allowed" text/popup **/ -.html-support { +/** "Some HTML/Markdown Allowed" text/popup **/ +.syntax-support { font-size: .9em; color: #003595; float: right; } -.html-rtl .html-support { +.html-rtl .syntax-support { float: left; } -.html-support:hover { +.syntax-support:hover { color: #000; cursor: help; } From b579b856f0c4f41c85bd8e83845b2f51d3f61eb5 Mon Sep 17 00:00:00 2001 From: Christina Lin <44586776+chrstinalin@users.noreply.github.com> Date: Mon, 23 Dec 2024 11:28:00 -0500 Subject: [PATCH 03/11] tests --- src/olympia/addons/tests/test_models.py | 107 ++++++++++-------------- src/olympia/translations/models.py | 10 ++- 2 files changed, 54 insertions(+), 63 deletions(-) diff --git a/src/olympia/addons/tests/test_models.py b/src/olympia/addons/tests/test_models.py index a6c7a17e6806..a58a4d09af70 100644 --- a/src/olympia/addons/tests/test_models.py +++ b/src/olympia/addons/tests/test_models.py @@ -964,6 +964,9 @@ def newlines_helper(self, string_before): addon.save() return addon.privacy_policy.localized_string_clean + def replace_helper(self, string_before): + return string_before.replace('<', '<').replace('>', '>') + def test_newlines_normal(self): before = ( 'Paragraph one.\n' @@ -973,7 +976,14 @@ def test_newlines_normal(self): "Should be four nl's before this line." ) - after = before # Nothing special; this shouldn't change. + # Markdown. + after = ( + 'Paragraph one.\n' + 'This should be on the very next line.\n\n' + "Should be two nl's before this line.\n\n" + "Should be three nl's before this line.\n\n" + "Should be four nl's before this line." + ) assert self.newlines_helper(before) == after @@ -986,13 +996,7 @@ def test_newlines_ul(self): '' ) - after = ( - '' - ) + after = self.replace_helper(before) assert self.newlines_helper(before) == after @@ -1003,11 +1007,7 @@ def test_newlines_ul_tight(self): "There should be no nl's above this line." ) - after = ( - 'There should be one nl between this and the ul.\n' - '' - "There should be no nl's above this line." - ) + after = self.replace_helper(before) assert self.newlines_helper(before) == after @@ -1018,11 +1018,7 @@ def test_newlines_ul_loose(self): 'There should be one nl above this line.' ) - after = ( - "There should be two nl's between this and the ul.\n\n" - '\n' - 'There should be one nl above this line.' - ) + after = self.replace_helper(before) assert self.newlines_helper(before) == after @@ -1033,11 +1029,7 @@ def test_newlines_blockquote_tight(self): "There should be no nl's above this." ) - after = ( - 'There should be one nl below this.\n' - '
Hi
' - "There should be no nl's above this." - ) + after = self.replace_helper(before) assert self.newlines_helper(before) == after @@ -1048,11 +1040,7 @@ def test_newlines_blockquote_loose(self): 'There should be one nl above this.' ) - after = ( - 'There should be two nls below this.\n\n' - '
Hi
\n' - 'There should be one nl above this.' - ) + after = self.replace_helper(before) assert self.newlines_helper(before) == after @@ -1062,56 +1050,56 @@ def test_newlines_inline(self): 'The newlines should be kept' ) - after = before # Should stay the same + after = self.replace_helper(before) assert self.newlines_helper(before) == after def test_newlines_code_inline(self): before = "Code tags aren't blocks.\n\n" 'alert(test);\n\nSee?' - after = before # Should stay the same + after = self.replace_helper(before) assert self.newlines_helper(before) == after def test_newlines_li_newlines(self): before = '' - after = '' + after = self.replace_helper(before) assert self.newlines_helper(before) == after before = '' - after = '' + after = self.replace_helper(before) assert self.newlines_helper(before) == after before = '' - after = '' + after = self.replace_helper(before) assert self.newlines_helper(before) == after before = '' - after = '' + after = self.replace_helper(before) assert self.newlines_helper(before) == after # All together now before = '' - after = '' + after = self.replace_helper(before) assert self.newlines_helper(before) == after def test_newlines_empty_tag(self): before = 'This is a test!' - after = before + after = self.replace_helper(before) assert self.newlines_helper(before) == after def test_newlines_empty_tag_nested(self): before = 'This is a test!' - after = before + after = self.replace_helper(before) assert self.newlines_helper(before) == after def test_newlines_empty_tag_block_nested(self): b = 'Test.\n\n
\ntest.' - a = 'Test.\n\n
test.' + a = self.replace_helper(b) assert self.newlines_helper(b) == a @@ -1120,7 +1108,7 @@ def test_newlines_empty_tag_block_nested_spaced(self): 'Test.\n\n
\n\n
    \n\n
  • ' '
  • \n\n
\n\n
\ntest.' ) - after = 'Test.\n\n
test.' + after = self.replace_helper(before) assert self.newlines_helper(before) == after @@ -1130,10 +1118,7 @@ def test_newlines_li_newlines_inline(self): '
  • Test test test.
  • ' ) - after = ( - '' - ) + after = self.replace_helper(before) assert self.newlines_helper(before) == after @@ -1143,7 +1128,7 @@ def test_newlines_li_all_inline(self): 'stuff to see what happens.' ) - after = before # Should stay the same + after = self.replace_helper(before) assert self.newlines_helper(before) == after @@ -1152,31 +1137,31 @@ def test_newlines_spaced_blocks(self): '
    \n\n
      \n\n
    • \n\ntest\n\n
    • \n\n
    \n\n
    ' ) - after = '
    • test
    ' + after = self.replace_helper(before) assert self.newlines_helper(before) == after def test_newlines_spaced_inline(self): before = "Line.\n\n\nThis line is bold.\n\n\nThis isn't." - after = before + after = self.replace_helper(before) assert self.newlines_helper(before) == after def test_newlines_nested_inline(self): before = '\nThis line is bold.\n\nThis is also italic' - after = before + after = self.replace_helper(before) assert self.newlines_helper(before) == after def test_newlines_xss_script(self): before = "" - after = "<script>\n\nalert('test');\n</script>" + after = self.replace_helper(before) assert self.newlines_helper(before) == after def test_newlines_xss_inline(self): before = 'test' - after = 'test' + after = self.replace_helper(before) assert self.newlines_helper(before) == after @@ -1191,61 +1176,61 @@ def test_newlines_attribute_link_doublequote(self, mock_get_outgoing_url): def test_newlines_attribute_singlequote(self): before = "lol" - after = 'lol' + after = self.replace_helper(before) assert self.newlines_helper(before) == after def test_newlines_attribute_doublequote(self): before = 'lol' - after = before + after = self.replace_helper(before) assert self.newlines_helper(before) == after def test_newlines_attribute_nestedquotes_doublesingle(self): before = 'lol' - after = before + after = self.replace_helper(before) assert self.newlines_helper(before) == after def test_newlines_attribute_nestedquotes_singledouble(self): before = 'lol' - after = before + after = self.replace_helper(before) assert self.newlines_helper(before) == after def test_newlines_unclosed_b(self): before = 'test' - after = 'test' + after = self.replace_helper(before) assert self.newlines_helper(before) == after def test_newlines_unclosed_b_wrapped(self): before = 'This is a test' - after = 'This is a test' + after = self.replace_helper(before) assert self.newlines_helper(before) == after def test_newlines_unclosed_li(self): before = '
    • test
    ' - after = '
    • test
    ' + after = self.replace_helper(before) assert self.newlines_helper(before) == after def test_newlines_malformed_faketag(self): before = ''), extensions=['abbr']) + markdown = md.markdown( + cleaned.replace('>', '>'), extensions=['abbr', 'nl2br'] + ) # Keep only the allowed tags and attributes, strip the rest. cleaner = bleach.Cleaner( From d95731ce4833b5dbf18d9475604196dedc646ac0 Mon Sep 17 00:00:00 2001 From: Christina Lin <44586776+chrstinalin@users.noreply.github.com> Date: Mon, 23 Dec 2024 12:06:35 -0500 Subject: [PATCH 04/11] test --- src/olympia/devhub/tests/test_views_edit.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/olympia/devhub/tests/test_views_edit.py b/src/olympia/devhub/tests/test_views_edit.py index 4fb1ec60d5c1..853d816cffec 100644 --- a/src/olympia/devhub/tests/test_views_edit.py +++ b/src/olympia/devhub/tests/test_views_edit.py @@ -353,14 +353,14 @@ def test_edit_xss(self): Let's try to put xss in our description, and safe html, and verify that we are playing safe. """ - self.addon.description = 'This\nIS' "" + self.addon.description = 'This\n**IS**' "" self.addon.save() response = self.client.get(self.url) assert response.status_code == 200 doc = pq(response.content) assert doc('#addon-description span[lang]').html() == ( - "This
    IS<script>alert('awesome')</script>" + "This
    IS<script>alert('awesome')</script>" ) response = self.client.get(self.describe_edit_url) @@ -368,7 +368,7 @@ def test_edit_xss(self): assert b'" - after = self.replace_helper(before) + after = "<script>\n\nalert('test');\n</script>" assert self.newlines_helper(before) == after def test_newlines_xss_inline(self): before = 'test' - after = self.replace_helper(before) + after = '<b onclick="alert(\'test\');">test</b>' assert self.newlines_helper(before) == after @@ -1174,63 +1136,35 @@ def test_newlines_attribute_link_doublequote(self, mock_get_outgoing_url): assert 'rel="nofollow"' in parsed - def test_newlines_attribute_singlequote(self): - before = "lol" - after = self.replace_helper(before) - - assert self.newlines_helper(before) == after - - def test_newlines_attribute_doublequote(self): - before = 'lol' - after = self.replace_helper(before) - - assert self.newlines_helper(before) == after - - def test_newlines_attribute_nestedquotes_doublesingle(self): - before = 'lol' - after = self.replace_helper(before) - - assert self.newlines_helper(before) == after - - def test_newlines_attribute_nestedquotes_singledouble(self): - before = 'lol' - after = self.replace_helper(before) + def test_newlines_tag(self): + # user-inputted HTML is cleaned and ignored in favour of markdown. + # Disallowed markdown is stripped from the final result. + before = 'This is a bold **test!** \n\n --- \n\n' + after = 'This is a <b>bold</b> test!' assert self.newlines_helper(before) == after - def test_newlines_unclosed_b(self): + def test_newlines_unclosed_tag(self): before = 'test' - after = self.replace_helper(before) - - assert self.newlines_helper(before) == after - - def test_newlines_unclosed_b_wrapped(self): - before = 'This is a test' - after = self.replace_helper(before) - - assert self.newlines_helper(before) == after - - def test_newlines_unclosed_li(self): - before = '
    • test
    ' - after = self.replace_helper(before) + after = '<b>test' assert self.newlines_helper(before) == after def test_newlines_malformed_faketag(self): before = ''), extensions=['abbr', 'nl2br'] - ) + text_with_brs = cleaned.replace('>', '>') + markdown = md.markdown(text_with_brs, extensions=['abbr', 'fenced_code']) # Keep only the allowed tags and attributes, strip the rest. cleaner = bleach.Cleaner( From b552805106565787a49bdecb165aa08bf1f0dc8b Mon Sep 17 00:00:00 2001 From: Christina Lin <44586776+chrstinalin@users.noreply.github.com> Date: Tue, 24 Dec 2024 12:13:00 -0500 Subject: [PATCH 06/11] lint --- src/olympia/addons/tests/test_models.py | 59 +++++++++++-------------- 1 file changed, 27 insertions(+), 32 deletions(-) diff --git a/src/olympia/addons/tests/test_models.py b/src/olympia/addons/tests/test_models.py index dbc64243c5ea..2375a02953e8 100644 --- a/src/olympia/addons/tests/test_models.py +++ b/src/olympia/addons/tests/test_models.py @@ -989,15 +989,16 @@ def test_link_markdown(self, mock_get_outgoing_url): mock_get_outgoing_url.return_value = 'https://www.mozilla.org' before = 'Heres a link [to here!](https://www.mozilla.org "Click me!")' - after = ('Heres a link ' - '' - 'to here!' - '') + after = ( + 'Heres a link ' + '' + 'to here!' + '' + ) assert self.newlines_helper(before) == after - def test_abbr_markdown(self): before = ( 'TGIF ROFL\n\n' @@ -1013,16 +1014,22 @@ def test_abbr_markdown(self): def test_bold_markdown(self): before = "Line.\n\n__This line is bold.__\n\n**So is this.**\n\nThis isn't." - after = "Line.\n\nThis line is bold.\n\nSo is this.\n\nThis isn't." + after = ( + 'Line.\n\nThis line is bold.\n\n' + "So is this.\n\nThis isn't." + ) assert self.newlines_helper(before) == after def test_italics_markdown(self): before = "Line.\n\n_This line is emphasized._\n\n*So is this.*\n\nThis isn't." - after = "Line.\n\nThis line is emphasized.\n\nSo is this.\n\nThis isn't." + after = ( + 'Line.\n\nThis line is emphasized.\n\n' + "So is this.\n\nThis isn't." + ) assert self.newlines_helper(before) == after - + def test_empty_markdown(self): before = 'This is a **** test!' after = before @@ -1044,21 +1051,19 @@ def test_code_markdown(self): ' System.out.println("Hello Again!")' ) - after = 'System.out.println("Hello, World!")\n\nSystem.out.println("Hello Again!")\n' + after = ( + 'System.out.println("Hello, World!")\n\n' + 'System.out.println("Hello Again!")\n' + ) assert self.newlines_helper(before) == after - + def test_blockquote_markdown(self): - before = ( - 'Test.\n\n' - '> \n' - '> - \n' - '\ntest.' - ) + before = 'Test.\n\n' '> \n' '> - \n' '\ntest.' after = 'Test.\n
    \ntest.' assert self.newlines_helper(before) == after - + def test_ul_markdown(self): before = '* \nxx' after = '
    • xx
    ' @@ -1073,16 +1078,11 @@ def test_ul_markdown(self): assert self.newlines_helper(before) == after before = '*' - after = before # Doesn't do anything on its own + after = before # Doesn't do anything on its own assert self.newlines_helper(before) == after # All together now - before = ( - '* \nxx\n' - '* xx\n' - '* \n' - '* xx\nxx\n' - ) + before = '* \nxx\n' '* xx\n' '* \n' '* xx\nxx\n' after = '
    • xx
    • xx
    • xx\nxx
    ' assert self.newlines_helper(before) == after @@ -1101,16 +1101,11 @@ def test_ol_markdown(self): assert self.newlines_helper(before) == after before = '1.' - after = before # Doesn't do anything on its own + after = before # Doesn't do anything on its own assert self.newlines_helper(before) == after # All together now - before = ( - '1. \nxx\n' - '2. xx\n' - '3. \n' - '4. xx\nxx\n' - ) + before = '1. \nxx\n' '2. xx\n' '3. \n' '4. xx\nxx\n' after = '
    1. xx
    2. xx
    3. xx\nxx
    ' assert self.newlines_helper(before) == after From 085c76668683b1505ef6a46ef9260998bc962f77 Mon Sep 17 00:00:00 2001 From: Christina Lin <44586776+chrstinalin@users.noreply.github.com> Date: Tue, 24 Dec 2024 12:28:01 -0500 Subject: [PATCH 07/11] docs --- docs/topics/api/addons.rst | 8 ++++---- docs/topics/api/overview.rst | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/topics/api/addons.rst b/docs/topics/api/addons.rst index 7fa0527f62e7..727ffe6ab7e2 100644 --- a/docs/topics/api/addons.rst +++ b/docs/topics/api/addons.rst @@ -162,7 +162,7 @@ This endpoint allows you to fetch a specific add-on by id, slug or guid. :>json string created: The date the add-on was created. :>json object current_version: Object holding the current :ref:`version ` of the add-on. For performance reasons the ``license`` field omits the ``text`` property from both the search and detail endpoints. :>json string default_locale: The add-on default locale for translations. - :>json object|null description: The add-on description (See :ref:`translated fields `). This field might contain some HTML tags. + :>json object|null description: The add-on description (See :ref:`translated fields `). This field might contain markdown. :>json object|null developer_comments: Additional information about the add-on provided by the developer. (See :ref:`translated fields `). :>json string edit_url: The URL to the developer edit page for the add-on. :>json string guid: The add-on `extension identifier `_. @@ -203,7 +203,7 @@ This endpoint allows you to fetch a specific add-on by id, slug or guid. :>json string review_url: The URL to the reviewer review page for the add-on. :>json string slug: The add-on slug. :>json string status: The :ref:`add-on status `. - :>json object|null summary: The add-on summary (See :ref:`translated fields `). This field supports "linkification" and therefore might contain HTML hyperlinks. + :>json object|null summary: The add-on summary (See :ref:`translated fields `). This field supports "linkification" and therefore might contain Markdown hyperlinks. :>json object|null support_email: The add-on support email (See :ref:`translated fields `). :>json object|null support_url: The add-on support URL (See :ref:`translated fields ` and :ref:`Outgoing Links `). :>json array tags: List containing the tag names set on the add-on. @@ -311,7 +311,7 @@ is compatible with. :` the add-on belongs to for a given :ref:`add-on application `. :`_. :` and custom license text is fixed to `en-US`. - :`). This field can contain some HTML tags. + :`). This field can contain some Markdown. :`). :` and :ref:`Outgoing Links `). :` the add-on belongs to for a given :ref:`add-on application `. :`_. :` and custom license text is fixed to `en-US`. - :`). This field can contain some HTML tags. + :`). This field can contain some Markdown. :`). :` and :ref:`Outgoing Links `). :` to upload a new icon. diff --git a/docs/topics/api/overview.rst b/docs/topics/api/overview.rst index 18b5e30d4723..311dd68b60d9 100644 --- a/docs/topics/api/overview.rst +++ b/docs/topics/api/overview.rst @@ -246,7 +246,7 @@ Note, if the field is also a translated field then the ``url`` and ``outgoing`` values could be an object rather than a string (See :ref:`translated fields ` for translated field representations). -Fields supporting some HTML, such as add-on ``description`` or ``summary``, +Fields supporting some HTML or Markdown, such as add-on ``description`` or ``summary``, always wrap any links directly inside the content (the original url is not available). From 46209dfecdba7d721dad13d4aa3614fb09b7541d Mon Sep 17 00:00:00 2001 From: Christina Lin <44586776+chrstinalin@users.noreply.github.com> Date: Tue, 7 Jan 2025 12:21:45 -0500 Subject: [PATCH 08/11] review --- .../devhub/addons/edit/describe.html | 4 +- .../devhub/addons/edit/technical.html | 4 +- .../devhub/addons/submit/describe.html | 6 +-- .../templates/devhub/includes/macros.html | 2 +- .../devhub/includes/policy_form.html | 6 +-- src/olympia/translations/models.py | 2 + src/olympia/translations/tests/test_models.py | 48 +++++++++++++++++++ 7 files changed, 61 insertions(+), 11 deletions(-) diff --git a/src/olympia/devhub/templates/devhub/addons/edit/describe.html b/src/olympia/devhub/templates/devhub/addons/edit/describe.html index 7a795d9b3f96..8a65f2061bbc 100644 --- a/src/olympia/devhub/templates/devhub/addons/edit/describe.html +++ b/src/olympia/devhub/templates/devhub/addons/edit/describe.html @@ -1,4 +1,4 @@ -{% from "devhub/includes/macros.html" import tip, empty_unless, flags, select_cats, markdown_tip, trans_readonly %} +{% from "devhub/includes/macros.html" import tip, empty_unless, flags, select_cats, supported_syntax_tip, trans_readonly %}
    - {{ markdown_tip() }} + {{ supported_syntax_tip() }} {% else %} {% call empty_unless(addon.description) %}
    diff --git a/src/olympia/devhub/templates/devhub/addons/edit/technical.html b/src/olympia/devhub/templates/devhub/addons/edit/technical.html index 8873ba535a74..9d97c8735d54 100644 --- a/src/olympia/devhub/templates/devhub/addons/edit/technical.html +++ b/src/olympia/devhub/templates/devhub/addons/edit/technical.html @@ -1,4 +1,4 @@ -{% from "devhub/includes/macros.html" import tip, markdown_tip, empty_unless, flags %} +{% from "devhub/includes/macros.html" import tip, supported_syntax_tip, empty_unless, flags %} @@ -32,7 +32,7 @@

    {% if editable %} {{ main_form.developer_comments }} {{ main_form.developer_comments.errors }} - {{ markdown_tip() }} + {{ supported_syntax_tip() }} {% else %} {% call empty_unless(addon.developer_comments) %}
    {{ addon|all_locales('developer_comments', nl2br=True) }}
    diff --git a/src/olympia/devhub/templates/devhub/addons/submit/describe.html b/src/olympia/devhub/templates/devhub/addons/submit/describe.html index ba80a5b51454..e580185aed68 100644 --- a/src/olympia/devhub/templates/devhub/addons/submit/describe.html +++ b/src/olympia/devhub/templates/devhub/addons/submit/describe.html @@ -1,4 +1,4 @@ -{% from "devhub/includes/macros.html" import markdown_tip, select_cats %} +{% from "devhub/includes/macros.html" import supported_syntax_tip, select_cats %} {% from "includes/forms.html" import tip %} {% extends "devhub/addons/submit/base.html" %} @@ -113,7 +113,7 @@

    {{ _('Describe Add-on') }}

    data-for-startswith="{{ describe_form.description.auto_id }}_" data-minlength="{{ describe_form.description.field.min_length }}">
    - {{ markdown_tip() }} + {{ supported_syntax_tip() }} {% endif %} {% if addon.type != amo.ADDON_STATICTHEME %} @@ -180,7 +180,7 @@

    {{ _('Describe Add-on') }}

    {{ license_form.text.errors }} {{ license_form.text.label_tag() }} {{ license_form.text }} - {{ markdown_tip() }} + {{ supported_syntax_tip() }} {% endif %} diff --git a/src/olympia/devhub/templates/devhub/includes/macros.html b/src/olympia/devhub/templates/devhub/includes/macros.html index 7493132106b7..13c96ca93739 100644 --- a/src/olympia/devhub/templates/devhub/includes/macros.html +++ b/src/olympia/devhub/templates/devhub/includes/macros.html @@ -10,7 +10,7 @@

    {% endmacro %} -{% macro markdown_tip(title=None) %} +{% macro supported_syntax_tip(title=None) %}

    {# L10n: %s is a list of markdown syntax. #} {{ tip(_('End-User License Agreement'), _('Please note that a EULA is not ' @@ -13,7 +13,7 @@ {{ policy_form.eula.label }} {{ policy_form.eula }} - {{ markdown_tip() }} + {{ supported_syntax_tip() }} @@ -31,7 +31,7 @@ {{ policy_form.privacy_policy.label }} {{ policy_form.privacy_policy }} - {{ markdown_tip() }} + {{ supported_syntax_tip() }} diff --git a/src/olympia/translations/models.py b/src/olympia/translations/models.py index b92747639ceb..2ab86ce28a8d 100644 --- a/src/olympia/translations/models.py +++ b/src/olympia/translations/models.py @@ -239,6 +239,8 @@ def clean_localized_string(self): ) # hack; cleaning breaks blockquotes text_with_brs = cleaned.replace('>', '>') + # the base syntax of markdown library does not provide abbreviations or fenced code. + # see https://python-markdown.github.io/extensions/ markdown = md.markdown(text_with_brs, extensions=['abbr', 'fenced_code']) # Keep only the allowed tags and attributes, strip the rest. diff --git a/src/olympia/translations/tests/test_models.py b/src/olympia/translations/tests/test_models.py index c1e3b314bc57..ab17d0d817f0 100644 --- a/src/olympia/translations/tests/test_models.py +++ b/src/olympia/translations/tests/test_models.py @@ -19,6 +19,7 @@ from olympia.translations.models import ( LinkifiedTranslation, NoURLsTranslation, + PurifiedMarkdownTranslation, PurifiedTranslation, Translation, TranslationSequence, @@ -701,6 +702,53 @@ def test_external_text_link(self, get_outgoing_url_mock): assert doc('b')[0].text == 'markup' +class PurifiedMarkdownTranslationTest(TestCase): + def test_output(self): + assert isinstance(PurifiedMarkdownTranslation().__html__(), str) + + def test_raw_text(self): + s = ' This is some text ' + x = PurifiedMarkdownTranslation(localized_string=s) + assert x.__html__() == 'This is some text' + + def test_markdown(self): + s = '__bold text__ or _italics_ not bold' + x = PurifiedMarkdownTranslation(localized_string=s) + assert x.__html__() == 'bold text or italics <b>not bold</b>' + + def test_html(self): + s = '' + x = PurifiedMarkdownTranslation(localized_string=s) + assert x.__html__() == '<script>some naughty xss</script>' + + def test_internal_link(self): + s = '**markup** [bar](http://addons.mozilla.org/foo)' + x = PurifiedMarkdownTranslation(localized_string=s) + doc = pq(x.__html__()) + links = doc('a[href="http://addons.mozilla.org/foo"][rel="nofollow"]') + assert links[0].text == 'bar' + assert doc('strong')[0].text == 'markup' + + @patch('olympia.amo.urlresolvers.get_outgoing_url') + def test_external_link(self, get_outgoing_url_mock): + get_outgoing_url_mock.return_value = 'http://external.url' + s = '**markup** [bar](http://example.com)' + x = PurifiedMarkdownTranslation(localized_string=s) + doc = pq(x.__html__()) + links = doc('a[href="http://external.url"][rel="nofollow"]') + assert links[0].text == 'bar' + assert doc('strong')[0].text == 'markup' + + @patch('olympia.amo.urlresolvers.get_outgoing_url') + def test_external_text_link(self, get_outgoing_url_mock): + get_outgoing_url_mock.return_value = 'http://external.url' + s = '**markup** http://example.com' + x = PurifiedMarkdownTranslation(localized_string=s) + doc = pq(x.__html__()) + links = doc('a[href="http://external.url"][rel="nofollow"]') + assert links[0].text == 'http://example.com' + assert doc('strong')[0].text == 'markup' + class LinkifiedTranslationTest(TestCase): @patch('olympia.amo.urlresolvers.get_outgoing_url') def test_allowed_tags(self, get_outgoing_url_mock): From d0f052de8b91f34f3fb4658abd9c96324a3225a4 Mon Sep 17 00:00:00 2001 From: Christina Lin <44586776+chrstinalin@users.noreply.github.com> Date: Tue, 7 Jan 2025 12:30:03 -0500 Subject: [PATCH 09/11] lint --- src/olympia/translations/models.py | 4 ++-- src/olympia/translations/tests/test_models.py | 8 ++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/olympia/translations/models.py b/src/olympia/translations/models.py index 2ab86ce28a8d..73afb3e43261 100644 --- a/src/olympia/translations/models.py +++ b/src/olympia/translations/models.py @@ -239,8 +239,8 @@ def clean_localized_string(self): ) # hack; cleaning breaks blockquotes text_with_brs = cleaned.replace('>', '>') - # the base syntax of markdown library does not provide abbreviations or fenced code. - # see https://python-markdown.github.io/extensions/ + # the base syntax of markdown library does not provide abbreviations or fenced + # code. see https://python-markdown.github.io/extensions/ markdown = md.markdown(text_with_brs, extensions=['abbr', 'fenced_code']) # Keep only the allowed tags and attributes, strip the rest. diff --git a/src/olympia/translations/tests/test_models.py b/src/olympia/translations/tests/test_models.py index ab17d0d817f0..80eaf6c3e465 100644 --- a/src/olympia/translations/tests/test_models.py +++ b/src/olympia/translations/tests/test_models.py @@ -712,9 +712,12 @@ def test_raw_text(self): assert x.__html__() == 'This is some text' def test_markdown(self): - s = '__bold text__ or _italics_ not bold' + s = '__bold text__ or _italics_not bold' x = PurifiedMarkdownTranslation(localized_string=s) - assert x.__html__() == 'bold text or italics <b>not bold</b>' + assert x.__html__() == ( + 'bold text or italics' + '<b>not bold</b>' + ) def test_html(self): s = '' @@ -749,6 +752,7 @@ def test_external_text_link(self, get_outgoing_url_mock): assert links[0].text == 'http://example.com' assert doc('strong')[0].text == 'markup' + class LinkifiedTranslationTest(TestCase): @patch('olympia.amo.urlresolvers.get_outgoing_url') def test_allowed_tags(self, get_outgoing_url_mock): From a7cab9bfaf67c8f5e1f9ca1d5c03e9fe03dc8907 Mon Sep 17 00:00:00 2001 From: Christina Lin <44586776+chrstinalin@users.noreply.github.com> Date: Thu, 9 Jan 2025 09:59:41 -0500 Subject: [PATCH 10/11] review --- src/olympia/devhub/templates/devhub/addons/edit/describe.html | 2 +- .../devhub/templates/devhub/addons/edit/technical.html | 2 +- .../devhub/templates/devhub/addons/submit/describe.html | 4 ++-- src/olympia/devhub/templates/devhub/includes/macros.html | 4 ++-- src/olympia/devhub/templates/devhub/includes/policy_form.html | 4 ++-- src/olympia/devhub/views.py | 4 ++++ src/olympia/translations/models.py | 4 ++++ src/olympia/translations/tests/test_models.py | 2 +- 8 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/olympia/devhub/templates/devhub/addons/edit/describe.html b/src/olympia/devhub/templates/devhub/addons/edit/describe.html index 8a65f2061bbc..97e0a9c3e3f8 100644 --- a/src/olympia/devhub/templates/devhub/addons/edit/describe.html +++ b/src/olympia/devhub/templates/devhub/addons/edit/describe.html @@ -141,7 +141,7 @@

    - {{ supported_syntax_tip() }} + {{ supported_syntax_tip(allowed_markdown) }} {% else %} {% call empty_unless(addon.description) %}
    diff --git a/src/olympia/devhub/templates/devhub/addons/edit/technical.html b/src/olympia/devhub/templates/devhub/addons/edit/technical.html index 9d97c8735d54..c9269afb8b71 100644 --- a/src/olympia/devhub/templates/devhub/addons/edit/technical.html +++ b/src/olympia/devhub/templates/devhub/addons/edit/technical.html @@ -32,7 +32,7 @@

    {% if editable %} {{ main_form.developer_comments }} {{ main_form.developer_comments.errors }} - {{ supported_syntax_tip() }} + {{ supported_syntax_tip(allowed_markdown) }} {% else %} {% call empty_unless(addon.developer_comments) %}
    {{ addon|all_locales('developer_comments', nl2br=True) }}
    diff --git a/src/olympia/devhub/templates/devhub/addons/submit/describe.html b/src/olympia/devhub/templates/devhub/addons/submit/describe.html index e580185aed68..01ef7e266eee 100644 --- a/src/olympia/devhub/templates/devhub/addons/submit/describe.html +++ b/src/olympia/devhub/templates/devhub/addons/submit/describe.html @@ -113,7 +113,7 @@

    {{ _('Describe Add-on') }}

    data-for-startswith="{{ describe_form.description.auto_id }}_" data-minlength="{{ describe_form.description.field.min_length }}">
    - {{ supported_syntax_tip() }} + {{ supported_syntax_tip(allowed_markdown) }} {% endif %} {% if addon.type != amo.ADDON_STATICTHEME %} @@ -180,7 +180,7 @@

    {{ _('Describe Add-on') }}

    {{ license_form.text.errors }} {{ license_form.text.label_tag() }} {{ license_form.text }} - {{ supported_syntax_tip() }} + {{ supported_syntax_tip(allowed_markdown) }} {% endif %} diff --git a/src/olympia/devhub/templates/devhub/includes/macros.html b/src/olympia/devhub/templates/devhub/includes/macros.html index 13c96ca93739..c9b95c4aac47 100644 --- a/src/olympia/devhub/templates/devhub/includes/macros.html +++ b/src/olympia/devhub/templates/devhub/includes/macros.html @@ -10,11 +10,11 @@

    {% endmacro %} -{% macro supported_syntax_tip(title=None) %} +{% macro supported_syntax_tip(allowed_markdown, title=None) %}

    {# L10n: %s is a list of markdown syntax. #} {{ _('Some Markdown supported.') }}

    {% endmacro %} diff --git a/src/olympia/devhub/templates/devhub/includes/policy_form.html b/src/olympia/devhub/templates/devhub/includes/policy_form.html index de01d1b75d5f..3700912f50d3 100644 --- a/src/olympia/devhub/templates/devhub/includes/policy_form.html +++ b/src/olympia/devhub/templates/devhub/includes/policy_form.html @@ -13,7 +13,7 @@ {{ policy_form.eula.label }} {{ policy_form.eula }} - {{ supported_syntax_tip() }} + {{ supported_syntax_tip(allowed_markdown) }} @@ -31,7 +31,7 @@ {{ policy_form.privacy_policy.label }} {{ policy_form.privacy_policy }} - {{ supported_syntax_tip() }} + {{ supported_syntax_tip(allowed_markdown) }} diff --git a/src/olympia/devhub/views.py b/src/olympia/devhub/views.py index 7d71c94b55b6..3676e2263330 100644 --- a/src/olympia/devhub/views.py +++ b/src/olympia/devhub/views.py @@ -20,6 +20,7 @@ from django.views.decorators.cache import never_cache from django.views.decorators.csrf import csrf_exempt +from olympia.translations.models import PurifiedTranslation import waffle from csp.decorators import csp_update from django_statsd.clients import statsd @@ -357,6 +358,7 @@ def edit(request, addon_id, addon): 'previews': previews, 'header_preview': header_preview, 'supported_image_types': amo.SUPPORTED_IMAGE_TYPES, + 'allowed_markdown': PurifiedTranslation.get_allowed_tags(), } return TemplateResponse(request, 'devhub/addons/edit.html', context=data) @@ -485,6 +487,7 @@ def ownership(request, addon_id, addon): 'editable_body_class': 'no-edit' if not acl.check_addon_ownership(request.user, addon) else '', + 'allowed_markdown': PurifiedTranslation.get_allowed_tags(), } post_data = request.POST if request.method == 'POST' else None # Authors. @@ -1808,6 +1811,7 @@ def _submit_details(request, addon, version): 'version': version, 'sources_provided': latest_version.sources_provided, 'submit_page': 'version' if version else 'addon', + 'allowed_markdown': PurifiedTranslation.get_allowed_tags(), } post_data = request.POST if request.method == 'POST' else None diff --git a/src/olympia/translations/models.py b/src/olympia/translations/models.py index 73afb3e43261..32f48af727fc 100644 --- a/src/olympia/translations/models.py +++ b/src/olympia/translations/models.py @@ -225,6 +225,10 @@ def clean_localized_string(self): return cleaner.clean(str(self.localized_string)) + @classmethod + def get_allowed_tags(cls): + return ', '.join(cls.allowed_tags) + class PurifiedMarkdownTranslation(PurifiedTranslation): class Meta: diff --git a/src/olympia/translations/tests/test_models.py b/src/olympia/translations/tests/test_models.py index 80eaf6c3e465..9d11a3696096 100644 --- a/src/olympia/translations/tests/test_models.py +++ b/src/olympia/translations/tests/test_models.py @@ -702,7 +702,7 @@ def test_external_text_link(self, get_outgoing_url_mock): assert doc('b')[0].text == 'markup' -class PurifiedMarkdownTranslationTest(TestCase): +class TestPurifiedMarkdownTranslation(TestCase): def test_output(self): assert isinstance(PurifiedMarkdownTranslation().__html__(), str) From f1ed2a732b8330e3aca79061ebe9023c5f80f4e9 Mon Sep 17 00:00:00 2001 From: Christina Lin <44586776+chrstinalin@users.noreply.github.com> Date: Thu, 9 Jan 2025 10:00:06 -0500 Subject: [PATCH 11/11] ruff --- src/olympia/devhub/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/olympia/devhub/views.py b/src/olympia/devhub/views.py index 3676e2263330..1785dcbf1258 100644 --- a/src/olympia/devhub/views.py +++ b/src/olympia/devhub/views.py @@ -20,7 +20,6 @@ from django.views.decorators.cache import never_cache from django.views.decorators.csrf import csrf_exempt -from olympia.translations.models import PurifiedTranslation import waffle from csp.decorators import csp_update from django_statsd.clients import statsd @@ -71,6 +70,7 @@ from olympia.reviewers.forms import PublicWhiteboardForm from olympia.reviewers.models import Whiteboard from olympia.reviewers.utils import ReviewHelper +from olympia.translations.models import PurifiedTranslation from olympia.users.models import ( DeveloperAgreementRestriction, SuppressedEmailVerification,