diff --git a/taiga/base/filters.py b/taiga/base/filters.py
index a3cd516e..62b4dc20 100644
--- a/taiga/base/filters.py
+++ b/taiga/base/filters.py
@@ -33,7 +33,6 @@ logger = logging.getLogger(__name__)
# Base and Mixins
#####################################################################
-
class BaseFilterBackend(object):
"""
A base class from which all filter backend classes should inherit.
@@ -182,6 +181,10 @@ class CanViewMilestonesFilterBackend(PermissionBasedFilterBackend):
permission = "view_milestones"
+#####################################################################
+# Attachments filters
+#####################################################################
+
class PermissionBasedAttachmentFilterBackend(PermissionBasedFilterBackend):
permission = None
@@ -208,52 +211,9 @@ class CanViewWikiAttachmentFilterBackend(PermissionBasedAttachmentFilterBackend)
permission = "view_wiki_pages"
-class CanViewProjectObjFilterBackend(FilterBackend):
- def filter_queryset(self, request, queryset, view):
- project_id = None
- if (hasattr(view, "filter_fields") and "project" in view.filter_fields and
- "project" in request.QUERY_PARAMS):
- try:
- project_id = int(request.QUERY_PARAMS["project"])
- except:
- logger.error("Filtering project diferent value than an integer: {}".format(
- request.QUERY_PARAMS["project"]
- ))
- raise exc.BadRequest(_("'project' must be an integer value."))
-
- qs = queryset
-
- if request.user.is_authenticated() and request.user.is_superuser:
- qs = qs
- elif request.user.is_authenticated():
- membership_model = apps.get_model("projects", "Membership")
- memberships_qs = membership_model.objects.filter(user=request.user)
- if project_id:
- memberships_qs = memberships_qs.filter(project_id=project_id)
- memberships_qs = memberships_qs.filter(Q(role__permissions__contains=['view_project']) |
- Q(is_owner=True))
-
- projects_list = [membership.project_id for membership in memberships_qs]
-
- qs = qs.filter((Q(id__in=projects_list) |
- Q(public_permissions__contains=["view_project"])))
- else:
- qs = qs.filter(anon_permissions__contains=["view_project"])
-
- return super().filter_queryset(request, qs.distinct(), view)
-
-
-class IsProjectMemberFilterBackend(FilterBackend):
- def filter_queryset(self, request, queryset, view):
- if request.user.is_authenticated() and request.user.is_superuser:
- queryset = queryset
- elif request.user.is_authenticated():
- queryset = queryset.filter(project__members=request.user)
- else:
- queryset = queryset.none()
-
- return super().filter_queryset(request, queryset.distinct(), view)
-
+#####################################################################
+# User filters
+#####################################################################
class MembersFilterBackend(PermissionBasedFilterBackend):
permission = "view_project"
@@ -309,6 +269,10 @@ class MembersFilterBackend(PermissionBasedFilterBackend):
return qs.distinct()
+#####################################################################
+# Webhooks filters
+#####################################################################
+
class BaseIsProjectAdminFilterBackend(object):
def get_project_ids(self, request, view):
project_id = None
@@ -358,7 +322,7 @@ class IsProjectAdminFromWebhookLogFilterBackend(FilterBackend, BaseIsProjectAdmi
#####################################################################
-# Generic Attributes filters
+# Generic Attributes filters (for User Stories, Tasks and Issues)
#####################################################################
class BaseRelatedFieldsFilter(FilterBackend):
@@ -451,7 +415,6 @@ class TagsFilter(FilterBackend):
return super().filter_queryset(request, queryset, view)
-
class WatchersFilter(FilterBackend):
filter_name = 'watchers'
@@ -487,9 +450,12 @@ class QFilter(FilterBackend):
q = request.QUERY_PARAMS.get('q', None)
if q:
table = queryset.model._meta.db_table
- where_clause = ("to_tsvector('english_nostop', coalesce({table}.subject, '') || ' ' || "
- "coalesce({table}.ref) || ' ' || "
- "coalesce({table}.description, '')) @@ to_tsquery('english_nostop', %s)".format(table=table))
+ where_clause = ("""
+ to_tsvector('english_nostop',
+ coalesce({table}.subject, '') || ' ' ||
+ coalesce({table}.ref) || ' ' ||
+ coalesce({table}.description, '')) @@ to_tsquery('english_nostop', %s)
+ """.format(table=table))
queryset = queryset.extra(where=[where_clause], params=[to_tsquery(q)])
diff --git a/taiga/projects/api.py b/taiga/projects/api.py
index 2e344680..f48b3835 100644
--- a/taiga/projects/api.py
+++ b/taiga/projects/api.py
@@ -16,8 +16,8 @@
# along with this program. If not, see .
import uuid
-
from easy_thumbnails.source_generators import pil_image
+from dateutil.relativedelta import relativedelta
from django.apps import apps
from django.db.models import signals, Prefetch
@@ -51,12 +51,12 @@ from taiga.projects.issues.models import Issue
from taiga.projects.likes.mixins.viewsets import LikedResourceMixin, FansViewSetMixin
from taiga.permissions import service as permissions_service
-from . import serializers
+from . import filters as project_filters
from . import models
from . import permissions
+from . import serializers
from . import services
-from dateutil.relativedelta import relativedelta
######################################################
## Project
@@ -67,7 +67,8 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, ModelCrudViewSet)
admin_serializer_class = serializers.ProjectDetailAdminSerializer
list_serializer_class = serializers.ProjectSerializer
permission_classes = (permissions.ProjectPermission, )
- filter_backends = (filters.CanViewProjectObjFilterBackend,)
+ filter_backends = (project_filters.QFilter,
+ project_filters.CanViewProjectObjFilterBackend)
filter_fields = (("member", "members"),
"is_looking_for_people",
@@ -87,7 +88,7 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, ModelCrudViewSet)
"total_activity_last_year")
def _get_order_by_field_name(self):
- order_by_query_param = filters.CanViewProjectObjFilterBackend.order_by_query_param
+ order_by_query_param = project_filters.CanViewProjectObjFilterBackend.order_by_query_param
order_by = self.request.QUERY_PARAMS.get(order_by_query_param, None)
if order_by is not None and order_by.startswith("-"):
return order_by[1:]
diff --git a/taiga/projects/filters.py b/taiga/projects/filters.py
new file mode 100644
index 00000000..7d56ba95
--- /dev/null
+++ b/taiga/projects/filters.py
@@ -0,0 +1,101 @@
+# Copyright (C) 2014-2015 Andrey Antukh
+# Copyright (C) 2014-2015 Jesús Espino
+# Copyright (C) 2014-2015 David Barragán
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+import logging
+
+from django.apps import apps
+from django.db.models import Q
+from django.utils.translation import ugettext as _
+
+from taiga.base import exceptions as exc
+from taiga.base.filters import FilterBackend
+from taiga.base.utils.db import to_tsquery
+
+logger = logging.getLogger(__name__)
+
+
+class CanViewProjectObjFilterBackend(FilterBackend):
+ def filter_queryset(self, request, queryset, view):
+ project_id = None
+
+ # Filter by filter_fields
+ if (hasattr(view, "filter_fields") and "project" in view.filter_fields and
+ "project" in request.QUERY_PARAMS):
+ try:
+ project_id = int(request.QUERY_PARAMS["project"])
+ except:
+ logger.error("Filtering project diferent value than an integer: {}".format(
+ request.QUERY_PARAMS["project"]
+ ))
+ raise exc.BadRequest(_("'project' must be an integer value."))
+
+ qs = queryset
+
+ # Filter by user permissions
+ if request.user.is_authenticated() and request.user.is_superuser:
+ # superuser
+ qs = qs
+ elif request.user.is_authenticated():
+ # projet members
+ membership_model = apps.get_model("projects", "Membership")
+ memberships_qs = membership_model.objects.filter(user=request.user)
+ if project_id:
+ memberships_qs = memberships_qs.filter(project_id=project_id)
+ memberships_qs = memberships_qs.filter(Q(role__permissions__contains=['view_project']) |
+ Q(is_owner=True))
+
+ projects_list = [membership.project_id for membership in memberships_qs]
+
+ qs = qs.filter((Q(id__in=projects_list) |
+ Q(public_permissions__contains=["view_project"])))
+ else:
+ # external users / anonymous
+ qs = qs.filter(anon_permissions__contains=["view_project"])
+
+ return super().filter_queryset(request, qs.distinct(), view)
+
+
+class QFilter(FilterBackend):
+ def filter_queryset(self, request, queryset, view):
+ # NOTE: See migtration 0033_text_search_indexes
+ q = request.QUERY_PARAMS.get('q', None)
+ if q:
+ tsquery = "to_tsquery('english_nostop', %s)"
+ tsquery_params = [to_tsquery(q)]
+ tsvector = """
+ setweight(to_tsvector('english_nostop',
+ coalesce(projects_project.name, '')), 'A') ||
+ setweight(to_tsvector('english_nostop',
+ coalesce(inmutable_array_to_string(projects_project.tags), '')), 'B') ||
+ setweight(to_tsvector('english_nostop',
+ coalesce(projects_project.description, '')), 'C')
+ """
+
+ select = {
+ "rank": "ts_rank({tsvector},{tsquery})".format(tsquery=tsquery,
+ tsvector=tsvector),
+ }
+ select_params = tsquery_params
+ where = ["{tsvector} @@ {tsquery}".format(tsquery=tsquery,
+ tsvector=tsvector),]
+ params = tsquery_params
+ order_by = ["-rank", ]
+
+ queryset = queryset.extra(select=select,
+ select_params=select_params,
+ where=where,
+ params=params,
+ order_by=order_by)
+ return queryset
diff --git a/taiga/projects/migrations/0033_text_search_indexes.py b/taiga/projects/migrations/0033_text_search_indexes.py
new file mode 100644
index 00000000..df97a50d
--- /dev/null
+++ b/taiga/projects/migrations/0033_text_search_indexes.py
@@ -0,0 +1,50 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations
+
+
+DROP_INMUTABLE_ARRAY_TO_STRING_FUNCTION = """
+ DROP FUNCTION IF EXISTS inmutable_array_to_string(text[]) CASCADE
+"""
+
+
+# NOTE: This function is needed by taiga.projects.filters.QFilter
+CREATE_INMUTABLE_ARRAY_TO_STRING_FUNCTION = """
+ CREATE OR REPLACE FUNCTION inmutable_array_to_string(text[])
+ RETURNS text
+ LANGUAGE sql
+ IMMUTABLE AS $$SELECT array_to_string($1, ' ', '')$$
+"""
+
+
+DROP_INDEX = """
+ DROP INDEX IF EXISTS projects_project_textquery_idx;
+"""
+
+
+# NOTE: This index is needed by taiga.projects.filters.QFilter
+CREATE_INDEX = """
+ CREATE INDEX projects_project_textquery_idx
+ ON projects_project
+ USING gin((setweight(to_tsvector('english_nostop',
+ coalesce(projects_project.name, '')), 'A') ||
+ setweight(to_tsvector('english_nostop',
+ coalesce(inmutable_array_to_string(projects_project.tags), '')), 'B') ||
+ setweight(to_tsvector('english_nostop',
+ coalesce(projects_project.description, '')), 'C')));
+"""
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('projects', '0032_auto_20151202_1151'),
+ ]
+
+ operations = [
+ migrations.RunSQL([DROP_INMUTABLE_ARRAY_TO_STRING_FUNCTION, CREATE_INMUTABLE_ARRAY_TO_STRING_FUNCTION],
+ [DROP_INMUTABLE_ARRAY_TO_STRING_FUNCTION]),
+ migrations.RunSQL([DROP_INDEX, CREATE_INDEX],
+ [DROP_INDEX]),
+ ]
diff --git a/tests/integration/test_projects.py b/tests/integration/test_projects.py
index 011b4856..5349218c 100644
--- a/tests/integration/test_projects.py
+++ b/tests/integration/test_projects.py
@@ -509,3 +509,23 @@ def test_project_list_without_search_query_order_by_name(client):
assert response.data[0]["id"] == project1.id
assert response.data[1]["id"] == project2.id
assert response.data[2]["id"] == project3.id
+
+
+def test_project_list_with_search_query_order_by_ranking(client):
+ user = f.UserFactory.create(is_superuser=True)
+ project3 = f.create_project(name="test 3 - word", description="description 3", tags=["tag3"])
+ project1 = f.create_project(name="test 1", description="description 1 - word", tags=["tag1"])
+ project2 = f.create_project(name="test 2", description="description 2", tags=["word", "tag2"])
+ project4 = f.create_project(name="test 4", description="description 4", tags=["tag4"])
+ project5 = f.create_project(name="test 5", description="description 5", tags=["tag5"])
+
+ url = reverse("projects-list")
+
+ client.login(user)
+ response = client.json.get(url, {"q": "word"})
+
+ assert response.status_code == 200
+ assert len(response.data) == 3
+ assert response.data[0]["id"] == project3.id
+ assert response.data[1]["id"] == project2.id
+ assert response.data[2]["id"] == project1.id