rafalp 6 лет назад
Родитель
Сommit
6c86f15bf1

+ 1 - 1
misago/admin/admin.py

@@ -8,4 +8,4 @@ class MisagoAdminExtension:
             icon="fa fa-home",
             parent="misago:admin",
             link="misago:admin:index",
-        )
+        )

+ 4 - 2
misago/admin/discoverer.py

@@ -15,5 +15,7 @@ def discover_misago_admin():
         admin_module = import_module("%s.admin" % app.name)
         if hasattr(admin_module, "MisagoAdminExtension"):
             extension = getattr(admin_module, "MisagoAdminExtension")()
-            extension.register_navigation_nodes(site)
-            extension.register_urlpatterns(urlpatterns)
+            if hasattr(extension, "register_navigation_nodes"):
+                extension.register_navigation_nodes(site)
+            if hasattr(extension, "register_urlpatterns"):
+                extension.register_urlpatterns(urlpatterns)

+ 1 - 1
misago/templates/misago/admin/themes/assets/css.html

@@ -76,7 +76,7 @@
           </tr>
         {% empty %}
           <tr class="message-row">
-            <td colspan="6">{% trans "This theme has no CSS files." %}</td>
+            <td colspan="8">{% trans "This theme has no CSS files." %}</td>
           </tr>
         {% endfor %}
       </table>

+ 19 - 19
misago/themes/admin/css.py

@@ -46,11 +46,23 @@ def css_needs_rebuilding(css):
     return "url(" in css_source
 
 
-def rebuild_css(theme, css):
-    css_source = css.source_file.read()
-    build_source = change_css_source(theme, css_source)
-    if css.build_source:
-        css.build_source.delete(save=False)
+def get_theme_media_map(theme):
+    media_map = {}
+    for media in theme.media.all():
+        escaped_url = json.dumps(media.file.url)
+        media_map[media.name] = escaped_url
+
+        media_filename = str(media.file).split("/")[-1]
+        media_map[media_filename] = escaped_url
+    return media_map
+
+
+def rebuild_css(media_map, css):
+    if css.build_file:
+        css.build_file.delete(save=False)
+
+    css_source = css.source_file.read().decode("utf-8")
+    build_source = change_css_source(media_map, css_source).encode("utf-8")
 
     build_file_name = css.name
     if css.source_hash in build_file_name:
@@ -59,30 +71,18 @@ def rebuild_css(theme, css):
 
     css.build_file = build_file
     css.build_hash = get_file_hash(build_file)
-    css.size = len(build_source.encode("utf-8"))
+    css.size = len(build_source)
     css.save()
 
 
 CSS_URL_REGEX = re.compile(r"url\((.+)\)")
 
 
-def change_css_source(theme, css_source):
-    media_map = get_theme_media_map(theme)
+def change_css_source(media_map, css_source):
     url_replacer = get_url_replacer(media_map)
     return CSS_URL_REGEX.sub(url_replacer, css_source).strip()
 
 
-def get_theme_media_map(theme):
-    media_map = {}
-    for media in theme.media.all():
-        escaped_url = json.dumps(media.file.url)
-        media_map[media.name] = escaped_url
-
-        media_filename = str(media.file).split("/")[-1]
-        media_map[media_filename] = escaped_url
-    return media_map
-
-
 def get_url_replacer(media_map):
     def replacer(matchobj):
         url = matchobj.group(1).strip("\"'").strip()

+ 27 - 3
misago/themes/admin/tasks.py

@@ -2,21 +2,22 @@ import requests
 from celery import shared_task
 from requests.exceptions import RequestException
 
-from ..models import Css
+from ..models import Theme, Css
+from .css import get_theme_media_map, rebuild_css
 
 
 @shared_task
 def update_remote_css_size(pk):
     try:
         css = Css.objects.get(pk=pk, url__isnull=False)
-        css.size = get_remove_css_size(css.url)
     except Css.DoesNotExist:
         pass
     else:
+        css.size = get_remote_css_size(css.url)
         css.save(update_fields=["size"])
 
 
-def get_remove_css_size(url):
+def get_remote_css_size(url):
     try:
         response = requests.head(url)
         response.raise_for_status()
@@ -27,3 +28,26 @@ def get_remove_css_size(url):
             return int(response.headers.get("content-length", 0))
         except (TypeError, ValueError):
             return 0
+
+
+@shared_task
+def build_single_theme_css(pk):
+    try:
+        css = Css.objects.get(pk=pk, source_needs_building=True)
+    except Css.DoesNotExist:
+        pass
+    else:
+        media_map = get_theme_media_map(css.theme)
+        rebuild_css(media_map, css)
+
+
+@shared_task
+def build_theme_css(pk):
+    try:
+        theme = Theme.objects.get(pk=pk)
+    except Theme.DoesNotExist:
+        pass
+    else:
+        media_map = get_theme_media_map(theme)
+        for css in theme.css.filter(source_needs_building=True):
+            rebuild_css(media_map, css)

+ 25 - 0
misago/themes/admin/tests/conftest.py

@@ -62,3 +62,28 @@ def image(admin_client, theme):
     with open(os.path.join(TESTS_DIR, "images", "test.png"), "rb") as fp:
         admin_client.post(url, {"assets": [fp]})
     return theme.media.get(name="test.png")
+
+
+@pytest.fixture(autouse=True)
+def mock_build_single_theme_css(mocker):
+    delay = mocker.Mock()
+    mocker.patch(
+        "misago.themes.admin.views.build_single_theme_css", mocker.Mock(delay=delay)
+    )
+    return delay
+
+
+@pytest.fixture(autouse=True)
+def mock_build_theme_css(mocker):
+    delay = mocker.Mock()
+    mocker.patch("misago.themes.admin.views.build_theme_css", mocker.Mock(delay=delay))
+    return delay
+
+
+@pytest.fixture(autouse=True)
+def mock_update_remote_css_size(mocker):
+    delay = mocker.Mock()
+    mocker.patch(
+        "misago.themes.admin.views.update_remote_css_size", mocker.Mock(delay=delay)
+    )
+    return delay

+ 1 - 0
misago/themes/admin/tests/css/test.buildable.css

@@ -0,0 +1 @@
+body { background-image: url(/source/media/test.png); }

+ 0 - 0
misago/themes/admin/tests/snapshots/__init__.py


+ 34 - 0
misago/themes/admin/tests/snapshots/snap_test_css_files_building.py

@@ -0,0 +1,34 @@
+# -*- coding: utf-8 -*-
+# snapshottest: v1 - https://goo.gl/zC4yUc
+from __future__ import unicode_literals
+
+from snapshottest import Snapshot
+
+
+snapshots = Snapshot()
+
+snapshots[
+    "test_simple_url_to_file_is_replaced_with_valid_url 1"
+] = '.page-header { background-image: url("/media/themes/themedir/media/test.357c2ee3.png"); }'
+
+snapshots[
+    "test_relative_url_to_file_is_replaced_with_valid_url 1"
+] = '.page-header { background-image: url("/media/themes/themedir/media/test.357c2ee3.png"); }'
+
+snapshots[
+    "test_url_to_file_from_create_react_app_is_replaced_with_valid_url 1"
+] = '.page-header { background-image: url("/media/themes/themedir/media/test.357c2ee3.png"); }'
+
+snapshots[
+    "test_quoted_url_to_file_is_replaced_with_valid_url 1"
+] = '.page-header { background-image: url("/media/themes/themedir/media/test.357c2ee3.png"); }'
+
+snapshots[
+    "test_single_quoted_url_to_file_is_replaced_with_valid_url 1"
+] = '.page-header { background-image: url("/media/themes/themedir/media/test.357c2ee3.png"); }'
+
+snapshots[
+    "test_css_file_with_multiple_different_urls_is_correctly_replaced 1"
+] = """.page-header { background-image: url(http://cdn.example.com/bg.png); }
+.container { background-image: url("/media/themes/themedir/media/test.357c2ee3.png"); }
+.alert { background-image: url("g"); }"""

+ 89 - 0
misago/themes/admin/tests/test_css_files_building.py

@@ -0,0 +1,89 @@
+import pytest
+
+from ..css import change_css_source, get_theme_media_map
+
+
+@pytest.fixture
+def assert_snapshot_match(snapshot, theme):
+    def _assert_snapshot_match(result):
+        result = result.replace(theme.dirname, "themedir")
+        snapshot.assert_match(result)
+
+    return _assert_snapshot_match
+
+
+@pytest.fixture
+def media_map(theme, image):
+    return get_theme_media_map(theme)
+
+
+def test_simple_url_to_file_is_replaced_with_valid_url(
+    assert_snapshot_match, media_map, image
+):
+    css = ".page-header { background-image: url(%s); }" % image.name
+    result = change_css_source(media_map, css)
+    assert_snapshot_match(result)
+
+
+def test_relative_url_to_file_is_replaced_with_valid_url(
+    assert_snapshot_match, media_map, image
+):
+    css = ".page-header { background-image: url(./%s); }" % image.name
+    result = change_css_source(media_map, css)
+    assert_snapshot_match(result)
+
+
+def test_url_to_file_from_create_react_app_is_replaced_with_valid_url(
+    assert_snapshot_match, media_map, image
+):
+    hashed_name = str(image.file).split("/")[-1]
+    css = ".page-header { background-image: url(/static/media/%s); }" % hashed_name
+    result = change_css_source(media_map, css)
+    assert_snapshot_match(result)
+
+
+def test_quoted_url_to_file_is_replaced_with_valid_url(
+    assert_snapshot_match, media_map, image
+):
+    css = '.page-header { background-image: url("%s"); }' % image.name
+    result = change_css_source(media_map, css)
+    assert_snapshot_match(result)
+
+
+def test_single_quoted_url_to_file_is_replaced_with_valid_url(
+    assert_snapshot_match, media_map, image
+):
+    css = ".page-header { background-image: url('%s'); }" % image.name
+    result = change_css_source(media_map, css)
+    assert_snapshot_match(result)
+
+
+def test_absolute_https_url_to_file_is_not_replaced(media_map):
+    css = ".page-header { background-image: url(https://cdn.example.com/bg.png); }"
+    result = change_css_source(media_map, css)
+    assert result == css
+
+
+def test_absolute_http_url_to_file_is_not_replaced(media_map):
+    css = ".page-header { background-image: url(http://cdn.example.com/bg.png); }"
+    result = change_css_source(media_map, css)
+    assert result == css
+
+
+def test_absolute_protocol_relative_url_to_file_is_not_replaced(media_map):
+    css = ".page-header { background-image: url(://cdn.example.com/bg.png); }"
+    result = change_css_source(media_map, css)
+    assert result == css
+
+
+def test_css_file_with_multiple_different_urls_is_correctly_replaced(
+    assert_snapshot_match, media_map, image
+):
+    css = (
+        ".page-header { background-image: url(http://cdn.example.com/bg.png); }"
+        '\n.container { background-image: url("%s"); }'
+        '\n.alert { background-image: url("%s"); }'
+    ) % (image.name, str(image.file).strip("/")[-1])
+
+    result = change_css_source(media_map, css)
+    assert_snapshot_match(result)

+ 34 - 0
misago/themes/admin/tests/test_css_files_creation_and_edition.py

@@ -141,6 +141,23 @@ def test_css_file_with_image_url_is_created_with_rebuilding_flag(
     assert css.source_needs_building
 
 
+def test_creating_css_file_without_image_url_doesnt_trigger_single_css_file_rebuild(
+    theme, admin_client, create_link, data, image, mock_build_single_theme_css
+):
+    admin_client.post(create_link, data)
+    mock_build_single_theme_css.assert_not_called()
+
+
+def test_creating_css_file_with_image_url_triggers_single_css_file_rebuild(
+    theme, admin_client, create_link, data, image, mock_build_single_theme_css
+):
+    data["source"] = "body { background-image: url(/static/%s); }" % image.name
+    admin_client.post(create_link, data)
+    css = theme.css.last()
+
+    mock_build_single_theme_css.assert_called_once_with(css.pk)
+
+
 def test_css_file_is_created_with_correct_order(
     theme, admin_client, create_link, css_link, data
 ):
@@ -284,6 +301,23 @@ def test_removing_url_from_edited_file_removes_rebuilding_flag(
     assert not css.source_needs_building
 
 
+def test_adding_image_url_to_edited_file_triggers_single_css_file_rebuild(
+    theme, admin_client, edit_link, css, data, image, mock_build_single_theme_css
+):
+    data["source"] = "body { background-image: url(/static/%s); }" % image.name
+    admin_client.post(edit_link, data)
+
+    mock_build_single_theme_css.assert_called_once_with(css.pk)
+
+
+def test_removing_url_from_edited_file_deosnt_trigger_single_css_file_rebuild(
+    theme, admin_client, edit_link, css, data, mock_build_single_theme_css
+):
+    data["source"] = "body { background-image: none; }"
+    admin_client.post(edit_link, data)
+    mock_build_single_theme_css.assert_not_called()
+
+
 def test_css_order_stays_the_same_after_edit(admin_client, edit_link, css, data):
     original_order = css.order
     data["name"] = "changed.css"

+ 6 - 15
misago/themes/admin/tests/test_css_links_creation_and_deletion.py

@@ -24,15 +24,6 @@ def data():
     return {"name": "CSS link", "url": "https://example.com/cdn.css"}
 
 
-@pytest.fixture(autouse=True)
-def mock_task(mocker):
-    delay = mocker.Mock()
-    mocker.patch(
-        "misago.themes.admin.views.update_remote_css_size", mocker.Mock(delay=delay)
-    )
-    return delay
-
-
 def test_css_link_creation_form_is_displayed(admin_client, create_link):
     response = admin_client.get(create_link)
     assert response.status_code == 200
@@ -107,11 +98,11 @@ def test_css_link_is_created_with_correct_order(
 
 
 def test_css_link_creation_queues_task_to_download_remote_css_size(
-    theme, admin_client, create_link, css, data, mock_task
+    theme, admin_client, create_link, css, data, mock_update_remote_css_size
 ):
     admin_client.post(create_link, data)
     css_link = theme.css.last()
-    mock_task.assert_called_once_with(css_link.pk)
+    mock_update_remote_css_size.assert_called_once_with(css_link.pk)
 
 
 def test_error_message_is_set_if_user_attempts_to_create_css_link_in_default_theme(
@@ -168,20 +159,20 @@ def test_css_link_url_can_be_changed(admin_client, edit_link, css_link, data):
 
 
 def test_changing_css_link_url_queues_task_to_download_remote_css_size(
-    admin_client, edit_link, css_link, data, mock_task
+    admin_client, edit_link, css_link, data, mock_update_remote_css_size
 ):
     data["url"] = "https://new.css-link.com/test.css"
     admin_client.post(edit_link, data)
     css_link.refresh_from_db()
-    mock_task.assert_called_once_with(css_link.pk)
+    mock_update_remote_css_size.assert_called_once_with(css_link.pk)
 
 
 def test_not_changing_css_link_url_queues_task_to_download_remote_css_size(
-    admin_client, edit_link, css_link, data, mock_task
+    admin_client, edit_link, css_link, data, mock_update_remote_css_size
 ):
     admin_client.post(edit_link, data)
     css_link.refresh_from_db()
-    mock_task.assert_not_called()
+    mock_update_remote_css_size.assert_not_called()
 
 
 def test_css_order_stays_the_same_after_edit(admin_client, edit_link, css_link, data):

+ 9 - 0
misago/themes/admin/tests/test_uploading_css.py

@@ -232,6 +232,15 @@ def test_if_uploaded_css_file_contains_image_url_it_has_rebuild_flag_set(upload,
     assert css.source_needs_building
 
 
+def test_uploading_css_file_triggers_css_build(
+    upload, theme, css_file, mock_build_theme_css
+):
+    with open(css_file) as fp:
+        upload(theme, fp)
+
+    mock_build_theme_css.assert_called_once_with(theme.pk)
+
+
 def test_error_message_is_set_if_no_css_file_was_uploaded(upload, theme):
     response = upload(theme)
     assert_has_error_message(response)

+ 9 - 0
misago/themes/admin/tests/test_uploading_media.py

@@ -171,6 +171,15 @@ def test_thumbnail_is_generated_in_theme_directory_for_uploaded_image_file(
     assert theme.dirname in str(media.thumbnail)
 
 
+def test_uploading_media_triggers_css_build(
+    upload, theme, png_file, mock_build_theme_css
+):
+    with open(png_file, "rb") as fp:
+        upload(theme, fp)
+
+    mock_build_theme_css.assert_called_once_with(theme.pk)
+
+
 def test_error_message_is_set_if_no_media_was_uploaded(upload, theme):
     response = upload(theme)
     assert_has_error_message(response)

+ 6 - 1
misago/themes/admin/views.py

@@ -7,7 +7,7 @@ from ...admin.views import generic
 from ..models import Theme, Css
 from .css import move_css_down, move_css_up
 from .forms import CssEditorForm, CssLinkForm, ThemeForm, UploadCssForm, UploadMediaForm
-from .tasks import update_remote_css_size
+from .tasks import build_single_theme_css, build_theme_css, update_remote_css_size
 
 
 class ThemeAdmin(generic.AdminBaseMixin):
@@ -116,6 +116,7 @@ class UploadThemeAssets(ThemeAssetsActionAdmin, generic.TargetedView):
 
         if form.cleaned_data.get("assets"):
             form.save()
+            build_theme_css.delay(theme.pk)
             messages.success(request, self.message_success)
 
 
@@ -243,6 +244,8 @@ class ThemeCssFormAdmin(ThemeCssAdmin, generic.ModelFormView):
 
     def handle_form(self, form, request, theme, css):
         form.save()
+        if css.source_needs_building:
+            build_single_theme_css.delay(css.pk)
         messages.success(request, self.message_submit % {"name": css.name})
 
 
@@ -283,6 +286,8 @@ class EditThemeCss(NewThemeCss):
     def handle_form(self, form, request, theme, css):
         if form.has_changed():
             form.save()
+            if css.source_needs_building:
+                build_single_theme_css.delay(css.pk)
             messages.success(request, self.message_submit % {"name": css.name})
         else:
             message = gettext('No changes have been made to "%(css)s".')

+ 3 - 0
misago/themes/context_processors.py

@@ -15,6 +15,9 @@ def theme(request):
         for css in theme.css.all():
             if css.url:
                 styles.append(css.url)
+            if css.source_needs_building:
+                if css.build_file:
+                    styles.append(css.build_file.url)
             else:
                 styles.append(css.source_file.url)
 

+ 1 - 1
misago/themes/uploadto.py

@@ -11,7 +11,7 @@ def upload_source_css_to(instance, filename):
 
 
 def upload_build_css_to(instance, filename):
-    filename = add_hash_to_filename(instance.hash, filename)
+    filename = add_hash_to_filename(instance.build_hash, filename)
     return "themes/%s/css/%s" % (instance.theme.dirname, filename)