Browse Source

wip users lists

Rafał Pitoń 10 years ago
parent
commit
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.
 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
 MISAGO_POSTING_MIDDLEWARES
 --------------------------
 --------------------------
 
 

+ 8 - 1
misago/conf/defaults.py

@@ -314,7 +314,14 @@ MISAGO_THREAD_TAIL = 7
 MISAGO_RANKING_LENGTH = 30
 MISAGO_RANKING_LENGTH = 30
 
 
 # Controls max number of items displayed on ranked lists
 # 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
 # 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)
     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 response:
         if isinstance(exception, PermissionDenied):
         if isinstance(exception, PermissionDenied):
             try:
             try:

+ 1 - 1
misago/project_template/requirements.txt

@@ -1,5 +1,5 @@
 django==1.7.6
 django==1.7.6
-djangorestframework==3.0.2
+djangorestframework==3.1.3
 beautifulsoup4==4.3.2
 beautifulsoup4==4.3.2
 bleach==1.4.1
 bleach==1.4.1
 django-debug-toolbar==1.2.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:
     try:
         user = User.objects.get(pk=user_id)
         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()
             raise User.DoesNotExist()
     except User.DoesNotExist:
     except User.DoesNotExist:
         return Response({'detail': invalid_message},
         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 django.utils.translation import ugettext as _
 
 
 from rest_framework import status, viewsets, mixins
 from rest_framework import status, viewsets, mixins
+from rest_framework.pagination import PageNumberPagination
 from rest_framework.response import Response
 from rest_framework.response import Response
 
 
 from misago.users.models import UsernameChange
 from misago.users.models import UsernameChange
@@ -24,11 +25,15 @@ class UsernameChangesViewSetPermission(BasePermission):
         return True
         return True
 
 
 
 
+class UsernameChangesPagination(PageNumberPagination):
+    page_size = 20
+
+
 class UsernameChangesViewSet(mixins.ListModelMixin, viewsets.GenericViewSet):
 class UsernameChangesViewSet(mixins.ListModelMixin, viewsets.GenericViewSet):
     permission_classes = (UsernameChangesViewSetPermission,)
     permission_classes = (UsernameChangesViewSetPermission,)
     serializer_class = UsernameChangeSerializer
     serializer_class = UsernameChangeSerializer
     queryset = UsernameChange.objects
     queryset = UsernameChange.objects
-    paginate_by = 20
+    pagination_class = UsernameChangesPagination
 
 
     def get_queryset(self):
     def get_queryset(self):
         queryset = UsernameChange.objects.select_related('user', 'changed_by')
         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.shortcuts import get_object_or_404
 from django.utils.translation import ugettext as _
 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.parsers import JSONParser, MultiPartParser
 from rest_framework.response import Response
 from rest_framework.response import Response
 
 
 from misago.acl import add_acl
 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.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.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.avatar import avatar_endpoint
 from misago.users.api.userendpoints.create import create_endpoint
 from misago.users.api.userendpoints.create import create_endpoint
 from misago.users.api.userendpoints.signature import signature_endpoint
 from misago.users.api.userendpoints.signature import signature_endpoint
@@ -41,18 +45,50 @@ def allow_self_only(user, pk, message):
         raise PermissionDenied(message)
         raise PermissionDenied(message)
 
 
 
 
+class UsersPagination(PageNumberPagination):
+    page_size = 16
+
+
 class UserViewSet(viewsets.GenericViewSet):
 class UserViewSet(viewsets.GenericViewSet):
     permission_classes = (UserViewSetPermission,)
     permission_classes = (UserViewSetPermission,)
     parser_classes=(JSONParser, MultiPartParser)
     parser_classes=(JSONParser, MultiPartParser)
     serializer_class = UserSerializer
     serializer_class = UserSerializer
     queryset = get_user_model().objects
     queryset = get_user_model().objects
+    pagination_class = UsersPagination
 
 
     def get_queryset(self):
     def get_queryset(self):
         relations = ('rank', 'online_tracker', 'ban_cache')
         relations = ('rank', 'online_tracker', 'ban_cache')
         return self.queryset.select_related(*relations)
         return self.queryset.select_related(*relations)
 
 
     def list(self, request):
     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):
     def retrieve(self, request, pk=None):
         qs = self.get_queryset()
         qs = self.get_queryset()
@@ -64,9 +100,6 @@ class UserViewSet(viewsets.GenericViewSet):
             profile, context={'user': request.user})
             profile, context={'user': request.user})
         return Response(serializer.data)
         return Response(serializer.data)
 
 
-    def create(self, request):
-        return create_endpoint(request)
-
     @detail_route(methods=['get', 'post'])
     @detail_route(methods=['get', 'post'])
     def avatar(self, request, pk=None):
     def avatar(self, request, pk=None):
         allow_self_only(
         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']:
     if viewer and not viewer.acl['can_see_hidden_users']:
         queryset = queryset.filter(user__is_hiding_presence=False)
         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):
 def get_user_state(user, acl):

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

@@ -13,6 +13,8 @@ __all__ = [
     'AnonymousUserSerializer',
     'AnonymousUserSerializer',
     'BasicUserSerializer',
     'BasicUserSerializer',
     'UserSerializer',
     'UserSerializer',
+    'OnlineUserSerializer',
+    'ScoredUserSerializer',
     'UserProfileSerializer',
     'UserProfileSerializer',
 ]
 ]
 
 
@@ -87,7 +89,10 @@ class UserSerializer(serializers.ModelSerializer):
         )
         )
 
 
     def get_state(self, obj):
     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):
     def get_signature(self, obj):
         if obj.has_valid_signature:
         if obj.has_valid_signature:
@@ -96,6 +101,46 @@ class UserSerializer(serializers.ModelSerializer):
             return None
             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):
 class UserProfileSerializer(UserSerializer):
     email = serializers.SerializerMethodField()
     email = serializers.SerializerMethodField()
     is_followed = serializers.SerializerMethodField()
     is_followed = serializers.SerializerMethodField()

+ 1 - 2
misago/users/signals.py

@@ -10,8 +10,7 @@ Signal handlers
 """
 """
 @receiver(username_changed)
 @receiver(username_changed)
 def handle_name_change(sender, **kwargs):
 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,
     sender.warnings_given.update(giver_username=sender.username,
                                  giver_slug=sender.slug)
                                  giver_slug=sender.slug)
     sender.warnings_canceled.update(canceler_username=sender.username,
     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.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.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
 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):
 class UserForumOptionsTests(AuthenticatedUserTestCase):
     """
     """
     tests for user forum options RPC (POST to /api/users/1/forum-options/)
     tests for user forum options RPC (POST to /api/users/1/forum-options/)