From d6546dc5180afb6aea71794c1ef657b7102bf185 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Mon, 16 Jun 2014 15:40:51 +0200 Subject: [PATCH] Refactor searches module --- settings/common.py | 6 +- taiga/base/searches/api.py | 118 -------------------------- taiga/routers.py | 8 +- taiga/{base => }/searches/__init__.py | 0 taiga/searches/api.py | 78 +++++++++++++++++ taiga/{base => }/searches/models.py | 0 taiga/searches/services.py | 69 +++++++++++++++ tests/factories.py | 5 ++ tests/integration/test_searches.py | 81 ++++++++++++++++++ 9 files changed, 243 insertions(+), 122 deletions(-) delete mode 100644 taiga/base/searches/api.py rename taiga/{base => }/searches/__init__.py (100%) create mode 100644 taiga/searches/api.py rename taiga/{base => }/searches/models.py (100%) create mode 100644 taiga/searches/services.py create mode 100644 tests/integration/test_searches.py diff --git a/settings/common.py b/settings/common.py index 64fea1a6..09e99e81 100644 --- a/settings/common.py +++ b/settings/common.py @@ -167,7 +167,6 @@ INSTALLED_APPS = [ "django.contrib.staticfiles", "taiga.base", - "taiga.base.searches", "taiga.events", "taiga.front", "taiga.users", @@ -183,6 +182,7 @@ INSTALLED_APPS = [ "taiga.projects.history", "taiga.projects.notifications", "taiga.projects.votes", + "taiga.searches", "taiga.timeline", "taiga.mdrender", @@ -294,9 +294,11 @@ REST_FRAMEWORK = { DEFAULT_PROJECT_TEMPLATE = "scrum" PUBLIC_REGISTER_ENABLED = False +SEARCHES_MAX_RESULTS = 150 + # NOTE: DON'T INSERT MORE SETTINGS AFTER THIS LINE -TEST_RUNNER="django.test.runner.DiscoverRunner" +#TEST_RUNNER="django.test.runner.DiscoverRunner" # Test conditions if "test" in sys.argv: diff --git a/taiga/base/searches/api.py b/taiga/base/searches/api.py deleted file mode 100644 index 9deb2698..00000000 --- a/taiga/base/searches/api.py +++ /dev/null @@ -1,118 +0,0 @@ -# Copyright (C) 2014 Andrey Antukh -# Copyright (C) 2014 Jesús Espino -# Copyright (C) 2014 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 . - -from django.db.models.loading import get_model - -from rest_framework.response import Response -from rest_framework import viewsets - -from taiga.base import exceptions as excp -from taiga.projects.userstories.serializers import UserStorySerializer -from taiga.projects.tasks.serializers import TaskSerializer -from taiga.projects.issues.serializers import IssueSerializer -from taiga.projects.wiki.serializers import WikiPageSerializer - - -class SearchViewSet(viewsets.ViewSet): - def list(self, request, **kwargs): - project_model = get_model("projects", "Project") - text = request.QUERY_PARAMS.get('text', "") - get_all = request.QUERY_PARAMS.get('get_all', False) - project_id = request.QUERY_PARAMS.get('project', None) - - try: - project = self._get_project(project_id) - except (project_model.DoesNotExist, TypeError): - raise excp.PermissionDenied({"detail": "Wrong project id"}) - - result = { - "userstories": self._search_user_stories(project, text, get_all), - "tasks": self._search_tasks(project, text, get_all), - "issues": self._search_issues(project, text, get_all), - "wikipages": self._search_wiki_pages(project, text, get_all) - } - - result["count"] = sum(map(lambda x: len(x), result.values())) - return Response(result) - - def _get_project(self, project_id): - project_model = get_model("projects", "Project") - own_projects = (project_model.objects - .filter(members=self.request.user)) - - return own_projects.get(pk=project_id) - - def _search_user_stories(self, project, text, get_all): - where_clause = ("to_tsvector(userstories_userstory.subject || " - "userstories_userstory.description) @@ plainto_tsquery(%s)") - - model_cls = get_model("userstories", "UserStory") - if get_all != "false" and text == '': - queryset = model_cls.objects.filter(project_id=project.pk) - else: - queryset = (model_cls.objects - .extra(where=[where_clause], params=[text]) - .filter(project_id=project.pk)[:50]) - - serializer = UserStorySerializer(queryset, many=True) - return serializer.data - - def _search_tasks(self, project, text, get_all): - where_clause = ("to_tsvector(tasks_task.subject || tasks_task.description) " - "@@ plainto_tsquery(%s)") - - model_cls = get_model("tasks", "Task") - if get_all != "false" and text == '': - queryset = model_cls.objects.filter(project_id=project.pk) - else: - queryset = (model_cls.objects - .extra(where=[where_clause], params=[text]) - .filter(project_id=project.pk)[:50]) - - serializer = TaskSerializer(queryset, many=True) - return serializer.data - - def _search_issues(self, project, text, get_all): - where_clause = ("to_tsvector(issues_issue.subject || issues_issue.description) " - "@@ plainto_tsquery(%s)") - - model_cls = get_model("issues", "Issue") - if get_all != "false" and text == '': - queryset = model_cls.objects.filter(project_id=project.pk) - else: - queryset = (model_cls.objects - .extra(where=[where_clause], params=[text]) - .filter(project_id=project.pk)[:50]) - - serializer = IssueSerializer(queryset, many=True) - return serializer.data - - def _search_wiki_pages(self, project, text, get_all): - where_clause = ("to_tsvector(wiki_wikipage.slug || wiki_wikipage.content) " - "@@ plainto_tsquery(%s)") - - model_cls = get_model("wiki", "WikiPage") - if get_all != "false" and text == '': - queryset = model_cls.objects.filter(project_id=project.pk) - else: - queryset = (model_cls.objects - .extra(where=[where_clause], params=[text]) - .filter(project_id=project.pk)[:50]) - - serializer = WikiPageSerializer(queryset, many=True) - return serializer.data - - diff --git a/taiga/routers.py b/taiga/routers.py index 809544f2..39b61f99 100644 --- a/taiga/routers.py +++ b/taiga/routers.py @@ -32,11 +32,15 @@ from taiga.userstorage.api import StorageEntriesViewSet router.register(r"user-storage", StorageEntriesViewSet, base_name="user-storage") -# Resolver & Search -from taiga.base.searches.api import SearchViewSet +# Resolver from taiga.projects.references.api import ResolverViewSet router.register(r"resolver", ResolverViewSet, base_name="resolver") + + +# Search +from taiga.searches.api import SearchViewSet + router.register(r"search", SearchViewSet, base_name="search") diff --git a/taiga/base/searches/__init__.py b/taiga/searches/__init__.py similarity index 100% rename from taiga/base/searches/__init__.py rename to taiga/searches/__init__.py diff --git a/taiga/searches/api.py b/taiga/searches/api.py new file mode 100644 index 00000000..818ef625 --- /dev/null +++ b/taiga/searches/api.py @@ -0,0 +1,78 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 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 . + +from django.db.models.loading import get_model + +from rest_framework.response import Response +from rest_framework import viewsets + +from taiga.base import exceptions as excp +from taiga.projects.userstories.serializers import UserStorySerializer +from taiga.projects.tasks.serializers import TaskSerializer +from taiga.projects.issues.serializers import IssueSerializer +from taiga.projects.wiki.serializers import WikiPageSerializer + +from . import services + + +class SearchViewSet(viewsets.ViewSet): + def list(self, request, **kwargs): + project_model = get_model("projects", "Project") + + text = request.QUERY_PARAMS.get('text', "") + project_id = request.QUERY_PARAMS.get('project', None) + + try: + project = self._get_project(project_id) + except (project_model.DoesNotExist, TypeError): + raise excp.PermissionDenied({"detail": "Wrong project id"}) + + result = { + "userstories": self._search_user_stories(project, text), + "tasks": self._search_tasks(project, text), + "issues": self._search_issues(project, text), + "wikipages": self._search_wiki_pages(project, text) + } + + result["count"] = sum(map(lambda x: len(x), result.values())) + return Response(result) + + def _get_project(self, project_id): + project_model = get_model("projects", "Project") + own_projects = (project_model.objects + .filter(members=self.request.user)) + + return own_projects.get(pk=project_id) + + def _search_user_stories(self, project, text): + queryset = services.search_user_stories(project, text) + serializer = UserStorySerializer(queryset, many=True) + return serializer.data + + def _search_tasks(self, project, text): + queryset = services.search_tasks(project, text) + serializer = TaskSerializer(queryset, many=True) + return serializer.data + + def _search_issues(self, project, text): + queryset = services.search_issues(project, text) + serializer = IssueSerializer(queryset, many=True) + return serializer.data + + def _search_wiki_pages(self, project, text): + queryset = services.search_wiki_pages(project, text) + serializer = WikiPageSerializer(queryset, many=True) + return serializer.data diff --git a/taiga/base/searches/models.py b/taiga/searches/models.py similarity index 100% rename from taiga/base/searches/models.py rename to taiga/searches/models.py diff --git a/taiga/searches/services.py b/taiga/searches/services.py new file mode 100644 index 00000000..e9b9d14d --- /dev/null +++ b/taiga/searches/services.py @@ -0,0 +1,69 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 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 . + +from django.db.models.loading import get_model +from django.conf import settings + + +MAX_RESULTS = getattr(settings, "SEARCHES_MAX_RESULTS", 150) + + +def search_user_stories(project, text): + model_cls = get_model("userstories", "UserStory") + where_clause = ("to_tsvector(coalesce(userstories_userstory.subject) || ' ' || " + "coalesce(userstories_userstory.description)) @@ plainto_tsquery(%s)") + + if text: + return (model_cls.objects.extra(where=[where_clause], params=[text]) + .filter(project_id=project.pk)[:MAX_RESULTS]) + + return model_cls.objects.filter(project_id=project.pk)[:MAX_RESULTS] + + +def search_tasks(project, text): + model_cls = get_model("tasks", "Task") + where_clause = ("to_tsvector(coalesce(tasks_task.subject, '') || ' ' || coalesce(tasks_task.description, '')) " + "@@ plainto_tsquery(%s)") + + if text: + return (model_cls.objects.extra(where=[where_clause], params=[text]) + .filter(project_id=project.pk)[:MAX_RESULTS]) + + return model_cls.objects.filter(project_id=project.pk)[:MAX_RESULTS] + + +def search_issues(project, text): + model_cls = get_model("issues", "Issue") + where_clause = ("to_tsvector(coalesce(issues_issue.subject) || ' ' || coalesce(issues_issue.description)) " + "@@ plainto_tsquery(%s)") + + if text: + return (model_cls.objects.extra(where=[where_clause], params=[text]) + .filter(project_id=project.pk)[:MAX_RESULTS]) + + return model_cls.objects.filter(project_id=project.pk)[:MAX_RESULTS] + + +def search_wiki_pages(project, text): + model_cls = get_model("wiki", "WikiPage") + where_clause = ("to_tsvector(coalesce(wiki_wikipage.slug) || ' ' || coalesce(wiki_wikipage.content)) " + "@@ plainto_tsquery(%s)") + + if text: + return (model_cls.objects.extra(where=[where_clause], params=[text]) + .filter(project_id=project.pk)[:MAX_RESULTS]) + + return model_cls.objects.filter(project_id=project.pk)[:MAX_RESULTS] diff --git a/tests/factories.py b/tests/factories.py index fe2dbf01..9cdf991e 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -110,6 +110,7 @@ class UserStoryFactory(Factory): project = factory.SubFactory("tests.factories.ProjectFactory") owner = factory.SubFactory("tests.factories.UserFactory") subject = factory.Sequence(lambda n: "User Story {}".format(n)) + description = factory.Sequence(lambda n: "User Story {} description".format(n)) class MilestoneFactory(Factory): @@ -124,6 +125,7 @@ class IssueFactory(Factory): FACTORY_FOR = get_model("issues", "Issue") subject = factory.Sequence(lambda n: "Issue {}".format(n)) + description = factory.Sequence(lambda n: "Issue {} description".format(n)) owner = factory.SubFactory("tests.factories.UserFactory") project = factory.SubFactory("tests.factories.ProjectFactory") status = factory.SubFactory("tests.factories.IssueStatusFactory") @@ -137,6 +139,7 @@ class TaskFactory(Factory): FACTORY_FOR = get_model("tasks", "Task") subject = factory.Sequence(lambda n: "Task {}".format(n)) + description = factory.Sequence(lambda n: "Task {} description".format(n)) owner = factory.SubFactory("tests.factories.UserFactory") project = factory.SubFactory("tests.factories.ProjectFactory") status = factory.SubFactory("tests.factories.TaskStatusFactory") @@ -148,6 +151,8 @@ class WikiPageFactory(Factory): project = factory.SubFactory("tests.factories.ProjectFactory") owner = factory.SubFactory("tests.factories.UserFactory") + slug = factory.Sequence(lambda n: "wiki-page-{}".format(n)) + content = factory.Sequence(lambda n: "Wiki Page {} content".format(n)) class IssueStatusFactory(Factory): diff --git a/tests/integration/test_searches.py b/tests/integration/test_searches.py new file mode 100644 index 00000000..b9754f25 --- /dev/null +++ b/tests/integration/test_searches.py @@ -0,0 +1,81 @@ +import pytest + +from django.core.urlresolvers import reverse + +from .. import factories as f + + +pytestmark = pytest.mark.django_db + +@pytest.fixture +def searches_initial_data(): + m = type("InitialData", (object,), {})() + + m.project1 = f.ProjectFactory.create() + m.project2 = f.ProjectFactory.create() + + m.member1 = f.MembershipFactory.create(project=m.project1) + m.member2 = f.MembershipFactory.create(project=m.project1) + + m.us1 = f.UserStoryFactory.create(project=m.project1) + m.us2 = f.UserStoryFactory.create(project=m.project1, description="Back to the future") + m.us3 = f.UserStoryFactory.create(project=m.project2) + + m.tsk1 = f.TaskFactory.create(project=m.project2) + m.tsk2 = f.TaskFactory.create(project=m.project1) + m.tsk3 = f.TaskFactory.create(project=m.project1, subject="Back to the future") + + m.iss1 = f.IssueFactory.create(project=m.project1, subject="Backend and Frontend") + m.iss2 = f.IssueFactory.create(project=m.project2) + m.iss3 = f.IssueFactory.create(project=m.project1) + + m.wiki1 = f.WikiPageFactory.create(project=m.project1) + m.wiki2 = f.WikiPageFactory.create(project=m.project1, content="Frontend, future") + m.wiki3 = f.WikiPageFactory.create(project=m.project2) + + return m + + +def test_search_all_objects_in_my_project(client, searches_initial_data): + data = searches_initial_data + + client.login(data.member1.user) + + response = client.get(reverse("search-list"), {"project": data.project1.id}) + assert response.status_code == 200 + assert response.data["count"] == 8 + assert len(response.data["userstories"]) == 2 + assert len(response.data["tasks"]) == 2 + assert len(response.data["issues"]) == 2 + assert len(response.data["wikipages"]) == 2 + + +def test_search_all_objects_in_project_is_not_mine(client, searches_initial_data): + data = searches_initial_data + + client.login(data.member1.user) + + response = client.get(reverse("search-list"), {"project": data.project2.id}) + assert response.status_code == 403 + + +def test_search_text_query_in_my_project(client, searches_initial_data): + data = searches_initial_data + + client.login(data.member1.user) + + response = client.get(reverse("search-list"), {"project": data.project1.id, "text": "future"}) + assert response.status_code == 200 + assert response.data["count"] == 3 + assert len(response.data["userstories"]) == 1 + assert len(response.data["tasks"]) == 1 + assert len(response.data["issues"]) == 0 + assert len(response.data["wikipages"]) == 1 + + response = client.get(reverse("search-list"), {"project": data.project1.id, "text": "back"}) + assert response.status_code == 200 + assert response.data["count"] == 2 + assert len(response.data["userstories"]) == 1 + assert len(response.data["tasks"]) == 1 + assert len(response.data["issues"]) == 0 + assert len(response.data["wikipages"]) == 0