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

Merge pull request #1189 from rafalp/theme-import-export

Add theme import/export
Rafał Pitoń 6 лет назад
Родитель
Сommit
5e9354d79a
31 измененных файлов с 746 добавлено и 26 удалено
  1. 1 1
      misago/templates/misago/admin/agreements/form.html
  2. 1 1
      misago/templates/misago/admin/attachmenttypes/form.html
  3. 1 1
      misago/templates/misago/admin/bans/form.html
  4. 1 1
      misago/templates/misago/admin/categories/form.html
  5. 1 1
      misago/templates/misago/admin/categoryroles/form.html
  6. 1 1
      misago/templates/misago/admin/datadownloads/form.html
  7. 1 1
      misago/templates/misago/admin/generic/form.html
  8. 1 1
      misago/templates/misago/admin/ranks/form.html
  9. 1 1
      misago/templates/misago/admin/roles/form.html
  10. 1 1
      misago/templates/misago/admin/themes/assets/css-editor-form.html
  11. 1 1
      misago/templates/misago/admin/themes/assets/css-link-form.html
  12. 1 1
      misago/templates/misago/admin/themes/form.html
  13. 44 0
      misago/templates/misago/admin/themes/import.html
  14. 25 6
      misago/templates/misago/admin/themes/list.html
  15. 1 1
      misago/templates/misago/admin/users/ban.html
  16. 1 1
      misago/templates/misago/admin/users/edit.html
  17. 1 1
      misago/templates/misago/admin/users/new.html
  18. 1 1
      misago/templates/misago/admin/warnings/form.html
  19. 4 0
      misago/themes/admin/__init__.py
  20. 109 0
      misago/themes/admin/exporter.py
  21. 55 0
      misago/themes/admin/forms.py
  22. 211 0
      misago/themes/admin/importer.py
  23. 6 0
      misago/themes/admin/tests/conftest.py
  24. 0 0
      misago/themes/admin/tests/snapshots/snap_test_building_css_files.py
  25. 0 0
      misago/themes/admin/tests/test_building_css_files.py
  26. 0 0
      misago/themes/admin/tests/test_creating_and_deleting_css_files.py
  27. 0 0
      misago/themes/admin/tests/test_creating_and_deleting_css_links.py
  28. 0 0
      misago/themes/admin/tests/test_creating_and_editing_themes.py
  29. 21 0
      misago/themes/admin/tests/test_exporting_themes.py
  30. 213 0
      misago/themes/admin/tests/test_importing_themes.py
  31. 42 4
      misago/themes/admin/views.py

+ 1 - 1
misago/templates/misago/admin/agreements/form.html

@@ -33,7 +33,7 @@
 
 {% block form-extra %}
 class="form-horizontal"
-{% endblock form-extra%}
+{% endblock form-extra %}
 
 
 {% block form-body %}

+ 1 - 1
misago/templates/misago/admin/attachmenttypes/form.html

@@ -33,7 +33,7 @@
 
 {% block form-extra %}
 class="form-horizontal"
-{% endblock form-extra%}
+{% endblock form-extra %}
 
 
 {% block form-body %}

+ 1 - 1
misago/templates/misago/admin/bans/form.html

@@ -33,7 +33,7 @@
 
 {% block form-extra %}
 class="form-horizontal"
-{% endblock form-extra%}
+{% endblock form-extra %}
 
 
 {% block form-body %}

+ 1 - 1
misago/templates/misago/admin/categories/form.html

@@ -33,7 +33,7 @@
 
 {% block form-extra %}
 class="form-horizontal"
-{% endblock form-extra%}
+{% endblock form-extra %}
 
 
 {% block form-body %}

+ 1 - 1
misago/templates/misago/admin/categoryroles/form.html

@@ -33,7 +33,7 @@
 
 {% block form-extra %}
 class="form-horizontal"
-{% endblock form-extra%}
+{% endblock form-extra %}
 
 
 {% block form-body %}

+ 1 - 1
misago/templates/misago/admin/datadownloads/form.html

@@ -19,7 +19,7 @@
 
 {% block form-extra %}
 class="form-horizontal"
-{% endblock form-extra%}
+{% endblock form-extra %}
 
 
 {% block form-body %}

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

@@ -16,7 +16,7 @@
   <div class="col-xs-12 {% block form-main-col-class %}col-md-8 col-md-offset-2{% endblock form-main-col-class %}">
 
     <div class="form-panel">
-      <form role="form" method="post" {% block form-extra %}{% endblock form-extra%}>
+      <form role="form" method="post" {% block form-extra %}{% endblock form-extra %}>
         {% csrf_token %}
 
         <div class="form-header">

+ 1 - 1
misago/templates/misago/admin/ranks/form.html

@@ -33,7 +33,7 @@
 
 {% block form-extra %}
 class="form-horizontal"
-{% endblock form-extra%}
+{% endblock form-extra %}
 
 
 {% block form-body %}

+ 1 - 1
misago/templates/misago/admin/roles/form.html

@@ -33,7 +33,7 @@
 
 {% block form-extra %}
 class="form-horizontal"
-{% endblock form-extra%}
+{% endblock form-extra %}
 
 
 {% block form-body %}

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

@@ -46,7 +46,7 @@
 
 {% block form-extra %}
 class="form-horizontal"
-{% endblock form-extra%}
+{% endblock form-extra %}
 
 
 {% block form-body %}

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

@@ -31,7 +31,7 @@
 
 {% block form-extra %}
 class="form-horizontal"
-{% endblock form-extra%}
+{% endblock form-extra %}
 
 
 {% block form-body %}

+ 1 - 1
misago/templates/misago/admin/themes/form.html

@@ -33,7 +33,7 @@
 
 {% block form-extra %}
 class="form-horizontal"
-{% endblock form-extra%}
+{% endblock form-extra %}
 
 
 {% block form-body %}

+ 44 - 0
misago/templates/misago/admin/themes/import.html

@@ -0,0 +1,44 @@
+{% extends "misago/admin/generic/form.html" %}
+{% load i18n misago_admin_form %}
+
+
+{% block title %}
+{% trans "Import theme" %} | {{ active_link.name }} | {{ block.super }}
+{% endblock title %}
+
+
+{% block page-target %}
+{% trans "Import theme" %}
+{% endblock page-target %}
+
+
+{% block form-header %}
+<h1>
+  {% trans "Import theme" %}
+</h1>
+{% endblock %}
+
+
+{% block form-extra %}
+class="form-horizontal" enctype="multipart/form-data"
+{% endblock form-extra %}
+
+{% block form-body %}
+<div class="form-body no-fieldsets">
+  {% with label_class="col-md-3" field_class="col-md-9" %}
+    {% form_row form.name label_class field_class %}
+    {% form_row form.parent label_class field_class %}
+    {% form_row form.upload label_class field_class %}
+  {% endwith %}
+</div>
+{% endblock form-body %}
+
+
+{% block form-footer-class %}
+col-md-offset-3
+{% endblock form-footer-class %}
+
+
+{% block form-footer %}
+<button class="btn btn-primary">{% trans "Import" %}</button>
+{% endblock %}

+ 25 - 6
misago/templates/misago/admin/themes/list.html

@@ -4,9 +4,13 @@
 
 {% block page-actions %}
 <div class="page-actions">
+    <a href="{% url 'misago:admin:appearance:themes:import' %}" class="btn btn-success">
+      <span class="fa fa-upload"></span>
+      {% trans "Import theme" %}
+    </a>
   <a href="{% url 'misago:admin:appearance:themes:new' %}" class="btn btn-success">
     <span class="fa fa-plus-circle"></span>
-    {% trans "New theme" %}
+    {% trans "Create theme" %}
   </a>
 </div>
 {% endblock %}
@@ -20,6 +24,7 @@
 <th>&nbsp;</th>
 <th>&nbsp;</th>
 <th>&nbsp;</th>
+<th>&nbsp;</th>
 {% endblock table-header %}
 
 
@@ -46,7 +51,7 @@
 </td>
 <td class="row-action">
   {% if not item.is_default %}
-    <a href="{% url 'misago:admin:appearance:themes:assets' pk=item.pk %}" class="btn btn-primary tooltip-top" title="{% trans "Assets (CSS, images and fonts)" %}">
+    <a href="{% url 'misago:admin:appearance:themes:assets' pk=item.pk %}" class="btn btn-primary tooltip-top" title="{% trans 'Assets (CSS, images and fonts)' %}">
       <span class="fa fa-pencil-square-o"></span>
     </a>
   {% else %}
@@ -57,7 +62,7 @@
 </td>
 <td class="row-action">
   {% if not item.is_default %}
-    <a href="{% url 'misago:admin:appearance:themes:edit' pk=item.pk %}" class="btn btn-primary tooltip-top" title="{% trans "Edit information" %}">
+    <a href="{% url 'misago:admin:appearance:themes:edit' pk=item.pk %}" class="btn btn-primary tooltip-top" title="{% trans 'Edit information' %}">
       <span class="fa fa-pencil"></span>
     </a>
   {% else %}
@@ -67,14 +72,14 @@
   {% endif %}
 </td>
 <td class="row-action">
-  <a href="{% url 'misago:admin:appearance:themes:new' %}?parent={{ item.pk }}" class="btn btn-primary tooltip-top" title="{% trans "Create child theme" %}">
+  <a href="{% url 'misago:admin:appearance:themes:new' %}?parent={{ item.pk }}" class="btn btn-primary tooltip-top" title="{% trans 'Create child theme' %}">
     <span class="fa fa-file-o"></span>
   </a>
 </td>
 <td class="row-action">
   {% if not item.is_active %}
     <form action="{% url 'misago:admin:appearance:themes:activate' pk=item.pk %}" method="post">
-      <button class="btn btn-default tooltip-top" title="{% trans "Activate" %}">
+      <button class="btn btn-default tooltip-top" title="{% trans 'Activate' %}">
         {% csrf_token %}
         <span class="fa fa-check-square"></span>
       </button>
@@ -86,10 +91,24 @@
   {% endif %}
 </td>
 <td class="row-action">
+  {% if not item.is_default %}
+    <form action="{% url 'misago:admin:appearance:themes:export' pk=item.pk %}" method="POST">
+      {% csrf_token %}
+      <button class="btn btn-primary tooltip-top" title="{% trans 'Export' %}">
+        <span class="fa fa-download"></span>
+      </button>
+    </form>
+  {% else %}
+    <button class="btn" type="button" disabled>
+      <span class="fa fa-download"></span>
+    </button>
+  {% endif %}
+</td>
+<td class="row-action">
   {% if not item.is_active and not item.is_default %}
     <form action="{% url 'misago:admin:appearance:themes:delete' pk=item.pk %}" method="POST" class="delete-prompt">
       {% csrf_token %}
-      <button class="btn btn-danger tooltip-top" title="{% trans "Delete" %}">
+      <button class="btn btn-danger tooltip-top" title="{% trans 'Delete' %}">
         <span class="fa fa-times"></span>
       </button>
     </form>

+ 1 - 1
misago/templates/misago/admin/users/ban.html

@@ -19,7 +19,7 @@
 
 {% block form-extra %}
 class="form-horizontal"
-{% endblock form-extra%}
+{% endblock form-extra %}
 
 
 {% block form-body %}

+ 1 - 1
misago/templates/misago/admin/users/edit.html

@@ -21,7 +21,7 @@
 
 {% block form-extra %}
 class="form-horizontal"
-{% endblock form-extra%}
+{% endblock form-extra %}
 
 
 {% block form-body %}

+ 1 - 1
misago/templates/misago/admin/users/new.html

@@ -19,7 +19,7 @@
 
 {% block form-extra %}
 class="form-horizontal"
-{% endblock form-extra%}
+{% endblock form-extra %}
 
 
 {% block form-body %}

+ 1 - 1
misago/templates/misago/admin/warnings/form.html

@@ -33,7 +33,7 @@
 
 {% block form-extra %}
 class="form-horizontal"
-{% endblock form-extra%}
+{% endblock form-extra %}
 
 
 {% block form-body %}

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

@@ -14,6 +14,8 @@ from .views import (
     NewTheme,
     NewThemeCss,
     NewThemeCssLink,
+    ExportTheme,
+    ImportTheme,
     ThemeAssets,
     ThemesList,
     UploadThemeCss,
@@ -35,6 +37,8 @@ class MisagoAdminExtension:
             url(r"^edit/(?P<pk>\d+)/$", EditTheme.as_view(), name="edit"),
             url(r"^delete/(?P<pk>\d+)/$", DeleteTheme.as_view(), name="delete"),
             url(r"^activate/(?P<pk>\d+)/$", ActivateTheme.as_view(), name="activate"),
+            url(r"^export/(?P<pk>\d+)/$", ExportTheme.as_view(), name="export"),
+            url(r"^import/$", ImportTheme.as_view(), name="import"),
             url(r"^assets/(?P<pk>\d+)/$", ThemeAssets.as_view(), name="assets"),
             url(
                 r"^assets/(?P<pk>\d+)/delete-css/$",

+ 109 - 0
misago/themes/admin/exporter.py

@@ -0,0 +1,109 @@
+import json
+import os
+import shutil
+from tempfile import TemporaryDirectory
+
+from django.http import FileResponse
+
+from ...core.utils import slugify
+
+
+def export_theme(theme):
+    with TemporaryDirectory() as tmp_dir:
+        export_dir = create_export_directory(tmp_dir, theme)
+
+        manifest = create_theme_manifest(theme)
+
+        manifest["css"] = write_theme_css(export_dir, theme)
+        manifest["media"] = write_theme_media(export_dir, theme)
+        write_theme_manifest(export_dir, manifest)
+
+        export_file = zip_theme_export(tmp_dir, export_dir)
+        export_filename = os.path.split(export_file)[-1]
+
+        response = FileResponse(open(export_file, "rb"), content_type="application/zip")
+        response["Content-Length"] = os.path.getsize(export_file)
+        response["Content-Disposition"] = "inline; filename=%s" % export_filename
+
+        return response
+
+
+def create_export_directory(tmp_dir, theme):
+    export_name = get_export_name(theme)
+    export_dir = os.path.join(tmp_dir, export_name)
+    os.mkdir(export_dir)
+    return export_dir
+
+
+def get_export_name(theme):
+    if theme.version:
+        return "%s-%s" % (slugify(theme.name), theme.version.replace(".", "-"))
+    return slugify(theme.name)
+
+
+def create_theme_manifest(theme):
+    return {
+        "name": theme.name,
+        "version": theme.version,
+        "author": theme.author,
+        "url": theme.url,
+        "css": [],
+        "media": [],
+    }
+
+
+def write_theme_css(export_dir, theme):
+    files_dir = create_sub_directory(export_dir, "css")
+    files = []
+
+    for css in theme.css.all():
+        if css.url:
+            files.append({"name": css.name, "url": css.url})
+        else:
+            files.append(
+                {"name": css.name, "path": copy_asset_file(files_dir, css.source_file)}
+            )
+
+    return files
+
+
+def write_theme_media(export_dir, theme):
+    files_dir = create_sub_directory(export_dir, "media")
+    files = []
+
+    for media in theme.media.all():
+        files.append(
+            {
+                "name": media.name,
+                "type": media.type,
+                "path": copy_asset_file(files_dir, media.file),
+            }
+        )
+
+    return files
+
+
+def create_sub_directory(export_dir, dirname):
+    new_dir = os.path.join(export_dir, dirname)
+    os.mkdir(new_dir)
+    return new_dir
+
+
+def copy_asset_file(export_dir, asset_file):
+    filename = os.path.split(asset_file.name)[-1]
+    dst_path = os.path.join(export_dir, filename)
+    with open(dst_path, "wb") as fp:
+        for chunk in asset_file.chunks():
+            fp.write(chunk)
+    return filename
+
+
+def write_theme_manifest(export_dir, manifest):
+    manifest_path = os.path.join(export_dir, "manifest.json")
+    with open(manifest_path, "w") as fp:
+        json.dump(manifest, fp, ensure_ascii=False, indent=2)
+
+
+def zip_theme_export(tmp_dir, export_dir):
+    dir_name = os.path.split(export_dir)[-1]
+    return shutil.make_archive(export_dir, "zip", tmp_dir, dir_name)

+ 55 - 0
misago/themes/admin/forms.py

@@ -1,3 +1,5 @@
+import re
+
 from django import forms
 from django.core.files.base import ContentFile
 from django.utils.translation import gettext, gettext_lazy as _
@@ -46,6 +48,59 @@ class ThemeForm(forms.ModelForm):
         )
 
 
+class ImportForm(forms.Form):
+    name = forms.CharField(
+        label=_("Name"),
+        help_text=_("Leave this field empty to use theme name from imported file."),
+        max_length=255,
+        required=False,
+    )
+    parent = ThemeChoiceField(label=_("Parent"), required=False)
+    upload = forms.FileField(
+        label=_("Theme file"), help_text=_("Theme file should be a ZIP file.")
+    )
+
+    def clean_upload(self):
+        data = self.cleaned_data["upload"]
+        error_message = gettext("Uploaded file is not a valid ZIP file.")
+        if not data.name.lower().endswith(".zip"):
+            raise forms.ValidationError(error_message)
+        if data.content_type not in ("application/zip", "application/octet-stream"):
+            raise forms.ValidationError(error_message)
+        return data
+
+
+class ThemeManifest(forms.Form):
+    name = forms.CharField(max_length=255)
+    version = forms.CharField(max_length=255, required=False)
+    author = forms.CharField(max_length=255, required=False)
+    url = forms.URLField(max_length=255, required=False)
+
+
+class ThemeCssUrlManifest(forms.Form):
+    name = forms.CharField(max_length=255)
+    url = forms.URLField(max_length=255)
+
+
+def create_css_file_manifest(allowed_path):
+    class ThemeCssFileManifest(forms.Form):
+        name = forms.CharField(max_length=255, validators=[validate_css_name])
+        path = forms.FilePathField(
+            allowed_path, match=re.compile(r"\.css$", re.IGNORECASE)
+        )
+
+    return ThemeCssFileManifest
+
+
+def create_media_file_manifest(allowed_path):
+    class ThemeMediaFileManifest(forms.Form):
+        name = forms.CharField(max_length=255)
+        type = forms.CharField(max_length=255)
+        path = forms.FilePathField(allowed_path)
+
+    return ThemeMediaFileManifest
+
+
 class UploadAssetsForm(forms.Form):
     allowed_content_types = []
     allowed_extensions = []

+ 211 - 0
misago/themes/admin/importer.py

@@ -0,0 +1,211 @@
+import json
+import os
+from tempfile import TemporaryDirectory
+from zipfile import BadZipFile, ZipFile
+
+from django.core.files.uploadedfile import SimpleUploadedFile
+from django.utils.translation import gettext as _, gettext_lazy
+
+from ..models import Theme
+from .css import create_css
+from .forms import (
+    ThemeCssUrlManifest,
+    ThemeManifest,
+    create_css_file_manifest,
+    create_media_file_manifest,
+)
+from .media import create_media
+from .tasks import build_theme_css, update_remote_css_size
+
+INVALID_MANIFEST_ERROR = gettext_lazy(
+    '"manifest.json" contained by ZIP file is not a valid theme manifest file.'
+)
+
+
+class ThemeImportError(BaseException):
+    pass
+
+
+class InvalidThemeManifest(ThemeImportError):
+    def __init__(self):
+        super().__init__(INVALID_MANIFEST_ERROR)
+
+
+def import_theme(name, parent, zipfile):
+    with TemporaryDirectory() as tmp_dir:
+        extract_zipfile_to_tmp_dir(zipfile, tmp_dir)
+        validate_zipfile_contains_single_directory(tmp_dir)
+        theme_dir = os.path.join(tmp_dir, os.listdir(tmp_dir)[0])
+        cleaned_manifest = clean_theme_contents(theme_dir)
+
+        theme = create_theme_from_manifest(name, parent, cleaned_manifest)
+
+        try:
+            create_css_from_manifest(theme_dir, theme, cleaned_manifest["css"])
+            create_media_from_manifest(theme_dir, theme, cleaned_manifest["media"])
+        except Exception:
+            theme.delete()
+            raise InvalidThemeManifest()
+        else:
+            for css in theme.css.filter(url__isnull=False):
+                update_remote_css_size.delay(css.pk)
+            build_theme_css.delay(theme.pk)
+
+        return theme
+
+
+def extract_zipfile_to_tmp_dir(zipfile, tmp_dir):
+    try:
+        ZipFile(zipfile).extractall(tmp_dir)
+    except BadZipFile:
+        raise ThemeImportError(_("Uploaded ZIP file could not be extracted."))
+
+
+def validate_zipfile_contains_single_directory(tmp_dir):
+    dir_contents = os.listdir(tmp_dir)
+    if not dir_contents:
+        raise ThemeImportError(_("Uploaded ZIP file is empty."))
+    if len(dir_contents) > 1:
+        raise ThemeImportError(_("Uploaded ZIP file should contain single directory."))
+    if not os.path.isdir(os.path.join(tmp_dir, dir_contents[0])):
+        raise ThemeImportError(_("Uploaded ZIP file didn't contain a directory."))
+
+
+def clean_theme_contents(theme_dir):
+    manifest = read_manifest(theme_dir)
+    return clean_manifest(theme_dir, manifest)
+
+
+def read_manifest(theme_dir):
+    try:
+        with open(os.path.join(theme_dir, "manifest.json")) as fp:
+            return json.load(fp)
+    except FileNotFoundError:
+        raise ThemeImportError(
+            _('Uploaded ZIP file didn\'t contain a "manifest.json".')
+        )
+    except json.decoder.JSONDecodeError:
+        raise ThemeImportError(
+            _('"manifest.json" contained by ZIP file is not a valid JSON file.')
+        )
+
+
+def clean_manifest(theme_dir, manifest):
+    if not isinstance(manifest, dict):
+        raise InvalidThemeManifest()
+
+    form = ThemeManifest(manifest)
+    if not form.is_valid():
+        raise InvalidThemeManifest()
+
+    cleaned_manifest = form.cleaned_data.copy()
+    cleaned_manifest["css"] = clean_css_list(theme_dir, manifest.get("css"))
+    cleaned_manifest["media"] = clean_media_list(theme_dir, manifest.get("media"))
+
+    return cleaned_manifest
+
+
+def clean_css_list(theme_dir, manifest):
+    if not isinstance(manifest, list):
+        raise InvalidThemeManifest()
+
+    theme_css_dir = os.path.join(theme_dir, "css")
+    if not os.path.isdir(theme_css_dir):
+        raise InvalidThemeManifest()
+
+    cleaned_data = []
+    for item in manifest:
+        cleaned_data.append(clean_css_list_item(theme_css_dir, item))
+    return cleaned_data
+
+
+def clean_css_list_item(theme_css_dir, item):
+    if not isinstance(item, dict):
+        raise InvalidThemeManifest()
+
+    if item.get("url"):
+        return clean_css_url(item)
+    if item.get("path"):
+        return clean_css_file(theme_css_dir, item)
+
+    raise InvalidThemeManifest()
+
+
+def clean_css_url(data):
+    form = ThemeCssUrlManifest(data)
+    if not form.is_valid():
+        raise InvalidThemeManifest()
+    return form.cleaned_data
+
+
+def clean_css_file(theme_css_dir, data):
+    file_manifest = create_css_file_manifest(theme_css_dir)
+
+    if data.get("path"):
+        data["path"] = os.path.join(theme_css_dir, str(data["path"]))
+
+    form = file_manifest(data)
+    if not form.is_valid():
+        raise InvalidThemeManifest()
+    return form.cleaned_data
+
+
+def clean_media_list(theme_dir, manifest):
+    if not isinstance(manifest, list):
+        raise InvalidThemeManifest()
+
+    theme_media_dir = os.path.join(theme_dir, "media")
+    if not os.path.isdir(theme_media_dir):
+        raise InvalidThemeManifest()
+
+    cleaned_data = []
+    for item in manifest:
+        cleaned_data.append(clean_media_list_item(theme_media_dir, item))
+    return cleaned_data
+
+
+def clean_media_list_item(theme_media_dir, data):
+    if not isinstance(data, dict):
+        raise InvalidThemeManifest()
+
+    file_manifest = create_media_file_manifest(theme_media_dir)
+
+    if data.get("path"):
+        data["path"] = os.path.join(theme_media_dir, str(data["path"]))
+
+    form = file_manifest(data)
+    if not form.is_valid():
+        raise InvalidThemeManifest()
+
+    return form.cleaned_data
+
+
+def create_theme_from_manifest(name, parent, cleaned_data):
+    return Theme.objects.create(
+        name=name or cleaned_data["name"],
+        parent=parent,
+        version=cleaned_data.get("version") or None,
+        author=cleaned_data.get("author") or None,
+        url=cleaned_data.get("url") or None,
+    )
+
+
+def create_css_from_manifest(tmp_dir, theme, manifest):
+    for item in manifest:
+        if "url" in item:
+            theme.css.create(**item, order=theme.css.count())
+        else:
+            with open(item["path"], "rb") as fp:
+                file_obj = SimpleUploadedFile(
+                    item["name"], fp.read(), content_type="text/css"
+                )
+                create_css(theme, file_obj)
+
+
+def create_media_from_manifest(tmp_dir, theme, manifest):
+    for item in manifest:
+        with open(item["path"], "rb") as fp:
+            file_obj = SimpleUploadedFile(
+                item["name"], fp.read(), content_type=item["type"]
+            )
+            create_media(theme, file_obj)

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

@@ -85,6 +85,9 @@ def mock_build_single_theme_css(mocker):
 def mock_build_theme_css(mocker):
     delay = mocker.Mock()
     mocker.patch("misago.themes.admin.views.build_theme_css", mocker.Mock(delay=delay))
+    mocker.patch(
+        "misago.themes.admin.importer.build_theme_css", mocker.Mock(delay=delay)
+    )
     return delay
 
 
@@ -94,4 +97,7 @@ def mock_update_remote_css_size(mocker):
     mocker.patch(
         "misago.themes.admin.views.update_remote_css_size", mocker.Mock(delay=delay)
     )
+    mocker.patch(
+        "misago.themes.admin.importer.update_remote_css_size", mocker.Mock(delay=delay)
+    )
     return delay

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


+ 0 - 0
misago/themes/admin/tests/test_css_files_building.py → misago/themes/admin/tests/test_building_css_files.py


+ 0 - 0
misago/themes/admin/tests/test_css_files_creation_and_edition.py → misago/themes/admin/tests/test_creating_and_deleting_css_files.py


+ 0 - 0
misago/themes/admin/tests/test_css_links_creation_and_deletion.py → misago/themes/admin/tests/test_creating_and_deleting_css_links.py


+ 0 - 0
misago/themes/admin/tests/test_theme_creation_and_edition.py → misago/themes/admin/tests/test_creating_and_editing_themes.py


+ 21 - 0
misago/themes/admin/tests/test_exporting_themes.py

@@ -0,0 +1,21 @@
+from django.urls import reverse
+
+from ....test import assert_has_error_message
+
+
+def test_exporting_default_theme_sets_error_message(admin_client, default_theme):
+    export_link = reverse(
+        "misago:admin:appearance:themes:export", kwargs={"pk": default_theme.pk}
+    )
+    response = admin_client.post(export_link)
+    assert_has_error_message(response)
+
+
+def test_exporting_nonexisting_theme_sets_error_message(
+    admin_client, nonexisting_theme
+):
+    export_link = reverse(
+        "misago:admin:appearance:themes:export", kwargs={"pk": nonexisting_theme.pk}
+    )
+    response = admin_client.post(export_link)
+    assert_has_error_message(response)

+ 213 - 0
misago/themes/admin/tests/test_importing_themes.py

@@ -0,0 +1,213 @@
+import os
+
+import pytest
+from django.urls import reverse
+
+from ....test import assert_contains
+from ...models import Theme
+
+import_link = reverse("misago:admin:appearance:themes:import")
+
+
+class MockThemeExport:
+    def __init__(self, response):
+        self.name = "theme-export.zip"
+        self.content_type = response["content-type"]
+        self.size = response["content-length"]
+        self._response = response
+
+    def read(self):
+        return self._response.getvalue()
+
+
+@pytest.fixture
+def reimport_theme(admin_client):
+    def export_import_theme(theme, extra_data=None):
+        export_link = reverse(
+            "misago:admin:appearance:themes:export", kwargs={"pk": theme.pk}
+        )
+        theme_export = MockThemeExport(admin_client.post(export_link))
+
+        data = extra_data or {}
+        data["upload"] = theme_export
+        admin_client.post(import_link, data)
+
+        return Theme.objects.filter(pk__gt=theme.pk).last()
+
+    return export_import_theme
+
+
+def assert_filenames_are_same(src, dst):
+    source_filename = os.path.split(src.path)[-1]
+    imported_filename = os.path.split(dst.path)[-1]
+    assert source_filename == imported_filename
+
+
+def test_import_theme_form_is_displayed(db, admin_client):
+    response = admin_client.get(import_link)
+    assert_contains(response, "Import theme")
+
+
+def test_theme_import_fails_if_export_was_not_uploaded(db, admin_client):
+    admin_client.post(import_link, {"upload": None})
+    assert Theme.objects.count() == 1
+
+
+def test_empty_theme_export_can_be_imported_back(reimport_theme, theme):
+    assert reimport_theme(theme)
+
+
+def test_theme_can_be_imported_with_custom_name(reimport_theme, theme):
+    imported_theme = reimport_theme(theme, {"name": "Imported theme"})
+    assert imported_theme.name == "Imported theme"
+
+
+def test_theme_can_be_imported_without_parent(reimport_theme, theme):
+    imported_theme = reimport_theme(theme)
+    assert imported_theme.parent is None
+
+
+def test_theme_can_be_imported_with_custom_parent(reimport_theme, theme):
+    imported_theme = reimport_theme(theme, {"parent": theme.pk})
+    assert imported_theme.parent == theme
+
+
+def test_theme_can_be_imported_with_default_theme_asparent(
+    reimport_theme, theme, default_theme
+):
+    imported_theme = reimport_theme(theme, {"parent": default_theme.pk})
+    assert imported_theme.parent == default_theme
+
+
+def test_theme_import_fails_if_parent_is_nonexisisting(
+    reimport_theme, theme, nonexisting_theme
+):
+    assert not reimport_theme(theme, {"parent": nonexisting_theme.pk})
+
+
+def test_importing_theme_under_parent_rebuilds_themes_tree(reimport_theme, theme):
+    imported_theme = reimport_theme(theme, {"parent": theme.pk})
+    theme.refresh_from_db()
+
+    assert imported_theme.tree_id == theme.tree_id
+    assert theme.lft == 1
+    assert theme.rght == 4
+    assert imported_theme.lft == 2
+    assert imported_theme.rght == 3
+
+
+def test_theme_details_are_exported_and_imported_back(reimport_theme, theme):
+    theme.name = "Exported Theme"
+    theme.version = "0.1.2 FINAL"
+    theme.author = "John Doe"
+    theme.url = "https://example.com"
+    theme.save()
+
+    imported_theme = reimport_theme(theme)
+
+    assert imported_theme.name == theme.name
+    assert imported_theme.version == theme.version
+    assert imported_theme.author == theme.author
+    assert imported_theme.url == theme.url
+
+
+def test_imported_theme_has_own_dirname(reimport_theme, theme):
+    imported_theme = reimport_theme(theme)
+    assert theme.dirname != imported_theme.dirname
+
+
+def test_css_file_is_exported_and_imported_back(reimport_theme, theme, css):
+    imported_theme = reimport_theme(theme)
+
+    imported_css = imported_theme.css.last()
+    assert imported_css.name == css.name
+    assert imported_css.source_hash == css.source_hash
+    assert imported_css.size == css.size
+
+    assert_filenames_are_same(css.source_file, imported_css.source_file)
+
+
+def test_css_link_is_exported_and_imported_back(reimport_theme, theme, css_link):
+    imported_theme = reimport_theme(theme)
+
+    imported_css = imported_theme.css.last()
+    assert imported_css.name == css_link.name
+    assert imported_css.url == css_link.url
+
+
+def test_importing_css_link_triggers_getting_remote_css_size_task(
+    reimport_theme, theme, css_link, mock_update_remote_css_size
+):
+    reimport_theme(theme)
+    mock_update_remote_css_size.assert_called_once()
+
+
+def test_theme_export_containing_css_file_and_link_can_be_imported_back(
+    reimport_theme, theme, css, css_link
+):
+    imported_theme = reimport_theme(theme)
+    css_names = list(imported_theme.css.values_list("name", flat=True))
+    assert css_names == [css.name, css_link.name]
+
+
+def test_theme_css_order_is_preserved_after_import(
+    reimport_theme, theme, css, css_link
+):
+    css_link.order = 1
+    css_link.save()
+
+    css.order = 2
+    css.save()
+
+    imported_theme = reimport_theme(theme)
+    css_names = list(imported_theme.css.values_list("name", flat=True))
+    assert css_names == [css_link.name, css.name]
+
+
+def test_theme_export_containing_media_file_can_be_imported_back(
+    reimport_theme, theme, media
+):
+    imported_theme = reimport_theme(theme)
+
+    imported_media = imported_theme.media.last()
+    assert imported_media.name == media.name
+    assert imported_media.hash == media.hash
+    assert imported_media.type == media.type
+    assert imported_media.size == media.size
+
+    assert_filenames_are_same(media.file, imported_media.file)
+
+
+def test_theme_export_containing_image_file_can_be_imported_back(
+    reimport_theme, theme, image
+):
+    imported_theme = reimport_theme(theme)
+
+    imported_image = imported_theme.media.last()
+    assert imported_image.name == image.name
+    assert imported_image.hash == image.hash
+    assert imported_image.type == image.type
+    assert imported_image.width == image.width
+    assert imported_image.height == image.height
+    assert imported_image.size == image.size
+
+    assert_filenames_are_same(image.file, imported_image.file)
+
+
+def test_theme_export_containing_different_files_can_be_imported_back(
+    reimport_theme, theme, css, css_link, media, image
+):
+    imported_theme = reimport_theme(theme)
+
+    css_names = list(imported_theme.css.values_list("name", flat=True))
+    assert css_names == [css.name, css_link.name]
+
+    media_names = list(imported_theme.media.values_list("name", flat=True))
+    assert media_names == [image.name, media.name]
+
+
+def test_importing_theme_triggers_css_build(
+    reimport_theme, theme, css_link, mock_build_theme_css
+):
+    imported_theme = reimport_theme(theme)
+    mock_build_theme_css.assert_called_once_with(imported_theme.pk)

+ 42 - 4
misago/themes/admin/views.py

@@ -7,7 +7,16 @@ from ...admin.views import generic
 from ..cache import clear_theme_cache
 from ..models import Theme, Css
 from .css import move_css_down, move_css_up
-from .forms import CssEditorForm, CssLinkForm, ThemeForm, UploadCssForm, UploadMediaForm
+from .exporter import export_theme
+from .forms import (
+    CssEditorForm,
+    CssLinkForm,
+    ImportForm,
+    ThemeForm,
+    UploadCssForm,
+    UploadMediaForm,
+)
+from .importer import ThemeImportError, import_theme
 from .tasks import build_single_theme_css, build_theme_css, update_remote_css_size
 
 
@@ -88,6 +97,33 @@ def set_theme_as_active(request, theme):
     clear_theme_cache()
 
 
+class ExportTheme(ThemeAdmin, generic.ButtonView):
+    def check_permissions(self, request, target):
+        if target.is_default:
+            return gettext("Default theme can't be exported.")
+
+    def button_action(self, request, target):
+        return export_theme(target)
+
+
+class ImportTheme(ThemeAdmin, generic.FormView):
+    form = ImportForm
+    template = "import.html"
+
+    def handle_form(self, form, request):
+        try:
+            self.import_theme(request, **form.cleaned_data)
+            return redirect(self.root_link)
+        except ThemeImportError as e:
+            form.add_error("upload", str(e))
+            return self.render(request, {"form": form})
+
+    def import_theme(self, request, *_, name, parent, upload):
+        theme = import_theme(name, parent, upload)
+        message = gettext('Theme "%(name)s" has been imported.')
+        messages.success(request, message % {"name": theme})
+
+
 class ThemeAssetsAdmin(ThemeAdmin):
     def check_permissions(self, request, theme):
         if theme.is_default:
@@ -249,6 +285,8 @@ class MoveThemeCssDown(ThemeCssAdmin):
 
 
 class ThemeCssFormAdmin(ThemeCssAdmin, generic.ModelFormView):
+    is_atomic = False  # atomic updates cause race condition with celery tasks
+
     def real_dispatch(self, request, theme, css=None):
         form = self.initialize_form(self.form, request, theme, css)
 
@@ -339,10 +377,10 @@ class NewThemeCssLink(ThemeCssFormAdmin):
             return form(request.POST, instance=css)
         return form(instance=css)
 
-    def handle_form(self, form, *args):
-        super().handle_form(form, *args)
+    def handle_form(self, form, request, theme, css):
+        super().handle_form(form, request, theme, css)
         if "url" in form.changed_data:
-            update_remote_css_size.delay(form.instance.pk)
+            update_remote_css_size.delay(css.pk)
             clear_theme_cache()
 
     def redirect_to_edit_form(self, theme, css):