Просмотр исходного кода

Make icons configurable in admin

Rafał Pitoń 6 лет назад
Родитель
Сommit
3d09d6931f
48 измененных файлов с 812 добавлено и 77 удалено
  1. 2 0
      devproject/settings.py
  2. 3 4
      misago/conf/admin/forms/users.py
  3. 33 28
      misago/core/tests/test_validators.py
  4. 5 0
      misago/core/validators.py
  5. 1 0
      misago/icons/__init__.py
  6. 19 0
      misago/icons/admin/__init__.py
  7. 124 0
      misago/icons/admin/forms.py
  8. 0 0
      misago/icons/admin/tests/__init__.py
  9. 38 0
      misago/icons/admin/tests/conftest.py
  10. 105 0
      misago/icons/admin/tests/test_apple_touch_icon.py
  11. 136 0
      misago/icons/admin/tests/test_favicon.py
  12. 26 0
      misago/icons/admin/tests/test_icons_view.py
  13. 26 0
      misago/icons/admin/views.py
  14. 7 0
      misago/icons/apps.py
  15. 43 0
      misago/icons/conftest.py
  16. 5 0
      misago/icons/context_processors.py
  17. 37 0
      misago/icons/migrations/0001_initial.py
  18. 0 0
      misago/icons/migrations/__init__.py
  19. 24 0
      misago/icons/models.py
  20. 0 0
      misago/icons/tests/__init__.py
  21. 26 0
      misago/icons/tests/test_icons_context_processor.py
  22. 21 0
      misago/icons/tests/test_icons_markup.py
  23. BIN
      misago/static/apple-touch-icon-114.png
  24. BIN
      misago/static/apple-touch-icon-120.png
  25. BIN
      misago/static/apple-touch-icon-144.png
  26. BIN
      misago/static/apple-touch-icon-152.png
  27. BIN
      misago/static/apple-touch-icon-167.png
  28. BIN
      misago/static/apple-touch-icon-57.png
  29. BIN
      misago/static/apple-touch-icon-72.png
  30. BIN
      misago/static/apple-touch-icon-76.png
  31. BIN
      misago/static/apple-touch-icon-80.png
  32. BIN
      misago/static/apple-touch-icon.png
  33. 0 0
      misago/static/misago/admin/apple-touch-icon.png
  34. BIN
      misago/static/misago/admin/favicon-16.png
  35. BIN
      misago/static/misago/admin/favicon-32.png
  36. 0 0
      misago/static/misago/admin/favicon.ico
  37. BIN
      misago/static/misago/apple-touch-icon.png
  38. BIN
      misago/static/misago/favicon-16.png
  39. BIN
      misago/static/misago/favicon-32.png
  40. BIN
      misago/static/misago/favicon.ico
  41. 4 13
      misago/templates/misago/admin/base.html
  42. 2 2
      misago/templates/misago/admin/conf/form.html
  43. 2 2
      misago/templates/misago/admin/generic/form.html
  44. 93 0
      misago/templates/misago/admin/icons.html
  45. 4 4
      misago/templates/misago/admin/messages.html
  46. 26 1
      misago/templates/misago/base.html
  47. 0 20
      misago/templates/misago/head.html
  48. 0 3
      misago/themes/admin/__init__.py

+ 2 - 0
devproject/settings.py

@@ -182,6 +182,7 @@ INSTALLED_APPS = [
     "misago.cache",
     "misago.core",
     "misago.conf",
+    "misago.icons",
     "misago.themes",
     "misago.markup",
     "misago.legal",
@@ -273,6 +274,7 @@ TEMPLATES = [
                 "misago.core.context_processors.misago_version",
                 "misago.core.context_processors.request_path",
                 "misago.core.context_processors.momentjs_locale",
+                "misago.icons.context_processors.icons",
                 "misago.search.context_processors.search_providers",
                 "misago.themes.context_processors.theme",
                 "misago.legal.context_processors.legal_links",

+ 3 - 4
misago/conf/admin/forms/users.py

@@ -2,6 +2,7 @@ from django import forms
 from django.utils.translation import gettext_lazy as _
 
 from ....admin.forms import YesNoSwitch
+from ....core.validators import validate_image_square
 from ....users.validators import validate_username_content
 from ... import settings
 from .base import ChangeSettingsForm
@@ -194,13 +195,11 @@ class ChangeUsersSettingsForm(ChangeSettingsForm):
         if not upload or upload == self.initial.get("blank_avatar"):
             return None
 
-        if upload.image.width != upload.image.height:
-            raise forms.ValidationError(_("Submitted image is not a square."))
-
+        validate_image_square(upload.image)
         min_size = max(settings.MISAGO_AVATARS_SIZES)
         if upload.image.width < min_size:
             raise forms.ValidationError(
-                _("Submitted image's edge should be at least %(size)s pixels long.")
+                _("Uploaded image's edge should be at least %(size)s pixels long.")
                 % {"size": min_size}
             )
 

+ 33 - 28
misago/core/tests/test_validators.py

@@ -1,38 +1,43 @@
+import pytest
 from django.core.exceptions import ValidationError
-from django.test import TestCase
 
-from ..validators import validate_sluggable
+from ..validators import validate_image_square, validate_sluggable
 
 
-class ValidateSluggableTests(TestCase):
-    def test_error_messages_set(self):
-        """custom error messages are set and used"""
-        error_short = "I'm short custom error!"
-        error_long = "I'm long custom error!"
+def test_sluggable_validator_raises_error_if_result_slug_will_be_empty():
+    validator = validate_sluggable()
+    with pytest.raises(ValidationError):
+        validator("!#@! !@#@")
 
-        validator = validate_sluggable(error_short, error_long)
 
-        self.assertEqual(validator.error_short, error_short)
-        self.assertEqual(validator.error_long, error_long)
+def test_sluggable_validator_raises_custom_error_if_result_slug_will_be_empty():
+    error_message = "I'm short custom error!"
+    validator = validate_sluggable(error_short=error_message)
+    with pytest.raises(ValidationError) as e:
+        validator("!#@! !@#@")
+    assert error_message in str(e.value)
 
-    def test_faulty_input_validation(self):
-        """invalid values raise errors"""
-        validator = validate_sluggable()
 
-        with self.assertRaises(ValidationError):
-            validator("!#@! !@#@")
-        with self.assertRaises(ValidationError):
-            validator(
-                "!#@! !@#@ 1234567890 1234567890 1234567890 1234567890"
-                "1234567890 1234567890 1234567890 1234567890 1234567890"
-                "1234567890 1234567890 1234567890 1234567890 1234567890"
-                "1234567890 1234567890 1234567890 1234567890 1234567890"
-                "1234567890 1234567890 1234567890 1234567890 1234567890"
-            )
+def test_sluggable_validator_raises_error_if_result_slug_will_be_too_long():
+    validator = validate_sluggable()
+    with pytest.raises(ValidationError):
+        validator("a" * 256)
 
-    def test_valid_input_validation(self):
-        """valid values don't raise errors"""
-        validator = validate_sluggable()
 
-        validator("User")
-        validator("Lorem ipsum123!")
+def test_sluggable_validator_raises_custom_error_if_result_slug_will_be_too_long():
+    error_message = "I'm long custom error!"
+    validator = validate_sluggable(error_long=error_message)
+    with pytest.raises(ValidationError) as e:
+        validator("a" * 256)
+    assert error_message in str(e.value)
+
+
+def test_square_square_validator_validates_square_image(mocker):
+    image = mocker.Mock(width=100, height=100)
+    validate_image_square(image)
+
+
+def test_square_square_validator_raises_error_if_image_is_not_square(mocker):
+    image = mocker.Mock(width=100, height=200)
+    with pytest.raises(ValidationError):
+        validate_image_square(image)

+ 5 - 0
misago/core/validators.py

@@ -19,3 +19,8 @@ class validate_sluggable:
 
         if len(slug) > 255:
             raise ValidationError(self.error_long)
+
+
+def validate_image_square(image):
+    if image.width != image.height:
+        raise ValidationError(_("Uploaded image is not a square."))

+ 1 - 0
misago/icons/__init__.py

@@ -0,0 +1 @@
+default_app_config = "misago.icons.apps.MisagoIconsConfig"

+ 19 - 0
misago/icons/admin/__init__.py

@@ -0,0 +1,19 @@
+from django.conf.urls import url
+from django.utils.translation import gettext_lazy as _
+
+from .views import icons_admin
+
+
+class MisagoAdminExtension:
+    def register_urlpatterns(self, urlpatterns):
+        # Icons
+        urlpatterns.namespace(r"^icons/", "icons", "settings")
+        urlpatterns.patterns("settings:icons", url(r"^$", icons_admin, name="index"))
+
+    def register_navigation_nodes(self, site):
+        site.add_node(
+            name=_("Icons"),
+            description=_("Upload favicon and application icon for the site."),
+            parent="settings",
+            namespace="icons",
+        )

+ 124 - 0
misago/icons/admin/forms.py

@@ -0,0 +1,124 @@
+from io import BytesIO
+
+
+from PIL import Image
+from django import forms
+from django.core.files.base import ContentFile
+from django.utils.translation import gettext_lazy as _
+
+from ...core.utils import get_file_hash
+from ...core.validators import validate_image_square
+from ..models import Icon
+
+FAVICON_MIN_SIZE = 48
+FAVICON_SIZES = ((16, 16), (32, 32), (48, 48))
+APPLE_TOUCH_MIN_SIZE = 180
+VALID_MIME = ("image/gif", "image/jpeg", "image/png")
+
+
+class IconsForm(forms.Form):
+    favicon = forms.ImageField(
+        label=_("Upload image"),
+        help_text=_("Uploaded image should be a square that is 48px wide and tall."),
+        required=False,
+    )
+    favicon_delete = forms.BooleanField(label=_("Delete custom icon"), required=False)
+
+    apple_touch_icon = forms.ImageField(
+        label=_("Upload image"),
+        help_text=_("Uploaded image should be square at least 180px wide and tall."),
+        required=False,
+    )
+    apple_touch_icon_delete = forms.BooleanField(
+        label=_("Delete custom icon"), required=False
+    )
+
+    def clean_favicon(self):
+        upload = self.cleaned_data.get("favicon")
+        if not upload or upload == self.initial.get("favicon"):
+            return None
+
+        validate_image_square(upload.image)
+        validate_image_dimensions(upload.image, FAVICON_MIN_SIZE)
+        validate_image_mime_type(upload)
+
+        return upload
+
+    def clean_apple_touch_icon(self):
+        upload = self.cleaned_data.get("apple_touch_icon")
+        if not upload or upload == self.initial.get("apple_touch_icon"):
+            return None
+
+        validate_image_square(upload.image)
+        validate_image_dimensions(upload.image, APPLE_TOUCH_MIN_SIZE)
+        validate_image_mime_type(upload)
+
+        return upload
+
+    def save(self):
+        if self.cleaned_data.get("favicon"):
+            self.save_favicon(self.cleaned_data["favicon"])
+        elif self.cleaned_data.get("favicon_delete"):
+            self.delete_icons(Icon.FAVICON_TYPES)
+
+        if self.cleaned_data.get("apple_touch_icon"):
+            self.save_apple_touch_icon(self.cleaned_data["apple_touch_icon"])
+        elif self.cleaned_data.get("apple_touch_icon_delete"):
+            self.delete_icons([Icon.TYPE_APPLE_TOUCH_ICON])
+
+    def save_favicon(self, image):
+        self.delete_icons(Icon.FAVICON_TYPES)
+        save_favicon(image)
+        save_icon(image, (32, 32), Icon.TYPE_FAVICON_32)
+        save_icon(image, (16, 16), Icon.TYPE_FAVICON_16)
+
+    def save_apple_touch_icon(self, image):
+        self.delete_icons([Icon.TYPE_APPLE_TOUCH_ICON])
+        save_icon(image, (180, 180), Icon.TYPE_APPLE_TOUCH_ICON)
+
+    def delete_icons(self, icon_types):
+        for icon in Icon.objects.filter(type__in=icon_types):
+            icon.delete()
+
+
+def save_favicon(image):
+    icon = Image.open(image)
+
+    buffer = BytesIO()
+    icon.save(buffer, "ICO", sizes=FAVICON_SIZES)
+    buffer.seek(0)
+
+    icon_file = ContentFile(buffer.read())
+    icon_file.name = "%s.%s.ico" % ("favicon", get_file_hash(icon_file))
+
+    Icon.objects.create(type=Icon.TYPE_FAVICON, image=icon_file, size=icon_file.size)
+
+
+def save_icon(image, size, icon_type):
+    icon = Image.open(image)
+    icon.thumbnail(size)
+
+    buffer = BytesIO()
+    icon.save(buffer, "PNG")
+    buffer.seek(0)
+
+    icon_file = ContentFile(buffer.read())
+    icon_file.name = "%s.%s.png" % (
+        icon_type.replace("_", "-"),
+        get_file_hash(icon_file),
+    )
+
+    Icon.objects.create(type=icon_type, image=icon_file, size=icon_file.size)
+
+
+def validate_image_dimensions(image, size):
+    if image.width < size:
+        raise forms.ValidationError(
+            _("Uploaded image's edge should be at least %(size)s pixels long.")
+            % {"size": size}
+        )
+
+
+def validate_image_mime_type(upload):
+    if upload.content_type not in VALID_MIME:
+        raise forms.ValidationError(_("Uploaded image was not gif, jpeg or png."))

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


+ 38 - 0
misago/icons/admin/tests/conftest.py

@@ -0,0 +1,38 @@
+from io import BytesIO
+
+import pytest
+from PIL import Image
+from django.urls import reverse
+
+
+@pytest.fixture
+def admin_link():
+    return reverse("misago:admin:settings:icons:index")
+
+
+def get_image(width, height):
+    image = Image.new("RGBA", (width, height))
+    buffer = BytesIO()
+    image.save(buffer, "PNG")
+    buffer.seek(0)
+    return buffer.read()
+
+
+@pytest.fixture
+def image():
+    return get_image(185, 185)
+
+
+@pytest.fixture
+def image_alt():
+    return get_image(85, 85)
+
+
+@pytest.fixture
+def image_non_square():
+    return get_image(180, 200)
+
+
+@pytest.fixture
+def image_small():
+    return get_image(20, 20)

+ 105 - 0
misago/icons/admin/tests/test_apple_touch_icon.py

@@ -0,0 +1,105 @@
+import os
+
+import pytest
+from django.core.files.uploadedfile import SimpleUploadedFile
+
+from ...models import Icon
+
+
+@pytest.fixture
+def apple_touch_icon(db, image):
+    return Icon.objects.create(
+        type=Icon.TYPE_APPLE_TOUCH_ICON,
+        image=SimpleUploadedFile("image.png", image, "image/png"),
+    )
+
+
+def test_new_touch_icon_can_be_set(admin_client, admin_link, image):
+    admin_client.post(
+        admin_link,
+        {"apple_touch_icon": SimpleUploadedFile("image.png", image, "image/png")},
+    )
+    Icon.objects.get(type=Icon.TYPE_APPLE_TOUCH_ICON)
+
+
+def test_setting_new_touch_icon_removes_old_one(
+    admin_client, admin_link, image, apple_touch_icon
+):
+    admin_client.post(
+        admin_link,
+        {"apple_touch_icon": SimpleUploadedFile("image.png", image, "image/png")},
+    )
+
+    with pytest.raises(Icon.DoesNotExist):
+        apple_touch_icon.refresh_from_db()
+
+
+def test_setting_new_touch_icon_removes_old_one_image_file(
+    admin_client, admin_link, image, apple_touch_icon
+):
+    admin_client.post(
+        admin_link,
+        {"apple_touch_icon": SimpleUploadedFile("image.png", image, "image/png")},
+    )
+
+    assert not os.path.exists(apple_touch_icon.image.path)
+
+
+def test_submitting_form_without_new_icon_does_not_delete_old_one(
+    admin_client, admin_link, apple_touch_icon
+):
+    admin_client.post(admin_link, {})
+    apple_touch_icon.refresh_from_db()
+
+
+def test_icon_can_be_deleted_without_setting_new_one(
+    admin_client, admin_link, apple_touch_icon
+):
+    admin_client.post(admin_link, {"apple_touch_icon_delete": "1"})
+
+    with pytest.raises(Icon.DoesNotExist):
+        apple_touch_icon.refresh_from_db()
+
+
+def test_deleting_icon_also_deletes_its_image_file(
+    admin_client, admin_link, apple_touch_icon
+):
+    admin_client.post(admin_link, {"apple_touch_icon_delete": "1"})
+    assert not os.path.exists(apple_touch_icon.image.path)
+
+
+def test_uploading_invalid_icon_does_not_remove_current_icon(
+    admin_client, admin_link, apple_touch_icon, image_small
+):
+    admin_client.post(
+        admin_link,
+        {"apple_touch_icon": SimpleUploadedFile("image.png", image_small, "image/png")},
+    )
+
+    apple_touch_icon.refresh_from_db()
+
+
+def test_icon_is_not_set_because_it_was_not_square(
+    admin_client, admin_link, image_non_square
+):
+    admin_client.post(
+        admin_link,
+        {
+            "apple_touch_icon": SimpleUploadedFile(
+                "image.png", image_non_square, "image/png"
+            )
+        },
+    )
+
+    assert not Icon.objects.exists()
+
+
+def test_icon_is_not_set_because_it_was_too_small(
+    admin_client, admin_link, image_small
+):
+    admin_client.post(
+        admin_link,
+        {"apple_touch_icon": SimpleUploadedFile("image.png", image_small, "image/png")},
+    )
+
+    assert not Icon.objects.exists()

+ 136 - 0
misago/icons/admin/tests/test_favicon.py

@@ -0,0 +1,136 @@
+import os
+
+import pytest
+from django.core.files.uploadedfile import SimpleUploadedFile
+
+from ...models import Icon
+
+
+@pytest.fixture
+def favicon(db, image_alt):
+    return Icon.objects.create(
+        type=Icon.TYPE_FAVICON,
+        image=SimpleUploadedFile("favicon.png", image_alt, "image/png"),
+    )
+
+
+@pytest.fixture
+def favicon_32(db, image_alt):
+    return Icon.objects.create(
+        type=Icon.TYPE_FAVICON_32,
+        image=SimpleUploadedFile("favicon-32.png", image_alt, "image/png"),
+    )
+
+
+@pytest.fixture
+def favicon_16(db, image_alt):
+    return Icon.objects.create(
+        type=Icon.TYPE_FAVICON_16,
+        image=SimpleUploadedFile("favicon-16.png", image_alt, "image/png"),
+    )
+
+
+def test_uploading_favicon_sets_favicon_images(admin_client, admin_link, image):
+    admin_client.post(
+        admin_link, {"favicon": SimpleUploadedFile("image.png", image, "image/png")}
+    )
+    Icon.objects.get(type=Icon.TYPE_FAVICON)
+    Icon.objects.get(type=Icon.TYPE_FAVICON_32)
+    Icon.objects.get(type=Icon.TYPE_FAVICON_16)
+
+
+def test_uploading_favicon_removes_existing_favicon_images(
+    admin_client, admin_link, image, favicon, favicon_32, favicon_16
+):
+    admin_client.post(
+        admin_link, {"favicon": SimpleUploadedFile("image.png", image, "image/png")}
+    )
+
+    with pytest.raises(Icon.DoesNotExist):
+        favicon.refresh_from_db()
+
+    with pytest.raises(Icon.DoesNotExist):
+        favicon_32.refresh_from_db()
+
+    with pytest.raises(Icon.DoesNotExist):
+        favicon_16.refresh_from_db()
+
+
+def test_uploading_new_favicon_removes_old_one_image_file(
+    admin_client, admin_link, image, favicon, favicon_32, favicon_16
+):
+    admin_client.post(
+        admin_link, {"favicon": SimpleUploadedFile("image.png", image, "image/png")}
+    )
+
+    assert not os.path.exists(favicon.image.path)
+    assert not os.path.exists(favicon_32.image.path)
+    assert not os.path.exists(favicon_16.image.path)
+
+
+def test_submitting_form_without_new_icon_does_not_delete_old_favicon_images(
+    admin_client, admin_link, favicon, favicon_32, favicon_16
+):
+    admin_client.post(admin_link, {})
+    favicon.refresh_from_db()
+    favicon_32.refresh_from_db()
+    favicon_16.refresh_from_db()
+
+
+def test_favicon_can_be_deleted_without_setting_new_one(
+    admin_client, admin_link, favicon, favicon_32, favicon_16
+):
+    admin_client.post(admin_link, {"favicon_delete": "1"})
+
+    with pytest.raises(Icon.DoesNotExist):
+        favicon.refresh_from_db()
+
+    with pytest.raises(Icon.DoesNotExist):
+        favicon_32.refresh_from_db()
+
+    with pytest.raises(Icon.DoesNotExist):
+        favicon_16.refresh_from_db()
+
+
+def test_deleting_icon_also_deletes_its_image_files(
+    admin_client, admin_link, favicon, favicon_32, favicon_16
+):
+    admin_client.post(admin_link, {"favicon_delete": "1"})
+    assert not os.path.exists(favicon.image.path)
+    assert not os.path.exists(favicon_32.image.path)
+    assert not os.path.exists(favicon_16.image.path)
+
+
+def test_uploading_invalid_icon_does_not_remove_current_icon(
+    admin_client, admin_link, favicon, favicon_32, favicon_16, image_small
+):
+    admin_client.post(
+        admin_link,
+        {"favicon": SimpleUploadedFile("image.png", image_small, "image/png")},
+    )
+
+    favicon.refresh_from_db()
+    favicon_32.refresh_from_db()
+    favicon_16.refresh_from_db()
+
+
+def test_icon_is_not_set_because_it_was_not_square(
+    admin_client, admin_link, image_non_square
+):
+    admin_client.post(
+        admin_link,
+        {"favicon": SimpleUploadedFile("image.png", image_non_square, "image/png")},
+    )
+
+    assert not Icon.objects.exists()
+
+
+def test_icon_is_not_set_because_it_was_too_small(
+    admin_client, admin_link, image_small
+):
+    admin_client.post(
+        admin_link,
+        {"favicon": SimpleUploadedFile("image.png", image_small, "image/png")},
+    )
+
+    assert not Icon.objects.exists()

+ 26 - 0
misago/icons/admin/tests/test_icons_view.py

@@ -0,0 +1,26 @@
+from ....test import assert_contains
+
+
+def test_icons_admin_view_displays(admin_client, admin_link):
+    response = admin_client.get(admin_link)
+    assert response.status_code == 200
+
+
+def test_set_favicon_displays(admin_client, admin_link, favicon):
+    response = admin_client.get(admin_link)
+    assert_contains(response, favicon.image.url)
+
+
+def test_set_favicon_32_displays(admin_client, admin_link, favicon_32):
+    response = admin_client.get(admin_link)
+    assert_contains(response, favicon_32.image.url)
+
+
+def test_set_favicon_16_displays(admin_client, admin_link, favicon_16):
+    response = admin_client.get(admin_link)
+    assert_contains(response, favicon_16.image.url)
+
+
+def test_set_apple_touch_icon_displays(admin_client, admin_link, apple_touch_icon):
+    response = admin_client.get(admin_link)
+    assert_contains(response, apple_touch_icon.image.url)

+ 26 - 0
misago/icons/admin/views.py

@@ -0,0 +1,26 @@
+from django.contrib import messages
+from django.shortcuts import redirect
+from django.utils.translation import gettext as _
+
+from ...admin.views import render
+from ..models import Icon
+from .forms import IconsForm
+
+
+def icons_admin(request):
+    form = IconsForm()
+    if request.method == "POST":
+        form = IconsForm(request.POST, request.FILES)
+        if form.is_valid():
+            form.save()
+
+            messages.success(request, _("Icons have been updated."))
+            return redirect("misago:admin:settings:icons:index")
+
+    return render(
+        request, "misago/admin/icons.html", {"form": form, "icons": get_icons()}
+    )
+
+
+def get_icons():
+    return {i.type: i for i in Icon.objects.all()}

+ 7 - 0
misago/icons/apps.py

@@ -0,0 +1,7 @@
+from django.apps import AppConfig
+
+
+class MisagoIconsConfig(AppConfig):
+    name = "misago.icons"
+    label = "misago_icons"
+    verbose_name = "Misago Icons"

+ 43 - 0
misago/icons/conftest.py

@@ -0,0 +1,43 @@
+import pytest
+
+from .models import Icon
+
+
+@pytest.fixture
+def favicon(db):
+    return Icon.objects.create(
+        type=Icon.TYPE_FAVICON, image="static/favicon.png", size=1, width=48, height=48
+    )
+
+
+@pytest.fixture
+def favicon_32(db):
+    return Icon.objects.create(
+        type=Icon.TYPE_FAVICON_32,
+        image="static/favicon-32.png",
+        size=1,
+        width=32,
+        height=32,
+    )
+
+
+@pytest.fixture
+def favicon_16(db):
+    return Icon.objects.create(
+        type=Icon.TYPE_FAVICON_16,
+        image="static/favicon-16.png",
+        size=1,
+        width=16,
+        height=16,
+    )
+
+
+@pytest.fixture
+def apple_touch_icon(db):
+    return Icon.objects.create(
+        type=Icon.TYPE_APPLE_TOUCH_ICON,
+        image="static/test.png",
+        size=1,
+        width=180,
+        height=180,
+    )

+ 5 - 0
misago/icons/context_processors.py

@@ -0,0 +1,5 @@
+from .models import Icon
+
+
+def icons(request):
+    return {"icons": {i.type: i.image.url for i in Icon.objects.all()}}

+ 37 - 0
misago/icons/migrations/0001_initial.py

@@ -0,0 +1,37 @@
+# Generated by Django 2.2.1 on 2019-06-09 19:13
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = []
+
+    operations = [
+        migrations.CreateModel(
+            name="Icon",
+            fields=[
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("type", models.CharField(max_length=255)),
+                (
+                    "image",
+                    models.ImageField(
+                        height_field="height", upload_to="icon", width_field="width"
+                    ),
+                ),
+                ("size", models.PositiveIntegerField(default=0)),
+                ("width", models.PositiveIntegerField(default=0)),
+                ("height", models.PositiveIntegerField(default=0)),
+            ],
+        )
+    ]

+ 0 - 0
misago/icons/migrations/__init__.py


+ 24 - 0
misago/icons/models.py

@@ -0,0 +1,24 @@
+from django.db import models
+
+
+class Icon(models.Model):
+    TYPE_FAVICON = "favicon"
+    TYPE_FAVICON_32 = "favicon_32"
+    TYPE_FAVICON_16 = "favicon_16"
+
+    TYPE_APPLE_TOUCH_ICON = "apple_touch_icon"
+
+    FAVICON_TYPES = (TYPE_FAVICON, TYPE_FAVICON_32, TYPE_FAVICON_16)
+
+    type = models.CharField(max_length=255)
+    image = models.ImageField(
+        upload_to="icon", height_field="height", width_field="width"
+    )
+    size = models.PositiveIntegerField(default=0)
+    width = models.PositiveIntegerField(default=0)
+    height = models.PositiveIntegerField(default=0)
+
+    def delete(self, *args, **kwargs):
+        if self.image:
+            self.image.delete(save=False)
+        return super().delete(*args, **kwargs)

+ 0 - 0
misago/icons/tests/__init__.py


+ 26 - 0
misago/icons/tests/test_icons_context_processor.py

@@ -0,0 +1,26 @@
+from ..context_processors import icons
+from ..models import Icon
+
+
+def test_context_processor_adds_icons_key_to_context(db):
+    assert "icons" in icons(None)
+
+
+def test_icons_context_defaults_to_empty_dict(db):
+    assert icons(None) == {"icons": {}}
+
+
+def test_set_favicon_icon_is_present_in_context(favicon):
+    assert Icon.TYPE_FAVICON in icons(None)["icons"]
+
+
+def test_set_favicon_32_icon_is_present_in_context(favicon_32):
+    assert Icon.TYPE_FAVICON_32 in icons(None)["icons"]
+
+
+def test_set_favicon_16_icon_is_present_in_context(favicon_16):
+    assert Icon.TYPE_FAVICON_16 in icons(None)["icons"]
+
+
+def test_set_apple_touch_icon_icon_is_present_in_context(apple_touch_icon):
+    assert Icon.TYPE_APPLE_TOUCH_ICON in icons(None)["icons"]

+ 21 - 0
misago/icons/tests/test_icons_markup.py

@@ -0,0 +1,21 @@
+from ...test import assert_contains
+
+
+def test_favicon_html_is_present_in_html_if_set(client, favicon):
+    response = client.get("/")
+    assert_contains(response, favicon.image.url)
+
+
+def test_favicon_32_html_is_present_in_html_if_set(client, favicon_32):
+    response = client.get("/")
+    assert_contains(response, favicon_32.image.url)
+
+
+def test_favicon_16_html_is_present_in_html_if_set(client, favicon_16):
+    response = client.get("/")
+    assert_contains(response, favicon_16.image.url)
+
+
+def test_apple_touch_icon_html_is_present_in_html_if_set(client, apple_touch_icon):
+    response = client.get("/")
+    assert_contains(response, apple_touch_icon.image.url)

BIN
misago/static/apple-touch-icon-114.png


BIN
misago/static/apple-touch-icon-120.png


BIN
misago/static/apple-touch-icon-144.png


BIN
misago/static/apple-touch-icon-152.png


BIN
misago/static/apple-touch-icon-167.png


BIN
misago/static/apple-touch-icon-57.png


BIN
misago/static/apple-touch-icon-72.png


BIN
misago/static/apple-touch-icon-76.png


BIN
misago/static/apple-touch-icon-80.png


BIN
misago/static/apple-touch-icon.png


+ 0 - 0
misago/static/apple-touch-icon-180.png → misago/static/misago/admin/apple-touch-icon.png


BIN
misago/static/misago/admin/favicon-16.png


BIN
misago/static/misago/admin/favicon-32.png


+ 0 - 0
misago/static/favicon.ico → misago/static/misago/admin/favicon.ico


BIN
misago/static/misago/apple-touch-icon.png


BIN
misago/static/misago/favicon-16.png


BIN
misago/static/misago/favicon-32.png


BIN
misago/static/misago/favicon.ico


+ 4 - 13
misago/templates/misago/admin/base.html

@@ -7,19 +7,10 @@
     <title>{% spaceless %}{% block title %}{% trans "Misago Administration" %}{% endblock %}{% endspaceless %}</title>
     <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.7.2/css/all.css" integrity="sha384-fnmOCqbTlWIlj8LyTjo7mOUStjsKC4pOpQbqyi7RrhN7udi9RwhKkMHpvLbHG9Sr" crossorigin="anonymous">
     <link href="{% static 'misago/admin/index.css' %}" rel="stylesheet">
-    <link rel="apple-touch-icon" href="{% static 'apple-touch-icon.png' %}">
-    <link rel="apple-touch-icon" sizes="57x57" href="{% static 'apple-touch-icon-57.png' %}">
-    <link rel="apple-touch-icon" sizes="72x72" href="{% static 'apple-touch-icon-72.png' %}">
-    <link rel="apple-touch-icon" sizes="76x76" href="{% static 'apple-touch-icon-76.png' %}">
-    <link rel="apple-touch-icon" sizes="80x80" href="{% static 'apple-touch-icon-80.png' %}">
-    <link rel="apple-touch-icon" sizes="114x114" href="{% static 'apple-touch-icon-114.png' %}">
-    <link rel="apple-touch-icon" sizes="120x120" href="{% static 'apple-touch-icon-120.png' %}">
-    <link rel="apple-touch-icon" sizes="144x144" href="{% static 'apple-touch-icon-144.png' %}">
-    <link rel="apple-touch-icon" sizes="152x152" href="{% static 'apple-touch-icon-152.png' %}">
-    <link rel="apple-touch-icon" sizes="167x167" href="{% static 'apple-touch-icon-167.png' %}">
-    <link rel="apple-touch-icon" sizes="180x180" href="{% static 'apple-touch-icon-180.png' %}">
-    <link rel="shortcut icon" href="{% static 'favicon.ico' %}">
-    <link rel="icon" sizes="16x16 32x32" href="{% static 'favicon.ico' %}">
+    <link rel="apple-touch-icon" sizes="180x180" href="{% static 'misago/admin/apple-touch-icon.png' %}">
+    <link rel="icon" type="image/png" sizes="32x32" href="{% static 'misago/admin/favicon-32.png' %}">
+    <link rel="icon" type="image/png" sizes="16x16" href="{% static 'misago/admin/favicon-16.png' %}">
+    <link rel="shortcut icon" href="{% static 'misago/admin/favicon.ico' %}">
     {% block extra-head %}{% endblock extra-head %}
   </head>
   {% block body %}{% endblock %}

+ 2 - 2
misago/templates/misago/admin/conf/form.html

@@ -19,11 +19,11 @@
 
       {% if form.is_bound and not form.is_valid %}
         {% for error in form.non_field_errors %}
-          <div class="alert alert-danger text-center" role="alert">
+          <div class="alert alert-danger" role="alert">
             {{ error }}
           </div>
         {% empty %}
-          <div class="alert alert-danger text-center" role="alert">
+          <div class="alert alert-danger" role="alert">
             {% trans "Form was completed with errors." %}
           </div>
         {% endfor %}

+ 2 - 2
misago/templates/misago/admin/generic/form.html

@@ -17,11 +17,11 @@
 
           {% if form.is_bound and not form.is_valid %}
             {% for error in form.non_field_errors %}
-              <div class="alert alert-danger text-center" role="alert">
+              <div class="alert alert-danger" role="alert">
                 {{ error }}
               </div>
             {% empty %}
-              <div class="alert alert-danger text-center" role="alert">
+              <div class="alert alert-danger" role="alert">
                 {% trans "Form was completed with errors." %}
               </div>
             {% endfor %}

+ 93 - 0
misago/templates/misago/admin/icons.html

@@ -0,0 +1,93 @@
+{% extends "misago/admin/conf/form.html" %}
+{% load i18n misago_admin_form %}
+
+
+{% block form-body %}
+<div class="form-fieldset">
+  <fieldset>
+    <legend>{% trans "Favicon" %}</legend>
+
+    <p>
+      {% trans "Favicon is small icon that internet browsers display next to your site in its interface." %}
+    </p>
+
+    {% if icons.favicon or icons.favicon_16 or icons.favicon_32 %}
+      <div class="row justify-content-left align-items-end mt-1 mb-3">
+        {% if icons.favicon %}
+          <div class="col-auto">
+            <div class="control-image-preview control-image-metadata">
+              <div class="p-0 m-0 d-flex align-items-center justify-content-center" style="height: 48px; width: 128px;">
+                <img src="{{ icons.favicon.image.url }}" alt="" />
+              </div>
+              <div>
+                <span>{{ icons.favicon.size|filesizeformat }}</span>
+                <span>{{ icons.favicon.width }}&times;{{ icons.favicon.height }}</span>
+              </div>
+            </div>
+          </div>
+        {% endif %}
+        {% if icons.favicon_32 %}
+          <div class="col-auto">
+            <div class="control-image-preview control-image-metadata">
+              <div class="p-0 m-0 d-flex align-items-center justify-content-center" style="height: 48px; width: 128px;">
+                <img src="{{ icons.favicon_32.image.url }}" alt="" />
+              </div>
+              <div>
+                <span>{{ icons.favicon_32.size|filesizeformat }}</span>
+                <span>{{ icons.favicon_32.width }}&times;{{ icons.favicon_32.height }}</span>
+              </div>
+            </div>
+          </div>
+        {% endif %}
+        {% if icons.favicon_16 %}
+          <div class="col-auto">
+            <div class="control-image-preview control-image-metadata">
+              <div class="p-0 m-0 d-flex align-items-center justify-content-center" style="height: 48px; width: 128px;">
+                <img src="{{ icons.favicon_16.image.url }}" alt="" />
+              </div>
+              <div>
+                <span>{{ icons.favicon_16.size|filesizeformat }}</span>
+                <span>{{ icons.favicon_16.width }}&times;{{ icons.favicon_16.height }}</span>
+              </div>
+            </div>
+          </div>
+        {% endif %}
+      </div>
+    {% endif %}
+  
+    {% form_row form.favicon %}
+
+    {% if icons.favicon or icons.favicon_16 or icons.favicon_32 %}
+      {% form_checkbox_row form.favicon_delete %}
+    {% endif %}
+
+  </fieldset>
+</div>
+<div class="form-fieldset">
+  <fieldset>
+    <legend>{% trans "Apple Touch Icon" %}</legend>
+
+    <p>
+      {% trans "Apple devices and Safari web browser will use this image to represent the site in its interfaces." %}
+    </p>
+
+    {% if icons.apple_touch_icon %}
+      <div class="control-image-preview control-image-metadata mt-1 mb-3">
+        <img src="{{ icons.apple_touch_icon.image.url }}" alt="" />
+        <div>
+          <span>{{ icons.apple_touch_icon.size|filesizeformat }}</span>
+          <span>
+            {{ icons.apple_touch_icon.width }}&times;{{ icons.apple_touch_icon.height }}
+          </span>
+        </div>
+      </div>
+    {% endif %}
+
+    {% form_row form.apple_touch_icon %}
+    {% if icons.apple_touch_icon %}
+      {% form_checkbox_row form.apple_touch_icon_delete %}
+    {% endif %}
+
+  </fieldset>
+</div>
+{% endblock form-body %}

+ 4 - 4
misago/templates/misago/admin/messages.html

@@ -1,13 +1,13 @@
 {% load i18n %}
 {% for message in messages %}
   {% if 'info' in message.tags %}
-    <p class="alert alert-info text-center">
+    <p class="alert alert-info">
   {% elif 'success' in message.tags %}
-    <p class="alert alert-success text-center">
+    <p class="alert alert-success">
   {% elif 'warning' in message.tags %}
-    <p class="alert alert-warning text-center">
+    <p class="alert alert-warning">
   {% elif 'error' in message.tags %}
-    <p class="alert alert-danger text-center">
+    <p class="alert alert-danger">
   {% endif %}
     {{ message }}
   </p>

+ 26 - 1
misago/templates/misago/base.html

@@ -28,8 +28,33 @@
           {% endif %}
         {% endblock og-image %}
       {% endblock og-tags %}
+      {% if theme.include_defaults %}
+        <link href="{% static 'misago/css/misago.css' %}" rel="stylesheet">
+      {% endif %}
+      {% for css_url in theme.styles %}
+        <link href="{{ css_url }}" rel="stylesheet">
+      {% endfor %}
+      {% if icons.apple_touch_icon %}
+        <link rel="apple-touch-icon" sizes="180x180" href="{{ icons.apple_touch_icon }}" />
+      {% else %}
+        <link rel="apple-touch-icon" sizes="180x180" href="{% static 'misago/apple-touch-icon.png' %}" />
+      {% endif %}
+      {% if icons.favicon_32 %}
+        <link rel="icon" type="image/png" sizes="32x32" href="{{ icons.favicon_32 }}" />
+      {% else %}
+        <link rel="icon" type="image/png" sizes="32x32" href="{% static 'misago/favicon-32.png' %}" />
+      {% endif %}
+      {% if icons.favicon_16 %}
+        <link rel="icon" type="image/png" sizes="16x16" href="{{ icons.favicon_16 }}" />
+      {% else %}
+        <link rel="icon" type="image/png" sizes="16x16" href="{% static 'misago/favicon-16.png' %}" />
+      {% endif %}
+      {% if icons.favicon %}
+        <link rel="shortcut icon" href="{{ icons.favicon }}" />
+      {% else %}
+        <link rel="shortcut icon" href="{% static 'misago/favicon.ico' %}" />
+      {% endif %}
     {% endspaceless %}
-    {% include "misago/head.html" %}
     <script type="application/ld+json">{"@context":"http://schema.org","@type":"WebSite","url":"{{ settings.forum_address }}"}</script>
   </head>
   <body {% if misago_agreement %}class="agreement-overlay-visible"{% endif %}>

+ 0 - 20
misago/templates/misago/head.html

@@ -1,20 +0,0 @@
-{% load static %}
-{% if theme.include_defaults %}
-  <link href="{% static 'misago/css/misago.css' %}" rel="stylesheet">
-{% endif %}
-{% for css_url in theme.styles %}
-  <link href="{{ css_url }}" rel="stylesheet">
-{% endfor %}
-<link rel="apple-touch-icon" href="{% static 'apple-touch-icon.png' %}">
-<link rel="apple-touch-icon" sizes="57x57" href="{% static 'apple-touch-icon-57.png' %}">
-<link rel="apple-touch-icon" sizes="72x72" href="{% static 'apple-touch-icon-72.png' %}">
-<link rel="apple-touch-icon" sizes="76x76" href="{% static 'apple-touch-icon-76.png' %}">
-<link rel="apple-touch-icon" sizes="80x80" href="{% static 'apple-touch-icon-80.png' %}">
-<link rel="apple-touch-icon" sizes="114x114" href="{% static 'apple-touch-icon-114.png' %}">
-<link rel="apple-touch-icon" sizes="120x120" href="{% static 'apple-touch-icon-120.png' %}">
-<link rel="apple-touch-icon" sizes="144x144" href="{% static 'apple-touch-icon-144.png' %}">
-<link rel="apple-touch-icon" sizes="152x152" href="{% static 'apple-touch-icon-152.png' %}">
-<link rel="apple-touch-icon" sizes="167x167" href="{% static 'apple-touch-icon-167.png' %}">
-<link rel="apple-touch-icon" sizes="180x180" href="{% static 'apple-touch-icon-180.png' %}">
-<link rel="shortcut icon" href="{% static 'favicon.ico' %}">
-<link rel="icon" sizes="16x16 32x32" href="{% static 'favicon.ico' %}">

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

@@ -25,9 +25,6 @@ from .views import (
 
 class MisagoAdminExtension:
     def register_urlpatterns(self, urlpatterns):
-        # Appearance section
-        urlpatterns.namespace(r"^appearance/", "appearance")
-
         # Themes
         urlpatterns.namespace(r"^themes/", "themes")
         urlpatterns.patterns(