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

Move themes admin under misago.themes, add reordering CSS

rafalp 6 лет назад
Родитель
Сommit
6f79ccc92a
30 измененных файлов с 539 добавлено и 114 удалено
  1. 0 78
      misago/admin/admin.py
  2. 0 21
      misago/admin/themes/css.py
  3. 0 0
      misago/admin/themes/tests/__init__.py
  4. 5 5
      misago/admin/views/generic/formsbuttons.py
  5. 22 2
      misago/templates/misago/admin/themes/assets/css.html
  6. 7 2
      misago/templates/misago/admin/themes/assets/list.html
  7. 1 1
      misago/templates/misago/admin/themes/assets/media.html
  8. 89 0
      misago/themes/admin/__init__.py
  9. 66 0
      misago/themes/admin/css.py
  10. 1 1
      misago/themes/admin/forms.py
  11. 0 0
      misago/themes/admin/media.py
  12. 0 0
      misago/themes/admin/tests/__init__.py
  13. 4 2
      misago/themes/admin/tests/conftest.py
  14. 0 0
      misago/themes/admin/tests/css/empty.css
  15. 1 0
      misago/themes/admin/tests/css/test-changed.css
  16. 0 0
      misago/themes/admin/tests/css/test.0046cb3b.css
  17. 0 0
      misago/themes/admin/tests/css/test.4846cb3b.css
  18. 0 0
      misago/themes/admin/tests/css/test.css
  19. 0 0
      misago/themes/admin/tests/font/Lato.ttf
  20. 0 0
      misago/themes/admin/tests/font/OFL.txt
  21. 0 0
      misago/themes/admin/tests/images/test.png
  22. 0 0
      misago/themes/admin/tests/images/test.svg
  23. 0 0
      misago/themes/admin/tests/test_browsing_theme_assets.py
  24. 0 0
      misago/themes/admin/tests/test_deleting_assets.py
  25. 0 0
      misago/themes/admin/tests/test_empty_file_can_be_hashed.py
  26. 207 0
      misago/themes/admin/tests/test_reordering_css.py
  27. 72 0
      misago/themes/admin/tests/test_uploading_css.py
  28. 21 0
      misago/themes/admin/tests/test_uploading_media.py
  29. 0 0
      misago/themes/admin/utils.py
  30. 43 2
      misago/themes/admin/views.py

+ 0 - 78
misago/admin/admin.py

@@ -1,78 +0,0 @@
-from django.conf.urls import url
-from django.utils.deprecation import MiddlewareMixin
-from django.utils.translation import gettext_lazy as _
-
-from .themes.views import (
-    ActivateTheme,
-    DeleteTheme,
-    DeleteThemeCss,
-    DeleteThemeMedia,
-    EditTheme,
-    NewTheme,
-    ThemeAssets,
-    ThemesList,
-    UploadThemeCss,
-    UploadThemeMedia,
-)
-
-
-class MisagoAdminExtension(MiddlewareMixin):
-    def register_urlpatterns(self, urlpatterns):
-        # Appearance section
-        urlpatterns.namespace(r"^appearance/", "appearance")
-
-        # Themes
-        urlpatterns.namespace(r"^themes/", "themes", "appearance")
-        urlpatterns.patterns(
-            "appearance:themes",
-            url(r"^$", ThemesList.as_view(), name="index"),
-            url(r"^new/$", NewTheme.as_view(), name="new"),
-            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"^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-media/$",
-                DeleteThemeMedia.as_view(),
-                name="delete-media",
-            ),
-            url(
-                r"^assets/(?P<pk>\d+)/upload-css/$",
-                UploadThemeCss.as_view(),
-                name="upload-css",
-            ),
-            url(
-                r"^assets/(?P<pk>\d+)/upload-media/$",
-                UploadThemeMedia.as_view(),
-                name="upload-media",
-            ),
-        )
-
-    def register_navigation_nodes(self, site):
-        site.add_node(
-            name=_("Home"),
-            icon="fa fa-home",
-            parent="misago:admin",
-            link="misago:admin:index",
-        )
-
-        site.add_node(
-            name=_("Appearance"),
-            icon="fa fa-paint-brush",
-            parent="misago:admin",
-            namespace="misago:admin:appearance",
-            link="misago:admin:appearance:themes:index",
-        )
-
-        site.add_node(
-            name=_("Themes"),
-            icon="fa fa-tint",
-            parent="misago:admin:appearance",
-            namespace="misago:admin:appearance:themes",
-            link="misago:admin:appearance:themes:index",
-        )

+ 0 - 21
misago/admin/themes/css.py

@@ -1,21 +0,0 @@
-from .utils import get_file_hash
-
-
-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, source_file=css, source_hash=get_file_hash(css), size=css.size
-    )

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


+ 5 - 5
misago/admin/views/generic/formsbuttons.py

@@ -36,16 +36,16 @@ class TargetedView(AdminView):
             return None
 
     def get_target(self, kwargs):
+        if len(kwargs) > 1:
+            raise ValueError("TargetedView.get_target() received more than one kwarg")
         if len(kwargs) != 1:
             return self.get_model()()
 
-        select_for_update = self.get_model().objects
+        queryset = self.get_model().objects
         if self.is_atomic:
-            select_for_update = select_for_update.select_for_update()
-        # Does not work on Python 3:
-        # return select_for_update.get(pk=kwargs[kwargs.keys()[0]])
+            queryset = queryset.select_for_update()
         (pk,) = kwargs.values()
-        return select_for_update.get(pk=pk)
+        return queryset.get(pk=pk)
 
     def check_permissions(self, request, target):
         pass

+ 22 - 2
misago/templates/misago/admin/themes/assets/css.html

@@ -18,7 +18,7 @@
     </button>
   </div>
   {% with theme.css.all as css %}
-    <form action="{% url 'misago:admin:appearance:themes:delete-css' pk=theme.pk %}" method="post">
+    <form class="mass-delete" action="{% url 'misago:admin:appearance:themes:delete-css' pk=theme.pk %}" method="post">
       {% csrf_token %}
       <table class="table">
         <tr>
@@ -27,11 +27,13 @@
           <th>{% trans "Modified" %}</th>
           <th>{% trans "Size" %}</th>
           <th style="width: 1%;">&nbsp;</th>
+          <th style="width: 1%;">&nbsp;</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 %}
+        {% for item in css %}
           <tr>
             <td>
               <a class="btn btn-default btn-sm tooltip-top" href="{{ item.file.url }}" target="blank" title="{% trans 'Preview' %}">
@@ -46,6 +48,16 @@
             </td>
             <td>{{ item.size|filesizeformat }}</td>
             <td style="width: 1%;">
+              <button type="button" class="btn btn-default btn-sm btn-move tooltip-top" data-form="move-down-{{ item.pk }}" title="{% trans 'Move down' %}" {% if forloop.last %}disabled{% endif %}>
+                <span class="fa fa-chevron-down"></span>
+              </button>
+            </td>
+            <td style="width: 1%;">
+              <button type="button" class="btn btn-default btn-sm btn-move tooltip-top" data-form="move-up-{{ item.pk }}" title="{% trans 'Move up' %}" {% if forloop.first %}disabled{% endif %}>
+                <span class="fa fa-chevron-up"></span>
+              </button>
+            </td>
+            <td style="width: 1%;">
               <a href="#" class="btn btn-default btn-sm">
                 <span class="fa fa-pencil"></span>
                 {% trans "Edit" %}
@@ -70,5 +82,13 @@
         </div>
       {% endif %}
     </form>
+    {% for item in css %}
+      <form action="{% url 'misago:admin:appearance:themes:move-css-up' pk=theme.pk css_pk=item.pk %}" method="post" id="move-up-{{ item.pk }}">
+        {% csrf_token %}
+      </form>
+      <form action="{% url 'misago:admin:appearance:themes:move-css-down' pk=theme.pk css_pk=item.pk %}" method="post" id="move-down-{{ item.pk }}">
+        {% csrf_token %}
+      </form>
+    {% endfor %}
   {% endwith %}
 </div><!-- /.table-panel -->

+ 7 - 2
misago/templates/misago/admin/themes/assets/list.html

@@ -78,7 +78,7 @@
 
 <script type="text/javascript">
   $(function() {
-    $(".table-panel form").each(function(_, form) {
+    $(".table-panel form.mass-delete").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);
@@ -98,7 +98,7 @@
       $(form).find(".panel-footer button").prop("disabled", checked === 0);
     });
 
-    $(".table-panel form").submit(function(event) {
+    $(".table-panel form.mass-delete").submit(function(event) {
       var checked = $(event.target).find("td input:checkbox:checked").length;
       if (checked === 0) {
         event.preventDefault();
@@ -111,6 +111,11 @@
         return false;
       }
     });
+
+    $(".btn-move").click(function(event) {
+      var form = $(event.target).data("form");
+      $('#' + form).submit();
+    });
   });
 </script>
 {% endblock javascripts %}

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

@@ -10,7 +10,7 @@
     </button>
   </div>
   {% with theme.media.all as media %}
-    <form action="{% url 'misago:admin:appearance:themes:delete-media' pk=theme.pk %}" method="post">
+    <form  class="mass-delete" action="{% url 'misago:admin:appearance:themes:delete-media' pk=theme.pk %}" method="post">
       {% csrf_token %}
       <table class="table">
         <tr>

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

@@ -0,0 +1,89 @@
+from django.conf.urls import url
+from django.utils.translation import gettext_lazy as _
+
+from .views import (
+    ActivateTheme,
+    DeleteTheme,
+    DeleteThemeCss,
+    DeleteThemeMedia,
+    EditTheme,
+    MoveThemeCssDown,
+    MoveThemeCssUp,
+    NewTheme,
+    ThemeAssets,
+    ThemesList,
+    UploadThemeCss,
+    UploadThemeMedia,
+)
+
+
+class MisagoAdminExtension:
+    def register_urlpatterns(self, urlpatterns):
+        # Appearance section
+        urlpatterns.namespace(r"^appearance/", "appearance")
+
+        # Themes
+        urlpatterns.namespace(r"^themes/", "themes", "appearance")
+        urlpatterns.patterns(
+            "appearance:themes",
+            url(r"^$", ThemesList.as_view(), name="index"),
+            url(r"^new/$", NewTheme.as_view(), name="new"),
+            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"^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-media/$",
+                DeleteThemeMedia.as_view(),
+                name="delete-media",
+            ),
+            url(
+                r"^assets/(?P<pk>\d+)/upload-css/$",
+                UploadThemeCss.as_view(),
+                name="upload-css",
+            ),
+            url(
+                r"^assets/(?P<pk>\d+)/upload-media/$",
+                UploadThemeMedia.as_view(),
+                name="upload-media",
+            ),
+            url(
+                r"^assets/(?P<pk>\d+)/move-css-down/(?P<css_pk>\d+)/$",
+                MoveThemeCssDown.as_view(),
+                name="move-css-down",
+            ),
+            url(
+                r"^assets/(?P<pk>\d+)/move-css-up/(?P<css_pk>\d+)/$",
+                MoveThemeCssUp.as_view(),
+                name="move-css-up",
+            ),
+        )
+
+    def register_navigation_nodes(self, site):
+        site.add_node(
+            name=_("Home"),
+            icon="fa fa-home",
+            parent="misago:admin",
+            link="misago:admin:index",
+        )
+
+        site.add_node(
+            name=_("Appearance"),
+            icon="fa fa-paint-brush",
+            parent="misago:admin",
+            namespace="misago:admin:appearance",
+            link="misago:admin:appearance:themes:index",
+        )
+
+        site.add_node(
+            name=_("Themes"),
+            icon="fa fa-tint",
+            parent="misago:admin:appearance",
+            namespace="misago:admin:appearance:themes",
+            link="misago:admin:appearance:themes:index",
+        )

+ 66 - 0
misago/themes/admin/css.py

@@ -0,0 +1,66 @@
+from ..models import Css
+from .utils import get_file_hash
+
+
+def create_css(theme, css):
+    order = None
+    if css_exists(theme, css):
+        order = get_css_order(theme, css)
+        delete_css(theme, css)
+    save_css(theme, css, order)
+
+
+def css_exists(theme, css):
+    return theme.css.filter(name=css.name).exists()
+
+
+def get_css_order(theme, css):
+    return theme.css.get(name=css.name).order
+
+
+def delete_css(theme, css):
+    theme.css.get(name=css.name).delete()
+
+
+def save_css(theme, css, order=None):
+    if order is None:
+        order = get_next_css_order(theme)
+
+    theme.css.create(
+        name=css.name,
+        source_file=css,
+        source_hash=get_file_hash(css),
+        size=css.size,
+        order=order,
+    )
+
+
+def get_next_css_order(theme):
+    last_css = theme.css.order_by("-order").last()
+    if last_css:
+        return last_css.order + 1
+    return 0
+
+
+def move_css_up(theme, css):
+    previous_css = theme.css.filter(order__lt=css.order).order_by("-order").first()
+    if not previous_css:
+        return False
+
+    css.order, previous_css.order = previous_css.order, css.order
+    css.save(update_fields=["order"])
+    previous_css.save(update_fields=["order"])
+
+    return True
+
+
+def move_css_down(theme, css):
+    next_css = theme.css.filter(order__gt=css.order).order_by("order").first()
+    if not next_css:
+        return False
+
+    css.order, next_css.order = next_css.order, css.order
+    css.save(update_fields=["order"])
+    next_css.save(update_fields=["order"])
+
+    return True

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

@@ -2,7 +2,7 @@ from django import forms
 from django.utils.translation import gettext, gettext_lazy as _
 from mptt.forms import TreeNodeChoiceField
 
-from ...themes.models import Theme
+from ..models import Theme
 from .css import create_css
 from .media import create_media
 

+ 0 - 0
misago/admin/themes/media.py → misago/themes/admin/media.py


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


+ 4 - 2
misago/admin/themes/tests/conftest.py → misago/themes/admin/tests/conftest.py

@@ -3,7 +3,7 @@ import os
 import pytest
 from django.urls import reverse
 
-from ....themes.models import Theme
+from ...models import Theme
 
 
 @pytest.fixture
@@ -39,7 +39,9 @@ def css(admin_client, theme):
 
 @pytest.fixture
 def css_link(admin_client, theme):
-    return theme.css.create(name="CSS link", url="https://somecdn/somefont.css")
+    return theme.css.create(
+        name="CSS link", url="https://test.cdn/somefont.css", order=theme.css.count()
+    )
 
 
 @pytest.fixture

+ 0 - 0
misago/admin/themes/tests/css/empty.css → misago/themes/admin/tests/css/empty.css


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

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

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


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


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


+ 0 - 0
misago/admin/themes/tests/font/Lato.ttf → misago/themes/admin/tests/font/Lato.ttf


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


+ 0 - 0
misago/admin/themes/tests/images/test.png → misago/themes/admin/tests/images/test.png


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


+ 0 - 0
misago/admin/themes/tests/test_browsing_theme_assets.py → misago/themes/admin/tests/test_browsing_theme_assets.py


+ 0 - 0
misago/admin/themes/tests/test_deleting_assets.py → misago/themes/admin/tests/test_deleting_assets.py


+ 0 - 0
misago/admin/themes/tests/test_empty_file_can_be_hashed.py → misago/themes/admin/tests/test_empty_file_can_be_hashed.py


+ 207 - 0
misago/themes/admin/tests/test_reordering_css.py

@@ -0,0 +1,207 @@
+import pytest
+from django.urls import reverse
+
+from ....test import assert_has_error_message
+
+FIRST = 0
+MIDDLE = 1
+LAST = 2
+
+
+@pytest.fixture
+def css_list(theme):
+    return [
+        theme.css.create(name="CSS", url="https://test.cdn/font.css", order=FIRST),
+        theme.css.create(name="CSS", url="https://test.cdn/font.css", order=MIDDLE),
+        theme.css.create(name="CSS", url="https://test.cdn/font.css", order=LAST),
+    ]
+
+
+@pytest.fixture
+def move_up(admin_client):
+    def move_up_client(theme, css):
+        url = reverse(
+            "misago:admin:appearance:themes:move-css-up",
+            kwargs={"pk": theme.pk, "css_pk": css.pk},
+        )
+        return admin_client.post(url)
+
+    return move_up_client
+
+
+@pytest.fixture
+def move_down(admin_client):
+    def move_down_client(theme, css):
+        url = reverse(
+            "misago:admin:appearance:themes:move-css-down",
+            kwargs={"pk": theme.pk, "css_pk": css.pk},
+        )
+        return admin_client.post(url)
+
+    return move_down_client
+
+
+def test_first_css_cant_be_moved_up(move_up, theme, css_list):
+    first_css = css_list[FIRST]
+    move_up(theme, first_css)
+    first_css.refresh_from_db()
+    assert first_css.order == FIRST
+
+
+def test_last_css_cant_be_moved_down(move_down, theme, css_list):
+    last_css = css_list[LAST]
+    move_down(theme, last_css)
+    last_css.refresh_from_db()
+    assert last_css.order == LAST
+
+
+def test_first_css_can_be_moved_down(move_down, theme, css_list):
+    first_css = css_list[FIRST]
+    move_down(theme, first_css)
+    first_css.refresh_from_db()
+    assert first_css.order == MIDDLE
+
+
+def test_last_css_can_be_moved_up(move_up, theme, css_list):
+    last_css = css_list[LAST]
+    move_up(theme, last_css)
+    last_css.refresh_from_db()
+    assert last_css.order == MIDDLE
+
+
+def test_middle_css_can_be_moved_down(move_down, theme, css_list):
+    middle_css = css_list[MIDDLE]
+    move_down(theme, middle_css)
+    middle_css.refresh_from_db()
+    assert middle_css.order == LAST
+
+
+def test_middle_css_can_be_moved_up(move_up, theme, css_list):
+    middle_css = css_list[MIDDLE]
+    move_up(theme, middle_css)
+    middle_css.refresh_from_db()
+    assert middle_css.order == FIRST
+
+
+def test_middle_css_can_be_moved_down(move_down, theme, css_list):
+    middle_css = css_list[MIDDLE]
+    move_down(theme, middle_css)
+    middle_css.refresh_from_db()
+    assert middle_css.order == LAST
+
+
+def test_middle_css_can_be_moved_up(move_up, theme, css_list):
+    middle_css = css_list[MIDDLE]
+    move_up(theme, middle_css)
+    middle_css.refresh_from_db()
+    assert middle_css.order == FIRST
+
+
+def test_first_css_changes_order_with_middle_css_when_moved_down(
+    move_down, theme, css_list
+):
+    move_down(theme, css_list[FIRST])
+    middle_css = css_list[MIDDLE]
+    middle_css.refresh_from_db()
+    assert middle_css.order == FIRST
+
+
+def test_last_css_changes_order_with_middle_css_when_moved_up(move_up, theme, css_list):
+    move_up(theme, css_list[LAST])
+    middle_css = css_list[MIDDLE]
+    middle_css.refresh_from_db()
+    assert middle_css.order == LAST
+
+
+def test_middle_css_changes_order_with_last_css_when_moved_down(
+    move_down, theme, css_list
+):
+    move_down(theme, css_list[MIDDLE])
+    last_css = css_list[LAST]
+    last_css.refresh_from_db()
+    assert last_css.order == MIDDLE
+
+
+def test_middle_css_changes_order_with_first_css_when_moved_up(
+    move_up, theme, css_list
+):
+    move_up(theme, css_list[MIDDLE])
+    first_css = css_list[FIRST]
+    first_css.refresh_from_db()
+    assert first_css.order == MIDDLE
+
+
+def test_first_css_changes_order_with_last_css_when_moved_down_after_middle_deletion(
+    move_down, theme, css_list
+):
+    css_list[MIDDLE].delete()
+    move_down(theme, css_list[FIRST])
+    last_css = css_list[LAST]
+    last_css.refresh_from_db()
+    assert last_css.order == FIRST
+
+
+def test_last_css_changes_order_with_first_css_when_moved_up_after_middle_deletion(
+    move_up, theme, css_list
+):
+    css_list[MIDDLE].delete()
+    move_up(theme, css_list[LAST])
+    first_css = css_list[FIRST]
+    first_css.refresh_from_db()
+    assert first_css.order == LAST
+
+
+def test_if_css_doesnt_belong_to_theme_move_down_action_sets_error_message(
+    move_down, other_theme, css_list
+):
+    response = move_down(other_theme, css_list[MIDDLE])
+    assert_has_error_message(response)
+
+
+def test_if_css_doesnt_belong_to_theme_move_up_action_sets_error_message(
+    move_up, other_theme, css_list
+):
+    response = move_up(other_theme, css_list[MIDDLE])
+    assert_has_error_message(response)
+
+
+def test_if_ran_for_default_theme_move_down_action_sets_error_message(
+    move_down, default_theme, css_list
+):
+    response = move_down(default_theme, css_list[MIDDLE])
+    assert_has_error_message(response)
+
+
+def test_if_ran_for_default_theme_move_up_action_sets_error_message(
+    move_up, default_theme, css_list
+):
+    response = move_up(default_theme, css_list[MIDDLE])
+    assert_has_error_message(response)
+
+
+def test_if_given_nonexisting_css_id_move_down_action_sets_error_message(
+    mocker, move_down, theme, css_list
+):
+    response = move_down(theme, mocker.Mock(pk=css_list[LAST].pk + 1))
+    assert_has_error_message(response)
+
+
+def test_if_given_nonexisting_css_id_move_up_action_sets_error_message(
+    mocker, move_up, theme, css_list
+):
+    response = move_up(theme, mocker.Mock(pk=css_list[LAST].pk + 1))
+    assert_has_error_message(response)
+
+
+def test_if_given_nonexisting_theme_id_move_down_action_sets_error_message(
+    mocker, move_down, nonexisting_theme, css_list
+):
+    response = move_down(nonexisting_theme, css_list[FIRST])
+    assert_has_error_message(response)
+
+
+def test_if_given_nonexisting_theme_id_move_up_action_sets_error_message(
+    mocker, move_up, nonexisting_theme, css_list
+):
+    response = move_up(nonexisting_theme, css_list[LAST])
+    assert_has_error_message(response)

+ 72 - 0
misago/admin/themes/tests/test_uploading_css.py → misago/themes/admin/tests/test_uploading_css.py

@@ -1,6 +1,7 @@
 import os
 
 import pytest
+from django.core.files.uploadedfile import UploadedFile
 from django.urls import reverse
 
 from ....test import assert_has_error_message
@@ -54,6 +55,36 @@ def test_multiple_css_files_can_be_uploaded_at_once(
             assert theme.css.count() == 2
 
 
+def test_css_files_uploaded_one_after_another_are_ordered(
+    upload, theme, css_file, hashed_css_file
+):
+    with open(css_file) as fp:
+        upload(theme, fp)
+    first_css = theme.css.last()
+    assert first_css.name == str(css_file).split("/")[-1]
+    assert first_css.order == 0
+
+    with open(hashed_css_file) as fp:
+        upload(theme, fp)
+    last_css = theme.css.last()
+    assert last_css.name == str(hashed_css_file).split("/")[-1]
+    assert last_css.order == 1
+
+
+def test_multiple_css_files_uploaded_at_once_are_ordered(
+    upload, theme, css_file, hashed_css_file
+):
+    with open(css_file) as fp1:
+        with open(hashed_css_file) as fp2:
+            upload(theme, [fp1, fp2])
+
+    assert list(theme.css.values_list("name", flat=True)) == [
+        str(css_file).split("/")[-1],
+        str(hashed_css_file).split("/")[-1],
+    ]
+    assert list(theme.css.values_list("order", flat=True)) == [0, 1]
+
+
 def test_uploaded_file_is_rejected_if_its_not_css_file(upload, theme, other_file):
     with open(other_file, "rb") as fp:
         upload(theme, fp)
@@ -141,6 +172,47 @@ def test_new_hash_is_added_to_css_file_name_if_it_contains_incorrect_hash(
     assert css.source_hash in filename
 
 
+def test_newly_uploaded_css_file_replaces_old_one_if_file_names_are_same(
+    upload, theme, css_file
+):
+    with open(css_file) as fp:
+        upload(theme, fp)
+    original_css = theme.css.get()
+
+    with open(os.path.join(TESTS_DIR, "css", "test-changed.css")) as fp:
+        size = len(fp.read())
+        fp.seek(0)
+        upload(
+            theme, UploadedFile(fp, name="test.css", content_type="text/css", size=size)
+        )
+    updated_css = theme.css.last()
+
+    assert updated_css.name == original_css.name
+    assert updated_css.source_hash != original_css.source_hash
+    assert theme.css.count() == 1
+
+
+def test_newly_uploaded_css_file_reuses_replaced_file_order_if_names_are_same(
+    upload, theme, css_file, hashed_css_file
+):
+    with open(css_file) as fp:
+        upload(theme, fp)
+    original_css = theme.css.last()
+
+    with open(hashed_css_file) as fp:
+        upload(theme, fp)
+
+    with open(os.path.join(TESTS_DIR, "css", "test-changed.css")) as fp:
+        size = len(fp.read())
+        fp.seek(0)
+        upload(
+            theme, UploadedFile(fp, name="test.css", content_type="text/css", size=size)
+        )
+
+    updated_css = theme.css.get(order=original_css.order)
+    assert updated_css.name == original_css.name
+
+
 def test_error_message_is_set_if_no_css_file_was_uploaded(upload, theme):
     response = upload(theme)
     assert_has_error_message(response)

+ 21 - 0
misago/admin/themes/tests/test_uploading_media.py → misago/themes/admin/tests/test_uploading_media.py

@@ -1,6 +1,7 @@
 import os
 
 import pytest
+from django.core.files.uploadedfile import UploadedFile
 from django.urls import reverse
 
 from ....test import assert_has_error_message
@@ -131,6 +132,26 @@ def test_new_hash_is_added_to_media_file_name_if_it_contains_incorrect_hash(
     assert media.hash in filename
 
 
+def test_newly_uploaded_media_file_replaces_old_one_if_file_names_are_same(
+    upload, theme, hashable_file
+):
+    with open(hashable_file) as fp:
+        upload(theme, fp)
+    original_media = theme.media.get()
+
+    with open(os.path.join(TESTS_DIR, "css", "test-changed.css")) as fp:
+        size = len(fp.read())
+        fp.seek(0)
+        upload(
+            theme, UploadedFile(fp, name="test.css", content_type="text/css", size=size)
+        )
+    updated_media = theme.media.last()
+
+    assert updated_media.name == original_media.name
+    assert updated_media.hash != original_media.hash
+    assert theme.media.count() == 1
+
+
 def test_image_dimensions_are_set_for_uploaded_image_file(upload, theme, png_file):
     with open(png_file, "rb") as fp:
         upload(theme, fp)

+ 0 - 0
misago/admin/themes/utils.py → misago/themes/admin/utils.py


+ 43 - 2
misago/admin/themes/views.py → misago/themes/admin/views.py

@@ -4,8 +4,9 @@ from django.shortcuts import redirect
 from django.urls import reverse
 from django.utils.translation import gettext, gettext_lazy as _
 
-from ...themes.models import Theme
-from ..views import generic
+from ...admin.views import generic
+from ..models import Theme
+from .css import move_css_down, move_css_up
 from .forms import ThemeForm, UploadCssForm, UploadMediaForm
 
 
@@ -163,3 +164,43 @@ class DeleteThemeCss(DeleteThemeAssets):
 class DeleteThemeMedia(DeleteThemeAssets):
     message_submit = _("Selected media have been deleted.")
     queryset_attr = "media"
+
+
+class ThemeCssAdmin(ThemeAssetsAdmin, generic.TargetedView):
+    def wrapped_dispatch(self, request, pk, css_pk):
+        theme = self.get_target_or_none(request, {"pk": pk})
+        if not theme:
+            messages.error(request, self.message_404)
+            return redirect(self.root_link)
+
+        error = self.check_permissions(  # pylint: disable=assignment-from-no-return
+            request, theme
+        )
+        if error:
+            messages.error(request, error)
+            return redirect(self.root_link)
+
+        try:
+            css = theme.css.select_for_update().get(pk=css_pk)
+        except ObjectDoesNotExist:
+            css_error = gettext("Requested CSS could not be found in the theme.")
+            messages.error(request, css_error)
+            return self.redirect_to_theme_assets(theme)
+
+        return self.real_dispatch(request, theme, css)
+
+
+class MoveThemeCssUp(ThemeCssAdmin):
+    def real_dispatch(self, request, theme, css):
+        if request.method == "POST" and move_css_up(theme, css):
+            messages.success(request, gettext('"%s" was moved up.') % css)
+
+        return self.redirect_to_theme_assets(theme)
+
+
+class MoveThemeCssDown(ThemeCssAdmin):
+    def real_dispatch(self, request, theme, css):
+        if request.method == "POST" and move_css_down(theme, css):
+            messages.success(request, gettext('"%s" was moved down.') % css)
+
+        return self.redirect_to_theme_assets(theme)