diff --git a/openlobby/core/api/schema.py b/openlobby/core/api/schema.py index 4bd463243cc9c44ea140f4a45419967e6475e65e..9060e6ca465f24f7a8757dd0247cac4707374a2f 100644 --- a/openlobby/core/api/schema.py +++ b/openlobby/core/api/schema.py @@ -1,14 +1,23 @@ import graphene -from graphene import relay from django.db.models import Count, Q +from graphene import relay from . import types -from ..models import OpenIdClient from .paginator import Paginator from .sanitizers import extract_text from .. import search +from ..models import OpenIdClient from ..models import User, Report +AUTHOR_SORT_LAST_NAME_ID = 1 +AUTHOR_SORT_TOTAL_REPORTS_ID = 2 + +class AuthorSortEnum(graphene.Enum): + LAST_NAME = AUTHOR_SORT_LAST_NAME_ID + TOTAL_REPORTS = AUTHOR_SORT_TOTAL_REPORTS_ID + + class Meta: + description = 'Sort by field.' class AuthorsConnection(relay.Connection): total_count = graphene.Int() @@ -38,6 +47,8 @@ class Query: authors = relay.ConnectionField( AuthorsConnection, description='List of Authors. Returns first 10 nodes if pagination is not specified.', + sort=AuthorSortEnum(), + reversed=graphene.Boolean(default_value=False, description="Reverse order of sort") ) search_reports = relay.ConnectionField( SearchReportsConnection, @@ -59,10 +70,10 @@ class Query: paginator = Paginator(**kwargs) total = User.objects.filter(is_author=True).count() - authors = User.objects.filter(is_author=True)\ - .annotate(total_reports=Count('report', filter=Q(report__is_draft=False)))\ - .order_by('last_name', 'first_name')[ - paginator.slice_from:paginator.slice_to] + + authors = User.objects\ + .sorted(**kwargs)\ + .filter(is_author=True)[paginator.slice_from:paginator.slice_to] page_info = paginator.get_page_info(total) diff --git a/openlobby/core/models.py b/openlobby/core/models.py index 470de4c2f75960aba1845fb0006b208c7e3d9210..8c46ec0e6455123c6318a998e037bd34e0888686 100644 --- a/openlobby/core/models.py +++ b/openlobby/core/models.py @@ -1,9 +1,26 @@ +import time + from django.conf import settings -from django.db import models +from django.contrib.auth.base_user import BaseUserManager from django.contrib.auth.models import AbstractUser from django.contrib.postgres.fields import JSONField +from django.db import models +from django.db.models import Count +from django.db.models import Q from django.utils import timezone -import time + + +class UserManager(BaseUserManager): + def sorted(self, **kwargs): + # inline import intentionally + from openlobby.core.api.schema import AUTHOR_SORT_LAST_NAME_ID, AUTHOR_SORT_TOTAL_REPORTS_ID + qs = self.get_queryset().annotate(total_reports=Count('report', filter=Q(report__is_draft=False))) + sort_field = kwargs.get('sort', AUTHOR_SORT_LAST_NAME_ID) + if sort_field == AUTHOR_SORT_LAST_NAME_ID: + return qs.order_by('{}last_name'.format('-' if kwargs.get('reversed', False) else ''), 'first_name') + elif sort_field == AUTHOR_SORT_TOTAL_REPORTS_ID: + return qs.order_by('{}total_reports'.format('' if kwargs.get('reversed', False) else '-'), 'last_name') + raise NotImplemented("Other sort types are not implemented") class User(AbstractUser): @@ -14,12 +31,13 @@ class User(AbstractUser): extra = JSONField(null=True, blank=True) is_author = models.BooleanField(default=False) has_colliding_name = models.BooleanField(default=False) + objects = UserManager() def save(self, *args, **kwargs): # deal with first name and last name collisions if self.is_author: collisions = User.objects.filter(first_name=self.first_name, last_name=self.last_name, - is_author=True).exclude(id=self.id) + is_author=True).exclude(id=self.id) if collisions.count() > 0: self.has_colliding_name = True collisions.update(has_colliding_name=True) diff --git a/tests/schema/snapshots/snap_test_authors.py b/tests/schema/snapshots/snap_test_authors.py index af1983302e23288e0d90904421bb8aa855bb14f8..cf9fba0e09487210dbca4670b052d2d65f9c3fd3 100644 --- a/tests/schema/snapshots/snap_test_authors.py +++ b/tests/schema/snapshots/snap_test_authors.py @@ -266,3 +266,248 @@ snapshots['test_with_reports 1'] = { } } } + +snapshots['test_sort_by_last_name 1'] = { + 'data': { + 'authors': { + 'edges': [ + { + 'cursor': 'MQ==', + 'node': { + 'extra': None, + 'firstName': 'Shaun', + 'hasCollidingName': False, + 'id': 'QXV0aG9yOjM=', + 'lastName': 'Sheep', + 'totalReports': 0 + } + }, + { + 'cursor': 'Mg==', + 'node': { + 'extra': None, + 'firstName': 'Spongebob', + 'hasCollidingName': False, + 'id': 'QXV0aG9yOjI=', + 'lastName': 'Squarepants', + 'totalReports': 1 + } + }, + { + 'cursor': 'Mw==', + 'node': { + 'extra': '{"movies": 1}', + 'firstName': 'Winston', + 'hasCollidingName': False, + 'id': 'QXV0aG9yOjE=', + 'lastName': 'Wolfe', + 'totalReports': 2 + } + } + ], + 'pageInfo': { + 'endCursor': 'Mw==', + 'hasNextPage': False, + 'hasPreviousPage': False, + 'startCursor': 'MQ==' + }, + 'totalCount': 3 + } + } +} + +snapshots['test_sort_by_last_name_reversed 1'] = { + 'data': { + 'authors': { + 'edges': [ + { + 'cursor': 'MQ==', + 'node': { + 'extra': '{"movies": 1}', + 'firstName': 'Winston', + 'hasCollidingName': False, + 'id': 'QXV0aG9yOjE=', + 'lastName': 'Wolfe', + 'totalReports': 2 + } + }, + { + 'cursor': 'Mg==', + 'node': { + 'extra': None, + 'firstName': 'Spongebob', + 'hasCollidingName': False, + 'id': 'QXV0aG9yOjI=', + 'lastName': 'Squarepants', + 'totalReports': 1 + } + }, + { + 'cursor': 'Mw==', + 'node': { + 'extra': None, + 'firstName': 'Shaun', + 'hasCollidingName': False, + 'id': 'QXV0aG9yOjM=', + 'lastName': 'Sheep', + 'totalReports': 0 + } + } + ], + 'pageInfo': { + 'endCursor': 'Mw==', + 'hasNextPage': False, + 'hasPreviousPage': False, + 'startCursor': 'MQ==' + }, + 'totalCount': 3 + } + } +} + +snapshots['test_sort_by_total_reports 1'] = { + 'data': { + 'authors': { + 'edges': [ + { + 'cursor': 'MQ==', + 'node': { + 'extra': '{"movies": 1}', + 'firstName': 'Winston', + 'hasCollidingName': False, + 'id': 'QXV0aG9yOjE=', + 'lastName': 'Wolfe', + 'totalReports': 2 + } + }, + { + 'cursor': 'Mg==', + 'node': { + 'extra': None, + 'firstName': 'Spongebob', + 'hasCollidingName': False, + 'id': 'QXV0aG9yOjI=', + 'lastName': 'Squarepants', + 'totalReports': 1 + } + }, + { + 'cursor': 'Mw==', + 'node': { + 'extra': None, + 'firstName': 'Shaun', + 'hasCollidingName': False, + 'id': 'QXV0aG9yOjM=', + 'lastName': 'Sheep', + 'totalReports': 0 + } + } + ], + 'pageInfo': { + 'endCursor': 'Mw==', + 'hasNextPage': False, + 'hasPreviousPage': False, + 'startCursor': 'MQ==' + }, + 'totalCount': 3 + } + } +} + +snapshots['test_sort_by_total_reports_reversed 1'] = { + 'data': { + 'authors': { + 'edges': [ + { + 'cursor': 'MQ==', + 'node': { + 'extra': None, + 'firstName': 'Shaun', + 'hasCollidingName': False, + 'id': 'QXV0aG9yOjM=', + 'lastName': 'Sheep', + 'totalReports': 0 + } + }, + { + 'cursor': 'Mg==', + 'node': { + 'extra': None, + 'firstName': 'Spongebob', + 'hasCollidingName': False, + 'id': 'QXV0aG9yOjI=', + 'lastName': 'Squarepants', + 'totalReports': 1 + } + }, + { + 'cursor': 'Mw==', + 'node': { + 'extra': '{"movies": 1}', + 'firstName': 'Winston', + 'hasCollidingName': False, + 'id': 'QXV0aG9yOjE=', + 'lastName': 'Wolfe', + 'totalReports': 2 + } + } + ], + 'pageInfo': { + 'endCursor': 'Mw==', + 'hasNextPage': False, + 'hasPreviousPage': False, + 'startCursor': 'MQ==' + }, + 'totalCount': 3 + } + } +} + +snapshots['test_sort_by_default_reversed 1'] = { + 'data': { + 'authors': { + 'edges': [ + { + 'cursor': 'MQ==', + 'node': { + 'extra': '{"movies": 1}', + 'firstName': 'Winston', + 'hasCollidingName': False, + 'id': 'QXV0aG9yOjE=', + 'lastName': 'Wolfe', + 'totalReports': 2 + } + }, + { + 'cursor': 'Mg==', + 'node': { + 'extra': None, + 'firstName': 'Spongebob', + 'hasCollidingName': False, + 'id': 'QXV0aG9yOjI=', + 'lastName': 'Squarepants', + 'totalReports': 1 + } + }, + { + 'cursor': 'Mw==', + 'node': { + 'extra': None, + 'firstName': 'Shaun', + 'hasCollidingName': False, + 'id': 'QXV0aG9yOjM=', + 'lastName': 'Sheep', + 'totalReports': 0 + } + } + ], + 'pageInfo': { + 'endCursor': 'Mw==', + 'hasNextPage': False, + 'hasPreviousPage': False, + 'startCursor': 'MQ==' + }, + 'totalCount': 3 + } + } +} diff --git a/tests/schema/test_authors.py b/tests/schema/test_authors.py index 0e83176eb619f6d23935f667e1eb9bd0e4a50734..5c59a37386187dff0949ef7bb8dedee57d827897 100644 --- a/tests/schema/test_authors.py +++ b/tests/schema/test_authors.py @@ -199,3 +199,148 @@ def test_with_reports(client, snapshot): """ response = call_api(client, query) snapshot.assert_match(response) + +def test_sort_by_last_name(client, snapshot): + prepare_reports() + query = """ + query { + authors(sort: LAST_NAME) { + totalCount + edges { + cursor + node { + id + firstName + lastName + hasCollidingName + totalReports + extra + } + } + pageInfo { + hasPreviousPage + hasNextPage + startCursor + endCursor + } + } + } + """ + response = call_api(client, query) + snapshot.assert_match(response) + +def test_sort_by_last_name_reversed(client, snapshot): + prepare_reports() + query = """ + query { + authors(sort: LAST_NAME, reversed: true) { + totalCount + edges { + cursor + node { + id + firstName + lastName + hasCollidingName + totalReports + extra + } + } + pageInfo { + hasPreviousPage + hasNextPage + startCursor + endCursor + } + } + } + """ + response = call_api(client, query) + snapshot.assert_match(response) + +def test_sort_by_total_reports(client, snapshot): + prepare_reports() + query = """ + query { + authors(sort: TOTAL_REPORTS, reversed: false) { + totalCount + edges { + cursor + node { + id + firstName + lastName + hasCollidingName + totalReports + extra + } + } + pageInfo { + hasPreviousPage + hasNextPage + startCursor + endCursor + } + } + } + """ + response = call_api(client, query) + snapshot.assert_match(response) + +def test_sort_by_total_reports_reversed(client, snapshot): + prepare_reports() + query = """ + query { + authors(sort: TOTAL_REPORTS, reversed: true) { + totalCount + edges { + cursor + node { + id + firstName + lastName + hasCollidingName + totalReports + extra + } + } + pageInfo { + hasPreviousPage + hasNextPage + startCursor + endCursor + } + } + } + """ + response = call_api(client, query) + snapshot.assert_match(response) + +def test_sort_by_default_reversed(client, snapshot): + prepare_reports() + query = """ + query { + authors(reversed: true) { + totalCount + edges { + cursor + node { + id + firstName + lastName + hasCollidingName + totalReports + extra + } + } + pageInfo { + hasPreviousPage + hasNextPage + startCursor + endCursor + } + } + } + """ + response = call_api(client, query) + snapshot.assert_match(response) \ No newline at end of file diff --git a/tests/test_models.py b/tests/test_models.py index 2cc7a8c4c64d063d0824ad4bbb8df4b73ed9081d..80e382a634e2b929e4c7a7ec5912bd2d1cee682d 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,11 +1,11 @@ -import pytest -import arrow -from django.conf import settings from unittest.mock import patch -from openlobby.core.models import Report, User, OpenIdClient, LoginAttempt +import arrow +import pytest +from django.conf import settings +from openlobby.core.api.schema import AUTHOR_SORT_LAST_NAME_ID, AUTHOR_SORT_TOTAL_REPORTS_ID from openlobby.core.documents import ReportDoc - +from openlobby.core.models import Report, User, OpenIdClient, LoginAttempt pytestmark = [pytest.mark.django_db, pytest.mark.usefixtures('django_es')] @@ -82,7 +82,7 @@ def test_login_attempt__default_expiration(): client = OpenIdClient.objects.create(name='a', client_id='b', client_secret='c') with patch('openlobby.core.models.time.time', return_value=10000): attempt = LoginAttempt.objects.create(openid_client=client, state='foo', - app_redirect_uri='http://openlobby/app') + app_redirect_uri='http://openlobby/app') assert attempt.expiration == 10000 + settings.LOGIN_ATTEMPT_EXPIRATION @@ -118,3 +118,42 @@ def test_user__name_collision_excludes_self_on_update(): u = User.objects.create(username='a', is_author=True, first_name='Ryan', last_name='Gosling') u.save() assert User.objects.get(username='a').has_colliding_name is False + + +def test_user__sorted_default(): + User.objects.create(username='a', is_author=False, first_name='Ryan', last_name='AGosling') + User.objects.create(username='b', is_author=True, first_name='Ryan', last_name='BGosling') + User.objects.create(username='c', is_author=False, first_name='Ryan', last_name='CGosling') + assert User.objects.sorted()[0].username == 'a' + + +def test_user__sorted_default_reversed(): + User.objects.create(username='a', is_author=False, first_name='Ryan', last_name='AGosling') + User.objects.create(username='b', is_author=True, first_name='Ryan', last_name='BGosling') + User.objects.create(username='c', is_author=False, first_name='Ryan', last_name='CGosling') + assert User.objects.sorted(reversed=True)[0].username == 'c' + + +def test_user__sorted_last_name(): + User.objects.create(username='a', is_author=False, first_name='Ryan', last_name='AGosling') + User.objects.create(username='b', is_author=True, first_name='Ryan', last_name='BGosling') + User.objects.create(username='c', is_author=False, first_name='Ryan', last_name='CGosling') + assert User.objects.sorted(sort=AUTHOR_SORT_LAST_NAME_ID)[0].username == 'a' + assert User.objects.sorted(sort=AUTHOR_SORT_LAST_NAME_ID, reversed=False)[0].username == 'a' + assert User.objects.sorted(sort=AUTHOR_SORT_LAST_NAME_ID, reversed=True)[0].username == 'c' + + +def test_user__sorted_total_reports(): + author = User.objects.create(username='a', is_author=True, first_name='Ryan', last_name='AGosling') + User.objects.create(username='b', is_author=True, first_name='Ryan', last_name='BGosling') + date = arrow.get(2018, 1, 1).datetime + Report.objects.create( + id=7, + author=author, + date=date, + published=date, + body='Lorem ipsum.', + ) + assert User.objects.sorted(sort=AUTHOR_SORT_TOTAL_REPORTS_ID)[0].username == 'a' + assert User.objects.sorted(sort=AUTHOR_SORT_TOTAL_REPORTS_ID, reversed=False)[0].username == 'a' + assert User.objects.sorted(sort=AUTHOR_SORT_TOTAL_REPORTS_ID, reversed=True)[0].username == 'b'