Browse Source

Superbasic themes implementation

rafalp 6 years ago
parent
commit
d87de3110c

+ 2 - 1
devproject/settings.py

@@ -180,7 +180,7 @@ INSTALLED_APPS = [
     "misago.cache",
     "misago.cache",
     "misago.core",
     "misago.core",
     "misago.conf",
     "misago.conf",
-    "misago.theming",
+    "misago.themes",
     "misago.markup",
     "misago.markup",
     "misago.legal",
     "misago.legal",
     "misago.categories",
     "misago.categories",
@@ -269,6 +269,7 @@ TEMPLATES = [
                 "misago.core.context_processors.site_address",
                 "misago.core.context_processors.site_address",
                 "misago.core.context_processors.momentjs_locale",
                 "misago.core.context_processors.momentjs_locale",
                 "misago.search.context_processors.search_providers",
                 "misago.search.context_processors.search_providers",
+                "misago.themes.context_processors.theme",
                 "misago.users.context_processors.user_links",
                 "misago.users.context_processors.user_links",
                 # Data preloaders
                 # Data preloaders
                 "misago.conf.context_processors.preload_settings_json",
                 "misago.conf.context_processors.preload_settings_json",

+ 10 - 0
misago/admin/admin.py

@@ -5,10 +5,15 @@ from django.utils.translation import gettext_lazy as _
 from .themes.views import (
 from .themes.views import (
     ActivateTheme,
     ActivateTheme,
     DeleteTheme,
     DeleteTheme,
+    DeleteThemeCss,
+    DeleteThemeFonts,
+    DeleteThemeImages,
     EditTheme,
     EditTheme,
     NewTheme,
     NewTheme,
     ThemeAssets,
     ThemeAssets,
     ThemesList,
     ThemesList,
+    UploadThemeCss,
+    UploadThemeImages,
 )
 )
 
 
 
 
@@ -27,6 +32,11 @@ class MisagoAdminExtension(MiddlewareMixin):
             url(r"^delete/(?P<pk>\d+)/$", DeleteTheme.as_view(), name="delete"),
             url(r"^delete/(?P<pk>\d+)/$", DeleteTheme.as_view(), name="delete"),
             url(r"^activate/(?P<pk>\d+)/$", ActivateTheme.as_view(), name="activate"),
             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+)/$", 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"),
         )
         )
 
 
     def register_navigation_nodes(self, site):
     def register_navigation_nodes(self, site):

+ 75 - 0
misago/admin/themes/assets.py

@@ -0,0 +1,75 @@
+import hashlib
+import io
+
+from PIL import Image
+from django.core.files.images import ImageFile
+
+IMAGE_THUMBNAIL_SIZE = (32, 32)
+
+
+def create_css(theme, css):
+    if css_exists(theme, css):
+        delete_css(theme, css)
+    save_css(theme, css)
+
+
+def css_exists(theme, css):
+    return theme.css.filter(name=css.name).exists()
+
+
+def delete_css(theme, css):
+    theme.css.get(name=css.name).delete()
+
+
+def save_css(theme, css):
+    theme.css.create(
+        name=css.name,
+        file=css,
+        hash=get_file_hash(css),
+        size=css.size,
+    )
+
+
+def create_image(theme, image):
+    if image_exists(theme, image):
+        delete_image(theme, image)
+    save_image(theme, image)
+
+
+def image_exists(theme, image):
+    return theme.images.filter(name=image.name).exists()
+
+
+def delete_image(theme, image):
+    theme.images.get(name=image.name).delete()
+
+
+def save_image(theme, image):
+    theme.images.create(
+        name=image.name,
+        file=image,
+        hash=get_file_hash(image),
+        type=image.content_type,
+        size=image.size,
+        thumbnail=get_image_thumbnail(image),
+    )
+
+
+def get_image_thumbnail(image):
+    img = Image.open(image.file)
+    img.thumbnail(IMAGE_THUMBNAIL_SIZE)
+    file = io.BytesIO()
+    img.save(file, format="png")
+    img.close()
+
+    filename = image.name.split('.')[0]
+    return ImageFile(file, name='thumb_%s.png' % filename)
+
+
+def get_file_hash(file):
+    if file.size is None:
+        return "000000000000"
+    file_hash = hashlib.md5()
+    for chunk in file.chunks():
+        file_hash.update(chunk)
+    return file_hash.hexdigest()[:12]

+ 37 - 1
misago/admin/themes/forms.py

@@ -3,8 +3,9 @@ from django.utils.html import conditional_escape, mark_safe
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 from mptt.forms import TreeNodeChoiceField
 from mptt.forms import TreeNodeChoiceField
 
 
-from ...theming.models import Theme
+from ...themes.models import Theme
 from ..forms import YesNoSwitch
 from ..forms import YesNoSwitch
+from .assets import create_css, create_image
 
 
 
 
 class ThemeChoiceField(TreeNodeChoiceField):
 class ThemeChoiceField(TreeNodeChoiceField):
@@ -40,3 +41,38 @@ class ThemeForm(forms.ModelForm):
             lft__gte=self.instance.lft,
             lft__gte=self.instance.lft,
             rght__lte=self.instance.rght,
             rght__lte=self.instance.rght,
         )
         )
+
+
+class UploadAssetsForm(forms.Form):
+    assets = forms.FileField(
+        widget=forms.ClearableFileInput(attrs={'multiple': True})
+    )
+
+    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 get_uploaded_assets(self):
+        return self.files.getlist('assets')
+
+
+class UploadCssForm(UploadAssetsForm):
+    def save(self):
+        for css in self.get_uploaded_assets():
+            create_css(self.instance, css)
+        return True
+
+
+class UploadImagesForm(UploadAssetsForm):
+    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

+ 82 - 7
misago/admin/themes/views.py

@@ -1,12 +1,13 @@
 from django.contrib import messages
 from django.contrib import messages
 from django.core.exceptions import PermissionDenied
 from django.core.exceptions import PermissionDenied
+from django.db.models import ObjectDoesNotExist
 from django.shortcuts import redirect
 from django.shortcuts import redirect
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.translation import gettext, gettext_lazy as _
 from django.utils.translation import gettext, gettext_lazy as _
 
 
-from ...theming.models import Theme
+from ...themes.models import Theme
 from ..views import generic
 from ..views import generic
-from .forms import ThemeForm
+from .forms import ThemeForm, UploadCssForm, UploadImagesForm
 
 
 
 
 class ThemeAdmin(generic.AdminBaseMixin):
 class ThemeAdmin(generic.AdminBaseMixin):
@@ -60,17 +61,91 @@ class ActivateTheme(ThemeAdmin, generic.ButtonView):
         messages.success(request, message % {"name": target})
         messages.success(request, message % {"name": target})
 
 
 
 
-class ThemeAssets(ThemeAdmin, generic.TargetedView):
-    template = "assets.html"
+def set_theme_as_active(request, theme):
+    Theme.objects.update(is_active=False)
+    Theme.objects.filter(pk=theme.pk).update(is_active=True)
+
 
 
+class ThemeAssetsAdmin(ThemeAdmin):
     def check_permissions(self, request, theme):
     def check_permissions(self, request, theme):
         if theme.is_default:
         if theme.is_default:
             return gettext("Default theme assets can't be edited.")
             return gettext("Default theme assets can't be edited.")
 
 
+    def redirect_to_theme_assets(self, theme):
+        link = reverse("misago:admin:appearance:themes:assets", kwargs={"pk": theme.pk})
+        return redirect(link)
+
+
+class ThemeAssets(ThemeAssetsAdmin, generic.TargetedView):
+    template = "assets/list.html"
+
     def real_dispatch(self, request, theme):
     def real_dispatch(self, request, theme):
         return self.render(request, {"theme": theme})
         return self.render(request, {"theme": theme})
 
 
 
 
-def set_theme_as_active(request, theme):
-    Theme.objects.update(is_active=False)
-    Theme.objects.filter(pk=theme.pk).update(is_active=True)
+class ThemeAssetsActionAdmin(ThemeAssetsAdmin):
+    def real_dispatch(self, request, theme):
+        if request.method == "POST":
+            self.action(request, theme)
+
+        return self.redirect_to_theme_assets(theme)
+
+    def action(self, request, theme):
+        raise NotImplementedError(
+            "action method must be implemented in inheriting class"
+        )
+
+
+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 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
+
+
+class DeleteThemeAssets(ThemeAssetsActionAdmin, generic.TargetedView):
+    message_submit = None
+    queryset_attr = None
+
+    def action(self, request, theme):
+        items = self.clean_items_list(request)
+        if items:
+            queryset = getattr(theme, self.queryset_attr)
+            for item in items:
+                self.delete_item(queryset, item)
+
+            messages.success(request, self.message_submit)
+
+    def clean_items_list(self, request):
+        try:
+            return {int(i) for i in request.POST.getlist("item")}
+        except (ValueError, TypeError):
+            pass
+
+    def delete_item(self, queryset, item):
+        try:
+            queryset.get(pk=item).delete()
+        except ObjectDoesNotExist:
+            pass
+
+
+class DeleteThemeCss(DeleteThemeAssets):
+    message_submit = _("Selected css files have been deleted.")
+    queryset_attr = "css"
+
+
+class DeleteThemeImages(DeleteThemeAssets):
+    message_submit = _("Selected images have been deleted.")
+    queryset_attr = "images"
+
+
+class DeleteThemeFonts(DeleteThemeAssets):
+    message_submit = _("Selected font files have been deleted.")
+    queryset_attr = "fonts"

+ 0 - 117
misago/templates/misago/admin/themes/assets.html

@@ -1,117 +0,0 @@
-{% extends "misago/admin/generic/base.html" %}
-{% load i18n %}
-
-
-{% block title %}
-{% trans "Assets" %} | {{ theme }} | {{ active_link.name }} | {{ block.super }}
-{% endblock title%}
-
-
-{% block extra-head %}
-<style>
-  .panel-heading .btn-sm {
-    box-shadow: none !important;
-    margin: 0px;
-    margin-top: -23px;
-    margin-left: 14px;
-  }
-</style>
-{% endblock extra-head %}
-
-
-{% block page-header %}
-{{ block.super }}
-<div class="sub">
-  <span class="fa fa-chevron-right"></span>
-  {{ theme }}
-</div>
-{% endblock page-header %}
-
-
-{% block view %}
-<div class="table-panel">
-  <div class="panel-heading">
-    <h3 class="panel-title">
-      {% trans "CSS files" %}
-    </h3>
-    <button type="button" class="btn btn-default btn-sm pull-right">
-      <span class="fa fa-upload"></span>
-      {% trans "Upload CSS" %}
-    </button>
-    <button type="button" class="btn btn-default btn-sm pull-right">
-      <span class="fa fa-link"></span>
-      {% trans "Link" %}
-    </button>
-    <button type="button" class="btn btn-default btn-sm pull-right">
-      <span class="fa fa-file-text"></span>
-      {% trans "Create" %}
-    </button>
-  </div>
-  <table class="table">
-    <tr>
-      <th>{% trans "Filename" %}</th>
-    </tr>
-    {% for item in theme.css.all %}
-      <tr>
-        <td>{{ item }}</td>
-      </tr>
-    {% empty %}
-      <tr class="message-row">
-        <td>{% trans "This theme has no CSS files." %}</td>
-      </tr>
-    {% endfor %}
-  </table>
-</div><!-- /.table-panel -->
-
-<div class="table-panel">
-  <div class="panel-heading">
-    <h3 class="panel-title">
-      {% trans "Images" %}
-    </h3>
-    <button type="button" class="btn btn-default btn-sm pull-right">
-      <span class="fa fa-upload"></span>
-      {% trans "Upload images" %}
-    </button>
-  </div>
-  <table class="table">
-    <tr>
-      <th>{% trans "Filename" %}</th>
-    </tr>
-    {% for item in theme.images.all %}
-      <tr>
-        <td>{{ item }}</td>
-      </tr>
-    {% empty %}
-      <tr class="message-row">
-        <td>{% trans "This theme has no images." %}</td>
-      </tr>
-    {% endfor %}
-  </table>
-</div><!-- /.table-panel -->
-
-<div class="table-panel">
-  <div class="panel-heading">
-    <h3 class="panel-title">
-      {% trans "Fonts" %}
-    </h3>
-    <button type="button" class="btn btn-default btn-sm pull-right">
-      <span class="fa fa-upload"></span>
-      {% trans "Upload fonts" %}
-    </button>
-  </div>
-  <table class="table">
-    <tr>
-      <th>{% trans "Filename" %}</th>
-    </tr>
-    {% for item in theme.fonts.all %}
-      <tr>
-        <td>{{ item }}</td>
-      </tr>
-    {% empty %}
-      <tr class="message-row">
-        <td>{% trans "This theme has no fonts." %}</td>
-      </tr>
-    {% endfor %}
-  </table>
-</div><!-- /.table-panel -->
-{% endblock view %}

+ 74 - 0
misago/templates/misago/admin/themes/assets/css.html

@@ -0,0 +1,74 @@
+{% load i18n %}
+<div class="table-panel">
+  <div class="panel-heading">
+    <h3 class="panel-title">
+      {% trans "CSS files" %}
+    </h3>
+    <button type="button" class="btn btn-default btn-sm pull-right" data-toggle="modal" data-target="#uploadCss">
+      <span class="fa fa-upload"></span>
+      {% trans "Upload CSS" %}
+    </button>
+    <button type="button" class="btn btn-default btn-sm pull-right">
+      <span class="fa fa-link"></span>
+      {% trans "Link" %}
+    </button>
+    <button type="button" class="btn btn-default btn-sm pull-right">
+      <span class="fa fa-file-text"></span>
+      {% trans "Create" %}
+    </button>
+  </div>
+  {% with theme.css.all as css %}
+    <form action="{% url 'misago:admin:appearance:themes:delete-css' pk=theme.pk %}" method="post">
+      {% csrf_token %}
+      <table class="table">
+        <tr>
+          <th style="width: 1%;">&nbsp;</th>
+          <th>{% trans "Name" %}</th>
+          <th>{% trans "Modified" %}</th>
+          <th>{% trans "Size" %}</th>
+          <th style="width: 1%;">&nbsp;</th>
+          <th style="width: 1%;">
+            <input type="checkbox" {% if not css %}disabled{% endif %}>
+          </th>
+        </tr>
+        {% for item in theme.css.all %}
+          <tr>
+            <td>
+              <a class="btn btn-default btn-sm tooltip-top" href="{{ item.file.url }}" target="blank" title="{% trans 'Preview' %}">
+                <span class="fa fa-share-square-o"></span>
+              </a>
+            </td>
+            <td>
+              <strong>{{ item }}</strong>
+            </td>
+            <td>
+              <abbr class="moment" data-iso="{{ item.modified_on.isoformat }}" data-format="LL"></abbr>
+            </td>
+            <td>{{ item.size|filesizeformat }}</td>
+            <td style="width: 1%;">
+              <a href="#" class="btn btn-default btn-sm">
+                <span class="fa fa-pencil"></span>
+                {% trans "Edit" %}
+              </a>
+            </td>
+            <td style="width: 1%;">
+              <input type="checkbox" name="item" value="{{ item.pk }}">
+            </td>
+          </tr>
+        {% empty %}
+          <tr class="message-row">
+            <td colspan="3">{% trans "This theme has no CSS files." %}</td>
+          </tr>
+        {% endfor %}
+      </table>
+      {% if css %}
+        <div class="panel-footer text-right">
+          <button class="btn btn-danger btn-sm" disabled>
+            <span class="fa fa-remove"></span>
+            {% trans "Delete selected" %}
+          </button>
+        </div>
+      {% endif %}
+    </form>
+  {% endwith %}
+</div><!-- /.table-panel -->

+ 47 - 0
misago/templates/misago/admin/themes/assets/fonts.html

@@ -0,0 +1,47 @@
+{% load i18n %}
+<div class="table-panel">
+  <div class="panel-heading">
+    <h3 class="panel-title">
+      {% trans "Fonts" %}
+    </h3>
+    <button type="button" class="btn btn-default btn-sm pull-right">
+      <span class="fa fa-upload"></span>
+      {% trans "Upload fonts" %}
+    </button>
+  </div>
+  {% with theme.fonts.all as fonts %}
+    <form action="{% url 'misago:admin:appearance:themes:delete-fonts' pk=theme.pk %}" method="post">
+      <table class="table">
+        <tr>
+          <th>{% trans "Name" %}</th>
+          <th style="width: 1%;">&nbsp;</th>
+          <th style="width: 1%;">
+            <input type="checkbox" {% if not fonts %}disabled{% endif %}>
+          </th>
+        </tr>
+        {% for item in fonts %}
+          <tr>
+            <td>
+              <strong>{{ item }}</strong>
+            </td>
+            <td style="width: 1%;">
+              <input type="checkbox" name="item" value="{{ item.pk }}">
+            </td>
+          </tr>
+        {% empty %}
+          <tr class="message-row">
+            <td colspan="3">{% trans "This theme has no fonts." %}</td>
+          </tr>
+        {% endfor %}
+      </table>
+      {% if fonts %}
+        <div class="panel-footer text-right">
+          <button class="btn btn-danger btn-sm" disabled>
+            <span class="fa fa-remove"></span>
+            {% trans "Delete selected" %}
+          </button>
+        </div>
+      {% endif %}
+    </form>
+  {% endwith %}
+</div><!-- /.table-panel -->

+ 63 - 0
misago/templates/misago/admin/themes/assets/images.html

@@ -0,0 +1,63 @@
+{% load i18n %}
+<div class="table-panel">
+  <div class="panel-heading">
+    <h3 class="panel-title">
+      {% trans "Images" %}
+    </h3>
+    <button type="button" class="btn btn-default btn-sm pull-right" data-toggle="modal" data-target="#uploadImages">
+      <span class="fa fa-upload"></span>
+      {% trans "Upload images" %}
+    </button>
+  </div>
+  {% with theme.images.all as images %}
+    <form action="{% url 'misago:admin:appearance:themes:delete-images' pk=theme.pk %}" method="post">
+      {% csrf_token %}
+      <table class="table">
+        <tr>
+          <th style="width: 1%;">&nbsp;</th>
+          <th>{% trans "Name" %}</th>
+          <th>{% trans "Modified" %}</th>
+          <th>{% trans "Size" %}</th>
+          <th>{% trans "Type" %}</th>
+          <th>{% trans "Dimensions" %}</th>
+          <th style="width: 1%;">
+            <input type="checkbox" {% if not images %}disabled{% endif %}>
+          </th>
+        </tr>
+        {% for item in images %}
+          <tr>
+            <td style="width: 1%;">
+              <a class="img-preview tooltip-top" href="{{ item.file.url }}" target="blank" title="{% trans 'Preview' %}">
+                <span style="background-image: url({{ item.thumbnail.url }});"></span>
+              </a>
+            </td>
+            <td>
+              <strong>{{ item }}</strong>
+            </td>
+            <td>
+              <abbr class="moment" data-iso="{{ item.modified_on.isoformat }}" data-format="LL"></abbr>
+            </td>
+            <td>{{ item.size|filesizeformat }}</td>
+            <td>{{ item.type }}</td>
+            <td>{{ item.width }}&times;{{ item.height }}</td>
+            <td style="width: 1%;">
+              <input type="checkbox" name="item" value="{{ item.pk }}">
+            </td>
+          </tr>
+        {% empty %}
+          <tr class="message-row">
+            <td colspan="8">{% trans "This theme has no images." %}</td>
+          </tr>
+        {% endfor %}
+      </table>
+      {% if images %}
+        <div class="panel-footer text-right">
+          <button class="btn btn-danger btn-sm" disabled>
+            <span class="fa fa-remove"></span>
+            {% trans "Delete selected" %}
+          </button>
+        </div>
+      {% endif %}
+    </form>
+  {% endwith %}
+</div><!-- /.table-panel -->

+ 116 - 0
misago/templates/misago/admin/themes/assets/list.html

@@ -0,0 +1,116 @@
+{% extends "misago/admin/generic/base.html" %}
+{% load i18n %}
+
+
+{% block title %}
+{% trans "Assets" %} | {{ theme }} | {{ active_link.name }} | {{ block.super }}
+{% endblock title%}
+
+
+{% block extra-head %}
+<style>
+  .table-panel .btn {
+    box-shadow: none !important;
+  }
+
+  .panel-heading {
+    border-bottom: 1px solid #ddd;
+  }
+
+    .panel-heading .btn-sm {
+      margin: 0px;
+      margin-top: -23px;
+      margin-left: 14px;
+    }
+
+  .img-preview {
+    display: block;
+    background: #fff;
+    border: 1px solid #ccc;
+    padding: 1px;
+    width: 16px;
+    height: 16px;
+  }
+
+    .img-preview span {
+      display: block;
+      background-size: cover;
+      width: 100%;
+      height: 100%;
+    }
+
+  .modal-footer {
+    margin: 0px;
+  }
+
+  .form-group, .help-block {
+    margin-bottom: 0px;
+  }
+</style>
+{% endblock extra-head %}
+
+
+{% block page-header %}
+{{ block.super }}
+<div class="sub">
+  <span class="fa fa-chevron-right"></span>
+  {{ theme }}
+</div>
+{% endblock page-header %}
+
+
+{% block view %}
+{% include "misago/admin/themes/assets/css.html" %}
+{% include "misago/admin/themes/assets/images.html" %}
+{% include "misago/admin/themes/assets/fonts.html" %}
+{% endblock view %}
+
+
+{% block content %}
+{{ block.super }}
+
+{% include "misago/admin/themes/assets/upload-css.html" %}
+{% include "misago/admin/themes/assets/upload-images.html" %}
+{% endblock content%}
+
+{% block javascripts %}
+{{ block.super }}
+
+<script type="text/javascript">
+  $(function() {
+    $(".table-panel form").each(function(_, form) {
+      $(form).find("th input:checkbox").change(function(event) {
+        $(form).find("td input:checkbox").prop("checked", event.target.checked);
+        $(form).find(".panel-footer button").prop("disabled", !event.target.checked);
+      });
+
+      $(form).find("td input:checkbox").change(function(event) {
+        var total = $(form).find("td input:checkbox").length;
+        var checked = $(form).find("td input:checkbox:checked").length;
+        $(form).find("th input:checkbox").prop("checked", total === checked);
+        $(form).find(".panel-footer button").prop("disabled", checked === 0);
+      });
+
+      // sync state in case page was rendered from browser cache
+      var total = $(form).find("td input:checkbox").length;
+      var checked = $(form).find("td input:checkbox:checked").length;
+      $(form).find("th input:checkbox").prop("checked", total && total === checked);
+      $(form).find(".panel-footer button").prop("disabled", checked === 0);
+    });
+
+    $(".table-panel form").submit(function(event) {
+      var checked = $(event.target).find("td input:checkbox:checked").length;
+      if (checked === 0) {
+        event.preventDefault();
+        return false;
+      }
+
+      var confirmation = confirm("{% trans 'Are you sure you want to delete selected items?' %}");
+      if (!confirmation) {
+        event.preventDefault();
+        return false;
+      }
+    });
+  });
+</script>
+{% endblock javascripts %}

+ 24 - 0
misago/templates/misago/admin/themes/assets/upload-css.html

@@ -0,0 +1,24 @@
+{% load i18n %}
+<div class="modal fade" id="uploadCss" tabindex="-1" role="dialog">
+  <div class="modal-dialog" role="document">
+    <div class="modal-content">
+      <div class="modal-header">
+        <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
+        <h4 class="modal-title">{% trans "Upload CSS" %}</h4>
+      </div>
+      <form action="{% url 'misago:admin:appearance:themes:upload-css' pk=theme.pk %}" method="post" enctype="multipart/form-data">
+        {% csrf_token %}
+        <div class="modal-body">
+          <div class="form-group">
+            <label for="uploadCssInput">{% trans "Select CSS files upload" %}:</label>
+            <input type="file" class="form-control" id="uploadCssInput" name="assets" multiple>
+          </div>
+        </div>
+        <div class="modal-footer">
+          <button type="button" class="btn btn-default" data-dismiss="modal">{% trans "Cancel" %}</button>
+          <button class="btn btn-primary">{% trans "Upload" %}</button>
+        </div>
+      </form>
+    </div><!-- /.modal-content -->
+  </div><!-- /.modal-dialog -->
+</div><!-- /.modal -->

+ 27 - 0
misago/templates/misago/admin/themes/assets/upload-images.html

@@ -0,0 +1,27 @@
+{% load i18n %}
+<div class="modal fade" id="uploadImages" tabindex="-1" role="dialog">
+  <div class="modal-dialog" role="document">
+    <div class="modal-content">
+      <div class="modal-header">
+        <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
+        <h4 class="modal-title">{% trans "Upload images" %}</h4>
+      </div>
+      <form action="{% url 'misago:admin:appearance:themes:upload-images' pk=theme.pk %}" method="post" enctype="multipart/form-data">
+        {% csrf_token %}
+        <div class="modal-body">
+          <div class="form-group">
+            <label for="uploadImagesInput">{% trans "Select image files to upload" %}:</label>
+            <input type="file" class="form-control" id="uploadImagesInput" name="assets" multiple>
+            <p class="help-block">
+              {% trans 'Uploaded images will be placed in "img" folder. Image urls in CSS should be prefixed with "img/".' %}
+            </p>
+          </div>
+        </div>
+        <div class="modal-footer">
+          <button type="button" class="btn btn-default" data-dismiss="modal">{% trans "Cancel" %}</button>
+          <button class="btn btn-primary">{% trans "Upload" %}</button>
+        </div>
+      </form>
+    </div><!-- /.modal-content -->
+  </div><!-- /.modal-dialog -->
+</div><!-- /.modal -->

+ 0 - 0
misago/theming/migrations/__init__.py → misago/templates/misago/admin/themes/style.css


+ 6 - 1
misago/templates/misago/head.html

@@ -1,5 +1,10 @@
 {% load static %}
 {% load static %}
-<link href="{% static 'misago/css/misago.css' %}" rel="stylesheet">
+{% 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" 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="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="72x72" href="{% static 'apple-touch-icon-72.png' %}">

+ 3 - 0
misago/themes/__init__.py

@@ -0,0 +1,3 @@
+default_app_config = "misago.themes.apps.MisagoThemesConfig"
+
+THEME_CACHE = "theme"

+ 7 - 0
misago/themes/apps.py

@@ -0,0 +1,7 @@
+from django.apps import AppConfig
+
+
+class MisagoThemesConfig(AppConfig):
+    name = "misago.themes"
+    label = "misago_themes"
+    verbose_name = "Misago Theming"

+ 0 - 0
misago/theming/cache.py → misago/themes/cache.py


+ 22 - 0
misago/themes/context_processors.py

@@ -0,0 +1,22 @@
+from .models import Theme
+
+def theme(request):
+    active_theme = Theme.objects.get(is_active=True)
+    themes = active_theme.get_ancestors(include_self=True)
+    themes = themes.prefetch_related("css")
+
+    include_defaults = False
+    styles = []
+
+    for theme in themes:
+        if theme.is_default:
+            include_defaults = True
+        for css in theme.css.all():
+            styles.append(css.file.url)
+
+    return {
+        "theme": {
+            "include_defaults": include_defaults,
+            "styles": styles,
+        }
+    }

+ 105 - 0
misago/themes/migrations/0001_initial.py

@@ -0,0 +1,105 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.17 on 2018-12-27 17:35
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+import misago.themes.utils
+import mptt.fields
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            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)),
+                ('file', models.FileField(blank=True, max_length=255, null=True, upload_to=misago.themes.utils.upload_css_to)),
+                ('hash', models.CharField(max_length=12)),
+                ('size', models.PositiveIntegerField()),
+                ('order', models.IntegerField(default=0)),
+                ('is_enabled', models.BooleanField(default=True)),
+                ('modified_on', models.DateTimeField(auto_now=True)),
+            ],
+            options={
+                'ordering': ['order'],
+            },
+        ),
+        migrations.CreateModel(
+            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.utils.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'],
+            },
+        ),
+        migrations.CreateModel(
+            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.utils.upload_image_to, width_field='width')),
+                ('hash', models.CharField(max_length=12)),
+                ('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.utils.upload_image_thumbnail_to)),
+                ('modified_on', models.DateTimeField(auto_now=True)),
+            ],
+            options={
+                'ordering': ['name'],
+            },
+        ),
+        migrations.CreateModel(
+            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.utils.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,
+            },
+        ),
+        migrations.AddField(
+            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'),
+        ),
+        migrations.AddField(
+            model_name='css',
+            name='theme',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='css', to='misago_themes.Theme'),
+        ),
+    ]

+ 2 - 2
misago/theming/migrations/0002_create_default_theme.py → misago/themes/migrations/0002_create_default_theme_and_cache_version.py

@@ -6,7 +6,7 @@ from ...cache.operations import StartCacheVersioning
 
 
 
 
 def create_default_theme(apps, schema_editor):
 def create_default_theme(apps, schema_editor):
-    Theme = apps.get_model("misago_theming", "Theme")
+    Theme = apps.get_model("misago_themes", "Theme")
     Theme.objects.create(
     Theme.objects.create(
         name="default",
         name="default",
         is_default=True,
         is_default=True,
@@ -20,7 +20,7 @@ def create_default_theme(apps, schema_editor):
 
 
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
 
 
-    dependencies = [("misago_theming", "0001_initial")]
+    dependencies = [("misago_themes", "0001_initial")]
 
 
     operations = [
     operations = [
         StartCacheVersioning(THEME_CACHE),
         StartCacheVersioning(THEME_CACHE),

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


+ 36 - 14
misago/theming/models.py → misago/themes/models.py

@@ -2,12 +2,21 @@ from django.db import models
 from django.utils.translation import gettext
 from django.utils.translation import gettext
 from mptt.models import MPTTModel, TreeForeignKey
 from mptt.models import MPTTModel, TreeForeignKey
 
 
+from .utils import (
+    generate_theme_dirname,
+    upload_css_to,
+    upload_font_to,
+    upload_image_to,
+    upload_image_thumbnail_to,
+)
+
 
 
 class Theme(MPTTModel):
 class Theme(MPTTModel):
     parent = TreeForeignKey(
     parent = TreeForeignKey(
         "self", on_delete=models.PROTECT, null=True, blank=True, related_name="children"
         "self", on_delete=models.PROTECT, null=True, blank=True, related_name="children"
     )
     )
     name = models.CharField(max_length=255)
     name = models.CharField(max_length=255)
+    dirname = models.CharField(max_length=8, default=generate_theme_dirname)
     is_default = models.BooleanField(default=False)
     is_default = models.BooleanField(default=False)
     is_active = models.BooleanField(default=False)
     is_active = models.BooleanField(default=False)
 
 
@@ -16,7 +25,7 @@ class Theme(MPTTModel):
     url = models.URLField(max_length=255, null=True, blank=True)
     url = models.URLField(max_length=255, null=True, blank=True)
 
 
     class MPTTMeta:
     class MPTTMeta:
-        order_insertion_by = ["is_default", "name"]
+        order_insertion_by = ["-is_default", "name"]
 
 
     def __str__(self):
     def __str__(self):
         if self.is_default:
         if self.is_default:
@@ -33,18 +42,23 @@ class Css(models.Model):
 
 
     name = models.CharField(max_length=255)
     name = models.CharField(max_length=255)
     url = models.URLField(max_length=255, null=True, blank=True)
     url = models.URLField(max_length=255, null=True, blank=True)
-    file = models.ImageField(max_length=255, null=True, blank=True)
+    file = models.FileField(
+        upload_to=upload_css_to, max_length=255, null=True, blank=True
+    )
+    hash = models.CharField(max_length=12)
     size = models.PositiveIntegerField()
     size = models.PositiveIntegerField()
 
 
     order = models.IntegerField(default=0)
     order = models.IntegerField(default=0)
     is_enabled = models.BooleanField(default=True)
     is_enabled = models.BooleanField(default=True)
-
-    uploaded_on = models.DateTimeField(auto_now_add=True)
-    updated_on = models.DateTimeField(auto_now=True)
+    modified_on = models.DateTimeField(auto_now=True)
 
 
     class Meta:
     class Meta:
         ordering = ["order"]
         ordering = ["order"]
 
 
+    def delete(self, *args, **kwargs):
+        self.file.delete(save=False)
+        super().delete(*args, **kwargs)
+
     def __str__(self):
     def __str__(self):
         return self.name
         return self.name
 
 
@@ -53,16 +67,19 @@ class Font(models.Model):
     theme = models.ForeignKey(Theme, on_delete=models.PROTECT, related_name="fonts")
     theme = models.ForeignKey(Theme, on_delete=models.PROTECT, related_name="fonts")
 
 
     name = models.CharField(max_length=255)
     name = models.CharField(max_length=255)
-    file = models.FileField(max_length=255)
+    file = models.FileField(upload_to=upload_font_to, max_length=255)
+    hash = models.CharField(max_length=12)
     type = models.CharField(max_length=255)
     type = models.CharField(max_length=255)
     size = models.PositiveIntegerField()
     size = models.PositiveIntegerField()
-
-    uploaded_on = models.DateTimeField(auto_now_add=True)
-    updated_on = models.DateTimeField(auto_now=True)
+    modified_on = models.DateTimeField(auto_now=True)
 
 
     class Meta:
     class Meta:
         ordering = ["name"]
         ordering = ["name"]
 
 
+    def delete(self, *args, **kwargs):
+        self.file.delete(save=False)
+        super().delete(*args, **kwargs)
+
     def __str__(self):
     def __str__(self):
         return self.name
         return self.name
 
 
@@ -71,17 +88,22 @@ class Image(models.Model):
     theme = models.ForeignKey(Theme, on_delete=models.PROTECT, related_name="images")
     theme = models.ForeignKey(Theme, on_delete=models.PROTECT, related_name="images")
 
 
     name = models.CharField(max_length=255)
     name = models.CharField(max_length=255)
-    file = models.ImageField(max_length=255)
+    file = models.ImageField(upload_to=upload_image_to, max_length=255, width_field="width", height_field="height")
+    hash = models.CharField(max_length=12)
     type = models.CharField(max_length=255)
     type = models.CharField(max_length=255)
     width = models.PositiveIntegerField()
     width = models.PositiveIntegerField()
-    heigh = models.PositiveIntegerField()
+    height = models.PositiveIntegerField()
     size = models.PositiveIntegerField()
     size = models.PositiveIntegerField()
-
-    uploaded_on = models.DateTimeField(auto_now_add=True)
-    updated_on = models.DateTimeField(auto_now=True)
+    thumbnail = models.ImageField(upload_to=upload_image_thumbnail_to, max_length=255)
+    modified_on = models.DateTimeField(auto_now=True)
 
 
     class Meta:
     class Meta:
         ordering = ["name"]
         ordering = ["name"]
 
 
+    def delete(self, *args, **kwargs):
+        self.file.delete(save=False)
+        self.thumbnail.delete(save=False)
+        super().delete(*args, **kwargs)
+
     def __str__(self):
     def __str__(self):
         return self.name
         return self.name

+ 24 - 0
misago/themes/utils.py

@@ -0,0 +1,24 @@
+from django.utils.crypto import get_random_string
+
+
+def generate_theme_dirname():
+    return get_random_string(8)
+
+
+def upload_css_to(instance, filename):
+    filename = filename.replace(".", ".%s." % instance.hash, 1)
+    return "themes/%s/%s" % (instance.theme.dirname, filename)
+
+
+def upload_font_to(instance, filename):
+    filename = filename.replace(".", ".%s." % instance.hash, 1)
+    return "themes/%s/font/%s" % (instance.theme.dirname, filename)
+
+
+def upload_image_to(instance, filename):
+    filename = filename.replace(".", ".%s." % instance.hash, 1)
+    return "themes/%s/img/%s" % (instance.theme.dirname, filename)
+
+
+def upload_image_thumbnail_to(instance, filename):
+    return "themes/%s/img/%s" % (instance.theme.dirname, filename)

+ 0 - 3
misago/theming/__init__.py

@@ -1,3 +0,0 @@
-default_app_config = "misago.theming.apps.MisagoThemingConfig"
-
-THEME_CACHE = "theme"

+ 0 - 7
misago/theming/apps.py

@@ -1,7 +0,0 @@
-from django.apps import AppConfig
-
-
-class MisagoThemingConfig(AppConfig):
-    name = "misago.theming"
-    label = "misago_theming"
-    verbose_name = "Misago Theming"

+ 0 - 49
misago/theming/migrations/0001_initial.py

@@ -1,49 +0,0 @@
-# -*- coding: utf-8 -*-
-# Generated by Django 1.11.16 on 2018-12-26 16:07
-from __future__ import unicode_literals
-
-from django.db import migrations, models
-import django.db.models.deletion
-import mptt.fields
-
-
-class Migration(migrations.Migration):
-
-    initial = True
-
-    dependencies = []
-
-    operations = [
-        migrations.CreateModel(
-            name="Theme",
-            fields=[
-                (
-                    "id",
-                    models.AutoField(
-                        auto_created=True,
-                        primary_key=True,
-                        serialize=False,
-                        verbose_name="ID",
-                    ),
-                ),
-                ("name", models.CharField(max_length=255)),
-                ("is_default", models.BooleanField(default=False)),
-                ("is_active", models.BooleanField(default=False)),
-                ("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_theming.Theme",
-                    ),
-                ),
-            ],
-            options={"abstract": False},
-        )
-    ]

+ 0 - 28
misago/theming/migrations/0003_auto_20181226_1841.py

@@ -1,28 +0,0 @@
-# -*- coding: utf-8 -*-
-# Generated by Django 1.11.17 on 2018-12-26 18:41
-from __future__ import unicode_literals
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [("misago_theming", "0002_create_default_theme")]
-
-    operations = [
-        migrations.AddField(
-            model_name="theme",
-            name="author",
-            field=models.CharField(blank=True, max_length=255, null=True),
-        ),
-        migrations.AddField(
-            model_name="theme",
-            name="license",
-            field=models.TextField(blank=True, max_length=50000, null=True),
-        ),
-        migrations.AddField(
-            model_name="theme",
-            name="url",
-            field=models.URLField(blank=True, max_length=255, null=True),
-        ),
-    ]

+ 0 - 19
misago/theming/migrations/0004_auto_20181226_2136.py

@@ -1,19 +0,0 @@
-# -*- coding: utf-8 -*-
-# Generated by Django 1.11.17 on 2018-12-26 21:36
-from __future__ import unicode_literals
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [("misago_theming", "0003_auto_20181226_1841")]
-
-    operations = [
-        migrations.RemoveField(model_name="theme", name="license"),
-        migrations.AddField(
-            model_name="theme",
-            name="version",
-            field=models.CharField(blank=True, max_length=255, null=True),
-        ),
-    ]

+ 0 - 110
misago/theming/migrations/0005_css_font_image.py

@@ -1,110 +0,0 @@
-# -*- coding: utf-8 -*-
-# Generated by Django 1.11.17 on 2018-12-26 22:22
-from __future__ import unicode_literals
-
-from django.db import migrations, models
-import django.db.models.deletion
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [("misago_theming", "0004_auto_20181226_2136")]
-
-    operations = [
-        migrations.CreateModel(
-            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)),
-                (
-                    "file",
-                    models.ImageField(
-                        blank=True, max_length=255, null=True, upload_to=""
-                    ),
-                ),
-                ("size", models.PositiveIntegerField()),
-                ("order", models.IntegerField(default=0)),
-                ("is_enabled", models.BooleanField(default=True)),
-                ("uploaded_on", models.DateTimeField(auto_now_add=True)),
-                ("updated_on", models.DateTimeField(auto_now=True)),
-                (
-                    "theme",
-                    models.ForeignKey(
-                        on_delete=django.db.models.deletion.PROTECT,
-                        related_name="css",
-                        to="misago_theming.Theme",
-                    ),
-                ),
-            ],
-            options={"ordering": ["order"]},
-        ),
-        migrations.CreateModel(
-            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="")),
-                ("type", models.CharField(max_length=255)),
-                ("size", models.PositiveIntegerField()),
-                ("uploaded_on", models.DateTimeField(auto_now_add=True)),
-                ("updated_on", models.DateTimeField(auto_now=True)),
-                (
-                    "theme",
-                    models.ForeignKey(
-                        on_delete=django.db.models.deletion.PROTECT,
-                        related_name="fonts",
-                        to="misago_theming.Theme",
-                    ),
-                ),
-            ],
-            options={"ordering": ["name"]},
-        ),
-        migrations.CreateModel(
-            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(max_length=255, upload_to="")),
-                ("type", models.CharField(max_length=255)),
-                ("width", models.PositiveIntegerField()),
-                ("heigh", models.PositiveIntegerField()),
-                ("size", models.PositiveIntegerField()),
-                ("uploaded_on", models.DateTimeField(auto_now_add=True)),
-                ("updated_on", models.DateTimeField(auto_now=True)),
-                (
-                    "theme",
-                    models.ForeignKey(
-                        on_delete=django.db.models.deletion.PROTECT,
-                        related_name="images",
-                        to="misago_theming.Theme",
-                    ),
-                ),
-            ],
-            options={"ordering": ["name"]},
-        ),
-    ]