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

+ 25 - 5
misago/admin/admin.py

@@ -32,11 +32,31 @@ class MisagoAdminExtension(MiddlewareMixin):
             url(r"^delete/(?P<pk>\d+)/$", DeleteTheme.as_view(), name="delete"),
             url(r"^activate/(?P<pk>\d+)/$", ActivateTheme.as_view(), name="activate"),
             url(r"^assets/(?P<pk>\d+)/$", ThemeAssets.as_view(), name="assets"),
-            url(r"^assets/(?P<pk>\d+)/delete-css/$", DeleteThemeCss.as_view(), name="delete-css"),
-            url(r"^assets/(?P<pk>\d+)/delete-fonts/$", DeleteThemeFonts.as_view(), name="delete-fonts"),
-            url(r"^assets/(?P<pk>\d+)/delete-images/$", DeleteThemeImages.as_view(), name="delete-images"),
-            url(r"^assets/(?P<pk>\d+)/upload-css/$", UploadThemeCss.as_view(), name="upload-css"),
-            url(r"^assets/(?P<pk>\d+)/upload-images/$", UploadThemeImages.as_view(), name="upload-images"),
+            url(
+                r"^assets/(?P<pk>\d+)/delete-css/$",
+                DeleteThemeCss.as_view(),
+                name="delete-css",
+            ),
+            url(
+                r"^assets/(?P<pk>\d+)/delete-fonts/$",
+                DeleteThemeFonts.as_view(),
+                name="delete-fonts",
+            ),
+            url(
+                r"^assets/(?P<pk>\d+)/delete-images/$",
+                DeleteThemeImages.as_view(),
+                name="delete-images",
+            ),
+            url(
+                r"^assets/(?P<pk>\d+)/upload-css/$",
+                UploadThemeCss.as_view(),
+                name="upload-css",
+            ),
+            url(
+                r"^assets/(?P<pk>\d+)/upload-images/$",
+                UploadThemeImages.as_view(),
+                name="upload-images",
+            ),
         )
 
     def register_navigation_nodes(self, site):

+ 4 - 7
misago/admin/themes/assets.py

@@ -23,10 +23,7 @@ def delete_css(theme, css):
 
 def save_css(theme, css):
     theme.css.create(
-        name=css.name,
-        file=css,
-        hash=get_file_hash(css),
-        size=css.size,
+        name=css.name, source_file=css, source_hash=get_file_hash(css), size=css.size
     )
 
 
@@ -62,8 +59,8 @@ def get_image_thumbnail(image):
     img.save(file, format="png")
     img.close()
 
-    filename = image.name.split('.')[0]
-    return ImageFile(file, name='thumb_%s.png' % filename)
+    filename = image.name.split(".")[0]
+    return ImageFile(file, name="thumb_%s.png" % filename)
 
 
 def get_file_hash(file):
@@ -72,4 +69,4 @@ def get_file_hash(file):
     file_hash = hashlib.md5()
     for chunk in file.chunks():
         file_hash.update(chunk)
-    return file_hash.hexdigest()[:8]
+    return file_hash.hexdigest()[:8]

+ 53 - 18
misago/admin/themes/forms.py

@@ -1,6 +1,6 @@
 from django import forms
 from django.utils.html import conditional_escape, mark_safe
-from django.utils.translation import gettext_lazy as _
+from django.utils.translation import gettext, gettext_lazy as _
 from mptt.forms import TreeNodeChoiceField
 
 from ...themes.models import Theme
@@ -44,35 +44,70 @@ class ThemeForm(forms.ModelForm):
 
 
 class UploadAssetsForm(forms.Form):
+    allowed_content_types = []
+    allowed_extensions = []
+
     assets = forms.FileField(
-        widget=forms.ClearableFileInput(attrs={'multiple': True})
+        widget=forms.ClearableFileInput(attrs={"multiple": True}),
+        error_messages={"required": _("No files have been uploaded.")},
     )
 
     def __init__(self, *args, instance=None):
         self.instance = instance
         super().__init__(*args)
 
-    def clean(self):
-        cleaned_data = super(UploadAssetsForm, self).clean()
-        return cleaned_data
+    def clean_assets(self):
+        assets = []
+        for asset in self.files.getlist("assets"):
+            try:
+                if self.allowed_content_types:
+                    self.validate_asset_content_type(asset)
+                if self.allowed_extensions:
+                    self.validate_asset_extension(asset)
+            except forms.ValidationError as e:
+                self.add_error("assets", e)
+            else:
+                assets.append(asset)
+
+        return assets
+
+    def validate_asset_content_type(self, asset):
+        if asset.content_type in self.allowed_content_types:
+            return
 
-    def get_uploaded_assets(self):
-        return self.files.getlist('assets')
+        message = gettext(
+            'File "%(file)s" content type "%(content_type)s" is not allowed.'
+        )
+        details = {"file": asset.name, "content_type": asset.content_type}
 
+        raise forms.ValidationError(message % details)
+
+    def validate_asset_extension(self, asset):
+        filename = asset.name.lower()
+        for extension in self.allowed_extensions:
+            if filename.endswith(".%s" % extension):
+                return
+
+        message = gettext('File "%(file)s" extension is invalid.')
+        details = {"file": asset.name}
+
+        raise forms.ValidationError(message % details)
 
-class UploadCssForm(UploadAssetsForm):
     def save(self):
-        for css in self.get_uploaded_assets():
-            create_css(self.instance, css)
-        return True
+        for asset in self.cleaned_data["assets"]:
+            self.save_asset(asset)
+
+
+class UploadCssForm(UploadAssetsForm):
+    allowed_content_types = ["text/css"]
+    allowed_extensions = ["css"]
+
+    def save_asset(self, asset):
+        create_css(self.instance, asset)
 
 
 class UploadImagesForm(UploadAssetsForm):
-    assets = forms.ImageField(
-        widget=forms.ClearableFileInput(attrs={'multiple': True})
-    )
+    assets = forms.ImageField(widget=forms.ClearableFileInput(attrs={"multiple": True}))
 
-    def save(self):
-        for image in self.get_uploaded_assets():
-            create_image(self.instance, image)
-        return True
+    def save_asset(self, asset):
+        create_image(self.instance, asset)

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


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

@@ -0,0 +1,13 @@
+import pytest
+
+from ....themes.models import Theme
+
+
+@pytest.fixture
+def default_theme(db):
+    return Theme.objects.get(is_default=True)
+
+
+@pytest.fixture
+def theme(db):
+    return Theme.objects.create(name="Custom theme")

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

@@ -0,0 +1 @@
+body { color: #efefef; }

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

@@ -0,0 +1 @@
+body { color: #efefef; }

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

@@ -0,0 +1 @@
+body { color: #efefef; }

BIN
misago/admin/themes/tests/font/Lato.ttf


+ 93 - 0
misago/admin/themes/tests/font/OFL.txt

@@ -0,0 +1,93 @@
+Copyright (c) 2010-2014 by tyPoland Lukasz Dziedzic (team@latofonts.com) with Reserved Font Name "Lato"
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded, 
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.

BIN
misago/admin/themes/tests/images/test.png


+ 5 - 0
misago/admin/themes/tests/images/test.svg

@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg width="100%" height="100%" viewBox="0 0 100 100" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.41421;">
+    <circle cx="50" cy="50" r="50"/>
+</svg>

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

@@ -0,0 +1,135 @@
+import os
+
+import pytest
+from django.urls import reverse
+
+TESTS_DIR = os.path.dirname(os.path.abspath(__file__))
+
+
+@pytest.fixture
+def css_file():
+    return os.path.join(TESTS_DIR, "css", "test.css")
+
+
+@pytest.fixture
+def other_file():
+    return os.path.join(TESTS_DIR, "images", "test.png")
+
+
+@pytest.fixture
+def hashed_css_file():
+    return os.path.join(TESTS_DIR, "css", "test.4846cb3b.css")
+
+
+@pytest.fixture
+def incorrectly_hashed_css_file():
+    return os.path.join(TESTS_DIR, "css", "test.0046cb3b.css")
+
+
+@pytest.fixture
+def upload(admin_client):
+    def post_upload(theme, asset_files):
+        url = reverse(
+            "misago:admin:appearance:themes:upload-css", kwargs={"pk": theme.pk}
+        )
+        if asset_files:
+            data = asset_files if isinstance(asset_files, list) else [asset_files]
+        else:
+            data = None
+        return admin_client.post(url, {"assets": data})
+
+    return post_upload
+
+
+def test_css_file_can_be_uploaded(upload, theme, css_file):
+    with open(css_file) as fp:
+        upload(theme, fp)
+        assert theme.css.exists()
+
+
+def test_multiple_css_files_can_be_uploaded_at_once(
+    upload, theme, css_file, hashed_css_file
+):
+    with open(css_file) as fp1:
+        with open(hashed_css_file) as fp2:
+            upload(theme, [fp1, fp2])
+            assert theme.css.exists()
+            assert theme.css.count() == 2
+
+
+def test_uploaded_file_is_rejected_if_its_not_css_file(upload, theme, other_file):
+    with open(other_file, "rb") as fp:
+        upload(theme, fp)
+        assert not theme.css.exists()
+
+
+def test_if_some_of_uploaded_files_are_incorrect_only_correct_files_are_added_to_theme(
+    upload, theme, css_file, other_file
+):
+    with open(css_file) as fp1:
+        with open(other_file, "rb") as fp2:
+            upload(theme, [fp1, fp2])
+            assert theme.css.exists()
+            assert theme.css.count() == 1
+
+    css_asset = theme.css.last()
+    expected_filename = str(css_file).split("/")[-1]
+    assert css_asset.name == expected_filename
+
+
+def test_css_file_is_uploaded_to_theme_directory(upload, theme, css_file):
+    with open(css_file) as fp:
+        upload(theme, fp)
+
+    css_asset = theme.css.last()
+    assert theme.dirname in str(css_asset.source_file)
+
+
+def test_css_file_name_is_set_as_asset_name(upload, theme, css_file):
+    with open(css_file) as fp:
+        upload(theme, fp)
+
+    css_asset = theme.css.last()
+    expected_filename = str(css_file).split("/")[-1]
+    assert css_asset.name == expected_filename
+
+
+def test_hash_is_added_to_uploaded_file_name(upload, theme, css_file, hashed_css_file):
+    with open(css_file) as fp:
+        upload(theme, fp)
+
+    css_asset = theme.css.last()
+    filename = str(css_asset.source_file.path).split("/")[-1]
+    expected_filename = str(hashed_css_file).split("/")[-1]
+    assert filename == expected_filename
+
+
+def test_hash_is_set_on_asset(upload, theme, css_file, hashed_css_file):
+    with open(css_file) as fp:
+        upload(theme, fp)
+
+    css_asset = theme.css.last()
+    assert css_asset.source_hash
+
+
+def test_css_file_name_is_preserved_if_it_already_contains_correct_hash(
+    upload, theme, hashed_css_file
+):
+    with open(hashed_css_file) as fp:
+        upload(theme, fp)
+
+    css_asset = theme.css.last()
+    filename = str(css_asset.source_file.path).split("/")[-1]
+    expected_filename = str(hashed_css_file).split("/")[-1]
+    assert filename == expected_filename
+
+
+def test_new_hash_is_added_to_css_file_name_if_it_contains_incorrect_hash(
+    upload, theme, incorrectly_hashed_css_file
+):
+    with open(incorrectly_hashed_css_file) as fp:
+        upload(theme, fp)
+
+    css_asset = theme.css.last()
+    filename = str(css_asset.source_file.path).split("/")[-1]
+    assert css_asset.source_hash in filename

+ 27 - 9
misago/admin/themes/views.py

@@ -96,18 +96,36 @@ class ThemeAssetsActionAdmin(ThemeAssetsAdmin):
         )
 
 
-class UploadThemeCss(ThemeAssetsActionAdmin, generic.TargetedView):
-    def action(self, request, theme):
-        form = UploadCssForm(request.POST, request.FILES, instance=theme)
-        if form.is_valid() and form.save():
-            pass  # display some user feedback
+class UploadThemeAssets(ThemeAssetsActionAdmin, generic.TargetedView):
+    message_partial_success = _(
+        "Some css files could not have been added to the style."
+    )
 
+    message_submit = None
+    form = None
 
-class UploadThemeImages(ThemeAssetsActionAdmin, generic.TargetedView):
     def action(self, request, theme):
-        form = UploadImagesForm(request.POST, request.FILES, instance=theme)
-        if form.is_valid() and form.save():
-            pass  # display some user feedback
+        form = self.form(request.POST, request.FILES, instance=theme)
+
+        if not form.is_valid():
+            if form.cleaned_data["assets"]:
+                messages.info(request, self.message_partial_success)
+            for error in form.errors["assets"]:
+                messages.error(request, error)
+
+        if form.cleaned_data["assets"]:
+            form.save()
+            messages.success(request, self.message_success)
+
+
+class UploadThemeCss(UploadThemeAssets):
+    message_success = _("New CSS files have been added to the style.")
+    form = UploadCssForm
+
+
+class UploadThemeImages(UploadThemeAssets):
+    message_success = _("New media files have been added to the style.")
+    form = UploadImagesForm
 
 
 class DeleteThemeAssets(ThemeAssetsActionAdmin, generic.TargetedView):

+ 2 - 6
misago/themes/context_processors.py

@@ -1,5 +1,6 @@
 from .models import Theme
 
+
 def theme(request):
     active_theme = Theme.objects.get(is_active=True)
     themes = active_theme.get_ancestors(include_self=True)
@@ -14,9 +15,4 @@ def theme(request):
         for css in theme.css.all():
             styles.append(css.file.url)
 
-    return {
-        "theme": {
-            "include_defaults": include_defaults,
-            "styles": styles,
-        }
-    }
+    return {"theme": {"include_defaults": include_defaults, "styles": styles}}

+ 154 - 71
misago/themes/migrations/0001_initial.py

@@ -1,7 +1,4 @@
-# -*- coding: utf-8 -*-
-# Generated by Django 1.11.17 on 2018-12-28 23:20
-from __future__ import unicode_literals
-
+# Generated by Django 1.11.17 on 2018-12-29 16:02
 from django.db import migrations, models
 import django.db.models.deletion
 import misago.themes.uploadto
@@ -12,95 +9,181 @@ class Migration(migrations.Migration):
 
     initial = True
 
-    dependencies = [
-    ]
+    dependencies = []
 
     operations = [
         migrations.CreateModel(
-            name='Css',
+            name="Css",
             fields=[
-                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
-                ('name', models.CharField(max_length=255)),
-                ('url', models.URLField(blank=True, max_length=255, null=True)),
-                ('source_file', models.FileField(blank=True, max_length=255, null=True, upload_to=misago.themes.uploadto.upload_css_source_to)),
-                ('source_hash', models.CharField(blank=True, max_length=8, null=True)),
-                ('file', models.FileField(blank=True, max_length=255, null=True, upload_to=misago.themes.uploadto.upload_css_to)),
-                ('hash', models.CharField(blank=True, max_length=8, null=True)),
-                ('size', models.PositiveIntegerField(default=0)),
-                ('order', models.IntegerField(default=0)),
-                ('modified_on', models.DateTimeField(auto_now=True)),
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("name", models.CharField(max_length=255)),
+                ("url", models.URLField(blank=True, max_length=255, null=True)),
+                (
+                    "source_file",
+                    models.FileField(
+                        blank=True,
+                        max_length=255,
+                        null=True,
+                        upload_to=misago.themes.uploadto.upload_css_source_to,
+                    ),
+                ),
+                ("source_hash", models.CharField(blank=True, max_length=8, null=True)),
+                ("source_contains_urls", models.BooleanField(default=False)),
+                (
+                    "rebuild_file",
+                    models.FileField(
+                        blank=True,
+                        max_length=255,
+                        null=True,
+                        upload_to=misago.themes.uploadto.upload_css_to,
+                    ),
+                ),
+                ("rebuild_hash", models.CharField(blank=True, max_length=8, null=True)),
+                ("size", models.PositiveIntegerField(default=0)),
+                ("order", models.IntegerField(default=0)),
+                ("modified_on", models.DateTimeField(auto_now=True)),
             ],
-            options={
-                'ordering': ['order'],
-            },
+            options={"ordering": ["order"]},
         ),
         migrations.CreateModel(
-            name='Font',
+            name="Font",
             fields=[
-                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
-                ('name', models.CharField(max_length=255)),
-                ('file', models.FileField(max_length=255, upload_to=misago.themes.uploadto.upload_font_to)),
-                ('hash', models.CharField(max_length=12)),
-                ('type', models.CharField(max_length=255)),
-                ('size', models.PositiveIntegerField()),
-                ('modified_on', models.DateTimeField(auto_now=True)),
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("name", models.CharField(max_length=255)),
+                (
+                    "file",
+                    models.FileField(
+                        max_length=255, upload_to=misago.themes.uploadto.upload_font_to
+                    ),
+                ),
+                ("hash", models.CharField(max_length=12)),
+                ("type", models.CharField(max_length=255)),
+                ("size", models.PositiveIntegerField()),
+                ("modified_on", models.DateTimeField(auto_now=True)),
             ],
-            options={
-                'ordering': ['name'],
-            },
+            options={"ordering": ["name"]},
         ),
         migrations.CreateModel(
-            name='Image',
+            name="Image",
             fields=[
-                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
-                ('name', models.CharField(max_length=255)),
-                ('file', models.ImageField(height_field='height', max_length=255, upload_to=misago.themes.uploadto.upload_image_to, width_field='width')),
-                ('hash', models.CharField(max_length=8)),
-                ('type', models.CharField(max_length=255)),
-                ('width', models.PositiveIntegerField()),
-                ('height', models.PositiveIntegerField()),
-                ('size', models.PositiveIntegerField()),
-                ('thumbnail', models.ImageField(max_length=255, upload_to=misago.themes.uploadto.upload_image_thumbnail_to)),
-                ('modified_on', models.DateTimeField(auto_now=True)),
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("name", models.CharField(max_length=255)),
+                (
+                    "file",
+                    models.ImageField(
+                        height_field="height",
+                        max_length=255,
+                        upload_to=misago.themes.uploadto.upload_image_to,
+                        width_field="width",
+                    ),
+                ),
+                ("hash", models.CharField(max_length=8)),
+                ("type", models.CharField(max_length=255)),
+                ("width", models.PositiveIntegerField()),
+                ("height", models.PositiveIntegerField()),
+                ("size", models.PositiveIntegerField()),
+                (
+                    "thumbnail",
+                    models.ImageField(
+                        max_length=255,
+                        upload_to=misago.themes.uploadto.upload_image_thumbnail_to,
+                    ),
+                ),
+                ("modified_on", models.DateTimeField(auto_now=True)),
             ],
-            options={
-                'ordering': ['name'],
-            },
+            options={"ordering": ["name"]},
         ),
         migrations.CreateModel(
-            name='Theme',
+            name="Theme",
             fields=[
-                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
-                ('name', models.CharField(max_length=255)),
-                ('dirname', models.CharField(default=misago.themes.uploadto.generate_theme_dirname, max_length=8)),
-                ('is_default', models.BooleanField(default=False)),
-                ('is_active', models.BooleanField(default=False)),
-                ('version', models.CharField(blank=True, max_length=255, null=True)),
-                ('author', models.CharField(blank=True, max_length=255, null=True)),
-                ('url', models.URLField(blank=True, max_length=255, null=True)),
-                ('lft', models.PositiveIntegerField(db_index=True, editable=False)),
-                ('rght', models.PositiveIntegerField(db_index=True, editable=False)),
-                ('tree_id', models.PositiveIntegerField(db_index=True, editable=False)),
-                ('level', models.PositiveIntegerField(db_index=True, editable=False)),
-                ('parent', mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='children', to='misago_themes.Theme')),
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("name", models.CharField(max_length=255)),
+                (
+                    "dirname",
+                    models.CharField(
+                        default=misago.themes.uploadto.generate_theme_dirname,
+                        max_length=8,
+                    ),
+                ),
+                ("is_default", models.BooleanField(default=False)),
+                ("is_active", models.BooleanField(default=False)),
+                ("version", models.CharField(blank=True, max_length=255, null=True)),
+                ("author", models.CharField(blank=True, max_length=255, null=True)),
+                ("url", models.URLField(blank=True, max_length=255, null=True)),
+                ("lft", models.PositiveIntegerField(db_index=True, editable=False)),
+                ("rght", models.PositiveIntegerField(db_index=True, editable=False)),
+                ("tree_id", models.PositiveIntegerField(db_index=True, editable=False)),
+                ("level", models.PositiveIntegerField(db_index=True, editable=False)),
+                (
+                    "parent",
+                    mptt.fields.TreeForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.PROTECT,
+                        related_name="children",
+                        to="misago_themes.Theme",
+                    ),
+                ),
             ],
-            options={
-                'abstract': False,
-            },
+            options={"abstract": False},
         ),
         migrations.AddField(
-            model_name='image',
-            name='theme',
-            field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='images', to='misago_themes.Theme'),
+            model_name="image",
+            name="theme",
+            field=models.ForeignKey(
+                on_delete=django.db.models.deletion.PROTECT,
+                related_name="images",
+                to="misago_themes.Theme",
+            ),
         ),
         migrations.AddField(
-            model_name='font',
-            name='theme',
-            field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='fonts', to='misago_themes.Theme'),
+            model_name="font",
+            name="theme",
+            field=models.ForeignKey(
+                on_delete=django.db.models.deletion.PROTECT,
+                related_name="fonts",
+                to="misago_themes.Theme",
+            ),
         ),
         migrations.AddField(
-            model_name='css',
-            name='theme',
-            field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='css', to='misago_themes.Theme'),
+            model_name="css",
+            name="theme",
+            field=models.ForeignKey(
+                on_delete=django.db.models.deletion.PROTECT,
+                related_name="css",
+                to="misago_themes.Theme",
+            ),
         ),
     ]

+ 14 - 4
misago/themes/models.py

@@ -47,10 +47,11 @@ class Css(models.Model):
         upload_to=upload_css_source_to, max_length=255, null=True, blank=True
     )
     source_hash = models.CharField(max_length=8, null=True, blank=True)
-    file = models.FileField(
+    source_contains_urls = models.BooleanField(default=False)
+    rebuild_file = models.FileField(
         upload_to=upload_css_to, max_length=255, null=True, blank=True
     )
-    hash = models.CharField(max_length=8, null=True, blank=True)
+    rebuild_hash = models.CharField(max_length=8, null=True, blank=True)
     size = models.PositiveIntegerField(default=0)
 
     order = models.IntegerField(default=0)
@@ -60,7 +61,11 @@ class Css(models.Model):
         ordering = ["order"]
 
     def delete(self, *args, **kwargs):
-        self.file.delete(save=False)
+        if self.source_file:
+            self.source_file.delete(save=False)
+        if self.rebuild_file:
+            self.rebuild_file.delete(save=False)
+
         super().delete(*args, **kwargs)
 
     def __str__(self):
@@ -92,7 +97,12 @@ class Image(models.Model):
     theme = models.ForeignKey(Theme, on_delete=models.PROTECT, related_name="images")
 
     name = models.CharField(max_length=255)
-    file = models.ImageField(upload_to=upload_image_to, max_length=255, width_field="width", height_field="height")
+    file = models.ImageField(
+        upload_to=upload_image_to,
+        max_length=255,
+        width_field="width",
+        height_field="height",
+    )
     hash = models.CharField(max_length=8)
     type = models.CharField(max_length=255)
     width = models.PositiveIntegerField()

+ 1 - 1
misago/themes/tests/test_adding_hash_to_filename.py

@@ -13,4 +13,4 @@ def test_hash_is_added_before_file_extension_in_filename_with_multiple_dots():
 
 def test_hash_is_not_added_to_filename_if_it_already_contains_it():
     filename = add_hash_to_filename("hash", "test.hash.jpg")
-    assert filename == "test.hash.jpg"
+    assert filename == "test.hash.jpg"

+ 5 - 5
misago/themes/uploadto.py

@@ -6,22 +6,22 @@ def generate_theme_dirname():
 
 
 def upload_css_source_to(instance, filename):
-    add_hash_to_filename(instance.source_hash, filename)
+    filename = add_hash_to_filename(instance.source_hash, filename)
     return "themes/%s/css/%s" % (instance.theme.dirname, filename)
 
 
 def upload_css_to(instance, filename):
-    add_hash_to_filename(instance.hash, filename)
+    filename = add_hash_to_filename(instance.hash, filename)
     return "themes/%s/css/%s" % (instance.theme.dirname, filename)
 
 
 def upload_font_to(instance, filename):
-    add_hash_to_filename(instance.hash, filename)
-    return "themes/%s/font/%s" % (instance.theme.dirname, filename)
+    filename = add_hash_to_filename(instance.hash, filename)
+    return "themes/%s/media/%s" % (instance.theme.dirname, filename)
 
 
 def upload_image_to(instance, filename):
-    add_hash_to_filename(instance.hash, filename)
+    filename = add_hash_to_filename(instance.hash, filename)
     return "themes/%s/media/%s" % (instance.theme.dirname, filename)