Browse Source

Remove User.acl_cache, User.acl_ and direct imports from misago.acl

rafalp 6 years ago
parent
commit
3a907dd804
60 changed files with 398 additions and 277 deletions
  1. 3 2
      devproject/settings.py
  2. 2 0
      misago/acl/context_processors.py
  3. 12 0
      misago/acl/tests/test_user_acl_context_processor.py
  4. 2 2
      misago/categories/management/commands/fixcategoriestree.py
  5. 2 2
      misago/categories/models.py
  6. 4 6
      misago/categories/permissions.py
  7. 3 3
      misago/categories/utils.py
  8. 2 2
      misago/categories/views/categoriesadmin.py
  9. 3 3
      misago/categories/views/permsadmin.py
  10. 14 12
      misago/core/tests/test_errorpages.py
  11. 17 13
      misago/core/tests/test_exceptionhandler_middleware.py
  12. 2 2
      misago/faker/management/commands/createfakecategories.py
  13. 4 4
      misago/markup/flavours.py
  14. 2 2
      misago/readtracker/tests/test_threadstracker.py
  15. 1 1
      misago/templates/misago/navbar.html
  16. 1 1
      misago/templates/misago/threadslist/tabs.html
  17. 2 2
      misago/threads/api/attachments.py
  18. 2 2
      misago/threads/api/pollvotecreateendpoint.py
  19. 2 2
      misago/threads/api/postendpoints/edits.py
  20. 2 2
      misago/threads/api/postendpoints/merge.py
  21. 2 2
      misago/threads/api/postendpoints/patch_event.py
  22. 2 2
      misago/threads/api/postendpoints/patch_post.py
  23. 2 2
      misago/threads/api/postingendpoint/attachments.py
  24. 2 2
      misago/threads/api/postingendpoint/category.py
  25. 2 2
      misago/threads/api/postingendpoint/privatethread.py
  26. 2 2
      misago/threads/api/threadendpoints/editor.py
  27. 2 2
      misago/threads/api/threadendpoints/merge.py
  28. 4 3
      misago/threads/api/threadendpoints/patch.py
  29. 3 3
      misago/threads/api/threadpoll.py
  30. 2 2
      misago/threads/api/threadposts.py
  31. 5 4
      misago/threads/permissions/threads.py
  32. 5 5
      misago/threads/serializers/moderation.py
  33. 4 3
      misago/threads/tests/test_events.py
  34. 2 1
      misago/threads/tests/test_privatethread_patch_api.py
  35. 3 2
      misago/threads/tests/test_threads_editor_api.py
  36. 6 5
      misago/threads/tests/test_threads_merge_api.py
  37. 2 2
      misago/threads/viewmodels/category.py
  38. 2 2
      misago/threads/viewmodels/post.py
  39. 2 2
      misago/threads/viewmodels/posts.py
  40. 4 4
      misago/threads/viewmodels/thread.py
  41. 2 2
      misago/threads/viewmodels/threads.py
  42. 4 3
      misago/users/api/auth.py
  43. 2 1
      misago/users/api/userendpoints/signature.py
  44. 47 34
      misago/users/api/userendpoints/username.py
  45. 2 2
      misago/users/api/users.py
  46. 5 2
      misago/users/context_processors.py
  47. 3 3
      misago/users/models/rank.py
  48. 7 24
      misago/users/models/user.py
  49. 41 29
      misago/users/namechanges.py
  50. 1 1
      misago/users/permissions/moderation.py
  51. 1 4
      misago/users/permissions/profiles.py
  52. 9 7
      misago/users/serializers/auth.py
  53. 2 2
      misago/users/signatures.py
  54. 8 9
      misago/users/tests/test_bans.py
  55. 68 16
      misago/users/tests/test_namechanges.py
  56. 42 16
      misago/users/tests/test_signatures.py
  57. 4 1
      misago/users/tests/test_social_pipeline.py
  58. 3 3
      misago/users/viewmodels/threads.py
  59. 5 1
      misago/users/views/admin/users.py
  60. 2 2
      misago/users/views/profile.py

+ 3 - 2
devproject/settings.py

@@ -286,12 +286,13 @@ TEMPLATES = [
                 'django.contrib.auth.context_processors.auth',
                 'django.contrib.messages.context_processors.messages',
 
+                'misago.acl.context_processors.user_acl',
+                'misago.conf.context_processors.settings',
                 'misago.core.context_processors.site_address',
                 'misago.core.context_processors.momentjs_locale',
-                'misago.conf.context_processors.settings',
+                'misago.legal.context_processors.legal_links',
                 'misago.search.context_processors.search_providers',
                 'misago.users.context_processors.user_links',
-                'misago.legal.context_processors.legal_links',
 
                 # Data preloaders
                 'misago.conf.context_processors.preload_settings_json',

+ 2 - 0
misago/acl/context_processors.py

@@ -0,0 +1,2 @@
+def user_acl(request):
+    return {"user_acl": request.user_acl}

+ 12 - 0
misago/acl/tests/test_user_acl_context_processor.py

@@ -0,0 +1,12 @@
+from unittest.mock import Mock
+
+from django.test import TestCase
+
+from misago.acl.context_processors import user_acl
+
+
+class ContextProcessorsTests(TestCase):
+    def test_context_processor_adds_request_user_acl_to_context(self):
+        test_acl = {"test": True}
+        context = user_acl(Mock(user_acl=test_acl))
+        assert context == {"user_acl": test_acl}

+ 2 - 2
misago/categories/management/commands/fixcategoriestree.py

@@ -1,6 +1,6 @@
 from django.core.management.base import BaseCommand
 
-from misago.acl import version as acl_version
+from misago.acl.cache import clear as clear_acl_cache
 from misago.categories.models import Category
 
 
@@ -19,5 +19,5 @@ class Command(BaseCommand):
         self.stdout.write("Categories tree has been rebuild.")
 
         Category.objects.clear_cache()
-        acl_version.invalidate()
+        clear_acl_cache()
         self.stdout.write("Caches have been cleared.")

+ 2 - 2
misago/categories/models.py

@@ -3,7 +3,7 @@ from mptt.models import MPTTModel, TreeForeignKey
 
 from django.db import models
 
-from misago.acl import version as acl_version
+from misago.acl.cache import clear as clear_acl_cache
 from misago.acl.models import BaseRole
 from misago.conf import settings
 from misago.core.cache import cache
@@ -115,7 +115,7 @@ class Category(MPTTModel):
 
     def delete(self, *args, **kwargs):
         Category.objects.clear_cache()
-        acl_version.invalidate()
+        clear_acl_cache()
         return super().delete(*args, **kwargs)
 
     def synchronize(self):

+ 4 - 6
misago/categories/permissions.py

@@ -90,9 +90,9 @@ def add_acl_to_category(user_acl, target):
     target.acl['can_browse'] = can_browse_category(user_acl, target)
 
 
-def serialize_categories_acls(serialized_acl):
+def serialize_categories_acls(user_acl):
     categories_acl = []
-    for category, acl in serialized_acl.pop('categories').items():
+    for category, acl in user_acl.pop('categories').items():
         if acl['can_browse']:
             categories_acl.append({
                 'id': category,
@@ -102,14 +102,12 @@ def serialize_categories_acls(serialized_acl):
                 'can_hide_threads': acl.get('can_hide_threads', 0),
                 'can_close_threads': acl.get('can_close_threads', False),
             })
-    serialized_acl['categories'] = categories_acl
+    user_acl['categories'] = categories_acl
 
 
 def register_with(registry):
     registry.acl_annotator(Category, add_acl_to_category)
-
-    registry.acl_serializer(get_user_model(), serialize_categories_acls)
-    registry.acl_serializer(AnonymousUser, serialize_categories_acls)
+    registry.user_acl_serializer(serialize_categories_acls)
 
 
 def allow_see_category(user_acl, target):

+ 3 - 3
misago/categories/utils.py

@@ -1,11 +1,11 @@
-from misago.acl import add_acl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.readtracker import categoriestracker
 
 from .models import Category
 
 
 def get_categories_tree(user, user_acl, parent=None, join_posters=False):
-    if not user.acl_cache['visible_categories']:
+    if not user_acl['visible_categories']:
         return []
 
     if parent:
@@ -32,7 +32,7 @@ def get_categories_tree(user, user_acl, parent=None, join_posters=False):
         if category.parent_id and category.level > parent_level:
             categories_dict[category.parent_id].subcategories.append(category)
 
-    add_acl(user_acl, categories_list)
+    add_acl_to_obj(user_acl, categories_list)
     categoriestracker.make_read_aware(user, user_acl, categories_list)
 
     for category in reversed(visible_categories):

+ 2 - 2
misago/categories/views/categoriesadmin.py

@@ -2,7 +2,7 @@ from django.contrib import messages
 from django.shortcuts import redirect
 from django.utils.translation import gettext_lazy as _
 
-from misago.acl import version as acl_version
+from misago.acl.cache import clear as clear_acl_cache
 from misago.admin.views import generic
 from misago.categories import THREADS_ROOT_NAME
 from misago.categories.forms import CategoryFormFactory, DeleteFormFactory
@@ -88,7 +88,7 @@ class CategoryFormMixin(object):
             if copied_acls:
                 RoleCategoryACL.objects.bulk_create(copied_acls)
 
-        acl_version.invalidate()
+        clear_acl_cache()
         messages.success(request, self.message_submit % {'name': target.name})
 
 

+ 3 - 3
misago/categories/views/permsadmin.py

@@ -2,7 +2,7 @@ from django.contrib import messages
 from django.shortcuts import redirect
 from django.utils.translation import gettext_lazy as _
 
-from misago.acl import version as acl_version
+from misago.acl.cache import clear as clear_acl_cache
 from misago.acl.forms import get_permissions_forms
 from misago.acl.models import Role
 from misago.acl.views import RoleAdmin, RolesList
@@ -128,7 +128,7 @@ class CategoryPermissions(CategoryAdmin, generic.ModelFormView):
             if new_permissions:
                 RoleCategoryACL.objects.bulk_create(new_permissions)
 
-            acl_version.invalidate()
+            clear_acl_cache()
 
             message = _("Category %(name)s permissions have been changed.")
             messages.success(request, message % {'name': target.name})
@@ -196,7 +196,7 @@ class RoleCategoriesACL(RoleAdmin, generic.ModelFormView):
             if new_permissions:
                 RoleCategoryACL.objects.bulk_create(new_permissions)
 
-            acl_version.invalidate()
+            clear_acl_cache()
 
             message = _("Category permissions for role %(name)s have been changed.")
             messages.success(request, message % {'name': target.name})

+ 14 - 12
misago/core/tests/test_errorpages.py

@@ -4,9 +4,10 @@ from django.test import Client, TestCase, override_settings
 from django.test.client import RequestFactory
 from django.urls import reverse
 
+from misago.acl.useracl import get_user_acl
+from misago.users.models import AnonymousUser
 from misago.core.testproject.views import mock_custom_403_error_page, mock_custom_404_error_page
 from misago.core.utils import encode_json_html
-from misago.users.models import AnonymousUser
 
 
 class CSRFErrorViewTests(TestCase):
@@ -73,20 +74,21 @@ class ErrorPageViewsTests(TestCase):
         self.assertContains(response, "Banned in auth!", status_code=403)
 
 
+def test_request(url):
+    request = RequestFactory().get(url)
+    request.cache_versions = {"acl": "abcdefgh"}
+    request.user = AnonymousUser()
+    request.user_acl = get_user_acl(request.user, request.cache_versions)
+    request.include_frontend_context = True
+    request.frontend_context = {}
+    return request
+
+
 @override_settings(ROOT_URLCONF='misago.core.testproject.urlswitherrorhandlers')
 class CustomErrorPagesTests(TestCase):
     def setUp(self):
-        self.misago_request = RequestFactory().get(reverse('misago:index'))
-        self.site_request = RequestFactory().get(reverse('raise-403'))
-
-        self.misago_request.user = AnonymousUser()
-        self.site_request.user = AnonymousUser()
-
-        self.misago_request.include_frontend_context = True
-        self.site_request.include_frontend_context = True
-
-        self.misago_request.frontend_context = {}
-        self.site_request.frontend_context = {}
+        self.misago_request = test_request(reverse('misago:index'))
+        self.site_request = test_request(reverse('raise-403'))
 
     def test_shared_403_decorator(self):
         """shared_403_decorator calls correct error handler"""

+ 17 - 13
misago/core/tests/test_exceptionhandler_middleware.py

@@ -3,27 +3,31 @@ from django.test import TestCase
 from django.test.client import RequestFactory
 from django.urls import reverse
 
-from misago.core.middleware import ExceptionHandlerMiddleware
+from misago.acl.useracl import get_user_acl
 from misago.users.models import AnonymousUser
 
+from misago.core.middleware import ExceptionHandlerMiddleware
 
-class ExceptionHandlerMiddlewareTests(TestCase):
-    def setUp(self):
-        self.request = RequestFactory().get(reverse('misago:index'))
-        self.request.user = AnonymousUser()
-        self.request.include_frontend_context = True
-        self.request.frontend_context = {}
 
+def test_request():
+    request = RequestFactory().get(reverse('misago:index'))
+    request.cache_versions = {"acl": "abcdefgh"}
+    request.user = AnonymousUser()
+    request.user_acl = get_user_acl(request.user, request.cache_versions)
+    request.include_frontend_context = True
+    request.frontend_context = {}
+    return request
+
+
+class ExceptionHandlerMiddlewareTests(TestCase):
     def test_middleware_returns_response_for_supported_exception(self):
         """Middleware returns HttpResponse for supported exception"""
-        exception = Http404()
         middleware = ExceptionHandlerMiddleware()
-
-        self.assertTrue(middleware.process_exception(self.request, exception))
+        exception = Http404()
+        assert middleware.process_exception(test_request(), exception)
 
     def test_middleware_returns_none_for_non_supported_exception(self):
         """Middleware returns None for non-supported exception"""
-        exception = TypeError()
         middleware = ExceptionHandlerMiddleware()
-
-        self.assertFalse(middleware.process_exception(self.request, exception))
+        exception = TypeError()
+        assert middleware.process_exception(test_request(), exception) is None

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

@@ -5,7 +5,7 @@ from faker import Factory
 
 from django.core.management.base import BaseCommand
 
-from misago.acl import version as acl_version
+from misago.acl.cache import clear as clear_acl_cache
 from misago.categories.models import Category, RoleCategoryACL
 from misago.core.management.progressbar import show_progress
 
@@ -85,7 +85,7 @@ class Command(BaseCommand):
             created_count += 1
             show_progress(self, created_count, items_to_create, start_time)
 
-        acl_version.invalidate()
+        clear_acl_cache()
 
         total_time = time.time() - start_time
         total_humanized = time.strftime('%H:%M:%S', time.gmtime(total_time))

+ 4 - 4
misago/markup/flavours.py

@@ -43,15 +43,15 @@ def limited(request, text):
     return result['parsed_text']
 
 
-def signature(request, owner, text):
+def signature(request, owner, user_acl, text):
     result = parse(
         text,
         request,
         owner,
         allow_mentions=False,
-        allow_blocks=owner.acl_cache['allow_signature_blocks'],
-        allow_links=owner.acl_cache['allow_signature_links'],
-        allow_images=owner.acl_cache['allow_signature_images'],
+        allow_blocks=user_acl['allow_signature_blocks'],
+        allow_links=user_acl['allow_signature_links'],
+        allow_images=user_acl['allow_signature_images'],
     )
 
     return result['parsed_text']

+ 2 - 2
misago/readtracker/tests/test_threadstracker.py

@@ -4,7 +4,7 @@ from django.contrib.auth import get_user_model
 from django.test import TestCase
 from django.utils import timezone
 
-from misago.acl import add_acl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.acl.useracl import get_user_acl
 from misago.categories.models import Category
 from misago.conf import settings
@@ -28,7 +28,7 @@ class ThreadsTrackerTests(TestCase):
         self.user_acl = get_user_acl(self.user, cache_versions)
         self.category = Category.objects.get(slug='first-category')
 
-        add_acl(self.user_acl, self.category)
+        add_acl_to_obj(self.user_acl, self.category)
 
     def test_falsy_value(self):
         """passing falsy value to readtracker causes no errors"""

+ 1 - 1
misago/templates/misago/navbar.html

@@ -93,7 +93,7 @@
         <i class="material-icon">group</i>
       </a>
     </li>
-    {% if user.acl_cache.can_search %}
+    {% if user_acl.can_search %}
       <li>
         <a href="{% url 'misago:search' %}">
           <i class="material-icon">search</i>

+ 1 - 1
misago/templates/misago/threadslist/tabs.html

@@ -27,7 +27,7 @@
           {% trans "Subscribed" %}
         </a>
       </li>
-      {% if user.acl_cache.can_see_unapproved_content_lists and not hide_unapproved %}
+      {% if user_acl.can_see_unapproved_content_lists and not hide_unapproved %}
         <li{% if list_type == 'unapproved' %} class="active"{% endif %}>
           <a href="{{ category.get_absolute_url }}unapproved/">
             {% trans "Unapproved" %}

+ 2 - 2
misago/threads/api/attachments.py

@@ -5,7 +5,7 @@ from django.core.exceptions import PermissionDenied, ValidationError
 from django.template.defaultfilters import filesizeformat
 from django.utils.translation import gettext as _
 
-from misago.acl import add_acl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.threads.models import Attachment, AttachmentType
 from misago.threads.serializers import AttachmentSerializer
 from misago.users.audittrail import create_audit_trail
@@ -52,7 +52,7 @@ class AttachmentViewSet(viewsets.ViewSet):
             attachment.set_file(upload)
 
         attachment.save()
-        add_acl(request.user_acl, attachment)
+        add_acl_to_obj(request.user_acl, attachment)
 
         create_audit_trail(request, attachment)
 

+ 2 - 2
misago/threads/api/pollvotecreateendpoint.py

@@ -2,7 +2,7 @@ from copy import deepcopy
 
 from rest_framework.response import Response
 
-from misago.acl import add_acl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.threads.permissions import allow_vote_poll
 from misago.threads.serializers import PollSerializer, NewVoteSerializer
 
@@ -33,7 +33,7 @@ def poll_vote_create(request, thread, poll):
     remove_user_votes(request.user, poll, serializer.data['choices'])
     set_new_votes(request, poll, serializer.data['choices'])
 
-    add_acl(request.user_acl, poll)
+    add_acl_to_obj(request.user_acl, poll)
     serialized_poll = PollSerializer(poll).data
 
     poll.choices = list(map(presave_clean_choice, deepcopy(poll.choices)))

+ 2 - 2
misago/threads/api/postendpoints/edits.py

@@ -6,7 +6,7 @@ from django.shortcuts import get_object_or_404
 from django.utils import timezone
 from django.utils.translation import gettext as _
 
-from misago.acl import add_acl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.core.shortcuts import get_int_or_404
 from misago.markup import common_flavour
 from misago.threads.checksums import update_post_checksum
@@ -71,7 +71,7 @@ def revert_post_endpoint(request, post):
     post.is_new = False
     post.edits = post_edits + 1
 
-    add_acl(request.user_acl, post)
+    add_acl_to_obj(request.user_acl, post)
 
     if post.poster:
         make_users_status_aware(request, [post.poster])

+ 2 - 2
misago/threads/api/postendpoints/merge.py

@@ -3,7 +3,7 @@ from rest_framework.response import Response
 from django.core.exceptions import PermissionDenied
 from django.utils.translation import gettext as _
 
-from misago.acl import add_acl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.threads.serializers import MergePostsSerializer, PostSerializer
 
 
@@ -55,6 +55,6 @@ def posts_merge_endpoint(request, thread):
     first_post.thread = thread
     first_post.category = thread.category
 
-    add_acl(request.user_acl, first_post)
+    add_acl_to_obj(request.user_acl, first_post)
 
     return Response(PostSerializer(first_post, context={'user': request.user}).data)

+ 2 - 2
misago/threads/api/postendpoints/patch_event.py

@@ -1,7 +1,7 @@
 from django.core.exceptions import PermissionDenied
 from django.utils.translation import gettext as _
 
-from misago.acl import add_acl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.core.apipatch import ApiPatch
 from misago.threads.moderation import posts as moderation
 from misago.threads.permissions import allow_hide_event, allow_unhide_event
@@ -13,7 +13,7 @@ event_patch_dispatcher = ApiPatch()
 def patch_acl(request, event, value):
     """useful little op that updates event acl to current state"""
     if value:
-        add_acl(request.user_acl, event)
+        add_acl_to_obj(request.user_acl, event)
         return {'acl': event.acl}
     else:
         return {'acl': None}

+ 2 - 2
misago/threads/api/postendpoints/patch_post.py

@@ -4,7 +4,7 @@ from rest_framework.response import Response
 from django.core.exceptions import PermissionDenied
 from django.utils.translation import gettext as _
 
-from misago.acl import add_acl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.conf import settings
 from misago.core.apipatch import ApiPatch
 from misago.threads.models import PostLike
@@ -23,7 +23,7 @@ post_patch_dispatcher = ApiPatch()
 def patch_acl(request, post, value):
     """useful little op that updates post acl to current state"""
     if value:
-        add_acl(request.user_acl, post)
+        add_acl_to_obj(request.user_acl, post)
         return {'acl': post.acl}
     else:
         return {'acl': None}

+ 2 - 2
misago/threads/api/postingendpoint/attachments.py

@@ -3,7 +3,7 @@ from rest_framework import serializers
 from django.utils.translation import gettext as _
 from django.utils.translation import ngettext
 
-from misago.acl import add_acl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.conf import settings
 from misago.threads.serializers import AttachmentSerializer
 
@@ -75,7 +75,7 @@ class AttachmentsSerializer(serializers.Serializer):
         if mode == PostingEndpoint.EDIT:
             queryset = post.attachment_set.select_related('filetype')
             attachments = list(queryset)
-            add_acl(user_acl, attachments)
+            add_acl_to_obj(user_acl, attachments)
         return attachments
 
     def get_new_attachments(self, user, ids):

+ 2 - 2
misago/threads/api/postingendpoint/category.py

@@ -4,7 +4,7 @@ from django.core.exceptions import PermissionDenied
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext_lazy
 
-from misago.acl import add_acl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.categories import THREADS_ROOT_NAME
 from misago.categories.models import Category
 from misago.categories.permissions import can_browse_category, can_see_category
@@ -28,7 +28,7 @@ class CategoryMiddleware(PostingMiddleware):
     def pre_save(self, serializer):
         category = serializer.category_cache
 
-        add_acl(self.user_acl, category)
+        add_acl_to_obj(self.user_acl, category)
 
         # set flags for savechanges middleware
         category.update_all = False

+ 2 - 2
misago/threads/api/postingendpoint/privatethread.py

@@ -1,4 +1,4 @@
-from misago.acl import add_acl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.categories import PRIVATE_THREADS_ROOT_NAME
 from misago.categories.models import Category
 
@@ -16,7 +16,7 @@ class PrivateThreadMiddleware(PostingMiddleware):
     def pre_save(self, serializer):
         category = Category.objects.private_threads()
 
-        add_acl(self.user_acl, category)
+        add_acl_to_obj(self.user_acl, category)
 
         # set flags for savechanges middleware
         category.update_all = False

+ 2 - 2
misago/threads/api/threadendpoints/editor.py

@@ -3,7 +3,7 @@ from rest_framework.response import Response
 from django.core.exceptions import PermissionDenied
 from django.utils.translation import gettext as _
 
-from misago.acl import add_acl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.categories import THREADS_ROOT_NAME
 from misago.categories.models import Category
 from misago.threads.permissions import can_start_thread
@@ -24,7 +24,7 @@ def thread_start_editor(request):
     ).order_by('-lft')
 
     for category in queryset:
-        add_acl(request.user_acl, category)
+        add_acl_to_obj(request.user_acl, category)
 
         post = False
         if can_start_thread(request.user_acl, category):

+ 2 - 2
misago/threads/api/threadendpoints/merge.py

@@ -4,7 +4,7 @@ from rest_framework.response import Response
 from django.core.exceptions import PermissionDenied
 from django.utils.translation import gettext as _
 
-from misago.acl import add_acl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.threads.events import record_event
 from misago.threads.mergeconflict import MergeConflict
 from misago.threads.models import Thread
@@ -189,5 +189,5 @@ def merge_threads(request, validated_data, threads, merge_conflict):
     new_thread.is_read = False
     new_thread.subscription = None
 
-    add_acl(request.user_acl, new_thread)
+    add_acl_to_obj(request.user_acl, new_thread)
     return new_thread

+ 4 - 3
misago/threads/api/threadendpoints/patch.py

@@ -7,7 +7,8 @@ from django.http import Http404
 from django.shortcuts import get_object_or_404
 from django.utils.translation import gettext as _
 
-from misago.acl import add_acl, useracl
+from misago.acl import useracl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.categories.models import Category
 from misago.categories.permissions import allow_browse_category, allow_see_category
 from misago.categories.serializers import CategorySerializer
@@ -37,7 +38,7 @@ thread_patch_dispatcher = ApiPatch()
 def patch_acl(request, thread, value):
     """useful little op that updates thread acl to current state"""
     if value:
-        add_acl(request.user_acl, thread)
+        add_acl_to_obj(request.user_acl, thread)
         return {'acl': thread.acl}
     else:
         return {'acl': None}
@@ -96,7 +97,7 @@ def patch_move(request, thread, value):
         Category.objects.all_categories().select_related('parent'), pk=category_pk
     )
 
-    add_acl(request.user_acl, new_category)
+    add_acl_to_obj(request.user_acl, new_category)
     allow_see_category(request.user_acl, new_category)
     allow_browse_category(request.user_acl, new_category)
     allow_start_thread(request.user_acl, new_category)

+ 3 - 3
misago/threads/api/threadpoll.py

@@ -7,7 +7,7 @@ from django.db import transaction
 from django.http import Http404
 from django.utils.translation import gettext as _
 
-from misago.acl import add_acl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.core.shortcuts import get_int_or_404
 from misago.threads.models import Poll
 from misago.threads.permissions import (
@@ -68,7 +68,7 @@ class ViewSet(viewsets.ViewSet):
 
         serializer.save()
 
-        add_acl(request.user_acl, instance)
+        add_acl_to_obj(request.user_acl, instance)
         for choice in instance.choices:
             choice['selected'] = False
 
@@ -91,7 +91,7 @@ class ViewSet(viewsets.ViewSet):
 
         serializer.save()
 
-        add_acl(request.user_acl, instance)
+        add_acl_to_obj(request.user_acl, instance)
         instance.make_choices_votes_aware(request.user)
 
         create_audit_trail(request, instance)

+ 2 - 2
misago/threads/api/threadposts.py

@@ -6,7 +6,7 @@ from django.core.exceptions import PermissionDenied
 from django.db import transaction
 from django.utils.translation import gettext as _
 
-from misago.acl import add_acl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.core.shortcuts import get_int_or_404
 from misago.threads.models import Post
 from misago.threads.permissions import allow_edit_post, allow_reply_thread
@@ -192,7 +192,7 @@ class ViewSet(viewsets.ViewSet):
 
         attachments = []
         for attachment in post.attachment_set.order_by('-id'):
-            add_acl(request.user_acl, attachment)
+            add_acl_to_obj(request.user_acl, attachment)
             attachments.append(attachment)
         attachments_json = AttachmentSerializer(
             attachments, many=True, context={'user': request.user}

+ 5 - 4
misago/threads/permissions/threads.py

@@ -5,9 +5,10 @@ from django.http import Http404
 from django.utils import timezone
 from django.utils.translation import gettext_lazy as _, ngettext
 
-from misago.acl import add_acl, algebra
+from misago.acl import algebra
 from misago.acl.decorators import return_boolean
 from misago.acl.models import Role
+from misago.acl.objectacl import add_acl_to_obj
 from misago.admin.forms import YesNoSwitch
 from misago.categories.models import Category, CategoryRole
 from misago.categories.permissions import get_categories_roles
@@ -1251,7 +1252,7 @@ def exclude_invisible_threads(user_acl, categories, queryset):
     show_owned_visible = []
 
     for category in categories:
-        add_acl(user_acl, category)
+        add_acl_to_obj(user_acl, category)
 
         if not (category.acl['can_see'] and category.acl['can_browse']):
             continue
@@ -1360,7 +1361,7 @@ def exclude_invisible_posts_in_categories(user_acl, categories, queryset):
     hide_invisible_events = []
 
     for category in categories:
-        add_acl(user_acl, category)
+        add_acl_to_obj(user_acl, category)
 
         if category.acl['can_approve_content']:
             show_all.append(category.pk)
@@ -1413,7 +1414,7 @@ def exclude_invisible_posts_in_categories(user_acl, categories, queryset):
 
 
 def exclude_invisible_posts_in_category(user_acl, category, queryset):
-    add_acl(user_acl, category)
+    add_acl_to_obj(user_acl, category)
 
     if not category.acl['can_approve_content']:
         if user_acl["is_authenticated"]:

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

@@ -4,7 +4,7 @@ from django.core.exceptions import PermissionDenied, ValidationError
 from django.http import Http404
 from django.utils.translation import gettext as _, gettext_lazy, ngettext
 
-from misago.acl import add_acl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.categories import THREADS_ROOT_NAME
 from misago.conf import settings
 from misago.threads.mergeconflict import MergeConflict
@@ -267,7 +267,7 @@ class NewThreadSerializer(serializers.Serializer):
 
     def validate_weight(self, weight):
         try:
-            add_acl(self.context['user_acl'], self.category)
+            add_acl_to_obj(self.context['user_acl'], self.category)
         except AttributeError:
             return weight  # don't validate weight further if category failed
 
@@ -284,7 +284,7 @@ class NewThreadSerializer(serializers.Serializer):
 
     def validate_is_hidden(self, is_hidden):
         try:
-            add_acl(self.context['user_acl'], self.category)
+            add_acl_to_obj(self.context['user_acl'], self.category)
         except AttributeError:
             return is_hidden  # don't validate hidden further if category failed
 
@@ -294,7 +294,7 @@ class NewThreadSerializer(serializers.Serializer):
 
     def validate_is_closed(self, is_closed):
         try:
-            add_acl(self.context['user_acl'], self.category)
+            add_acl_to_obj(self.context['user_acl'], self.category)
         except AttributeError:
             return is_closed  # don't validate closed further if category failed
 
@@ -523,7 +523,7 @@ class MergeThreadsSerializer(NewThreadSerializer):
 
         threads = []
         for thread in threads_queryset:
-            add_acl(user_acl, thread)
+            add_acl_to_obj(user_acl, thread)
             if can_see_thread(user_acl, thread):
                 threads.append(thread)
 

+ 4 - 3
misago/threads/tests/test_events.py

@@ -4,7 +4,8 @@ from django.contrib.auth import get_user_model
 from django.test import TestCase
 from django.utils import timezone
 
-from misago.acl import add_acl, useracl
+from misago.acl import useracl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.categories.models import Category
 from misago.threads.events import record_event
 from misago.threads.models import Thread
@@ -34,8 +35,8 @@ class EventsApiTests(TestCase):
         self.thread.save()
 
         user_acl = useracl.get_user_acl(self.user, cache_versions)
-        add_acl(user_acl, self.category)
-        add_acl(user_acl, self.thread)
+        add_acl_to_obj(user_acl, self.category)
+        add_acl_to_obj(user_acl, self.thread)
 
     def test_record_event_with_context(self):
         """record_event registers event with context in thread"""

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

@@ -145,11 +145,12 @@ class PrivateThreadAddParticipantApiTests(PrivateThreadPatchApiTestCase):
             'detail': ["BobBoberson can't participate in private threads."],
         })
 
+    @patch_user_acl({"max_private_thread_participants": 3})
     def test_add_too_many_users(self):
         """can't add user that is already participant"""
         ThreadParticipant.objects.set_owner(self.thread, self.user)
 
-        for i in range(self.user.acl_cache['max_private_thread_participants']):
+        for i in range(3):
             user = UserModel.objects.create_user(
                 'User%s' % i, 'user%s@example.com' % i, 'Pass.123'
             )

+ 3 - 2
misago/threads/tests/test_threads_editor_api.py

@@ -2,7 +2,8 @@ import os
 
 from django.urls import reverse
 
-from misago.acl import add_acl, useracl
+from misago.acl import useracl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.categories.models import Category
 from misago.threads import testutils
 from misago.threads.models import Attachment
@@ -557,7 +558,7 @@ class EditReplyEditorApiTests(EditorApiTestCase):
         response = self.client.get(self.api_link)
         user_acl = useracl.get_user_acl(self.user, cache_versions)
         for attachment in attachments:
-            add_acl(user_acl, attachment)
+            add_acl_to_obj(user_acl, attachment)
 
         self.assertEqual(response.status_code, 200)
         self.assertEqual(

+ 6 - 5
misago/threads/tests/test_threads_merge_api.py

@@ -2,7 +2,8 @@ import json
 
 from django.urls import reverse
 
-from misago.acl import add_acl, useracl
+from misago.acl import useracl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.categories.models import Category
 from misago.readtracker import poststracker
 from misago.threads import testutils
@@ -552,8 +553,8 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         new_thread.subscription = None
 
         user_acl = useracl.get_user_acl(self.user, cache_versions)
-        add_acl(user_acl, new_thread.category)
-        add_acl(user_acl, new_thread)
+        add_acl_to_obj(user_acl, new_thread.category)
+        add_acl_to_obj(user_acl, new_thread)
 
         self.assertEqual(response_json, ThreadsListSerializer(new_thread).data)
 
@@ -617,8 +618,8 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         self.assertTrue(new_thread.is_hidden)
 
         user_acl = useracl.get_user_acl(self.user, cache_versions)
-        add_acl(user_acl, new_thread.category)
-        add_acl(user_acl, new_thread)
+        add_acl_to_obj(user_acl, new_thread.category)
+        add_acl_to_obj(user_acl, new_thread)
 
         self.assertEqual(response_json, ThreadsListSerializer(new_thread).data)
 

+ 2 - 2
misago/threads/viewmodels/category.py

@@ -1,6 +1,6 @@
 from django.http import Http404
 
-from misago.acl import add_acl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.categories.models import Category
 from misago.categories.permissions import allow_browse_category, allow_see_category
 from misago.categories.serializers import CategorySerializer
@@ -15,7 +15,7 @@ __all__ = ['ThreadsRootCategory', 'ThreadsCategory', 'PrivateThreadsCategory']
 class ViewModel(BaseViewModel):
     def __init__(self, request, **kwargs):
         self._categories = self.get_categories(request)
-        add_acl(request.user_acl, self._categories)
+        add_acl_to_obj(request.user_acl, self._categories)
 
         self._model = self.get_category(request, self._categories, **kwargs)
 

+ 2 - 2
misago/threads/viewmodels/post.py

@@ -1,6 +1,6 @@
 from django.shortcuts import get_object_or_404
 
-from misago.acl import add_acl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.core.viewmodel import ViewModel as BaseViewModel
 from misago.threads.permissions import exclude_invisible_posts
 
@@ -12,7 +12,7 @@ class ViewModel(BaseViewModel):
     def __init__(self, request, thread, pk):
         model = self.get_post(request, thread, pk)
 
-        add_acl(request.user_acl, model)
+        add_acl_to_obj(request.user_acl, model)
 
         self._model = model
 

+ 2 - 2
misago/threads/viewmodels/posts.py

@@ -1,4 +1,4 @@
-from misago.acl import add_acl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.conf import settings
 from misago.core.shortcuts import paginate, pagination_dict
 from misago.readtracker.poststracker import make_read_aware
@@ -61,7 +61,7 @@ class ViewModel(object):
             posts.sort(key=lambda p: p.pk)
 
         # make posts and events ACL and reads aware
-        add_acl(request.user_acl, posts)
+        add_acl_to_obj(request.user_acl, posts)
         make_read_aware(request.user, posts)
 
         self._user = request.user

+ 4 - 4
misago/threads/viewmodels/thread.py

@@ -1,7 +1,7 @@
 from django.shortcuts import get_object_or_404
 from django.utils.translation import gettext as _
 
-from misago.acl import add_acl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.categories import PRIVATE_THREADS_ROOT_NAME, THREADS_ROOT_NAME
 from misago.categories.models import Category
 from misago.core.shortcuts import validate_slug
@@ -44,8 +44,8 @@ class ViewModel(BaseViewModel):
         if path_aware:
             model.path = self.get_thread_path(model.category)
 
-        add_acl(request.user_acl, model.category)
-        add_acl(request.user_acl, model)
+        add_acl_to_obj(request.user_acl, model.category)
+        add_acl_to_obj(request.user_acl, model)
 
         if read_aware:
             make_read_aware(request.user, request.user_acl, model)
@@ -56,7 +56,7 @@ class ViewModel(BaseViewModel):
 
         try:
             self._poll = model.poll
-            add_acl(request.user_acl, self._poll)
+            add_acl_to_obj(request.user_acl, self._poll)
 
             if poll_votes_aware:
                 self._poll.make_choices_votes_aware(request.user)

+ 2 - 2
misago/threads/viewmodels/threads.py

@@ -7,7 +7,7 @@ from django.utils import timezone
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext_lazy
 
-from misago.acl import add_acl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.conf import settings
 from misago.core.shortcuts import paginate, pagination_dict
 from misago.readtracker import threadstracker
@@ -68,7 +68,7 @@ class ViewModel(object):
             threads = list(pinned_threads) + list(list_page.object_list)
 
         add_categories_to_items(category_model, category.categories, threads)
-        add_acl(request.user_acl, threads)
+        add_acl_to_obj(request.user_acl, threads)
         make_subscription_aware(request.user, threads)
 
         if list_type in ('new', 'unread'):

+ 4 - 3
misago/users/api/auth.py

@@ -56,11 +56,12 @@ def login(request):
 def session_user(request):
     """GET /auth/ will return current auth user, either User or AnonymousUser"""
     if request.user.is_authenticated:
-        UserSerializer = AuthenticatedUserSerializer
+        serializer = AuthenticatedUserSerializer
     else:
-        UserSerializer = AnonymousUserSerializer
+        serializer = AnonymousUserSerializer
 
-    return Response(UserSerializer(request.user).data)
+    serialized_user = serializer(request.user, context={"acl": request.user_acl}).data
+    return Response(serialized_user)
 
 
 @api_view(['GET'])

+ 2 - 1
misago/users/api/userendpoints/signature.py

@@ -55,7 +55,8 @@ def get_signature_options(user):
 def edit_signature(request, user):
     serializer = EditSignatureSerializer(user, data=request.data)
     if serializer.is_valid():
-        set_user_signature(request, user, serializer.validated_data['signature'])
+        signature = serializer.validated_data['signature']
+        set_user_signature(request, user, request.user_acl, signature)
         user.save(update_fields=['signature', 'signature_parsed', 'signature_checksum'])
         return get_signature_options(user)
     else:

+ 47 - 34
misago/users/api/userendpoints/username.py

@@ -5,7 +5,7 @@ from django.db import IntegrityError
 from django.utils.translation import gettext as _
 
 from misago.conf import settings
-from misago.users.namechanges import UsernameChanges
+from misago.users.namechanges import get_username_options
 from misago.users.serializers import ChangeUsernameSerializer
 
 
@@ -13,17 +13,14 @@ def username_endpoint(request):
     if request.method == 'POST':
         return change_username(request)
     else:
-        return options_response(get_username_options(request.user))
+        options = get_username_options_from_request(request)
+        return options_response(options)
 
 
-def get_username_options(user):
-    options = UsernameChanges(user)
-    return {
-        'changes_left': options.left,
-        'next_on': options.next_on,
-        'length_min': settings.username_length_min,
-        'length_max': settings.username_length_max,
-    }
+def get_username_options_from_request(request):
+    return get_username_options(
+        settings, request.user, request.user_acl
+    )
 
 
 def options_response(options):
@@ -33,34 +30,46 @@ def options_response(options):
 
 
 def change_username(request):
-    options = get_username_options(request.user)
+    options = get_username_options_from_request(request)
     if not options['changes_left']:
-        return Response({
-            'detail': _("You can't change your username now."),
-            'options': options
-        },
-                        status=status.HTTP_400_BAD_REQUEST)
+        return Response(
+            {
+                'detail': _("You can't change your username now."),
+                'options': options
+            },
+            status=status.HTTP_400_BAD_REQUEST
+        )
 
-    serializer = ChangeUsernameSerializer(data=request.data, context={'user': request.user})
+    serializer = ChangeUsernameSerializer(
+        data=request.data, context={'user': request.user}
+    )
 
     if serializer.is_valid():
         try:
             serializer.change_username(changed_by=request.user)
+            updated_options = get_username_options_from_request(request)
+            if updated_options['next_on']:
+                updated_options['next_on'] = updated_options['next_on'].isoformat()
+
             return Response({
                 'username': request.user.username,
                 'slug': request.user.slug,
-                'options': get_username_options(request.user)
+                'options': updated_options,
             })
         except IntegrityError:
-            return Response({
-                'detail': _("Error changing username. Please try again."),
-            },
-                            status=status.HTTP_400_BAD_REQUEST)
+            return Response(
+                {
+                    'detail': _("Error changing username. Please try again."),
+                },
+                status=status.HTTP_400_BAD_REQUEST
+            )
     else:
-        return Response({
-            'detail': serializer.errors['non_field_errors'][0]
-        },
-                        status=status.HTTP_400_BAD_REQUEST)
+        return Response(
+            {
+                'detail': serializer.errors['non_field_errors'][0]
+            },
+            status=status.HTTP_400_BAD_REQUEST
+        )
 
 
 def moderate_username_endpoint(request, profile):
@@ -75,15 +84,19 @@ def moderate_username_endpoint(request, profile):
                     'slug': profile.slug,
                 })
             except IntegrityError:
-                return Response({
-                    'detail': _("Error changing username. Please try again."),
-                },
-                                status=status.HTTP_400_BAD_REQUEST)
+                return Response(
+                    {
+                        'detail': _("Error changing username. Please try again."),
+                    },
+                    status=status.HTTP_400_BAD_REQUEST
+                )
         else:
-            return Response({
-                'detail': serializer.errors['non_field_errors'][0]
-            },
-                            status=status.HTTP_400_BAD_REQUEST)
+            return Response(
+                {
+                    'detail': serializer.errors['non_field_errors'][0]
+                },
+                status=status.HTTP_400_BAD_REQUEST
+            )
     else:
         return Response({
             'length_min': settings.username_length_min,

+ 2 - 2
misago/users/api/users.py

@@ -11,7 +11,7 @@ from django.http import Http404
 from django.shortcuts import get_object_or_404
 from django.utils.translation import gettext as _
 
-from misago.acl import add_acl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.categories.models import Category
 from misago.conf import settings
 from misago.core.rest_permissions import IsAuthenticatedOrReadOnly
@@ -84,7 +84,7 @@ class UserViewSet(viewsets.GenericViewSet):
     def retrieve(self, request, pk=None):
         profile = self.get_user(request, pk)
 
-        add_acl(request.user_acl, profile)
+        add_acl_to_obj(request.user_acl, profile)
         profile.status = get_user_status(request, profile)
 
         serializer = UserProfileSerializer(profile, context={'request': request})

+ 5 - 2
misago/users/context_processors.py

@@ -36,8 +36,11 @@ def preload_user_json(request):
     })
 
     if request.user.is_authenticated:
-        request.frontend_context.update({'user': AuthenticatedUserSerializer(request.user).data})
+        serializer = AuthenticatedUserSerializer
     else:
-        request.frontend_context.update({'user': AnonymousUserSerializer(request.user).data})
+        serializer = AnonymousUserSerializer
+
+    serialized_user = serializer(request.user, context={"acl": request.user_acl}).data
+    request.frontend_context.update({'user': serialized_user})
 
     return {}

+ 3 - 3
misago/users/models/rank.py

@@ -1,7 +1,7 @@
 from django.db import models, transaction
 from django.urls import reverse
 
-from misago.acl import version as acl_version
+from misago.acl.cache import clear as clear_acl_cache
 from misago.core.utils import slugify
 
 
@@ -39,11 +39,11 @@ class Rank(models.Model):
         if not self.pk:
             self.set_order()
         else:
-            acl_version.invalidate()
+            clear_acl_cache()
         return super().save(*args, **kwargs)
 
     def delete(self, *args, **kwargs):
-        acl_version.invalidate()
+        clear_acl_cache()
         return super().delete(*args, **kwargs)
 
     def get_absolute_url(self):

+ 7 - 24
misago/users/models/user.py

@@ -11,7 +11,6 @@ from django.urls import reverse
 from django.utils import timezone
 from django.utils.translation import gettext_lazy as _
 
-from misago.acl import get_user_acl
 from misago.acl.models import Role
 from misago.conf import settings
 from misago.core.pgutils import PgPartialIndex
@@ -329,22 +328,6 @@ class User(AbstractBaseUser, PermissionsMixin):
         anonymize_user_data.send(sender=self)
 
     @property
-    def acl_cache(self):
-        try:
-            return self._acl_cache
-        except AttributeError:
-            self._acl_cache = get_user_acl(self)
-            return self._acl_cache
-
-    @acl_cache.setter
-    def acl_cache(self, value):
-        raise TypeError("acl_cache can't be assigned")
-
-    @property
-    def acl_(self):
-        raise NotImplementedError('user.acl_ property was renamed to user.acl')
-
-    @property
     def requires_activation_by_admin(self):
         return self.requires_activation == self.ACTIVATION_ADMIN
 
@@ -401,13 +384,17 @@ class User(AbstractBaseUser, PermissionsMixin):
 
             if self.pk:
                 changed_by = changed_by or self
-                self.record_name_change(changed_by, new_username, old_username)
+                namechange = self.record_name_change(
+                    changed_by, new_username, old_username
+                )
 
                 from misago.users.signals import username_changed
                 username_changed.send(sender=self)
 
+                return namechange
+
     def record_name_change(self, changed_by, new_username, old_username):
-        self.namechanges.create(
+        return self.namechanges.create(
             new_username=new_username,
             old_username=old_username,
             changed_by=changed_by,
@@ -525,11 +512,7 @@ class AnonymousUser(DjangoAnonymousUser):
 
     @property
     def acl_cache(self):
-        try:
-            return self._acl_cache
-        except AttributeError:
-            self._acl_cache = get_user_acl(self)
-            return self._acl_cache
+        raise Exception("AnonymousUser.acl_cache has been removed")
 
     @acl_cache.setter
     def acl_cache(self, value):

+ 41 - 29
misago/users/namechanges.py

@@ -8,32 +8,44 @@ from django.utils import timezone
 from .models import UsernameChange
 
 
-class UsernameChanges(object):
-    def __init__(self, user):
-        self.left = 0
-        self.next_on = None
-
-        if user.acl_cache['name_changes_allowed']:
-            self.count_namechanges(user)
-
-    def count_namechanges(self, user):
-        name_changes_allowed = user.acl_cache['name_changes_allowed']
-        name_changes_expire = user.acl_cache['name_changes_expire']
-
-        valid_changes_qs = user.namechanges.filter(changed_by=user)
-        if name_changes_expire:
-            cutoff = timezone.now() - timedelta(days=name_changes_expire)
-            valid_changes_qs = valid_changes_qs.filter(changed_on__gte=cutoff)
-
-        used_changes = valid_changes_qs.count()
-        if name_changes_allowed <= used_changes:
-            self.left = 0
-        else:
-            self.left = name_changes_allowed - used_changes
-
-        if not self.left and name_changes_expire:
-            try:
-                self.next_on = valid_changes_qs.latest().changed_on
-                self.next_on += timedelta(days=name_changes_expire)
-            except UsernameChange.DoesNotExist:
-                pass
+def get_username_options(settings, user, user_acl):
+    changes_left = get_left_namechanges(user, user_acl)
+    next_on = get_next_available_namechange(user, user_acl, changes_left)
+
+    return {
+        'changes_left': changes_left,
+        'next_on': next_on,
+        'length_min': settings.username_length_min,
+        'length_max': settings.username_length_max,
+    }
+
+
+def get_left_namechanges(user, user_acl):
+    name_changes_allowed = user_acl['name_changes_allowed']
+    if not name_changes_allowed:
+        return 0
+
+    valid_changes = get_valid_changes_queryset(user, user_acl)
+    used_changes = valid_changes.count()
+    if name_changes_allowed <= used_changes:
+        left = 0
+    return name_changes_allowed - used_changes
+
+
+def get_next_available_namechange(user, user_acl, changes_left):
+    name_changes_expire = user_acl['name_changes_expire']
+    if changes_left or not name_changes_expire:
+        return None
+    
+    valid_changes = get_valid_changes_queryset(user, user_acl)
+    name_last_changed_on = valid_changes.latest().changed_on
+    return name_last_changed_on + timedelta(days=name_changes_expire)
+
+
+def get_valid_changes_queryset(user, user_acl):
+    name_changes_expire = user_acl['name_changes_expire']
+    queryset = user.namechanges.filter(changed_by=user)
+    if user_acl['name_changes_expire']:
+        cutoff = timezone.now() - timedelta(days=name_changes_expire)
+        return queryset.filter(changed_on__gte=cutoff)
+    return queryset

+ 1 - 1
misago/users/permissions/moderation.py

@@ -171,7 +171,7 @@ def allow_lift_ban(user_acl, target):
     if not user_acl['can_lift_bans']:
         raise PermissionDenied(_("You can't lift bans."))
     # FIXME: this will require cache version delegation
-    ban = get_user_ban(target)
+    ban = get_user_ban(target, user_ac["cache_versions"])
     if not ban:
         raise PermissionDenied(_("This user is not banned."))
     if user_acl['max_lifted_ban_length']:

+ 1 - 4
misago/users/permissions/profiles.py

@@ -134,10 +134,7 @@ def allow_block_user(user_acl, target):
         raise PermissionDenied(_("You can't block administrators."))
     if user_acl["user_id"] == target.id:
         raise PermissionDenied(_("You can't block yourself."))
-    # FIXME: this will require changes in ACL checking
-    if not target.acl_cache['can_be_blocked'] or target.is_superuser:
-        message = _("%(user)s can't be blocked.") % {'user': target.username}
-        raise PermissionDenied(message)
+    # FIXME: check if user has "can be blocked" permission
 
 
 can_block_user = return_boolean(allow_block_user)

+ 9 - 7
misago/users/serializers/auth.py

@@ -3,11 +3,10 @@ from rest_framework import serializers
 from django.contrib.auth import get_user_model
 from django.urls import reverse
 
-from misago.acl import serialize_acl
+from misago.acl.useracl import serialize_user_acl
 
 from .user import UserSerializer
 
-
 UserModel = get_user_model()
 
 __all__ = [
@@ -43,7 +42,10 @@ class AuthenticatedUserSerializer(UserSerializer, AuthFlags):
         ]
 
     def get_acl(self, obj):
-        return serialize_acl(obj)
+        acl = self.context.get("acl")
+        if acl:
+            return serialize_user_acl(acl)
+        return {}
 
     def get_email(self, obj):
         return obj.email
@@ -81,7 +83,7 @@ class AnonymousUserSerializer(serializers.Serializer, AuthFlags):
     is_anonymous = serializers.SerializerMethodField()
 
     def get_acl(self, obj):
-        if hasattr(obj, 'acl_cache'):
-            return serialize_acl(obj)
-        else:
-            return {}
+        acl = self.context.get("acl")
+        if acl:
+            return serialize_user_acl(acl)
+        return {}

+ 2 - 2
misago/users/signatures.py

@@ -1,11 +1,11 @@
 from misago.markup import checksums, signature_flavour
 
 
-def set_user_signature(request, user, signature):
+def set_user_signature(request, user, user_acl, signature):
     user.signature = signature
 
     if signature:
-        user.signature_parsed = signature_flavour(request, user, signature)
+        user.signature_parsed = signature_flavour(request, user, user_acl, signature)
         user.signature_checksum = make_signature_checksum(user.signature_parsed, user)
     else:
         user.signature_parsed = ''

+ 8 - 9
misago/users/tests/test_bans.py

@@ -4,16 +4,15 @@ from django.contrib.auth import get_user_model
 from django.test import TestCase
 from django.utils import timezone
 
-from misago.cache.versions import get_cache_versions_from_db
-
 from misago.users.bans import (
     ban_ip, ban_user, get_email_ban, get_ip_ban, get_request_ip_ban, get_user_ban, get_username_ban)
 from misago.users.constants import BANS_CACHE
 from misago.users.models import Ban
 
-
 UserModel = get_user_model()
 
+cache_versions = {"bans": "abcdefgh"}
+
 
 class GetBanTests(TestCase):
     def test_get_username_ban(self):
@@ -134,7 +133,7 @@ class UserBansTests(TestCase):
 
     def test_no_ban(self):
         """user is not caught by ban"""
-        self.assertIsNone(get_user_ban(self.user, get_cache_versions_from_db()))
+        self.assertIsNone(get_user_ban(self.user, cache_versions))
         self.assertFalse(self.user.ban_cache.is_banned)
 
     def test_permanent_ban(self):
@@ -145,7 +144,7 @@ class UserBansTests(TestCase):
             staff_message='Staff reason',
         )
 
-        user_ban = get_user_ban(self.user, get_cache_versions_from_db())
+        user_ban = get_user_ban(self.user, cache_versions)
         self.assertIsNotNone(user_ban)
         self.assertEqual(user_ban.user_message, 'User reason')
         self.assertEqual(user_ban.staff_message, 'Staff reason')
@@ -160,7 +159,7 @@ class UserBansTests(TestCase):
             expires_on=timezone.now() + timedelta(days=7),
         )
 
-        user_ban = get_user_ban(self.user, get_cache_versions_from_db())
+        user_ban = get_user_ban(self.user, cache_versions)
         self.assertIsNotNone(user_ban)
         self.assertEqual(user_ban.user_message, 'User reason')
         self.assertEqual(user_ban.staff_message, 'Staff reason')
@@ -173,7 +172,7 @@ class UserBansTests(TestCase):
             expires_on=timezone.now() - timedelta(days=7),
         )
 
-        self.assertIsNone(get_user_ban(self.user, get_cache_versions_from_db()))
+        self.assertIsNone(get_user_ban(self.user, cache_versions))
         self.assertFalse(self.user.ban_cache.is_banned)
 
     def test_expired_non_flagged_ban(self):
@@ -184,7 +183,7 @@ class UserBansTests(TestCase):
         )
         Ban.objects.update(is_checked=True)
 
-        self.assertIsNone(get_user_ban(self.user, get_cache_versions_from_db()))
+        self.assertIsNone(get_user_ban(self.user, cache_versions))
         self.assertFalse(self.user.ban_cache.is_banned)
 
 
@@ -261,7 +260,7 @@ class BanUserTests(TestCase):
         self.assertEqual(ban.user_message, 'User reason')
         self.assertEqual(ban.staff_message, 'Staff reason')
 
-        db_ban = get_user_ban(user, get_cache_versions_from_db())
+        db_ban = get_user_ban(user, cache_versions)
         self.assertEqual(ban.pk, db_ban.ban_id)
 
 

+ 68 - 16
misago/users/tests/test_namechanges.py

@@ -1,28 +1,80 @@
+from datetime import timedelta
+
 from django.contrib.auth import get_user_model
 from django.test import TestCase
 
-from misago.users.namechanges import UsernameChanges
-
+from misago.users.namechanges import (
+    get_next_available_namechange, get_left_namechanges, get_username_options
+)
 
-UserModel = get_user_model()
+User = get_user_model()
 
 
 class UsernameChangesTests(TestCase):
-    def test_username_changes_helper(self):
-        """username changes are tracked correctly"""
-        test_user = UserModel.objects.create_user('Bob', 'bob@bob.com', 'pass123')
+    def test_user_without_permission_to_change_name_has_no_changes_left(self):
+        user = User.objects.create_user('User', 'test@example.com')
+        user_acl = {"name_changes_allowed": 0}
+        assert get_left_namechanges(user, user_acl) == 0
+
+    def test_user_without_namechanges_has_all_changes_left(self):
+        user = User.objects.create_user('User', 'test@example.com')
+        user_acl = {"name_changes_allowed": 3, "name_changes_expire": 0}
+        assert get_left_namechanges(user, user_acl) == 3
+
+    def test_user_own_namechanges_are_subtracted_from_changes_left(self):
+        user = User.objects.create_user('User', 'test@example.com')
+        user_acl = {"name_changes_allowed": 3, "name_changes_expire": 0}
+        user.set_username('Changed')
+
+        assert get_left_namechanges(user, user_acl) == 2
+
+    def test_user_own_recent_namechanges_subtract_from_changes_left(self):
+        user = User.objects.create_user('User', 'test@example.com')
+        user_acl = {"name_changes_allowed": 3, "name_changes_expire": 5}
+    
+        user.set_username('Changed')
+
+        assert get_left_namechanges(user, user_acl) == 2
+
+    def test_user_own_expired_namechanges_dont_subtract_from_changes_left(self):
+        user = User.objects.create_user('User', 'test@example.com')
+        user_acl = {"name_changes_allowed": 3, "name_changes_expire": 5}
+        
+        username_change = user.set_username('Changed')
+        username_change.changed_on -= timedelta(days=10)
+        username_change.save()
+
+        assert get_left_namechanges(user, user_acl) == 3
+
+    def test_user_namechanges_by_other_users_dont_subtract_from_changes_left(self):
+        user = User.objects.create_user('User', 'test@example.com')
+        user_acl = {"name_changes_allowed": 3, "name_changes_expire": 0}
+
+        username_change = user.set_username('Changed')
+        username_change.changed_by = None
+        username_change.save()
+
+        assert get_left_namechanges(user, user_acl) == 3
+
+    def test_user_next_available_namechange_is_none_for_user_with_changes_left(self):
+        user = User.objects.create_user('User', 'test@example.com')
+        user_acl = {"name_changes_allowed": 3, "name_changes_expire": 0}
 
-        namechanges = UsernameChanges(test_user)
-        self.assertEqual(namechanges.left, 2)
-        self.assertIsNone(namechanges.next_on)
+        assert get_next_available_namechange(user, user_acl, 3) is None
+    
+    def test_user_next_available_namechange_is_none_if_own_namechanges_dont_expire(self):
+        user = User.objects.create_user('User', 'test@example.com')
+        user_acl = {"name_changes_allowed": 1, "name_changes_expire": 0}
+        user.set_username('Changed')
 
-        self.assertEqual(test_user.namechanges.count(), 0)
+        assert get_next_available_namechange(user, user_acl, 0) is None
 
-        test_user.set_username('Boberson')
-        test_user.save(update_fields=['username', 'slug'])
+    def test_user_next_available_namechange_is_calculated_if_own_namechanges_expire(self):
+        user = User.objects.create_user('User', 'test@example.com')
+        user_acl = {"name_changes_allowed": 1, "name_changes_expire": 1}
 
-        namechanges = UsernameChanges(test_user)
-        self.assertEqual(namechanges.left, 1)
-        self.assertIsNone(namechanges.next_on)
+        username_change = user.set_username('Changed')
+        next_change_on = get_next_available_namechange(user, user_acl, 0)
 
-        self.assertEqual(test_user.namechanges.count(), 1)
+        assert next_change_on
+        assert next_change_on == username_change.changed_on + timedelta(days=1)

+ 42 - 16
misago/users/tests/test_signatures.py

@@ -1,10 +1,13 @@
+from unittest.mock import Mock
+
 from django.contrib.auth import get_user_model
 from django.test import TestCase
 
+from misago.acl.useracl import get_user_acl
 from misago.users import signatures
 
-
-UserModel = get_user_model()
+User = get_user_model()
+cache_versions = {"acl": "abcdefg"}
 
 
 class MockRequest(object):
@@ -14,22 +17,45 @@ class MockRequest(object):
         return '127.0.0.1:8000'
 
 
-class SignaturesTests(TestCase):
-    def test_signature_change(self):
-        """signature module allows for signature change"""
-        test_user = UserModel.objects.create_user('Bob', 'bob@bob.com', 'pass123')
+class UserSignatureTests(TestCase):
+    def test_user_signature_and_valid_checksum_is_set(self):
+        user = User.objects.create_user('Bob', 'bob@bob.com')
+        user.signature = "Test"
+        user.signature_parsed = "Test"
+        user.signature_checksum = "Test"
+        user.save()
+
+        request = Mock(scheme="http", get_host=Mock(return_value="127.0.0.1:800"))
+        user_acl = get_user_acl(user, cache_versions)
+
+        signatures.set_user_signature(request, user, user_acl, "Changed")
+
+        assert user.signature == "Changed"
+        assert user.signature_parsed == "<p>Changed</p>"
+        assert user.signature_checksum
+        assert signatures.is_user_signature_valid(user)
+
+    def test_user_signature_is_cleared(self):
+        user = User.objects.create_user('Bob', 'bob@bob.com')
+        user.signature = "Test"
+        user.signature_parsed = "Test"
+        user.signature_checksum = "Test"
+        user.save()
 
-        signatures.set_user_signature(MockRequest(), test_user, '')
+        request = Mock(scheme="http", get_host=Mock(return_value="127.0.0.1:800"))
+        user_acl = get_user_acl(user, cache_versions)
 
-        self.assertEqual(test_user.signature, '')
-        self.assertEqual(test_user.signature_parsed, '')
-        self.assertEqual(test_user.signature_checksum, '')
+        signatures.set_user_signature(request, user, user_acl, "")
 
-        signatures.set_user_signature(MockRequest(), test_user, 'Hello, world!')
+        assert not user.signature
+        assert not user.signature_parsed
+        assert not user.signature_checksum
 
-        self.assertEqual(test_user.signature, 'Hello, world!')
-        self.assertEqual(test_user.signature_parsed, '<p>Hello, world!</p>')
-        self.assertTrue(signatures.is_user_signature_valid(test_user))
+    def test_signature_validity_check_fails_for_incorrect_signature_checksum(self):
+        user = User.objects.create_user('Bob', 'bob@bob.com')
+        user.signature = "Test"
+        user.signature_parsed = "Test"
+        user.signature_checksum = "Test"
+        user.save()
 
-        test_user.signature_parsed = '<p>Injected evil HTML!</p>'
-        self.assertFalse(signatures.is_user_signature_valid(test_user))
+        assert not signatures.is_user_signature_valid(user)

+ 4 - 1
misago/users/tests/test_social_pipeline.py

@@ -6,6 +6,8 @@ from django.test import RequestFactory, override_settings
 from social_core.backends.github import GithubOAuth2
 from social_django.utils import load_strategy
 
+from misago.acl import ACL_CACHE
+from misago.acl.useracl import get_user_acl
 from misago.core.exceptions import SocialAuthFailed, SocialAuthBanned
 from misago.legal.models import Agreement
 
@@ -28,10 +30,11 @@ def create_request(user_ip='0.0.0.0', data=None):
     else:
         request = factory.post('/', data=json.dumps(data), content_type='application/json')
     request.include_frontend_context = True
-    request.cache_versions = {BANS_CACHE: "abcdefgh"}
+    request.cache_versions = {BANS_CACHE: "abcdefgh", ACL_CACHE: "abcdefgh"}
     request.frontend_context = {}
     request.session = {}
     request.user = AnonymousUser()
+    request.user_acl = get_user_acl(request.user, request.cache_versions)
     request.user_ip = user_ip
     return request
 

+ 3 - 3
misago/users/viewmodels/threads.py

@@ -1,4 +1,4 @@
-from misago.acl import add_acl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.conf import settings
 from misago.core.shortcuts import paginate, pagination_dict
 from misago.threads.permissions import exclude_invisible_threads
@@ -33,8 +33,8 @@ class UserThreads(object):
 
         add_categories_to_items(root_category.unwrap(), threads_categories, posts + threads)
 
-        add_acl(request.user_acl, threads)
-        add_acl(request.user_acl, posts)
+        add_acl_to_obj(request.user_acl, threads)
+        add_acl_to_obj(request.user_acl, posts)
 
         self._user = request.user
 

+ 5 - 1
misago/users/views/admin/users.py

@@ -5,6 +5,7 @@ from django.http import JsonResponse
 from django.shortcuts import redirect
 from django.utils.translation import gettext_lazy as _
 
+from misago.acl.useracl import get_user_acl
 from misago.admin.auth import start_admin_session
 from misago.admin.views import generic
 from misago.categories.models import Category
@@ -325,7 +326,10 @@ class EditUser(UserAdmin, generic.ModelFormView):
         target.roles.clear()
         target.roles.add(*form.cleaned_data['roles'])
 
-        set_user_signature(request, target, form.cleaned_data.get('signature'))
+        target_acl = get_user_acl(target, request.cache_versions)
+        set_user_signature(
+            request, target, target_acl, form.cleaned_data.get('signature')
+        )
 
         profilefields.update_user_profile_fields(request, target, form)
 

+ 2 - 2
misago/users/views/profile.py

@@ -3,7 +3,7 @@ from django.http import Http404
 from django.shortcuts import get_object_or_404, redirect, render
 from django.views import View
 
-from misago.acl import add_acl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.core.shortcuts import paginate, pagination_dict, validate_slug
 from misago.users.bans import get_user_ban
 from misago.users.online.utils import get_user_status
@@ -44,7 +44,7 @@ class ProfileView(View):
             raise Http404()
 
         validate_slug(profile, slug)
-        add_acl(request.user_acl, profile)
+        add_acl_to_obj(request.user_acl, profile)
 
         return profile