diff --git a/taiga/base/api/pagination.py b/taiga/base/api/pagination.py index dbab110b..e501ec9c 100644 --- a/taiga/base/api/pagination.py +++ b/taiga/base/api/pagination.py @@ -14,7 +14,13 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from django.core.paginator import Paginator, InvalidPage +from django.core.paginator import ( + EmptyPage, + Page, + PageNotAnInteger, + Paginator, + InvalidPage, +) from django.http import Http404 from django.utils.translation import ugettext as _ @@ -36,6 +42,90 @@ def strict_positive_int(integer_string, cutoff=None): return ret +class CustomPage(Page): + """Handle different number of items on the first page.""" + + def start_index(self): + """Return the 1-based index of the first item on this page.""" + paginator = self.paginator + # Special case, return zero if no items. + if paginator.count == 0: + return 0 + elif self.number == 1: + return 1 + return ( + (self.number - 2) * paginator.per_page + paginator.first_page + 1) + + def end_index(self): + """Return the 1-based index of the last item on this page.""" + paginator = self.paginator + # Special case for the last page because there can be orphans. + if self.number == paginator.num_pages: + return paginator.count + return (self.number - 1) * paginator.per_page + paginator.first_page + + +class LazyPaginator(Paginator): + """Implement lazy pagination.""" + + def __init__(self, object_list, per_page, **kwargs): + if 'first_page' in kwargs: + self.first_page = kwargs.pop('first_page') + else: + self.first_page = per_page + super(LazyPaginator, self).__init__(object_list, per_page, **kwargs) + + def get_current_per_page(self, number): + return self.first_page if number == 1 else self.per_page + + def validate_number(self, number): + try: + number = int(number) + except ValueError: + raise PageNotAnInteger('That page number is not an integer') + if number < 1: + raise EmptyPage('That page number is less than 1') + return number + + def page(self, number): + number = self.validate_number(number) + current_per_page = self.get_current_per_page(number) + if number == 1: + bottom = 0 + else: + bottom = ((number - 2) * self.per_page + self.first_page) + top = bottom + current_per_page + # Retrieve more objects to check if there is a next page. + objects = list(self.object_list[bottom:top + self.orphans + 1]) + objects_count = len(objects) + if objects_count > (current_per_page + self.orphans): + # If another page is found, increase the total number of pages. + self._num_pages = number + 1 + # In any case, return only objects for this page. + objects = objects[:current_per_page] + elif (number != 1) and (objects_count <= self.orphans): + raise EmptyPage('That page contains no results') + else: + # This is the last page. + self._num_pages = number + return Page(objects, number, self) + + def _get_count(self): + raise NotImplementedError + + count = property(_get_count) + + def _get_num_pages(self): + return self._num_pages + + num_pages = property(_get_num_pages) + + def _get_page_range(self): + raise NotImplementedError + + page_range = property(_get_page_range) + + class PaginationMixin(object): # Pagination settings paginate_by = api_settings.PAGINATE_BY @@ -77,6 +167,12 @@ class PaginationMixin(object): Paginate a queryset if required, either returning a page object, or `None` if pagination is not configured for this view. """ + if "HTTP_X_DISABLE_PAGINATION" in self.request.META: + return None + + if "HTTP_X_LAZY_PAGINATION" in self.request.META: + self.paginator_class = LazyPaginator + deprecated_style = False if page_size is not None: warnings.warn('The `page_size` parameter to `paginate_queryset()` ' @@ -103,6 +199,7 @@ class PaginationMixin(object): paginator = self.paginator_class(queryset, page_size, allow_empty_first_page=self.allow_empty) + page_kwarg = self.kwargs.get(self.page_kwarg) page_query_param = self.request.QUERY_PARAMS.get(self.page_kwarg) page = page_kwarg or page_query_param or 1 @@ -124,7 +221,9 @@ class PaginationMixin(object): if page is None: return page - self.headers["x-pagination-count"] = page.paginator.count + if not "HTTP_X_LAZY_PAGINATION" in self.request.META: + self.headers["x-pagination-count"] = page.paginator.count + self.headers["x-paginated"] = "true" self.headers["x-paginated-by"] = page.paginator.per_page self.headers["x-pagination-current"] = page.number diff --git a/taiga/permissions/service.py b/taiga/permissions/service.py index 90ed3050..0048fc26 100644 --- a/taiga/permissions/service.py +++ b/taiga/permissions/service.py @@ -24,10 +24,8 @@ def _get_user_project_membership(user, project): if user.is_anonymous(): return None - try: - return Membership.objects.get(user=user, project=project) - except Membership.DoesNotExist: - return None + return user.cached_membership_for_project(project) + def _get_object_project(obj): project = None @@ -48,6 +46,8 @@ def is_project_owner(user, obj): return True project = _get_object_project(obj) + if project is None: + return False membership = _get_user_project_membership(user, project) if membership and membership.is_owner: diff --git a/taiga/projects/issues/api.py b/taiga/projects/issues/api.py index 1df9875e..f38bfb2e 100644 --- a/taiga/projects/issues/api.py +++ b/taiga/projects/issues/api.py @@ -67,6 +67,7 @@ class IssueViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, W filter_fields = ("project", "status__is_closed") + order_by_fields = ("type", "status", "severity", @@ -143,7 +144,8 @@ class IssueViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, W def get_queryset(self): qs = super().get_queryset() - qs = qs.prefetch_related("attachments") + qs = qs.prefetch_related("attachments", "generated_user_stories") + qs = qs.select_related("owner", "assigned_to", "status", "project") qs = self.attach_votes_attrs_to_queryset(qs) return self.attach_watchers_attrs_to_queryset(qs) diff --git a/taiga/projects/issues/serializers.py b/taiga/projects/issues/serializers.py index f4457a5f..cfc10feb 100644 --- a/taiga/projects/issues/serializers.py +++ b/taiga/projects/issues/serializers.py @@ -52,7 +52,11 @@ class IssueSerializer(WatchersValidator, VoteResourceSerializerMixin, EditableWa return "" def get_generated_user_stories(self, obj): - return obj.generated_user_stories.values("id", "ref", "subject") + return [{ + "id": us.id, + "ref": us.ref, + "subject": us.subject, + } for us in obj.generated_user_stories.all()] def get_blocked_note_html(self, obj): return mdrender(obj.project, obj.blocked_note) diff --git a/taiga/projects/issues/services.py b/taiga/projects/issues/services.py index 8f8b4a06..83a5f818 100644 --- a/taiga/projects/issues/services.py +++ b/taiga/projects/issues/services.py @@ -133,18 +133,22 @@ def _get_issues_statuses(project, queryset): where_params = queryset_where_tuple[1] extra_sql = """ - SELECT "projects_issuestatus"."id", + WITH counters AS ( + SELECT status_id, count(status_id) count + FROM "issues_issue" + INNER JOIN "projects_project" ON ("issues_issue"."project_id" = "projects_project"."id") + WHERE {where} + GROUP BY status_id + ) + SELECT "projects_issuestatus"."id", "projects_issuestatus"."name", "projects_issuestatus"."color", "projects_issuestatus"."order", - (SELECT count(*) - FROM "issues_issue" - INNER JOIN "projects_project" ON - ("issues_issue"."project_id" = "projects_project"."id") - WHERE {where} AND "issues_issue"."status_id" = "projects_issuestatus"."id") + COALESCE(counters.count, 0) FROM "projects_issuestatus" - WHERE "projects_issuestatus"."project_id" = %s - ORDER BY "projects_issuestatus"."order"; + LEFT OUTER JOIN counters ON counters.status_id = projects_issuestatus.id + WHERE "projects_issuestatus"."project_id" = %s + ORDER BY "projects_issuestatus"."order"; """.format(where=where) with closing(connection.cursor()) as cursor: @@ -170,18 +174,22 @@ def _get_issues_types(project, queryset): where_params = queryset_where_tuple[1] extra_sql = """ - SELECT "projects_issuetype"."id", + WITH counters AS ( + SELECT type_id, count(type_id) count + FROM "issues_issue" + INNER JOIN "projects_project" ON ("issues_issue"."project_id" = "projects_project"."id") + WHERE {where} + GROUP BY type_id + ) + SELECT "projects_issuetype"."id", "projects_issuetype"."name", "projects_issuetype"."color", "projects_issuetype"."order", - (SELECT count(*) - FROM "issues_issue" - INNER JOIN "projects_project" ON - ("issues_issue"."project_id" = "projects_project"."id") - WHERE {where} AND "issues_issue"."type_id" = "projects_issuetype"."id") + COALESCE(counters.count, 0) FROM "projects_issuetype" - WHERE "projects_issuetype"."project_id" = %s - ORDER BY "projects_issuetype"."order"; + LEFT OUTER JOIN counters ON counters.type_id = projects_issuetype.id + WHERE "projects_issuetype"."project_id" = %s + ORDER BY "projects_issuetype"."order"; """.format(where=where) with closing(connection.cursor()) as cursor: @@ -207,18 +215,22 @@ def _get_issues_priorities(project, queryset): where_params = queryset_where_tuple[1] extra_sql = """ - SELECT "projects_priority"."id", + WITH counters AS ( + SELECT priority_id, count(priority_id) count + FROM "issues_issue" + INNER JOIN "projects_project" ON ("issues_issue"."project_id" = "projects_project"."id") + WHERE {where} + GROUP BY priority_id + ) + SELECT "projects_priority"."id", "projects_priority"."name", "projects_priority"."color", "projects_priority"."order", - (SELECT count(*) - FROM "issues_issue" - INNER JOIN "projects_project" ON - ("issues_issue"."project_id" = "projects_project"."id") - WHERE {where} AND "issues_issue"."priority_id" = "projects_priority"."id") + COALESCE(counters.count, 0) FROM "projects_priority" - WHERE "projects_priority"."project_id" = %s - ORDER BY "projects_priority"."order"; + LEFT OUTER JOIN counters ON counters.priority_id = projects_priority.id + WHERE "projects_priority"."project_id" = %s + ORDER BY "projects_priority"."order"; """.format(where=where) with closing(connection.cursor()) as cursor: @@ -244,18 +256,22 @@ def _get_issues_severities(project, queryset): where_params = queryset_where_tuple[1] extra_sql = """ - SELECT "projects_severity"."id", + WITH counters AS ( + SELECT severity_id, count(severity_id) count + FROM "issues_issue" + INNER JOIN "projects_project" ON ("issues_issue"."project_id" = "projects_project"."id") + WHERE {where} + GROUP BY severity_id + ) + SELECT "projects_severity"."id", "projects_severity"."name", "projects_severity"."color", "projects_severity"."order", - (SELECT count(*) - FROM "issues_issue" - INNER JOIN "projects_project" ON - ("issues_issue"."project_id" = "projects_project"."id") - WHERE {where} AND "issues_issue"."severity_id" = "projects_severity"."id") + COALESCE(counters.count, 0) FROM "projects_severity" - WHERE "projects_severity"."project_id" = %s - ORDER BY "projects_severity"."order"; + LEFT OUTER JOIN counters ON counters.severity_id = projects_severity.id + WHERE "projects_severity"."project_id" = %s + ORDER BY "projects_severity"."order"; """.format(where=where) with closing(connection.cursor()) as cursor: @@ -281,37 +297,55 @@ def _get_issues_assigned_to(project, queryset): where_params = queryset_where_tuple[1] extra_sql = """ - SELECT NULL, - NULL, - (SELECT count(*) - FROM "issues_issue" - INNER JOIN "projects_project" ON - ("issues_issue"."project_id" = "projects_project"."id" ) - WHERE {where} AND "issues_issue"."assigned_to_id" IS NULL) - UNION SELECT "users_user"."id", - "users_user"."full_name", - (SELECT count(*) - FROM "issues_issue" - INNER JOIN "projects_project" ON - ("issues_issue"."project_id" = "projects_project"."id" ) - WHERE {where} AND "issues_issue"."assigned_to_id" = "projects_membership"."user_id") - FROM "projects_membership" - INNER JOIN "users_user" ON - ("projects_membership"."user_id" = "users_user"."id") - WHERE "projects_membership"."project_id" = %s AND "projects_membership"."user_id" IS NOT NULL; + WITH counters AS ( + SELECT assigned_to_id, count(assigned_to_id) count + FROM "issues_issue" + INNER JOIN "projects_project" ON ("issues_issue"."project_id" = "projects_project"."id") + WHERE {where} AND "issues_issue"."assigned_to_id" IS NOT NULL + GROUP BY assigned_to_id + ) + SELECT + "projects_membership"."user_id" user_id, + "users_user"."full_name", + COALESCE("counters".count, 0) count + FROM projects_membership + LEFT OUTER JOIN counters ON ("projects_membership"."user_id" = "counters"."assigned_to_id") + INNER JOIN "users_user" ON ("projects_membership"."user_id" = "users_user"."id") + WHERE "projects_membership"."project_id" = %s AND "projects_membership"."user_id" IS NOT NULL + + -- unassigned issues + UNION + SELECT NULL user_id, NULL, count(coalesce(assigned_to_id, -1)) count + FROM "issues_issue" + INNER JOIN "projects_project" ON ("issues_issue"."project_id" = "projects_project"."id") + WHERE {where} AND "issues_issue"."assigned_to_id" IS NULL + GROUP BY assigned_to_id """.format(where=where) with closing(connection.cursor()) as cursor: - cursor.execute(extra_sql, where_params + where_params + [project.id]) + cursor.execute(extra_sql, where_params + [project.id] + where_params) rows = cursor.fetchall() result = [] + none_valued_added = False for id, full_name, count in rows: result.append({ "id": id, "full_name": full_name or "", "count": count, }) + + if id is None: + none_valued_added = True + + # If there was no issue with null assigned_to we manually add it + if not none_valued_added: + result.append({ + "id": None, + "full_name": "", + "count": 0, + }) + return sorted(result, key=itemgetter("full_name")) @@ -322,18 +356,31 @@ def _get_issues_owners(project, queryset): where_params = queryset_where_tuple[1] extra_sql = """ - SELECT "users_user"."id", - "users_user"."full_name", - (SELECT count(*) - FROM "issues_issue" - INNER JOIN "projects_project" ON - ("issues_issue"."project_id" = "projects_project"."id") - WHERE {where} and "issues_issue"."owner_id" = "projects_membership"."user_id") - FROM "projects_membership" - RIGHT OUTER JOIN "users_user" ON - ("projects_membership"."user_id" = "users_user"."id") - WHERE ("projects_membership"."project_id" = %s AND "projects_membership"."user_id" IS NOT NULL) - OR ("users_user"."is_system" IS TRUE); + WITH counters AS ( + SELECT "issues_issue"."owner_id" owner_id, count("issues_issue"."owner_id") count + FROM "issues_issue" + INNER JOIN "projects_project" ON ("issues_issue"."project_id" = "projects_project"."id") + WHERE {where} + GROUP BY "issues_issue"."owner_id" + ) + SELECT + "projects_membership"."user_id" id, + "users_user"."full_name", + COALESCE("counters".count, 0) count + FROM projects_membership + LEFT OUTER JOIN counters ON ("projects_membership"."user_id" = "counters"."owner_id") + INNER JOIN "users_user" ON ("projects_membership"."user_id" = "users_user"."id") + WHERE ("projects_membership"."project_id" = %s AND "projects_membership"."user_id" IS NOT NULL) + + -- System users + UNION + SELECT + "users_user"."id" user_id, + "users_user"."full_name" full_name, + COALESCE("counters".count, 0) count + FROM users_user + LEFT OUTER JOIN counters ON ("users_user"."id" = "counters"."owner_id") + WHERE ("users_user"."is_system" IS TRUE) """.format(where=where) with closing(connection.cursor()) as cursor: diff --git a/taiga/projects/milestones/api.py b/taiga/projects/milestones/api.py index d9e044be..056ed7d3 100644 --- a/taiga/projects/milestones/api.py +++ b/taiga/projects/milestones/api.py @@ -62,14 +62,16 @@ class MilestoneViewSet(HistoryResourceMixin, WatchedResourceMixin, ModelCrudView def get_queryset(self): qs = super().get_queryset() - qs = self.attach_watchers_attrs_to_queryset(qs) qs = qs.prefetch_related("user_stories", "user_stories__role_points", "user_stories__role_points__points", - "user_stories__role_points__role", - "user_stories__generated_from_issue", - "user_stories__project") - qs = qs.select_related("project") + "user_stories__role_points__role") + + qs = qs.select_related("project", + "owner") + + qs = self.attach_watchers_attrs_to_queryset(qs) + qs = qs.order_by("-estimated_start") return qs diff --git a/taiga/projects/milestones/serializers.py b/taiga/projects/milestones/serializers.py index 50e90a49..e3773654 100644 --- a/taiga/projects/milestones/serializers.py +++ b/taiga/projects/milestones/serializers.py @@ -21,16 +21,14 @@ from taiga.base.utils import json from taiga.projects.notifications.mixins import WatchedResourceModelSerializer from taiga.projects.notifications.validators import WatchersValidator -from ..userstories.serializers import UserStorySerializer +from ..userstories.serializers import MilestoneUserStorySerializer from . import models class MilestoneSerializer(WatchersValidator, WatchedResourceModelSerializer, serializers.ModelSerializer): - user_stories = UserStorySerializer(many=True, required=False, read_only=True) + user_stories = MilestoneUserStorySerializer(many=True, required=False, read_only=True) total_points = serializers.SerializerMethodField("get_total_points") closed_points = serializers.SerializerMethodField("get_closed_points") - client_increment_points = serializers.SerializerMethodField("get_client_increment_points") - team_increment_points = serializers.SerializerMethodField("get_team_increment_points") class Meta: model = models.Milestone @@ -42,12 +40,6 @@ class MilestoneSerializer(WatchersValidator, WatchedResourceModelSerializer, ser def get_closed_points(self, obj): return sum(obj.closed_points.values()) - def get_client_increment_points(self, obj): - return sum(obj.client_increment_points.values()) - - def get_team_increment_points(self, obj): - return sum(obj.team_increment_points.values()) - def validate_name(self, attrs, source): """ Check the milestone name is not duplicated in the project on creation diff --git a/taiga/projects/tasks/api.py b/taiga/projects/tasks/api.py index d48791e2..afd7d619 100644 --- a/taiga/projects/tasks/api.py +++ b/taiga/projects/tasks/api.py @@ -88,6 +88,13 @@ class TaskViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, Wa def get_queryset(self): qs = super().get_queryset() qs = self.attach_votes_attrs_to_queryset(qs) + qs = qs.select_related( + "milestone", + "owner", + "assigned_to", + "status", + "project") + return self.attach_watchers_attrs_to_queryset(qs) def pre_save(self, obj): diff --git a/taiga/projects/userstories/api.py b/taiga/projects/userstories/api.py index 66c7b521..6e8d747c 100644 --- a/taiga/projects/userstories/api.py +++ b/taiga/projects/userstories/api.py @@ -116,7 +116,12 @@ class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixi qs = qs.prefetch_related("role_points", "role_points__points", "role_points__role") - qs = qs.select_related("milestone", "project") + qs = qs.select_related("milestone", + "project", + "status", + "owner", + "assigned_to", + "generated_from_issue") qs = self.attach_votes_attrs_to_queryset(qs) return self.attach_watchers_attrs_to_queryset(qs) diff --git a/taiga/projects/userstories/serializers.py b/taiga/projects/userstories/serializers.py index c3f93d67..19dcab9f 100644 --- a/taiga/projects/userstories/serializers.py +++ b/taiga/projects/userstories/serializers.py @@ -45,6 +45,18 @@ class RolePointsField(serializers.WritableField): return json.loads(obj) +class MilestoneUserStorySerializer(serializers.ModelSerializer): + total_points = serializers.SerializerMethodField("get_total_points") + + class Meta: + model = models.UserStory + depth = 0 + fields = ("id", "ref", "subject", "is_closed", "is_blocked", "total_points") + + def get_total_points(self, obj): + return obj.get_total_points() + + class UserStorySerializer(WatchersValidator, VoteResourceSerializerMixin, EditableWatchedResourceModelSerializer, serializers.ModelSerializer): tags = TagsField(default=[], required=False) external_reference = PgArrayField(required=False) diff --git a/taiga/projects/userstories/services.py b/taiga/projects/userstories/services.py index bb05ae29..40d6a092 100644 --- a/taiga/projects/userstories/services.py +++ b/taiga/projects/userstories/services.py @@ -236,37 +236,55 @@ def _get_userstories_assigned_to(project, queryset): where_params = queryset_where_tuple[1] extra_sql = """ - SELECT NULL, - NULL, - (SELECT count(*) - FROM "userstories_userstory" - INNER JOIN "projects_project" ON - ("userstories_userstory"."project_id" = "projects_project"."id" ) - WHERE {where} AND "userstories_userstory"."assigned_to_id" IS NULL) - UNION SELECT "users_user"."id", - "users_user"."full_name", - (SELECT count(*) - FROM "userstories_userstory" - INNER JOIN "projects_project" ON - ("userstories_userstory"."project_id" = "projects_project"."id" ) - WHERE {where} AND "userstories_userstory"."assigned_to_id" = "projects_membership"."user_id") - FROM "projects_membership" - INNER JOIN "users_user" ON - ("projects_membership"."user_id" = "users_user"."id") - WHERE "projects_membership"."project_id" = %s AND "projects_membership"."user_id" IS NOT NULL; + WITH counters AS ( + SELECT assigned_to_id, count(assigned_to_id) count + FROM "userstories_userstory" + INNER JOIN "projects_project" ON ("userstories_userstory"."project_id" = "projects_project"."id") + WHERE {where} AND "userstories_userstory"."assigned_to_id" IS NOT NULL + GROUP BY assigned_to_id + ) + SELECT + "projects_membership"."user_id" user_id, + "users_user"."full_name", + COALESCE("counters".count, 0) count + FROM projects_membership + LEFT OUTER JOIN counters ON ("projects_membership"."user_id" = "counters"."assigned_to_id") + INNER JOIN "users_user" ON ("projects_membership"."user_id" = "users_user"."id") + WHERE "projects_membership"."project_id" = %s AND "projects_membership"."user_id" IS NOT NULL + + -- unassigned userstories + UNION + SELECT NULL user_id, NULL, count(coalesce(assigned_to_id, -1)) count + FROM "userstories_userstory" + INNER JOIN "projects_project" ON ("userstories_userstory"."project_id" = "projects_project"."id") + WHERE {where} AND "userstories_userstory"."assigned_to_id" IS NULL + GROUP BY assigned_to_id """.format(where=where) with closing(connection.cursor()) as cursor: - cursor.execute(extra_sql, where_params + where_params + [project.id]) + cursor.execute(extra_sql, where_params + [project.id] + where_params) rows = cursor.fetchall() result = [] + none_valued_added = False for id, full_name, count in rows: result.append({ "id": id, "full_name": full_name or "", "count": count, }) + + if id is None: + none_valued_added = True + + # If there was no userstory with null assigned_to we manually add it + if not none_valued_added: + result.append({ + "id": None, + "full_name": "", + "count": 0, + }) + return sorted(result, key=itemgetter("full_name")) @@ -277,18 +295,31 @@ def _get_userstories_owners(project, queryset): where_params = queryset_where_tuple[1] extra_sql = """ - SELECT "users_user"."id", - "users_user"."full_name", - (SELECT count(*) - FROM "userstories_userstory" - INNER JOIN "projects_project" ON - ("userstories_userstory"."project_id" = "projects_project"."id") - WHERE {where} AND "userstories_userstory"."owner_id" = "projects_membership"."user_id") - FROM "projects_membership" - RIGHT OUTER JOIN "users_user" ON - ("projects_membership"."user_id" = "users_user"."id") - WHERE (("projects_membership"."project_id" = %s AND "projects_membership"."user_id" IS NOT NULL) - OR ("users_user"."is_system" IS TRUE)); + WITH counters AS ( + SELECT "userstories_userstory"."owner_id" owner_id, count(coalesce("userstories_userstory"."owner_id", -1)) count + FROM "userstories_userstory" + INNER JOIN "projects_project" ON ("userstories_userstory"."project_id" = "projects_project"."id") + WHERE {where} + GROUP BY "userstories_userstory"."owner_id" + ) + SELECT + "projects_membership"."user_id" id, + "users_user"."full_name", + COALESCE("counters".count, 0) count + FROM projects_membership + LEFT OUTER JOIN counters ON ("projects_membership"."user_id" = "counters"."owner_id") + INNER JOIN "users_user" ON ("projects_membership"."user_id" = "users_user"."id") + WHERE ("projects_membership"."project_id" = %s AND "projects_membership"."user_id" IS NOT NULL) + + -- System users + UNION + SELECT + "users_user"."id" user_id, + "users_user"."full_name" full_name, + COALESCE("counters".count, 0) count + FROM users_user + LEFT OUTER JOIN counters ON ("users_user"."id" = "counters"."owner_id") + WHERE ("users_user"."is_system" IS TRUE) """.format(where=where) with closing(connection.cursor()) as cursor: diff --git a/taiga/timeline/api.py b/taiga/timeline/api.py index 49504499..3137f913 100644 --- a/taiga/timeline/api.py +++ b/taiga/timeline/api.py @@ -15,6 +15,7 @@ # along with this program. If not, see . from django.contrib.contenttypes.models import ContentType +from django.apps import apps from taiga.base import response from taiga.base.api.utils import get_object_or_404 @@ -46,6 +47,14 @@ class TimelineViewSet(ReadOnlyListViewSet): # Switch between paginated or standard style responses page = self.paginate_queryset(queryset) if page is not None: + user_ids = list(set([obj.data.get("user", {}).get("id", None) for obj in page.object_list])) + User = apps.get_model("users", "User") + users = {u.id: u for u in User.objects.filter(id__in=user_ids)} + + for obj in page.object_list: + user_id = obj.data.get("user", {}).get("id", None) + obj._prefetched_user = users.get(user_id, None) + serializer = self.get_pagination_serializer(page) else: serializer = self.get_serializer(queryset, many=True) diff --git a/taiga/timeline/serializers.py b/taiga/timeline/serializers.py index f5defc56..b5af436a 100644 --- a/taiga/timeline/serializers.py +++ b/taiga/timeline/serializers.py @@ -24,39 +24,34 @@ from taiga.users.services import get_photo_or_gravatar_url, get_big_photo_or_gra from . import models from . import service -class TimelineDataJsonField(serializers.WritableField): - """ - Timeline Json objects serializer. - """ - widget = widgets.Textarea - - def to_native(self, obj): - #Updates the data user info saved if the user exists - User = apps.get_model("users", "User") - userData = obj.get("user", None) - if userData: - try: - user = User.objects.get(id=userData["id"]) - obj["user"] = { - "id": user.pk, - "name": user.get_full_name(), - "photo": get_photo_or_gravatar_url(user), - "big_photo": get_big_photo_or_gravatar_url(user), - "username": user.username, - "is_profile_visible": user.is_active and not user.is_system, - "date_joined": user.date_joined - } - except User.DoesNotExist: - pass - - return obj - - def from_native(self, data): - return data - class TimelineSerializer(serializers.ModelSerializer): - data = TimelineDataJsonField() + data = serializers.SerializerMethodField("get_data") class Meta: model = models.Timeline + + def get_data(self, obj): + #Updates the data user info saved if the user exists + if hasattr(obj, "_prefetched_user"): + user = obj._prefetched_user + else: + User = apps.get_model("users", "User") + userData = obj.data.get("user", None) + try: + user = User.objects.get(id=userData["id"]) + except User.DoesNotExist: + user = None + + if user is not None: + obj.data["user"] = { + "id": user.pk, + "name": user.get_full_name(), + "photo": get_photo_or_gravatar_url(user), + "big_photo": get_big_photo_or_gravatar_url(user), + "username": user.username, + "is_profile_visible": user.is_active and not user.is_system, + "date_joined": user.date_joined + } + + return obj.data diff --git a/taiga/timeline/service.py b/taiga/timeline/service.py index cc03d676..5bfdeebb 100644 --- a/taiga/timeline/service.py +++ b/taiga/timeline/service.py @@ -96,6 +96,7 @@ def get_timeline(obj, namespace=None): if namespace is not None: timeline = timeline.filter(namespace=namespace) + timeline = timeline.select_related("project") timeline = timeline.order_by("-created", "-id") return timeline @@ -128,9 +129,7 @@ def filter_timeline_for_user(timeline, user): # Filtering private projects where user is member if not user.is_anonymous(): - membership_model = apps.get_model('projects', 'Membership') - memberships_qs = membership_model.objects.filter(user=user) - for membership in memberships_qs: + for membership in user.cached_memberships: for content_type_key, content_type in content_types.items(): if content_type_key in membership.role.permissions or membership.is_owner: tl_filter |= Q(project=membership.project, data_content_type=content_type) diff --git a/taiga/users/models.py b/taiga/users/models.py index b2828912..6bbd1ae2 100644 --- a/taiga/users/models.py +++ b/taiga/users/models.py @@ -23,6 +23,8 @@ import uuid from unidecode import unidecode +from django.apps import apps +from django.contrib.contenttypes.models import ContentType from django.db import models from django.dispatch import receiver from django.utils.translation import ugettext_lazy as _ @@ -39,6 +41,7 @@ from taiga.auth.tokens import get_token_for_user from taiga.base.utils.slug import slugify_uniquely from taiga.base.utils.iterators import split_by_n from taiga.permissions.permissions import MEMBERS_PERMISSIONS +from taiga.projects.notifications.choices import NotifyLevel from easy_thumbnails.files import get_thumbnailer @@ -135,6 +138,10 @@ class User(AbstractBaseUser, PermissionsMixin): new_email = models.EmailField(_('new email address'), null=True, blank=True) is_system = models.BooleanField(null=False, blank=False, default=False) + _cached_memberships = None + _cached_liked_ids = None + _cached_watched_ids = None + _cached_notify_levels = None USERNAME_FIELD = 'username' REQUIRED_FIELDS = ['email'] @@ -152,6 +159,63 @@ class User(AbstractBaseUser, PermissionsMixin): def __str__(self): return self.get_full_name() + def _fill_cached_memberships(self): + self._cached_memberships = {} + qs = self.memberships.prefetch_related("user", "project", "role") + for membership in qs.all(): + self._cached_memberships[membership.project.id] = membership + + @property + def cached_memberships(self): + if self._cached_memberships is None: + self._fill_cached_memberships() + + return self._cached_memberships.values() + + def cached_membership_for_project(self, project): + if self._cached_memberships is None: + self._fill_cached_memberships() + + return self._cached_memberships.get(project.id, None) + + def is_fan(self, obj): + if self._cached_liked_ids is None: + self._cached_liked_ids = set() + for like in self.likes.select_related("content_type").all(): + like_id = "{}-{}".format(like.content_type.id, like.object_id) + self._cached_liked_ids.add(like_id) + + obj_type = ContentType.objects.get_for_model(obj) + obj_id = "{}-{}".format(obj_type.id, obj.id) + return obj_id in self._cached_liked_ids + + def is_watcher(self, obj): + if self._cached_watched_ids is None: + self._cached_watched_ids = set() + for watched in self.watched.select_related("content_type").all(): + watched_id = "{}-{}".format(watched.content_type.id, watched.object_id) + self._cached_watched_ids.add(watched_id) + + notify_policies = self.notify_policies.select_related("project")\ + .exclude(notify_level=NotifyLevel.none) + + for notify_policy in notify_policies: + obj_type = ContentType.objects.get_for_model(notify_policy.project) + watched_id = "{}-{}".format(obj_type.id, notify_policy.project.id) + self._cached_watched_ids.add(watched_id) + + obj_type = ContentType.objects.get_for_model(obj) + obj_id = "{}-{}".format(obj_type.id, obj.id) + return obj_id in self._cached_watched_ids + + def get_notify_level(self, project): + if self._cached_notify_levels is None: + self._cached_notify_levels = {} + for notify_policy in self.notify_policies.select_related("project"): + self._cached_notify_levels[notify_policy.project.id] = notify_policy.notify_level + + return self._cached_notify_levels.get(project.id, None) + def get_short_name(self): "Returns the short name for the user." return self.username