Browse Source

Django2.2 (#1228)

* python 3.7.0, Django 2.1.1, djangorestframework 3.8.2, and others also updated

* Python 3.7 added to Travis

* Renamed param no_input

* removed django-crispy-forms - not support Django 2.1

* renamed get_sql_create_template_values to create_sql and return str instead of Statment. TypeError: argument of type 'Statement' is not iterable

* Fixed KeyError while display error messages

* Fixed build_active_posters_ranking

* Restored django-crispy-forms for travis

* Added trailing slash to dynamic routes

* Changed str to dict in test

* Changed detail_route to action with proper parameters

* Added missing file - this corrects few tests.

* Passwords in tests changed because caused error "password is too simply" and a few other small fixes.

* Travis, configuration for python 3.7 changed

* Changed errors comparisions in tests

* Restored bootstrap3/field.html because fo crispy

* Travis configuration - allow_failures python 2.7, 3.4, 3.5

* Django 2.1.1 - revert part of changes

* html fix

* Revert "html fix"

This reverts commit 704d1f1363a19e0cf8121493138bf3af6e29be2f.

* Upgraded to django 2.2.1 and djangrestframework 3.9.3

* Fix for PgPartialIndex - self.where replaced by conditions

* Fix for other error messages

* Fixes for a few tests in pytest

* Fix added 'db' to params function 'test_getter_reads_from_cache'

* Fixed warnings for deprecations

* Fixes

* Formating by black

* Password in test restored

* Moved AUTH_PASSWORD_VALIDATORS up to PASSWORD_HASHERS

* removed empty line

* Updated djangorestframework==3.9.4

* Declarations moved to __init__

* Changed to __init__(*args, **kwargs)

* pillow and 6 other libs updated
Wojciech Zając 6 years ago
parent
commit
51d6c91405
35 changed files with 247 additions and 164 deletions
  1. 13 0
      devproject/test_settings.py
  2. 1 1
      misago/cache/tests/test_getting_cache_versions.py
  3. 1 1
      misago/categories/urls/api.py
  4. 1 1
      misago/conf/context_processors.py
  5. 11 12
      misago/core/apirouter.py
  6. 26 14
      misago/core/pgutils.py
  7. 4 1
      misago/core/tests/test_exceptionhandlers.py
  8. 22 39
      misago/core/tests/test_pgpartialindex.py
  9. 1 1
      misago/core/tests/test_utils.py
  10. 2 2
      misago/faker/management/commands/createfakeposts.py
  11. 2 2
      misago/faker/management/commands/createfakethreads.py
  12. 4 2
      misago/legal/models.py
  13. 1 1
      misago/themes/admin/tests/test_importing_themes.py
  14. 1 1
      misago/themes/admin/tests/test_uploading_css.py
  15. 1 1
      misago/themes/admin/tests/test_uploading_media.py
  16. 7 1
      misago/threads/api/postendpoints/delete.py
  17. 6 1
      misago/threads/api/postendpoints/merge.py
  18. 5 1
      misago/threads/api/postendpoints/move.py
  19. 7 1
      misago/threads/api/postendpoints/split.py
  20. 4 1
      misago/threads/api/postingendpoint/attachments.py
  21. 5 1
      misago/threads/api/threadendpoints/delete.py
  22. 4 4
      misago/threads/api/threads.py
  23. 1 1
      misago/threads/serializers/moderation.py
  24. 1 1
      misago/threads/tests/test_privatethread_start_api.py
  25. 3 3
      misago/threads/tests/test_thread_bulkpatch_api.py
  26. 3 3
      misago/threads/tests/test_thread_postbulkpatch_api.py
  27. 2 1
      misago/threads/tests/test_threads_merge_api.py
  28. 6 6
      misago/threads/urls/api.py
  29. 1 1
      misago/users/admin/tests/test_users.py
  30. 63 21
      misago/users/api/users.py
  31. 1 3
      misago/users/tests/test_user_create_api.py
  32. 1 1
      misago/users/urls/api.py
  33. 1 1
      misago/users/views/avatarserver.py
  34. 6 6
      requirements.in
  35. 29 27
      requirements.txt

+ 13 - 0
devproject/test_settings.py

@@ -30,6 +30,19 @@ EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
 # Use MD5 password hashing to speed up test suite
 PASSWORD_HASHERS = ("django.contrib.auth.hashers.MD5PasswordHasher",)
 
+# Simplify password validation to ease writing test assertions
+AUTH_PASSWORD_VALIDATORS = [
+    {
+        "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
+        "OPTIONS": {"user_attributes": ["username", "email"]},
+    },
+    {
+        "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
+        "OPTIONS": {"min_length": 7},
+    },
+    {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
+]
+
 # Default misago address to test address
 MISAGO_ADDRESS = "http://testserver/"
 

+ 1 - 1
misago/cache/tests/test_getting_cache_versions.py

@@ -17,7 +17,7 @@ def test_cache_getter_returns_cache_versions_from_cache(mocker):
     cache_get.assert_called_once_with(CACHE_NAME)
 
 
-def test_getter_reads_from_cache(mocker, django_assert_num_queries):
+def test_getter_reads_from_cache(db, mocker, django_assert_num_queries):
     cache_get = mocker.patch("django.core.cache.cache.get", return_value=True)
     with django_assert_num_queries(0):
         assert get_cache_versions() is True

+ 1 - 1
misago/categories/urls/api.py

@@ -2,5 +2,5 @@ from ...core.apirouter import MisagoApiRouter
 from ..api import CategoryViewSet
 
 router = MisagoApiRouter()
-router.register(r"categories", CategoryViewSet, base_name="category")
+router.register(r"categories", CategoryViewSet, basename="category")
 urlpatterns = router.urls

+ 1 - 1
misago/conf/context_processors.py

@@ -1,4 +1,4 @@
-from django.contrib.staticfiles.templatetags.staticfiles import static
+from django.templatetags.static import static
 from django.urls import reverse
 from django.utils.translation import get_language
 

+ 11 - 12
misago/core/apirouter.py

@@ -1,9 +1,4 @@
-from rest_framework.routers import (
-    DefaultRouter,
-    DynamicDetailRoute,
-    DynamicListRoute,
-    Route,
-)
+from rest_framework.routers import DefaultRouter, DynamicRoute, Route
 
 
 class MisagoApiRouter(DefaultRouter):
@@ -17,14 +12,16 @@ class MisagoApiRouter(DefaultRouter):
             mapping={"get": "list", "post": "create"},
             name="{basename}-list",
             initkwargs={"suffix": "List"},
+            detail=False,
         ),
         # Dynamically generated list routes.
         # Generated using @list_route decorator
         # on methods of the viewset.
-        DynamicListRoute(
-            url=r"^{prefix}/{methodnamehyphen}{trailing_slash}$",
-            name="{basename}-{methodnamehyphen}",
+        DynamicRoute(
+            url=r"^{prefix}/{url_path}{trailing_slash}$",
+            name="{basename}-{url_name}",
             initkwargs={},
+            detail=False,
         ),
         # Detail route.
         Route(
@@ -37,12 +34,14 @@ class MisagoApiRouter(DefaultRouter):
             },
             name="{basename}-detail",
             initkwargs={"suffix": "Instance"},
+            detail=True,
         ),
         # Dynamically generated detail routes.
         # Generated using @detail_route decorator on methods of the viewset.
-        DynamicDetailRoute(
-            url=r"^{prefix}/{lookup}/{methodnamehyphen}{trailing_slash}$",
-            name="{basename}-{methodnamehyphen}",
+        DynamicRoute(
+            url=r"^{prefix}/{lookup}/{url_path}{trailing_slash}$",
+            name="{basename}-{url_name}",
             initkwargs={},
+            detail=True,
         ),
     ]

+ 26 - 14
misago/core/pgutils.py

@@ -1,4 +1,7 @@
-from django.db.models import Index
+import hashlib
+
+from django.db.models import Index, Q
+from django.utils.encoding import force_bytes
 
 
 class PgPartialIndex(Index):
@@ -10,9 +13,17 @@ class PgPartialIndex(Index):
             raise ValueError("partial index requires WHERE clause")
         self.where = where
 
+        if isinstance(where, dict):
+            condition = Q(**where)
+        else:
+            condition = where
+
+        if not name:
+            name = "_".join(where.keys())[:30]
+
         fields = fields or []
 
-        super().__init__(fields, name)
+        super().__init__(fields=fields, name=name, condition=condition)
 
     def set_name_with_model(self, model):
         table_name = model._meta.db_table
@@ -37,22 +48,23 @@ class PgPartialIndex(Index):
         )
         self.check_name()
 
-    def __repr__(self):
-        if self.where is None:
-            return super().__repr__()
+    @staticmethod
+    def _hash_generator(*args):
+        """
+        Method Index._hash_generator is removed in django 2.2
+        This method is copy from old django 2.1
+        """
+        h = hashlib.md5()
+        for arg in args:
+            h.update(force_bytes(arg))
 
-        where_items = []
-        for key in sorted(self.where.keys()):
-            where_items.append("=".join([key, repr(self.where[key])]))
-        return "<%(name)s: fields=%(fields)s, where=%(where)s>" % {
-            "name": self.__class__.__name__,
-            "fields": "'%s'" % (", ".join(self.fields)),
-            "where": "'%s'" % (", ".join(where_items)),
-        }
+        return h.hexdigest()[:6]
 
     def deconstruct(self):
         path, args, kwargs = super().deconstruct()
-        kwargs["where"] = self.where
+        # TODO: check this patch
+        kwargs["where"] = self.condition
+        del kwargs["condition"]
         return path, args, kwargs
 
     def get_sql_create_template_values(self, model, schema_editor, using):

+ 4 - 1
misago/core/tests/test_exceptionhandlers.py

@@ -64,7 +64,10 @@ class HandleAPIExceptionTests(TestCase):
         """permission denied exception is correctly handled"""
         response = exceptionhandler.handle_api_exception(PermissionDenied(), None)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.data["detail"], "Permission denied.")
+        self.assertEqual(
+            response.data["detail"],
+            "You do not have permission to perform this action.",
+        )
 
     def test_permission_message_denied(self):
         """permission denied with message is correctly handled"""

+ 22 - 39
misago/core/tests/test_pgpartialindex.py

@@ -15,8 +15,12 @@ class PgPartialIndexTests(TestCase):
                 where={"has_events": True},
             ).create_sql(Thread, editor)
 
-            self.assertIn('CREATE INDEX "test_partial" ON "misago_threads_thread"', sql)
-            self.assertIn('ON "misago_threads_thread" ("has_events", "is_hidden")', sql)
+            self.assertIn(
+                'CREATE INDEX "test_partial" ON "misago_threads_thread"', repr(sql)
+            )
+            self.assertIn(
+                'ON "misago_threads_thread" ("has_events", "is_hidden")', repr(sql)
+            )
 
     def test_where_clauses(self):
         """where clauses generate correctly"""
@@ -25,41 +29,16 @@ class PgPartialIndexTests(TestCase):
                 fields=["has_events"], name="test_partial", where={"has_events": True}
             ).create_sql(Thread, editor)
 
-            self.assertTrue(sql.endswith('WHERE "has_events" = true'))
+            self.assertTrue(
+                str(sql).endswith('WHERE "misago_threads_thread"."has_events" = true')
+            )
 
             sql = PgPartialIndex(
                 fields=["has_events"], name="test_partial", where={"has_events": False}
             ).create_sql(Thread, editor)
-            self.assertTrue(sql.endswith('WHERE "has_events" = false'))
-
-            sql = PgPartialIndex(
-                fields=["has_events"], name="test_partial", where={"has_events": 42}
-            ).create_sql(Thread, editor)
-            self.assertTrue(sql.endswith('WHERE "has_events" = 42'))
-
-            sql = PgPartialIndex(
-                fields=["has_events"], name="test_partial", where={"has_events__lt": 42}
-            ).create_sql(Thread, editor)
-            self.assertTrue(sql.endswith('WHERE "has_events" < 42'))
-
-            sql = PgPartialIndex(
-                fields=["has_events"], name="test_partial", where={"has_events__gt": 42}
-            ).create_sql(Thread, editor)
-            self.assertTrue(sql.endswith('WHERE "has_events" > 42'))
-
-            sql = PgPartialIndex(
-                fields=["has_events"],
-                name="test_partial",
-                where={"has_events__lte": 42},
-            ).create_sql(Thread, editor)
-            self.assertTrue(sql.endswith('WHERE "has_events" <= 42'))
-
-            sql = PgPartialIndex(
-                fields=["has_events"],
-                name="test_partial",
-                where={"has_events__gte": 42},
-            ).create_sql(Thread, editor)
-            self.assertTrue(sql.endswith('WHERE "has_events" >= 42'))
+            self.assertTrue(
+                str(sql).endswith('WHERE "misago_threads_thread"."has_events" = false')
+            )
 
     def test_multiple_where_clauses(self):
         """where clause with multiple conditions generates correctly"""
@@ -67,10 +46,13 @@ class PgPartialIndexTests(TestCase):
             sql = PgPartialIndex(
                 fields=["has_events"],
                 name="test_partial",
-                where={"has_events__gte": 42, "is_hidden": True},
+                where={"has_events": True, "is_hidden": True},
             ).create_sql(Thread, editor)
-            self.assertTrue(
-                sql.endswith('WHERE "has_events" >= 42 AND "is_hidden" = true')
+            self.assertEqual(
+                'CREATE INDEX "test_partial" ON "misago_threads_thread" ("has_events") WHERE '
+                '("misago_threads_thread"."has_events" = true AND '
+                '"misago_threads_thread"."is_hidden" = true)',
+                str(sql),
             )
 
     def test_set_name_with_model(self):
@@ -99,14 +81,14 @@ class PgPartialIndexTests(TestCase):
         index = PgPartialIndex(fields=["has_events"], where={"has_events": True})
         self.assertEqual(
             repr(index),
-            "<PgPartialIndex: fields='has_events', where='has_events=True'>",
+            "<PgPartialIndex: fields='has_events', condition=(AND: ('has_events', True))>",
         )
 
         index = PgPartialIndex(
             fields=["has_events", "is_hidden"], where={"has_events": True}
         )
         self.assertIn("fields='has_events, is_hidden',", repr(index))
-        self.assertIn(", where='has_events=True'", repr(index))
+        self.assertIn(", condition=(AND: ('has_events', True))>", repr(index))
 
         index = PgPartialIndex(
             fields=["has_events", "is_hidden", "is_closed"],
@@ -114,5 +96,6 @@ class PgPartialIndexTests(TestCase):
         )
         self.assertIn("fields='has_events, is_hidden, is_closed',", repr(index))
         self.assertIn(
-            ", where='has_events=True, is_closed=False, replies__gte=5'", repr(index)
+            ", condition=(AND: ('has_events', True), ('is_closed', False), ('replies__gte', 5))>",
+            repr(index),
         )

+ 1 - 1
misago/core/tests/test_utils.py

@@ -91,7 +91,7 @@ PLAINTEXT_FORMAT_CASES = [
     ("Lorem ipsum.", "<p>Lorem ipsum.</p>"),
     ("Lorem <b>ipsum</b>.", "<p>Lorem &lt;b&gt;ipsum&lt;/b&gt;.</p>"),
     ('Lorem "ipsum" dolor met.', "<p>Lorem &quot;ipsum&quot; dolor met.</p>"),
-    ("Lorem ipsum.\nDolor met.", "<p>Lorem ipsum.<br />Dolor met.</p>"),
+    ("Lorem ipsum.\nDolor met.", "<p>Lorem ipsum.<br>Dolor met.</p>"),
     ("Lorem ipsum.\n\nDolor met.", "<p>Lorem ipsum.</p>\n\n<p>Dolor met.</p>"),
     (
         "http://misago-project.org/login/",

+ 2 - 2
misago/faker/management/commands/createfakeposts.py

@@ -40,7 +40,7 @@ class Command(BaseCommand):
             if random.randint(0, 100) > 90:
                 poster = None
             else:
-                poster = User.objects.order_by("?")[:1].last()
+                poster = User.objects.order_by("?").last()
 
             # There's 5% chance post is unapproved
             if random.randint(0, 100) > 90:
@@ -51,7 +51,7 @@ class Command(BaseCommand):
                 if random.randint(0, 100) > 90:
                     hidden_by = None
                 else:
-                    hidden_by = User.objects.order_by("?")[:1].last()
+                    hidden_by = User.objects.order_by("?").last()
 
                 get_fake_hidden_post(fake, thread, poster, hidden_by)
 

+ 2 - 2
misago/faker/management/commands/createfakethreads.py

@@ -52,7 +52,7 @@ class Command(BaseCommand):
             if random.randint(0, 100) > 90:
                 starter = None
             else:
-                starter = User.objects.order_by("?")[:1].last()
+                starter = User.objects.order_by("?").last()
 
             # There's 10% chance thread is closed
             if random.randint(0, 100) > 90:
@@ -63,7 +63,7 @@ class Command(BaseCommand):
                 if random.randint(0, 100) > 90:
                     hidden_by = None
                 else:
-                    hidden_by = User.objects.order_by("?")[:1].last()
+                    hidden_by = User.objects.order_by("?").last()
 
                 thread = get_fake_hidden_thread(fake, category, starter, hidden_by)
 

+ 4 - 2
misago/legal/models.py

@@ -96,8 +96,10 @@ class Agreement(models.Model):
 
 
 class UserAgreement(models.Model):
-    user = models.ForeignKey(settings.AUTH_USER_MODEL)
-    agreement = models.ForeignKey(Agreement, related_name="accepted_by")
+    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
+    agreement = models.ForeignKey(
+        Agreement, related_name="accepted_by", on_delete=models.CASCADE
+    )
     accepted_on = models.DateTimeField(default=timezone.now)
 
     class Meta:

+ 1 - 1
misago/themes/admin/tests/test_importing_themes.py

@@ -47,7 +47,7 @@ def test_import_theme_form_is_displayed(admin_client):
 
 
 def test_theme_import_fails_if_export_was_not_uploaded(admin_client):
-    admin_client.post(import_link, {"upload": None})
+    admin_client.post(import_link, {"upload": ""})
     assert Theme.objects.count() == 1
 
 

+ 1 - 1
misago/themes/admin/tests/test_uploading_css.py

@@ -31,7 +31,7 @@ def upload(admin_client):
         if asset_files is not None:
             data = asset_files if isinstance(asset_files, list) else [asset_files]
         else:
-            data = None
+            data = ""
         return admin_client.post(url, {"assets": data})
 
     return post_upload

+ 1 - 1
misago/themes/admin/tests/test_uploading_media.py

@@ -36,7 +36,7 @@ def upload(admin_client):
         if asset_files is not None:
             data = asset_files if isinstance(asset_files, list) else [asset_files]
         else:
-            data = None
+            data = ""
         return admin_client.post(url, {"assets": data})
 
     return post_upload

+ 7 - 1
misago/threads/api/postendpoints/delete.py

@@ -36,7 +36,13 @@ def delete_bulk(request, thread):
             errors = serializer.errors["posts"]
         else:
             errors = list(serializer.errors.values())[0]
-        return Response({"detail": errors[0]}, status=400)
+        # Fix for KeyError - errors[0]
+        try:
+            errors = errors[0]
+        except KeyError:
+            if errors and isinstance(errors, dict):
+                errors = list(errors.values())[0][0]
+        return Response({"detail": errors}, status=400)
 
     for post in serializer.validated_data["posts"]:
         post.delete()

+ 6 - 1
misago/threads/api/postendpoints/merge.py

@@ -15,7 +15,12 @@ def posts_merge_endpoint(request, thread):
     )
 
     if not serializer.is_valid():
-        return Response({"detail": list(serializer.errors.values())[0][0]}, status=400)
+        # Fix for KeyError - errors[0]
+        errors = list(serializer.errors.values())[0]
+        try:
+            return Response({"detail": errors[0]}, status=400)
+        except KeyError:
+            return Response({"detail": list(errors.values())[0][0]}, status=400)
 
     posts = serializer.validated_data["posts"]
     first_post, merged_posts = posts[0], posts[1:]

+ 5 - 1
misago/threads/api/postendpoints/move.py

@@ -19,7 +19,11 @@ def posts_move_endpoint(request, thread, viewmodel):
             errors = serializer.errors["new_thread"]
         else:
             errors = list(serializer.errors.values())[0]
-        return Response({"detail": errors[0]}, status=400)
+        # Fix for KeyError - errors[0]
+        try:
+            return Response({"detail": errors[0]}, status=400)
+        except KeyError:
+            return Response({"detail": list(errors.values())[0][0]}, status=400)
 
     new_thread = serializer.validated_data["new_thread"]
 

+ 7 - 1
misago/threads/api/postendpoints/split.py

@@ -22,7 +22,13 @@ def posts_split_endpoint(request, thread):
 
     if not serializer.is_valid():
         if "posts" in serializer.errors:
-            errors = {"detail": serializer.errors["posts"][0]}
+            # Fix for KeyError - errors[0]
+            errors = serializer.errors["posts"]
+            try:
+                errors = {"detail": errors[0]}
+            except KeyError:
+                if isinstance(errors, dict):
+                    errors = {"detail": list(errors.values())[0][0]}
         else:
             errors = serializer.errors
 

+ 4 - 1
misago/threads/api/postingendpoint/attachments.py

@@ -1,6 +1,7 @@
 from django.utils.translation import gettext as _
 from django.utils.translation import ngettext
 from rest_framework import serializers
+from rest_framework.fields import empty
 
 from . import PostingEndpoint, PostingMiddleware
 from ....acl.objectacl import add_acl_to_obj
@@ -32,11 +33,13 @@ class AttachmentsSerializer(serializers.Serializer):
         child=serializers.IntegerField(), required=False
     )
 
-    def validate_attachments(self, ids):
+    def __init__(self, *args, **kwargs):
         self.update_attachments = False
         self.removed_attachments = []
         self.final_attachments = []
+        super().__init__(*args, **kwargs)
 
+    def validate_attachments(self, ids):
         ids = list(set(ids))
 
         validate_attachments_count(ids)

+ 5 - 1
misago/threads/api/threadendpoints/delete.py

@@ -24,7 +24,11 @@ def delete_bulk(request, viewmodel):
             errors = serializer.errors["threads"]
             if "details" in errors:
                 return Response(hydrate_error_details(errors["details"]), status=400)
-            return Response({"detail": errors[0]}, status=403)
+            # Fix for KeyError - errors[0]
+            try:
+                return Response({"detail": errors[0]}, status=403)
+            except KeyError:
+                return Response({"detail": list(errors.values())[0][0]}, status=403)
 
         errors = list(serializer.errors)[0][0]
         return Response({"detail": errors}, status=400)

+ 4 - 4
misago/threads/api/threads.py

@@ -2,7 +2,7 @@ from django.core.exceptions import PermissionDenied
 from django.db import transaction
 from django.utils.translation import gettext as _
 from rest_framework import viewsets
-from rest_framework.decorators import detail_route, list_route
+from rest_framework.decorators import action
 from rest_framework.response import Response
 
 from ...categories import PRIVATE_THREADS_ROOT_NAME, THREADS_ROOT_NAME
@@ -90,18 +90,18 @@ class ThreadViewSet(ViewSet):
             {"id": thread.pk, "title": thread.title, "url": thread.get_absolute_url()}
         )
 
-    @detail_route(methods=["post"], url_path="merge")
+    @action(detail=True, methods=["post"], url_path="merge", url_name="merge")
     @transaction.atomic
     def thread_merge(self, request, pk=None):
         thread = self.get_thread(request, pk).unwrap()
         return thread_merge_endpoint(request, thread, self.thread)
 
-    @list_route(methods=["post"], url_path="merge")
+    @action(detail=False, methods=["post"], url_path="merge", url_name="merge")
     @transaction.atomic
     def threads_merge(self, request):
         return threads_merge_endpoint(request)
 
-    @list_route(methods=["get"])
+    @action(detail=False, methods=["get"])
     def editor(self, request):
         return thread_start_editor(request)
 

+ 1 - 1
misago/threads/serializers/moderation.py

@@ -391,7 +391,7 @@ class SplitPostsSerializer(NewThreadSerializer):
             try:
                 allow_split_post(user_acl, post)
             except PermissionDenied as e:
-                raise ValidationError(e)
+                raise ValidationError(str(e))
 
             posts.append(post)
 

+ 1 - 1
misago/threads/tests/test_privatethread_start_api.py

@@ -52,7 +52,7 @@ class StartPrivateThreadTests(AuthenticatedUserTestCase):
         self.assertEqual(
             response.json(),
             {
-                "to": ["You have to enter user names."],
+                "to": ["This field is required."],
                 "title": ["You have to enter thread title."],
                 "post": ["You have to enter a message."],
             },

+ 3 - 3
misago/threads/tests/test_thread_bulkpatch_api.py

@@ -79,8 +79,8 @@ class BulkPatchSerializerTests(ThreadsBulkPatchApiTestCase):
         self.assertEqual(
             response.json(),
             {
-                "ids": ["A valid integer is required."],
-                "ops": ['Expected a dictionary of items but got type "int".'],
+                "ids": {"0": ["A valid integer is required."]},
+                "ops": {"0": ['Expected a dictionary of items but got type "int".']},
             },
         )
 
@@ -91,7 +91,7 @@ class BulkPatchSerializerTests(ThreadsBulkPatchApiTestCase):
         self.assertEqual(response.status_code, 400)
         self.assertEqual(
             response.json(),
-            {"ids": ["Ensure this value is greater than or equal to 1."]},
+            {"ids": {"0": ["Ensure this value is greater than or equal to 1."]}},
         )
 
     def test_too_large_input(self):

+ 3 - 3
misago/threads/tests/test_thread_postbulkpatch_api.py

@@ -79,8 +79,8 @@ class BulkPatchSerializerTests(ThreadPostBulkPatchApiTestCase):
         self.assertEqual(
             response.json(),
             {
-                "ids": ["A valid integer is required."],
-                "ops": ['Expected a dictionary of items but got type "int".'],
+                "ids": {"0": ["A valid integer is required."]},
+                "ops": {"0": ['Expected a dictionary of items but got type "int".']},
             },
         )
 
@@ -91,7 +91,7 @@ class BulkPatchSerializerTests(ThreadPostBulkPatchApiTestCase):
         self.assertEqual(response.status_code, 400)
         self.assertEqual(
             response.json(),
-            {"ids": ["Ensure this value is greater than or equal to 1."]},
+            {"ids": {"0": ["Ensure this value is greater than or equal to 1."]}},
         )
 
     def test_too_large_input(self):

+ 2 - 1
misago/threads/tests/test_threads_merge_api.py

@@ -66,7 +66,8 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         )
         self.assertEqual(response.status_code, 403)
         self.assertEqual(
-            response.json(), {"detail": "One or more thread ids received were invalid."}
+            response.json(),
+            {"detail": ["One or more thread ids received were invalid."]},
         )
 
     def test_merge_single_thread(self):

+ 6 - 6
misago/threads/urls/api.py

@@ -6,21 +6,21 @@ from ..api.threads import PrivateThreadViewSet, ThreadViewSet
 
 router = MisagoApiRouter()
 
-router.register(r"attachments", AttachmentViewSet, base_name="attachment")
+router.register(r"attachments", AttachmentViewSet, basename="attachment")
 
-router.register(r"threads", ThreadViewSet, base_name="thread")
+router.register(r"threads", ThreadViewSet, basename="thread")
 router.register(
-    r"threads/(?P<thread_pk>[^/.]+)/posts", ThreadPostsViewSet, base_name="thread-post"
+    r"threads/(?P<thread_pk>[^/.]+)/posts", ThreadPostsViewSet, basename="thread-post"
 )
 router.register(
-    r"threads/(?P<thread_pk>[^/.]+)/poll", ThreadPollViewSet, base_name="thread-poll"
+    r"threads/(?P<thread_pk>[^/.]+)/poll", ThreadPollViewSet, basename="thread-poll"
 )
 
-router.register(r"private-threads", PrivateThreadViewSet, base_name="private-thread")
+router.register(r"private-threads", PrivateThreadViewSet, basename="private-thread")
 router.register(
     r"private-threads/(?P<thread_pk>[^/.]+)/posts",
     PrivateThreadPostsViewSet,
-    base_name="private-thread-post",
+    basename="private-thread-post",
 )
 
 urlpatterns = router.urls

+ 1 - 1
misago/users/admin/tests/test_users.py

@@ -131,7 +131,7 @@ def get_default_edit_form_data(user):
         "roles": str(user.roles.all()[0].id),
         "email": user.email,
         "new_password": "",
-        "signature": user.signature,
+        "signature": user.signature or "",
         "is_signature_locked": str(user.is_signature_locked),
         "is_hiding_presence": str(user.is_hiding_presence),
         "limits_private_thread_invites_to": str(user.limits_private_thread_invites_to),

+ 63 - 21
misago/users/api/users.py

@@ -6,7 +6,7 @@ from django.http import Http404
 from django.shortcuts import get_object_or_404
 from django.utils.translation import gettext as _
 from rest_framework import status, viewsets
-from rest_framework.decorators import detail_route
+from rest_framework.decorators import action
 from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
 from rest_framework.response import Response
 
@@ -104,14 +104,19 @@ class UserViewSet(viewsets.GenericViewSet):
 
         return Response(profile_json)
 
-    @detail_route(methods=["get", "post"])
+    @action(methods=["get", "post"], detail=True)
     def avatar(self, request, pk=None):
         get_int_or_404(pk)
         allow_self_only(request.user, pk, _("You can't change other users avatars."))
 
         return avatar_endpoint(request)
 
-    @detail_route(methods=["post"])
+    @action(
+        methods=["post"],
+        detail=True,
+        url_name="forum-options",
+        url_path="forum-options",
+    )
     def forum_options(self, request, pk=None):
         get_int_or_404(pk)
         allow_self_only(request.user, pk, _("You can't change other users options."))
@@ -122,28 +127,35 @@ class UserViewSet(viewsets.GenericViewSet):
             return Response({"detail": _("Your forum options have been changed.")})
         return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
 
-    @detail_route(methods=["get", "post"])
+    @action(methods=["get", "post"], detail=True)
     def username(self, request, pk=None):
         get_int_or_404(pk)
         allow_self_only(request.user, pk, _("You can't change other users names."))
 
         return username_endpoint(request)
 
-    @detail_route(methods=["get", "post"])
+    @action(methods=["get", "post"], detail=True)
     def signature(self, request, pk=None):
         get_int_or_404(pk)
         allow_self_only(request.user, pk, _("You can't change other users signatures."))
 
         return signature_endpoint(request)
 
-    @detail_route(methods=["post"])
+    @action(
+        methods=["post"],
+        detail=True,
+        url_path="change-password",
+        url_name="change-password",
+    )
     def change_password(self, request, pk=None):
         get_int_or_404(pk)
         allow_self_only(request.user, pk, _("You can't change other users passwords."))
 
         return change_password_endpoint(request)
 
-    @detail_route(methods=["post"])
+    @action(
+        methods=["post"], detail=True, url_path="change-email", url_name="change-email"
+    )
     def change_email(self, request, pk=None):
         get_int_or_404(pk)
         allow_self_only(
@@ -152,19 +164,29 @@ class UserViewSet(viewsets.GenericViewSet):
 
         return change_email_endpoint(request)
 
-    @detail_route(methods=["get"])
+    @action(methods=["get"], detail=True)
     def details(self, request, pk=None):
         profile = self.get_user(request, pk)
         data = serialize_profilefields_data(request, profilefields, profile)
         return Response(data)
 
-    @detail_route(methods=["get", "post"])
+    @action(
+        methods=["get", "post"],
+        detail=True,
+        url_path="edit-details",
+        url_name="edit-details",
+    )
     def edit_details(self, request, pk=None):
         profile = self.get_user(request, pk)
         allow_edit_profile_details(request.user_acl, profile)
         return edit_details_endpoint(request, profile)
 
-    @detail_route(methods=["post"])
+    @action(
+        methods=["post"],
+        detail=True,
+        url_path="delete-own-account",
+        url_name="delete-own-account",
+    )
     def delete_own_account(self, request, pk=None):
         serializer = DeleteOwnAccountSerializer(
             data=request.data, context={"user": request.user}
@@ -173,7 +195,7 @@ class UserViewSet(viewsets.GenericViewSet):
         serializer.mark_account_for_deletion(request)
         return Response({})
 
-    @detail_route(methods=["post"])
+    @action(methods=["post"], detail=True)
     def follow(self, request, pk=None):
         profile = self.get_user(request, pk)
         allow_follow_user(request.user_acl, profile)
@@ -201,7 +223,7 @@ class UserViewSet(viewsets.GenericViewSet):
 
             return Response({"is_followed": followed, "followers": profile_followers})
 
-    @detail_route()
+    @action(detail=True)
     def ban(self, request, pk=None):
         profile = self.get_user(request, pk)
         allow_see_ban_details(request.user_acl, profile)
@@ -211,21 +233,36 @@ class UserViewSet(viewsets.GenericViewSet):
             return Response(BanDetailsSerializer(ban).data)
         return Response({})
 
-    @detail_route(methods=["get", "post"])
+    @action(
+        methods=["get", "post"],
+        detail=True,
+        url_path="moderate-avatar",
+        url_name="moderate-avatar",
+    )
     def moderate_avatar(self, request, pk=None):
         profile = self.get_user(request, pk)
         allow_moderate_avatar(request.user_acl, profile)
 
         return moderate_avatar_endpoint(request, profile)
 
-    @detail_route(methods=["get", "post"])
+    @action(
+        methods=["get", "post"],
+        detail=True,
+        url_path="moderate-username",
+        url_name="moderate-username",
+    )
     def moderate_username(self, request, pk=None):
         profile = self.get_user(request, pk)
         allow_rename_user(request.user_acl, profile)
 
         return moderate_username_endpoint(request, profile)
 
-    @detail_route(methods=["post"])
+    @action(
+        methods=["post"],
+        detail=True,
+        url_path="request-data-download",
+        url_name="request-data-download",
+    )
     def request_data_download(self, request, pk=None):
         get_int_or_404(pk)
         allow_self_only(
@@ -244,7 +281,7 @@ class UserViewSet(viewsets.GenericViewSet):
 
         return Response({"detail": "ok"})
 
-    @detail_route(methods=["get", "post"])
+    @action(methods=["get", "post"], detail=True)
     def delete(self, request, pk=None):
         profile = self.get_user(request, pk)
         allow_delete_user(request.user_acl, profile)
@@ -283,7 +320,12 @@ class UserViewSet(viewsets.GenericViewSet):
 
         return Response({})
 
-    @detail_route(methods=["get"])
+    @action(
+        methods=["get"],
+        detail=True,
+        url_path="data-downloads",
+        url_name="data-downloads",
+    )
     def data_downloads(self, request, pk=None):
         get_int_or_404(pk)
         allow_self_only(
@@ -294,7 +336,7 @@ class UserViewSet(viewsets.GenericViewSet):
         serializer = DataDownloadSerializer(queryset, many=True)
         return Response(serializer.data)
 
-    @detail_route(methods=["get"])
+    @action(methods=["get"], detail=True)
     def followers(self, request, pk=None):
         profile = self.get_user(request, pk)
 
@@ -308,7 +350,7 @@ class UserViewSet(viewsets.GenericViewSet):
 
         return Response(users.get_frontend_context())
 
-    @detail_route(methods=["get"])
+    @action(methods=["get"], detail=True)
     def follows(self, request, pk=None):
         profile = self.get_user(request, pk)
 
@@ -322,14 +364,14 @@ class UserViewSet(viewsets.GenericViewSet):
 
         return Response(users.get_frontend_context())
 
-    @detail_route(methods=["get"])
+    @action(methods=["get"], detail=True)
     def threads(self, request, pk=None):
         profile = self.get_user(request, pk)
         start = get_int_or_404(request.query_params.get("start", 0))
         feed = UserThreads(request, profile, start)
         return Response(feed.get_frontend_context())
 
-    @detail_route(methods=["get"])
+    @action(methods=["get"], detail=True)
     def posts(self, request, pk=None):
         profile = self.get_user(request, pk)
         start = get_int_or_404(request.query_params.get("start", 0))

+ 1 - 3
misago/users/tests/test_user_create_api.py

@@ -38,9 +38,7 @@ class UserCreateTests(UserTestCase):
 
     def test_invalid_data(self):
         """invalid request data errors with code 400"""
-        response = self.client.post(
-            self.api_link, "false", content_type="application/json"
-        )
+        response = self.client.post(self.api_link, {}, content_type="application/json")
         self.assertEqual(response.status_code, 400)
         self.assertEqual(
             response.json(),

+ 1 - 1
misago/users/urls/api.py

@@ -27,5 +27,5 @@ urlpatterns = [
 router = MisagoApiRouter()
 router.register(r"ranks", RanksViewSet)
 router.register(r"users", UserViewSet)
-router.register(r"username-changes", UsernameChangesViewSet, base_name="usernamechange")
+router.register(r"username-changes", UsernameChangesViewSet, basename="usernamechange")
 urlpatterns += router.urls

+ 1 - 1
misago/users/views/avatarserver.py

@@ -1,6 +1,6 @@
 from django.contrib.auth import get_user_model
-from django.contrib.staticfiles.templatetags.staticfiles import static
 from django.shortcuts import redirect
+from django.templatetags.static import static
 
 from ...conf import settings
 

+ 6 - 6
requirements.in

@@ -2,17 +2,17 @@ ariadne>0.2
 beautifulsoup4<4.8
 bleach<3.2
 celery[redis]
-django<2
-djangorestframework<3.7
+django<2.3
+djangorestframework<3.10
 django-debug-toolbar<1.12
-django-htmlmin<0.11
+django-htmlmin<0.12
 django-mptt
 Faker<1.1
 html5lib<1.1
-markdown<2.7
+markdown<3
 social-auth-app-django
-pillow<5
-psycopg2-binary<2.8
+pillow<7
+psycopg2-binary<2.9
 pytest
 pytest-django
 pytest-mock

+ 29 - 27
requirements.txt

@@ -2,59 +2,61 @@
 # This file is autogenerated by pip-compile
 # To update, run:
 #
-#    pip-compile --output-file requirements.txt requirements.in
+#    pip-compile --output-file=requirements.txt requirements.in
 #
-amqp==2.4.1               # via kombu
+amqp==2.4.2               # via kombu
 ariadne==0.3.0
 atomicwrites==1.3.0       # via pytest
-attrs==18.2.0             # via pytest
+attrs==19.1.0             # via pytest
 beautifulsoup4==4.7.1
-billiard==3.5.0.5         # via celery
+billiard==3.6.0.0         # via celery
 bleach==3.1.0
-celery[redis]==4.2.1
-certifi==2018.11.29       # via requests
+celery[redis]==4.3.0
+certifi==2019.3.9         # via requests
 chardet==3.0.4            # via requests
-defusedxml==0.5.0         # via python3-openid, social-auth-core
+defusedxml==0.6.0         # via python3-openid, social-auth-core
 django-debug-toolbar==1.11
-django-htmlmin==0.10.0
+django-htmlmin==0.11.0
 django-js-asset==1.2.2    # via django-mptt
-django-mptt==0.9.1
-django==1.11.20
-djangorestframework==3.6.4
-faker==1.0.2
+django-mptt==0.10.0
+django==2.2.1
+djangorestframework==3.9.4
+faker==1.0.7
 graphql-core-next==1.0.3  # via ariadne
 html5lib==1.0.1
 idna==2.8                 # via requests
-kombu==4.3.0              # via celery
+kombu==4.5.0              # via celery
 markdown==2.6.11
-more-itertools==6.0.0     # via pytest
+more-itertools==7.0.0     # via pytest
 oauthlib==3.0.1           # via requests-oauthlib, social-auth-core
-olefile==0.46             # via pillow
-pillow==4.3.0
-pluggy==0.9.0             # via pytest
-psycopg2-binary==2.7.7
+pillow==6.0.0
+pluggy==0.11.0            # via pytest
+psycopg2-binary==2.8.2
 py==1.8.0                 # via pytest
 pyjwt==1.7.1              # via social-auth-core
 pytest-django==3.4.8
-pytest-mock==1.10.1
-pytest==4.3.0
+pytest-mock==1.10.4
+pytest==4.5.0
 python-dateutil==2.8.0    # via faker
 python3-openid==3.1.0     # via social-auth-core
-pytz==2018.9
-redis==3.2.0              # via celery
+pytz==2019.1
+redis==3.2.1              # via celery
 requests-oauthlib==1.2.0  # via social-auth-core
 requests==2.21.0
-responses==0.10.5
+responses==0.10.6
 six==1.12.0               # via bleach, faker, html5lib, pytest, python-dateutil, responses, snapshottest, social-auth-app-django, social-auth-core
 snapshottest==0.5.0
 social-auth-app-django==3.1.0
 social-auth-core==3.1.0   # via social-auth-app-django
-soupsieve==1.8            # via beautifulsoup4
-sqlparse==0.2.4           # via django-debug-toolbar
+soupsieve==1.9.1          # via beautifulsoup4
+sqlparse==0.3.0           # via django, django-debug-toolbar
+starlette==0.11.4         # via ariadne
 termcolor==1.1.0          # via snapshottest
 text-unidecode==1.2       # via faker
 typing-extensions==3.7.2  # via ariadne
+typing==3.6.6             # via ariadne
 unidecode==1.0.23
-urllib3==1.24.1           # via requests
-vine==1.2.0               # via amqp
+urllib3==1.24.3           # via requests
+vine==1.3.0               # via amqp, celery
+wcwidth==0.1.7            # via pytest
 webencodings==0.5.1       # via bleach, html5lib