Rafał Pitoń 10 лет назад
Родитель
Сommit
39ad127800

+ 12 - 0
docs/developers/settings.rst

@@ -294,6 +294,18 @@ MISAGO_NOTIFICATIONS_MAX_AGE
 Max age, in days, of notifications stored in database. Notifications older than this will be delted.
 
 
+MISAGO_ONLINE_LIST_SIZE
+-----------------------
+
+Controls number of users displayed on online list.
+
+
+MISAGO_ONLINE_LIST_CACHE
+------------------------
+
+For how long should online list be cached, in seconds.
+
+
 MISAGO_POSTING_MIDDLEWARES
 --------------------------
 

+ 8 - 1
misago/conf/defaults.py

@@ -314,7 +314,14 @@ MISAGO_THREAD_TAIL = 7
 MISAGO_RANKING_LENGTH = 30
 
 # Controls max number of items displayed on ranked lists
-MISAGO_RANKING_SIZE = 30
+MISAGO_RANKING_SIZE = 50
+
+
+# Controls max number of items displayed on online list
+MISAGO_ONLINE_LIST_SIZE = 50
+
+# For how long should online list be cached (in seconds)
+MISAGO_ONLINE_LIST_CACHE = 40
 
 
 # Controls amount of data used for new threads/replies lists

+ 2 - 2
misago/core/exceptionhandler.py

@@ -82,8 +82,8 @@ def handle_misago_exception(request, exception):
     return handler(request, exception)
 
 
-def handle_api_exception(exception):
-    response = rest_exception_handler(exception)
+def handle_api_exception(exception, context):
+    response = rest_exception_handler(exception, context)
     if response:
         if isinstance(exception, PermissionDenied):
             try:

+ 1 - 1
misago/project_template/requirements.txt

@@ -1,5 +1,5 @@
 django==1.7.6
-djangorestframework==3.0.2
+djangorestframework==3.1.3
 beautifulsoup4==4.3.2
 bleach==1.4.1
 django-debug-toolbar==1.2.1

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

@@ -181,7 +181,7 @@ def change_forgotten_password(request, user_id, token):
 
     try:
         user = User.objects.get(pk=user_id)
-        if request.is_authenticated() and request.user.id != user.id:
+        if request.user.is_authenticated() and request.user.id != user.id:
             raise User.DoesNotExist()
     except User.DoesNotExist:
         return Response({'detail': invalid_message},

+ 98 - 0
misago/users/api/userendpoints/list.py

@@ -0,0 +1,98 @@
+from datetime import timedelta
+
+from django.contrib.auth import get_user_model
+from django.db.models import Count
+from django.utils import timezone
+
+from rest_framework.pagination import PageNumberPagination
+
+from misago.conf import settings
+from misago.core.cache import cache
+from misago.core.shortcuts import get_object_or_404
+from misago.forums.models import Forum
+
+from misago.users.online.utils import get_online_queryset
+from misago.users.permissions.profiles import allow_see_users_online_list
+from misago.users.models import Rank
+from misago.users.serializers import OnlineUserSerializer, ScoredUserSerializer
+
+
+def active(request, queryset):
+    cache_key = 'misago_active_posters_ranking'
+    ranking = cache.get(cache_key, False)
+    if ranking is False:
+        ranking = real_active()
+        cache.set(cache_key, ranking, 18*3600)
+    return ranking
+
+
+def real_active():
+    tracked_period = settings.MISAGO_RANKING_LENGTH
+    tracked_since = timezone.now() - timedelta(days=tracked_period)
+
+    ranked_forums = [forum.pk for forum in Forum.objects.all_forums()]
+
+    User = get_user_model()
+    queryset = User.objects.filter(posts__gt=0)
+    queryset = queryset.filter(post__posted_on__gte=tracked_since,
+                               post__forum__in=ranked_forums)
+    queryset = queryset.annotate(num_posts=Count('post'))
+    queryset = queryset.select_related('user__rank')
+    queryset = queryset.order_by('-num_posts', 'slug')
+
+    users_ranking = []
+    for result in queryset[:settings.MISAGO_RANKING_SIZE]:
+        result.score = result.num_posts
+        users_ranking.append(result)
+    return {'data': ScoredUserSerializer(users_ranking, many=True).data}
+
+
+def online(request, queryset):
+    allow_see_users_online_list(request.user)
+
+    cache_key = 'users_online_cache_%s' % request.user.acl_key
+    online_list = cache.get(cache_key, False)
+    if online_list is False:
+        online_list = real_online(request)
+        cache.set(cache_key, online_list, settings.MISAGO_ONLINE_LIST_CACHE)
+    return online_list
+
+
+def real_online(request):
+    queryset = get_online_queryset(request.user).order_by('last_click')
+    queryset = queryset[:settings.MISAGO_ONLINE_LIST_SIZE]
+
+    users_online = []
+    for result in queryset:
+        result.user.last_click = result.last_click
+        users_online.append(result.user)
+
+    return {'data': OnlineUserSerializer(users_online, many=True).data}
+
+
+def rank(request, queryset):
+    rank_slug = request.query_params.get('rank')
+    if not rank_slug:
+        return
+
+    rank = get_object_or_404(Rank.objects.filter(is_tab=True), slug=rank_slug)
+    queryset.filter(rank=rank)
+
+    return {'queryset': queryset, 'paginate': True}
+
+
+LISTS = {
+    'active': active,
+    'online': online,
+    'rank': rank,
+}
+
+
+def list_endpoint(request, queryset):
+    list_type = request.query_params.get('list')
+    list_handler = LISTS.get(list_type)
+
+    if list_handler:
+        return list_handler(request, queryset)
+    else:
+        return

+ 6 - 1
misago/users/api/usernamechanges.py

@@ -2,6 +2,7 @@ from django.core.exceptions import PermissionDenied
 from django.utils.translation import ugettext as _
 
 from rest_framework import status, viewsets, mixins
+from rest_framework.pagination import PageNumberPagination
 from rest_framework.response import Response
 
 from misago.users.models import UsernameChange
@@ -24,11 +25,15 @@ class UsernameChangesViewSetPermission(BasePermission):
         return True
 
 
+class UsernameChangesPagination(PageNumberPagination):
+    page_size = 20
+
+
 class UsernameChangesViewSet(mixins.ListModelMixin, viewsets.GenericViewSet):
     permission_classes = (UsernameChangesViewSetPermission,)
     serializer_class = UsernameChangeSerializer
     queryset = UsernameChange.objects
-    paginate_by = 20
+    pagination_class = UsernameChangesPagination
 
     def get_queryset(self):
         queryset = UsernameChange.objects.select_related('user', 'changed_by')

+ 41 - 8
misago/users/api/users.py

@@ -3,19 +3,23 @@ from django.core.exceptions import PermissionDenied
 from django.shortcuts import get_object_or_404
 from django.utils.translation import ugettext as _
 
-from rest_framework import status, viewsets
-from rest_framework.decorators import detail_route
+from rest_framework import mixins, status, viewsets
+from rest_framework.decorators import detail_route, list_route
+from rest_framework.pagination import PageNumberPagination
 from rest_framework.parsers import JSONParser, MultiPartParser
 from rest_framework.response import Response
 
 from misago.acl import add_acl
 
-from misago.users.rest_permissions import (BasePermission,
-    IsAuthenticatedOrReadOnly, UnbannedAnonOnly)
 from misago.users.forms.options import ForumOptionsForm
+from misago.users.permissions.profiles import (allow_browse_users_list,
+                                               allow_see_users_online_list)
 
+from misago.users.rest_permissions import (BasePermission,
+    IsAuthenticatedOrReadOnly, UnbannedAnonOnly)
 from misago.users.serializers import UserSerializer, UserProfileSerializer
 
+from misago.users.api.userendpoints.list import list_endpoint
 from misago.users.api.userendpoints.avatar import avatar_endpoint
 from misago.users.api.userendpoints.create import create_endpoint
 from misago.users.api.userendpoints.signature import signature_endpoint
@@ -41,18 +45,50 @@ def allow_self_only(user, pk, message):
         raise PermissionDenied(message)
 
 
+class UsersPagination(PageNumberPagination):
+    page_size = 16
+
+
 class UserViewSet(viewsets.GenericViewSet):
     permission_classes = (UserViewSetPermission,)
     parser_classes=(JSONParser, MultiPartParser)
     serializer_class = UserSerializer
     queryset = get_user_model().objects
+    pagination_class = UsersPagination
 
     def get_queryset(self):
         relations = ('rank', 'online_tracker', 'ban_cache')
         return self.queryset.select_related(*relations)
 
     def list(self, request):
-        pass
+        allow_browse_users_list(request.user)
+
+        users_list = list_endpoint(request, self.get_queryset())
+        if not users_list:
+            return Response([])
+
+        if 'data' in users_list:
+            return Response(users_list['data'])
+
+        if users_list.get('paginate'):
+            page = self.paginate_queryset(users_list['queryset'])
+            users_list['queryset'] = page
+
+        if users_list.get('serializer'):
+            serializer_class = users_list.get('serializer')
+        else:
+            serializer_class = self.serializer_class
+
+        serializer = serializer_class(
+            users_list['queryset'], many=True, context={'user': request.user})
+
+        if users_list.get('paginate'):
+            return self.get_paginated_response(serializer.data)
+        else:
+            return Response(serializer.data)
+
+    def create(self, request):
+        return create_endpoint(request)
 
     def retrieve(self, request, pk=None):
         qs = self.get_queryset()
@@ -64,9 +100,6 @@ class UserViewSet(viewsets.GenericViewSet):
             profile, context={'user': request.user})
         return Response(serializer.data)
 
-    def create(self, request):
-        return create_endpoint(request)
-
     @detail_route(methods=['get', 'post'])
     def avatar(self, request, pk=None):
         allow_self_only(

+ 1 - 1
misago/users/online/utils.py

@@ -16,7 +16,7 @@ def get_online_queryset(viewer=None):
     if viewer and not viewer.acl['can_see_hidden_users']:
         queryset = queryset.filter(user__is_hiding_presence=False)
 
-    return queryset.select_related('user')
+    return queryset.select_related('user', 'user__rank')
 
 
 def get_user_state(user, acl):

+ 46 - 1
misago/users/serializers/user.py

@@ -13,6 +13,8 @@ __all__ = [
     'AnonymousUserSerializer',
     'BasicUserSerializer',
     'UserSerializer',
+    'OnlineUserSerializer',
+    'ScoredUserSerializer',
     'UserProfileSerializer',
 ]
 
@@ -87,7 +89,10 @@ class UserSerializer(serializers.ModelSerializer):
         )
 
     def get_state(self, obj):
-        return get_user_state(obj, self.context['user'].acl)
+        if hasattr(obj, 'online_tracker'):
+            return get_user_state(obj, self.context['user'].acl)
+        else:
+            return {}
 
     def get_signature(self, obj):
         if obj.has_valid_signature:
@@ -96,6 +101,46 @@ class UserSerializer(serializers.ModelSerializer):
             return None
 
 
+class OnlineUserSerializer(UserSerializer):
+    last_click = serializers.SerializerMethodField()
+
+    class Meta:
+        model = get_user_model()
+        fields = (
+            'id',
+            'username',
+            'slug',
+            'avatar_hash',
+            'title',
+            'rank',
+            'last_click',
+            'signature',
+        )
+
+    def get_last_click(self, obj):
+        return obj.last_click
+
+
+class ScoredUserSerializer(UserSerializer):
+    score = serializers.SerializerMethodField()
+
+    class Meta:
+        model = get_user_model()
+        fields = (
+            'id',
+            'username',
+            'slug',
+            'avatar_hash',
+            'title',
+            'rank',
+            'score',
+            'signature',
+        )
+
+    def get_score(self, obj):
+        return obj.score
+
+
 class UserProfileSerializer(UserSerializer):
     email = serializers.SerializerMethodField()
     is_followed = serializers.SerializerMethodField()

+ 1 - 2
misago/users/signals.py

@@ -10,8 +10,7 @@ Signal handlers
 """
 @receiver(username_changed)
 def handle_name_change(sender, **kwargs):
-    sender.user_renames.update(changed_by_username=sender.username,
-                               changed_by_slug=sender.slug)
+    sender.user_renames.update(changed_by_username=sender.username)
     sender.warnings_given.update(giver_username=sender.username,
                                  giver_slug=sender.slug)
     sender.warnings_canceled.update(canceler_username=sender.username,

+ 159 - 0
misago/users/tests/test_users_api.py

@@ -1,10 +1,169 @@
+from datetime import timedelta
+
 from django.contrib.auth import get_user_model
+from django.utils import timezone
 
+from misago.acl.testutils import override_acl
 from misago.conf import settings
+from misago.core import threadstore
+from misago.core.cache import cache
+from misago.forums.models import Forum
+from misago.threads.testutils import post_thread
 
+from misago.users.models import Online, Rank
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
+class ActiveUsersListTests(AuthenticatedUserTestCase):
+    """
+    tests for active users list (GET /users/?list=active)
+    """
+    def setUp(self):
+        super(ActiveUsersListTests, self).setUp()
+        self.link = '/api/users/?list=active'
+
+        cache.clear()
+        threadstore.clear()
+
+        self.forum = Forum.objects.all_forums().filter(role="forum")[:1][0]
+        self.forum.labels = []
+
+    def test_empty_list(self):
+        """empty list is served"""
+        response = self.client.get(self.link)
+        self.assertEqual(response.status_code, 200)
+        self.assertNotIn(self.user.username, response.content)
+
+        response = self.client.get(self.link)
+        self.assertEqual(response.status_code, 200)
+        self.assertNotIn(self.user.username, response.content)
+
+    def test_filled_list(self):
+        """filled list is served"""
+        post_thread(self.forum, poster=self.user)
+        self.user.posts = 1
+        self.user.save()
+
+        self.logout_user()
+
+        response = self.client.get(self.link)
+        self.assertEqual(response.status_code, 200)
+        self.assertIn(self.user.username, response.content)
+
+        response = self.client.get(self.link)
+        self.assertEqual(response.status_code, 200)
+        self.assertIn(self.user.username, response.content)
+
+
+class OnlineListTests(AuthenticatedUserTestCase):
+    """
+    tests for online list (GET /users/?list=online)
+    """
+    def setUp(self):
+        super(OnlineListTests, self).setUp()
+        self.link = '/api/users/?list=online'
+
+        cache.clear()
+        threadstore.clear()
+
+    def test_no_permission(self):
+        """online list returns 403 if user has no permission"""
+        override_acl(self.user, {
+            'can_browse_users_list': 1,
+            'can_see_users_online_list': 0,
+        })
+
+        response = self.client.get(self.link)
+        self.assertEqual(response.status_code, 403)
+
+    def test_empty_list(self):
+        """empty online list returns 200"""
+        override_acl(self.user, {
+            'can_browse_users_list': 1,
+            'can_see_users_online_list': 1,
+        })
+
+        Online.objects.all().update(
+            last_click=timezone.now() - timedelta(days=5))
+
+        response = self.client.get(self.link)
+        self.assertEqual(response.status_code, 200)
+        self.assertNotIn(self.user.username, response.content)
+
+        override_acl(self.user, {
+            'can_browse_users_list': 1,
+            'can_see_users_online_list': 1,
+        })
+
+        response = self.client.get(self.link)
+        self.assertEqual(response.status_code, 200)
+        self.assertNotIn(self.user.username, response.content)
+
+    def test_filled_list(self):
+        """filled online list returns 200"""
+        override_acl(self.user, {
+            'can_browse_users_list': 1,
+            'can_see_users_online_list': 1,
+        })
+
+        Online.objects.all().update(
+            last_click=timezone.now())
+
+        response = self.client.get(self.link)
+        self.assertEqual(response.status_code, 200)
+        self.assertIn(self.user.username, response.content)
+
+        override_acl(self.user, {
+            'can_browse_users_list': 1,
+            'can_see_users_online_list': 1,
+        })
+
+        response = self.client.get(self.link)
+        self.assertEqual(response.status_code, 200)
+        self.assertIn(self.user.username, response.content)
+
+
+class RankListTests(AuthenticatedUserTestCase):
+    """
+    tests for rank list (GET /users/?list=rank&rank=slug)
+    """
+    def setUp(self):
+        super(RankListTests, self).setUp()
+        self.link = '/api/users/?list=rank&rank='
+
+    def test_nonexistent_rank(self):
+        """list for non-existing rank returns 404"""
+        response = self.client.get(self.link + 'this-rank-is-non-existing')
+        self.assertEqual(response.status_code, 404)
+
+    def test_empty_list(self):
+        """tab rank without members returns 200"""
+        rank_slug = self.user.rank.slug
+
+        self.user.rank = Rank.objects.filter(is_tab=False)[:1][0]
+        self.user.rank.save()
+
+        response = self.client.get(self.link + rank_slug)
+        self.assertEqual(response.status_code, 404)
+
+    def test_disabled_list(self):
+        """non-tab rank with members returns 404"""
+        self.user.rank.is_tab = False
+        self.user.rank.save()
+
+        response = self.client.get(self.link + self.user.rank.slug)
+        self.assertEqual(response.status_code, 404)
+
+    def test_filled_list(self):
+        """tab rank with members return 200"""
+        self.user.rank.is_tab = True
+        self.user.rank.save()
+
+        response = self.client.get(self.link + self.user.rank.slug)
+        self.assertEqual(response.status_code, 200)
+        self.assertIn(self.user.username, response.content)
+
+
 class UserForumOptionsTests(AuthenticatedUserTestCase):
     """
     tests for user forum options RPC (POST to /api/users/1/forum-options/)