Merge pull request #748 from taigaio/US/2928-cards-ui-refactor

US #2928: Cards UI refactor
remotes/origin/issue/4795/notification_even_they_are_disabled
David Barragán Merino 2016-06-27 11:43:49 +02:00 committed by GitHub
commit 941ab5bd9b
21 changed files with 814 additions and 111 deletions

View File

@ -14,6 +14,11 @@
- New API endpoints over projects to create, rename, edit, delete and mix tags.
- Tag color assignation is not automatic.
- Select a color (or not) to a tag when add it to stories, issues and tasks.
- Now comment owners and project admins can edit existing comments with the history Entry endpoint.
- Add a new permissions to allow add comments instead of use the existent modify permission for this purpose.
- Include created, modified and finished dates for tasks in CSV reports
- User stories and tasks listing API call support extra params to include more data (tasks and attachemnts and attachments, respectively)
### Misc
- [API] Improve performance of some calls over list.

View File

@ -152,7 +152,7 @@ class PermissionBasedFilterBackend(FilterBackend):
else:
qs = qs.filter(project__anon_permissions__contains=[self.permission])
return super().filter_queryset(request, qs.distinct(), view)
return super().filter_queryset(request, qs, view)
class CanViewProjectFilterBackend(PermissionBasedFilterBackend):
@ -268,7 +268,7 @@ class MembersFilterBackend(PermissionBasedFilterBackend):
qs = qs.filter(memberships__project__anon_permissions__contains=[self.permission])
return qs.distinct()
return qs
#####################################################################
@ -307,7 +307,7 @@ class IsProjectAdminFilterBackend(FilterBackend, BaseIsProjectAdminFilterBackend
else:
queryset = queryset.filter(project_id__in=project_ids)
return super().filter_queryset(request, queryset.distinct(), view)
return super().filter_queryset(request, queryset, view)
class IsProjectAdminFromWebhookLogFilterBackend(FilterBackend, BaseIsProjectAdminFilterBackend):

View File

@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.2 on 2016-06-17 12:33
from __future__ import unicode_literals
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('attachments', '0005_attachment_sha1'),
]
operations = [
migrations.AlterIndexTogether(
name='attachment',
index_together=set([('content_type', 'object_id')]),
),
]

View File

@ -70,6 +70,7 @@ class Attachment(models.Model):
permissions = (
("view_attachment", "Can view attachment"),
)
index_together = [("content_type", "object_id")]
def __init__(self, *args, **kwargs):
super(Attachment, self).__init__(*args, **kwargs)

View File

@ -16,11 +16,17 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.conf import settings
from taiga.base.api import serializers
from taiga.base.utils.thumbnails import get_thumbnail_url
from . import services
from . import models
import json
import serpy
class AttachmentSerializer(serializers.ModelSerializer):
url = serializers.SerializerMethodField("get_url")
@ -37,5 +43,31 @@ class AttachmentSerializer(serializers.ModelSerializer):
def get_url(self, obj):
return obj.attached_file.url
def get_thumbnail_card_url(self, obj):
return services.get_card_image_thumbnail_url(obj)
class ListBasicAttachmentsInfoSerializerMixin(serpy.Serializer):
"""
Assumptions:
- The queryset has an attribute called "include_attachments" indicating if the attachments array should contain information
about the related elements, otherwise it will be empty
- The method attach_basic_attachments has been used to include the necessary
json data about the attachments in the "attachments_attr" column
"""
attachments = serpy.MethodField()
def get_attachments(self, obj):
include_attachments = getattr(obj, "include_attachments", False)
if include_attachments:
assert hasattr(obj, "attachments_attr"), "instance must have a attachments_attr attribute"
if not include_attachments or obj.attachments_attr is None:
return []
for at in obj.attachments_attr:
at["thumbnail_card_url"] = get_thumbnail_url(at["attached_file"], settings.THN_ATTACHMENT_CARD)
return obj.attachments_attr

View File

@ -0,0 +1,44 @@
# -*- coding: utf-8 -*-
# Copyright (C) 2014-2016 Andrey Antukh <niwi@niwi.nz>
# Copyright (C) 2014-2016 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014-2016 David Barragán <bameda@dbarragan.com>
# Copyright (C) 2014-2016 Alejandro Alonso <alejandro.alonso@kaleidos.net>
# Copyright (C) 2014-2016 Anler Hernández <hello@anler.me>
# 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 <http://www.gnu.org/licenses/>.
from django.apps import apps
def attach_basic_attachments(queryset, as_field="attachments_attr"):
"""Attach basic attachments info as json column to each object of the queryset.
:param queryset: A Django user stories queryset object.
:param as_field: Attach the role points as an attribute with this name.
:return: Queryset object with the additional `as_field` field.
"""
model = queryset.model
type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(model)
sql = """SELECT json_agg(row_to_json(t))
FROM(
SELECT
attachments_attachment.id,
attachments_attachment.attached_file
FROM attachments_attachment
WHERE attachments_attachment.object_id = {tbl}.id AND attachments_attachment.content_type_id = {type_id}) t"""
sql = sql.format(tbl=model._meta.db_table, type_id=type.id)
queryset = queryset.extra(select={as_field: sql})
return queryset

View File

@ -21,9 +21,9 @@ from taiga.base.fields import PgArrayField
from taiga.base.neighbors import NeighborsSerializerMixin
from taiga.mdrender.service import render as mdrender
from taiga.projects.mixins.serializers import OwnerExtraInfoMixin
from taiga.projects.mixins.serializers import AssigedToExtraInfoMixin
from taiga.projects.mixins.serializers import StatusExtraInfoMixin
from taiga.projects.mixins.serializers import ListOwnerExtraInfoSerializerMixin
from taiga.projects.mixins.serializers import ListAssignedToExtraInfoSerializerMixin
from taiga.projects.mixins.serializers import ListStatusExtraInfoSerializerMixin
from taiga.projects.notifications.mixins import EditableWatchedResourceModelSerializer
from taiga.projects.notifications.mixins import ListWatchedResourceModelSerializer
from taiga.projects.notifications.validators import WatchersValidator
@ -39,6 +39,7 @@ from . import models
import serpy
class IssueSerializer(WatchersValidator, VoteResourceSerializerMixin, EditableWatchedResourceModelSerializer,
serializers.ModelSerializer):
tags = TagsAndTagsColorsField(default=[], required=False)
@ -75,8 +76,8 @@ class IssueSerializer(WatchersValidator, VoteResourceSerializerMixin, EditableWa
class IssueListSerializer(ListVoteResourceSerializerMixin, ListWatchedResourceModelSerializer,
OwnerExtraInfoMixin, AssigedToExtraInfoMixin, StatusExtraInfoMixin,
serializers.LightSerializer):
ListOwnerExtraInfoSerializerMixin, ListAssignedToExtraInfoSerializerMixin,
ListStatusExtraInfoSerializerMixin, serializers.LightSerializer):
id = serpy.Field()
ref = serpy.Field()
severity = serpy.Field(attr="severity_id")

View File

@ -35,6 +35,10 @@ from taiga.projects.notifications.utils import attach_watchers_to_queryset
from . import models
#####################################################
# Bulk actions
#####################################################
def get_issues_from_bulk(bulk_data, **additional_fields):
"""Convert `bulk_data` into a list of issues.
@ -83,6 +87,10 @@ def update_issues_order_in_bulk(bulk_data):
db.update_in_bulk_with_ids(issue_ids, new_order_values, model=models.Issue)
#####################################################
# CSV
#####################################################
def issues_to_csv(project, queryset):
csv_data = io.StringIO()
fieldnames = ["ref", "subject", "description", "sprint", "sprint_estimated_start",
@ -143,6 +151,10 @@ def issues_to_csv(project, queryset):
return csv_data
#####################################################
# Api filter data
#####################################################
def _get_issues_statuses(project, queryset):
compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None)
queryset_where_tuple = queryset.query.where.as_sql(compiler, connection)
@ -394,7 +406,7 @@ def _get_issues_owners(project, queryset):
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)
WHERE "projects_membership"."project_id" = %s AND "projects_membership"."user_id" IS NOT NULL
-- System users
UNION
@ -423,16 +435,44 @@ def _get_issues_owners(project, queryset):
return sorted(result, key=itemgetter("full_name"))
def _get_issues_tags(queryset):
tags = []
for t_list in queryset.values_list("tags", flat=True):
if t_list is None:
continue
tags += list(t_list)
def _get_issues_tags(project, queryset):
compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None)
queryset_where_tuple = queryset.query.where.as_sql(compiler, connection)
where = queryset_where_tuple[0]
where_params = queryset_where_tuple[1]
tags = [{"name":e, "count":tags.count(e)} for e in set(tags)]
extra_sql = """
WITH issues_tags AS (
SELECT tag,
COUNT(tag) counter FROM (
SELECT UNNEST(issues_issue.tags) tag
FROM issues_issue
INNER JOIN projects_project
ON (issues_issue.project_id = projects_project.id)
WHERE {where}) tags
GROUP BY tag),
project_tags AS (
SELECT reduce_dim(tags_colors) tag_color
FROM projects_project
WHERE id=%s)
return sorted(tags, key=itemgetter("name"))
SELECT tag_color[1] tag, COALESCE(issues_tags.counter, 0) counter
FROM project_tags
LEFT JOIN issues_tags ON project_tags.tag_color[1] = issues_tags.tag
ORDER BY tag
""".format(where=where)
with closing(connection.cursor()) as cursor:
cursor.execute(extra_sql, where_params + [project.id])
rows = cursor.fetchall()
result = []
for name, count in rows:
result.append({
"name": name,
"count": count,
})
return sorted(result, key=itemgetter("name"))
def get_issues_filters_data(project, querysets):
@ -447,7 +487,7 @@ def get_issues_filters_data(project, querysets):
("severities", _get_issues_severities(project, querysets["severities"])),
("assigned_to", _get_issues_assigned_to(project, querysets["assigned_to"])),
("owners", _get_issues_owners(project, querysets["owners"])),
("tags", _get_issues_tags(querysets["tags"])),
("tags", _get_issues_tags(project, querysets["tags"])),
])
return data

View File

@ -35,6 +35,7 @@ class MilestoneSerializer(WatchersValidator, WatchedResourceModelSerializer,
ValidateDuplicatedNameInProjectMixin):
total_points = serializers.SerializerMethodField("get_total_points")
closed_points = serializers.SerializerMethodField("get_closed_points")
user_stories = serializers.SerializerMethodField("get_user_stories")
class Meta:
model = models.Milestone
@ -46,6 +47,9 @@ class MilestoneSerializer(WatchersValidator, WatchedResourceModelSerializer,
def get_closed_points(self, obj):
return sum(obj.closed_points.values())
def get_user_stories(self, obj):
return UserStoryListSerializer(obj.user_stories.all(), many=True).data
class MilestoneListSerializer(ListWatchedResourceModelSerializer, serializers.LightSerializer):
id = serpy.Field()
@ -62,8 +66,16 @@ class MilestoneListSerializer(ListWatchedResourceModelSerializer, serializers.Li
order = serpy.Field()
watchers = serpy.Field()
user_stories = serpy.MethodField("get_user_stories")
total_points = serializers.Field(source="total_points_attr")
closed_points = serializers.Field(source="closed_points_attr")
total_points = serpy.MethodField()
closed_points = serpy.MethodField()
def get_user_stories(self, obj):
return UserStoryListSerializer(obj.user_stories.all(), many=True).data
def get_total_points(self, obj):
assert hasattr(obj, "total_points_attr"), "instance must have a total_points_attr attribute"
return obj.total_points_attr
def get_closed_points(self, obj):
assert hasattr(obj, "closed_points_attr"), "instance must have a closed_points_attr attribute"
return obj.closed_points_attr

View File

@ -44,7 +44,7 @@ class ValidateDuplicatedNameInProjectMixin(serializers.ModelSerializer):
return attrs
class CachedSerializedUsersMixin(serpy.Serializer):
class ListCachedUsersSerializerMixin(serpy.Serializer):
def to_value(self, instance):
self._serialized_users = {}
return super().to_value(instance)
@ -61,7 +61,7 @@ class CachedSerializedUsersMixin(serpy.Serializer):
return serialized_user
class OwnerExtraInfoMixin(CachedSerializedUsersMixin):
class ListOwnerExtraInfoSerializerMixin(ListCachedUsersSerializerMixin):
owner = serpy.Field(attr="owner_id")
owner_extra_info = serpy.MethodField()
@ -69,7 +69,7 @@ class OwnerExtraInfoMixin(CachedSerializedUsersMixin):
return self.get_user_extra_info(obj.owner)
class AssigedToExtraInfoMixin(CachedSerializedUsersMixin):
class ListAssignedToExtraInfoSerializerMixin(ListCachedUsersSerializerMixin):
assigned_to = serpy.Field(attr="assigned_to_id")
assigned_to_extra_info = serpy.MethodField()
@ -77,9 +77,10 @@ class AssigedToExtraInfoMixin(CachedSerializedUsersMixin):
return self.get_user_extra_info(obj.assigned_to)
class StatusExtraInfoMixin(serpy.Serializer):
class ListStatusExtraInfoSerializerMixin(serpy.Serializer):
status = serpy.Field(attr="status_id")
status_extra_info = serpy.MethodField()
def to_value(self, instance):
self._serialized_status = {}
return super().to_value(instance)

View File

@ -25,6 +25,8 @@ from taiga.base import exceptions as exc
from taiga.base.decorators import list_route
from taiga.base.api import ModelCrudViewSet, ModelListViewSet
from taiga.base.api.mixins import BlockedByProjectMixin
from taiga.projects.attachments.utils import attach_basic_attachments
from taiga.projects.history.mixins import HistoryResourceMixin
from taiga.projects.models import Project, TaskStatus
from taiga.projects.notifications.mixins import WatchedResourceMixin, WatchersViewSetMixin
@ -42,8 +44,18 @@ class TaskViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, Wa
TaggedResourceMixin, BlockedByProjectMixin, ModelCrudViewSet):
queryset = models.Task.objects.all()
permission_classes = (permissions.TaskPermission,)
filter_backends = (filters.CanViewTasksFilterBackend, filters.WatchersFilter)
retrieve_exclude_filters = (filters.WatchersFilter,)
filter_backends = (filters.CanViewTasksFilterBackend,
filters.OwnersFilter,
filters.AssignedToFilter,
filters.StatusesFilter,
filters.TagsFilter,
filters.WatchersFilter,
filters.QFilter)
retrieve_exclude_filters = (filters.OwnersFilter,
filters.AssignedToFilter,
filters.StatusesFilter,
filters.TagsFilter,
filters.WatchersFilter)
filter_fields = ["user_story",
"milestone",
"project",
@ -60,6 +72,44 @@ class TaskViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, Wa
return serializers.TaskSerializer
def get_queryset(self):
qs = super().get_queryset()
qs = self.attach_votes_attrs_to_queryset(qs)
qs = qs.select_related("milestone",
"project",
"status",
"owner",
"assigned_to")
qs = self.attach_watchers_attrs_to_queryset(qs)
if "include_attachments" in self.request.QUERY_PARAMS:
qs = attach_basic_attachments(qs)
qs = qs.extra(select={"include_attachments": "True"})
return qs
def pre_conditions_on_save(self, obj):
super().pre_conditions_on_save(obj)
if obj.milestone and obj.milestone.project != obj.project:
raise exc.WrongArguments(_("You don't have permissions to set this sprint to this task."))
if obj.user_story and obj.user_story.project != obj.project:
raise exc.WrongArguments(_("You don't have permissions to set this user story to this task."))
if obj.status and obj.status.project != obj.project:
raise exc.WrongArguments(_("You don't have permissions to set this status to this task."))
if obj.milestone and obj.user_story and obj.milestone != obj.user_story.milestone:
raise exc.WrongArguments(_("You don't have permissions to set this sprint to this task."))
def pre_save(self, obj):
if obj.user_story:
obj.milestone = obj.user_story.milestone
if not obj.id:
obj.owner = self.request.user
super().pre_save(obj)
def update(self, request, *args, **kwargs):
self.object = self.get_object_or_none()
project_id = request.DATA.get('project', None)
@ -91,38 +141,24 @@ class TaskViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, Wa
return super().update(request, *args, **kwargs)
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")
@list_route(methods=["GET"])
def filters_data(self, request, *args, **kwargs):
project_id = request.QUERY_PARAMS.get("project", None)
project = get_object_or_404(Project, id=project_id)
return self.attach_watchers_attrs_to_queryset(qs)
filter_backends = self.get_filter_backends()
statuses_filter_backends = (f for f in filter_backends if f != filters.StatusesFilter)
assigned_to_filter_backends = (f for f in filter_backends if f != filters.AssignedToFilter)
owners_filter_backends = (f for f in filter_backends if f != filters.OwnersFilter)
def pre_save(self, obj):
if obj.user_story:
obj.milestone = obj.user_story.milestone
if not obj.id:
obj.owner = self.request.user
super().pre_save(obj)
def pre_conditions_on_save(self, obj):
super().pre_conditions_on_save(obj)
if obj.milestone and obj.milestone.project != obj.project:
raise exc.WrongArguments(_("You don't have permissions to set this sprint to this task."))
if obj.user_story and obj.user_story.project != obj.project:
raise exc.WrongArguments(_("You don't have permissions to set this user story to this task."))
if obj.status and obj.status.project != obj.project:
raise exc.WrongArguments(_("You don't have permissions to set this status to this task."))
if obj.milestone and obj.user_story and obj.milestone != obj.user_story.milestone:
raise exc.WrongArguments(_("You don't have permissions to set this sprint to this task."))
queryset = self.get_queryset()
querysets = {
"statuses": self.filter_queryset(queryset, filter_backends=statuses_filter_backends),
"assigned_to": self.filter_queryset(queryset, filter_backends=assigned_to_filter_backends),
"owners": self.filter_queryset(queryset, filter_backends=owners_filter_backends),
"tags": self.filter_queryset(queryset)
}
return response.Ok(services.get_tasks_filters_data(project, querysets))
@list_route(methods=["GET"])
def by_ref(self, request):

View File

@ -31,6 +31,7 @@ class TaskPermission(TaigaResourcePermission):
partial_update_perms = CommentAndOrUpdatePerm('modify_task', 'comment_task')
destroy_perms = HasProjectPerm('delete_task')
list_perms = AllowAny()
filters_data_perms = AllowAny()
csv_perms = AllowAny()
bulk_create_perms = HasProjectPerm('add_task')
bulk_update_order_perms = HasProjectPerm('modify_task')

View File

@ -23,10 +23,12 @@ from taiga.base.api import serializers
from taiga.base.fields import PgArrayField
from taiga.base.neighbors import NeighborsSerializerMixin
from taiga.mdrender.service import render as mdrender
from taiga.projects.attachments.serializers import ListBasicAttachmentsInfoSerializerMixin
from taiga.projects.milestones.validators import SprintExistsValidator
from taiga.projects.mixins.serializers import OwnerExtraInfoMixin
from taiga.projects.mixins.serializers import AssigedToExtraInfoMixin
from taiga.projects.mixins.serializers import StatusExtraInfoMixin
from taiga.projects.mixins.serializers import ListOwnerExtraInfoSerializerMixin
from taiga.projects.mixins.serializers import ListAssignedToExtraInfoSerializerMixin
from taiga.projects.mixins.serializers import ListStatusExtraInfoSerializerMixin
from taiga.projects.notifications.mixins import EditableWatchedResourceModelSerializer
from taiga.projects.notifications.mixins import ListWatchedResourceModelSerializer
from taiga.projects.notifications.validators import WatchersValidator
@ -46,8 +48,10 @@ from . import models
import serpy
class TaskSerializer(WatchersValidator, VoteResourceSerializerMixin, EditableWatchedResourceModelSerializer,
serializers.ModelSerializer):
tags = TagsAndTagsColorsField(default=[], required=False)
external_reference = PgArrayField(required=False)
comment = serializers.SerializerMethodField("get_comment")
@ -83,8 +87,10 @@ class TaskSerializer(WatchersValidator, VoteResourceSerializerMixin, EditableWat
class TaskListSerializer(ListVoteResourceSerializerMixin, ListWatchedResourceModelSerializer,
OwnerExtraInfoMixin, AssigedToExtraInfoMixin, StatusExtraInfoMixin,
ListOwnerExtraInfoSerializerMixin, ListAssignedToExtraInfoSerializerMixin,
ListStatusExtraInfoSerializerMixin, ListBasicAttachmentsInfoSerializerMixin,
serializers.LightSerializer):
id = serpy.Field()
user_story = serpy.Field(attr="user_story_id")
ref = serpy.Field()

View File

@ -16,14 +16,19 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import io
import csv
import io
from collections import OrderedDict
from operator import itemgetter
from contextlib import closing
from django.db import connection
from django.utils.translation import ugettext as _
from taiga.base.utils import db, text
from taiga.projects.history.services import take_snapshot
from taiga.projects.tasks.apps import (
connect_tasks_signals,
disconnect_tasks_signals)
from taiga.projects.tasks.apps import connect_tasks_signals
from taiga.projects.tasks.apps import disconnect_tasks_signals
from taiga.events import events
from taiga.projects.votes.utils import attach_total_voters_to_queryset
from taiga.projects.notifications.utils import attach_watchers_to_queryset
@ -31,6 +36,10 @@ from taiga.projects.notifications.utils import attach_watchers_to_queryset
from . import models
#####################################################
# Bulk actions
#####################################################
def get_tasks_from_bulk(bulk_data, **additional_fields):
"""Convert `bulk_data` into a list of tasks.
@ -64,7 +73,7 @@ def create_tasks_in_bulk(bulk_data, callback=None, precall=None, **additional_fi
return tasks
def update_tasks_order_in_bulk(bulk_data:list, field:str, project:object):
def update_tasks_order_in_bulk(bulk_data: list, field: str, project: object):
"""
Update the order of some tasks.
`bulk_data` should be a list of tuples with the following format:
@ -85,7 +94,6 @@ def update_tasks_order_in_bulk(bulk_data:list, field:str, project:object):
def snapshot_tasks_in_bulk(bulk_data, user):
task_ids = []
for task_data in bulk_data:
try:
task = models.Task.objects.get(pk=task_data['task_id'])
@ -94,6 +102,10 @@ def snapshot_tasks_in_bulk(bulk_data, user):
pass
#####################################################
# CSV
#####################################################
def tasks_to_csv(project, queryset):
csv_data = io.StringIO()
fieldnames = ["ref", "subject", "description", "user_story", "sprint", "sprint_estimated_start",
@ -144,7 +156,7 @@ def tasks_to_csv(project, queryset):
"voters": task.total_voters,
"created_date": task.created_date,
"modified_date": task.modified_date,
"finished_date": task.finished_date,
"finished_date": task.finished_date,
}
for custom_attr in custom_attrs:
value = task.custom_attributes_values.attributes_values.get(str(custom_attr.id), None)
@ -153,3 +165,212 @@ def tasks_to_csv(project, queryset):
writer.writerow(task_data)
return csv_data
#####################################################
# Api filter data
#####################################################
def _get_tasks_statuses(project, queryset):
compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None)
queryset_where_tuple = queryset.query.where.as_sql(compiler, connection)
where = queryset_where_tuple[0]
where_params = queryset_where_tuple[1]
extra_sql = """
SELECT "projects_taskstatus"."id",
"projects_taskstatus"."name",
"projects_taskstatus"."color",
"projects_taskstatus"."order",
(SELECT count(*)
FROM "tasks_task"
INNER JOIN "projects_project" ON
("tasks_task"."project_id" = "projects_project"."id")
WHERE {where} AND "tasks_task"."status_id" = "projects_taskstatus"."id")
FROM "projects_taskstatus"
WHERE "projects_taskstatus"."project_id" = %s
ORDER BY "projects_taskstatus"."order";
""".format(where=where)
with closing(connection.cursor()) as cursor:
cursor.execute(extra_sql, where_params + [project.id])
rows = cursor.fetchall()
result = []
for id, name, color, order, count in rows:
result.append({
"id": id,
"name": _(name),
"color": color,
"order": order,
"count": count,
})
return sorted(result, key=itemgetter("order"))
def _get_tasks_assigned_to(project, queryset):
compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None)
queryset_where_tuple = queryset.query.where.as_sql(compiler, connection)
where = queryset_where_tuple[0]
where_params = queryset_where_tuple[1]
extra_sql = """
WITH counters AS (
SELECT assigned_to_id, count(assigned_to_id) count
FROM "tasks_task"
INNER JOIN "projects_project" ON ("tasks_task"."project_id" = "projects_project"."id")
WHERE {where} AND "tasks_task"."assigned_to_id" IS NOT NULL
GROUP BY assigned_to_id
)
SELECT "projects_membership"."user_id" user_id,
"users_user"."full_name",
"users_user"."username",
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 tasks
UNION
SELECT NULL user_id, NULL, NULL, count(coalesce(assigned_to_id, -1)) count
FROM "tasks_task"
INNER JOIN "projects_project" ON ("tasks_task"."project_id" = "projects_project"."id")
WHERE {where} AND "tasks_task"."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 + [project.id] + where_params)
rows = cursor.fetchall()
result = []
none_valued_added = False
for id, full_name, username, count in rows:
result.append({
"id": id,
"full_name": full_name or username or "",
"count": count,
})
if id is None:
none_valued_added = True
# If there was no task 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"))
def _get_tasks_owners(project, queryset):
compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None)
queryset_where_tuple = queryset.query.where.as_sql(compiler, connection)
where = queryset_where_tuple[0]
where_params = queryset_where_tuple[1]
extra_sql = """
WITH counters AS (
SELECT "tasks_task"."owner_id" owner_id,
count(coalesce("tasks_task"."owner_id", -1)) count
FROM "tasks_task"
INNER JOIN "projects_project" ON ("tasks_task"."project_id" = "projects_project"."id")
WHERE {where}
GROUP BY "tasks_task"."owner_id"
)
SELECT "projects_membership"."user_id" id,
"users_user"."full_name",
"users_user"."username",
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,
"users_user"."username" username,
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:
cursor.execute(extra_sql, where_params + [project.id])
rows = cursor.fetchall()
result = []
for id, full_name, username, count in rows:
if count > 0:
result.append({
"id": id,
"full_name": full_name or username or "",
"count": count,
})
return sorted(result, key=itemgetter("full_name"))
def _get_tasks_tags(project, queryset):
compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None)
queryset_where_tuple = queryset.query.where.as_sql(compiler, connection)
where = queryset_where_tuple[0]
where_params = queryset_where_tuple[1]
extra_sql = """
WITH tasks_tags AS (
SELECT tag,
COUNT(tag) counter FROM (
SELECT UNNEST(tasks_task.tags) tag
FROM tasks_task
INNER JOIN projects_project
ON (tasks_task.project_id = projects_project.id)
WHERE {where}) tags
GROUP BY tag),
project_tags AS (
SELECT reduce_dim(tags_colors) tag_color
FROM projects_project
WHERE id=%s)
SELECT tag_color[1] tag, COALESCE(tasks_tags.counter, 0) counter
FROM project_tags
LEFT JOIN tasks_tags ON project_tags.tag_color[1] = tasks_tags.tag
ORDER BY tag
""".format(where=where)
with closing(connection.cursor()) as cursor:
cursor.execute(extra_sql, where_params + [project.id])
rows = cursor.fetchall()
result = []
for name, count in rows:
result.append({
"name": name,
"count": count,
})
return sorted(result, key=itemgetter("name"))
def get_tasks_filters_data(project, querysets):
"""
Given a project and an tasks queryset, return a simple data structure
of all possible filters for the tasks in the queryset.
"""
data = OrderedDict([
("statuses", _get_tasks_statuses(project, querysets["statuses"])),
("assigned_to", _get_tasks_assigned_to(project, querysets["assigned_to"])),
("owners", _get_tasks_owners(project, querysets["owners"])),
("tags", _get_tasks_tags(project, querysets["tags"])),
])
return data

View File

@ -36,6 +36,7 @@ from taiga.base.api import ModelCrudViewSet
from taiga.base.api import ModelListViewSet
from taiga.base.api.utils import get_object_or_404
from taiga.projects.attachments.utils import attach_basic_attachments
from taiga.projects.history.mixins import HistoryResourceMixin
from taiga.projects.history.services import take_snapshot
from taiga.projects.milestones.models import Milestone
@ -49,6 +50,7 @@ from taiga.projects.votes.mixins.viewsets import VotedResourceMixin
from taiga.projects.votes.mixins.viewsets import VotersViewSetMixin
from taiga.projects.userstories.utils import attach_total_points
from taiga.projects.userstories.utils import attach_role_points
from taiga.projects.userstories.utils import attach_tasks
from . import models
from . import permissions
@ -85,9 +87,6 @@ class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixi
"kanban_order",
"total_voters"]
# Specific filter used for filtering neighbor user stories
_neighbor_tags_filter = filters.TagsFilter('neighbor_tags')
def get_serializer_class(self, *args, **kwargs):
if self.action in ["retrieve", "by_ref"]:
return serializers.UserStoryNeighborsSerializer
@ -105,10 +104,20 @@ class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixi
"owner",
"assigned_to",
"generated_from_issue")
qs = self.attach_votes_attrs_to_queryset(qs)
qs = self.attach_watchers_attrs_to_queryset(qs)
qs = attach_total_points(qs)
qs = attach_role_points(qs)
if "include_attachments" in self.request.QUERY_PARAMS:
qs = attach_basic_attachments(qs)
qs = qs.extra(select={"include_attachments": "True"})
if "include_tasks" in self.request.QUERY_PARAMS:
qs = attach_tasks(qs)
qs = qs.extra(select={"include_tasks": "True"})
return qs
def pre_conditions_on_save(self, obj):

View File

@ -29,20 +29,19 @@ from taiga.base.neighbors import NeighborsSerializerMixin
from taiga.base.utils import json
from taiga.mdrender.service import render as mdrender
from taiga.projects.attachments.serializers import ListBasicAttachmentsInfoSerializerMixin
from taiga.projects.milestones.validators import SprintExistsValidator
from taiga.projects.mixins.serializers import ListOwnerExtraInfoSerializerMixin
from taiga.projects.mixins.serializers import ListAssignedToExtraInfoSerializerMixin
from taiga.projects.mixins.serializers import ListStatusExtraInfoSerializerMixin
from taiga.projects.models import Project, UserStoryStatus
from taiga.projects.mixins.serializers import OwnerExtraInfoMixin
from taiga.projects.mixins.serializers import AssigedToExtraInfoMixin
from taiga.projects.mixins.serializers import StatusExtraInfoMixin
from taiga.projects.notifications.mixins import EditableWatchedResourceModelSerializer
from taiga.projects.notifications.mixins import ListWatchedResourceModelSerializer
from taiga.projects.notifications.validators import WatchersValidator
from taiga.projects.serializers import BasicUserStoryStatusSerializer
from taiga.projects.tagging.fields import TagsAndTagsColorsField
from taiga.projects.userstories.validators import UserStoryExistsValidator
from taiga.projects.validators import ProjectExistsValidator
from taiga.projects.validators import UserStoryStatusExistsValidator
from taiga.projects.validators import ProjectExistsValidator, UserStoryStatusExistsValidator
from taiga.projects.votes.mixins.serializers import VoteResourceSerializerMixin
from taiga.projects.votes.mixins.serializers import ListVoteResourceSerializerMixin
@ -136,7 +135,9 @@ class ListOriginIssueSerializer(serializers.LightSerializer):
class UserStoryListSerializer(ListVoteResourceSerializerMixin, ListWatchedResourceModelSerializer,
OwnerExtraInfoMixin, AssigedToExtraInfoMixin, StatusExtraInfoMixin, serializers.LightSerializer):
ListOwnerExtraInfoSerializerMixin, ListAssignedToExtraInfoSerializerMixin,
ListStatusExtraInfoSerializerMixin, ListBasicAttachmentsInfoSerializerMixin,
serializers.LightSerializer):
id = serpy.Field()
ref = serpy.Field()
@ -163,13 +164,11 @@ class UserStoryListSerializer(ListVoteResourceSerializerMixin, ListWatchedResour
is_blocked = serpy.Field()
blocked_note = serpy.Field()
tags = serpy.Field()
total_points = serpy.Field("total_points_attr")
total_points = serpy.MethodField()
comment = serpy.MethodField("get_comment")
origin_issue = ListOriginIssueSerializer(attr="generated_from_issue")
def to_value(self, instance):
self._serialized_status = {}
return super().to_value(instance)
tasks = serpy.MethodField()
def get_milestone_slug(self, obj):
return obj.milestone.slug if obj.milestone else None
@ -177,15 +176,31 @@ class UserStoryListSerializer(ListVoteResourceSerializerMixin, ListWatchedResour
def get_milestone_name(self, obj):
return obj.milestone.name if obj.milestone else None
def get_total_points(self, obj):
assert hasattr(obj, "total_points_attr"), "instance must have a total_points_attr attribute"
return obj.total_points_attr
def get_points(self, obj):
assert hasattr(obj, "role_points_attr"), "instance must have a role_points_attr attribute"
if obj.role_points_attr is None:
return {}
return dict(ChainMap(*json.loads(obj.role_points_attr)))
return dict(ChainMap(*obj.role_points_attr))
def get_comment(self, obj):
return ""
def get_tasks(self, obj):
include_tasks = getattr(obj, "include_tasks", False)
if include_tasks:
assert hasattr(obj, "tasks_attr"), "instance must have a tasks_attr attribute"
if not include_tasks or obj.tasks_attr is None:
return []
return obj.tasks_attr
class UserStoryNeighborsSerializer(NeighborsSerializerMixin, UserStorySerializer):
def serialize_neighbor(self, neighbor):

View File

@ -28,9 +28,8 @@ from django.utils.translation import ugettext as _
from taiga.base.utils import db, text
from taiga.projects.history.services import take_snapshot
from taiga.projects.userstories.apps import (
connect_userstories_signals,
disconnect_userstories_signals)
from taiga.projects.userstories.apps import connect_userstories_signals
from taiga.projects.userstories.apps import disconnect_userstories_signals
from taiga.events import events
from taiga.projects.votes.utils import attach_total_voters_to_queryset
@ -39,6 +38,10 @@ from taiga.projects.notifications.utils import attach_watchers_to_queryset
from . import models
#####################################################
# Bulk actions
#####################################################
def get_userstories_from_bulk(bulk_data, **additional_fields):
"""Convert `bulk_data` into a list of user stories.
@ -72,7 +75,7 @@ def create_userstories_in_bulk(bulk_data, callback=None, precall=None, **additio
return userstories
def update_userstories_order_in_bulk(bulk_data:list, field:str, project:object):
def update_userstories_order_in_bulk(bulk_data: list, field: str, project: object):
"""
Update the order of some user stories.
`bulk_data` should be a list of tuples with the following format:
@ -92,7 +95,7 @@ def update_userstories_order_in_bulk(bulk_data:list, field:str, project:object):
db.update_in_bulk_with_ids(user_story_ids, new_order_values, model=models.UserStory)
def update_userstories_milestone_in_bulk(bulk_data:list, milestone:object):
def update_userstories_milestone_in_bulk(bulk_data: list, milestone: object):
"""
Update the milestone of some user stories.
`bulk_data` should be a list of user story ids:
@ -108,7 +111,6 @@ def update_userstories_milestone_in_bulk(bulk_data:list, milestone:object):
def snapshot_userstories_in_bulk(bulk_data, user):
user_story_ids = []
for us_data in bulk_data:
try:
us = models.UserStory.objects.get(pk=us_data['us_id'])
@ -117,6 +119,10 @@ def snapshot_userstories_in_bulk(bulk_data, user):
pass
#####################################################
# Open/Close calcs
#####################################################
def calculate_userstory_is_closed(user_story):
if user_story.status is None:
return False
@ -144,7 +150,11 @@ def open_userstory(us):
us.save(update_fields=["is_closed", "finish_date"])
def userstories_to_csv(project,queryset):
#####################################################
# CSV
#####################################################
def userstories_to_csv(project, queryset):
csv_data = io.StringIO()
fieldnames = ["ref", "subject", "description", "sprint", "sprint_estimated_start",
"sprint_estimated_finish", "owner", "owner_full_name", "assigned_to",
@ -160,7 +170,7 @@ def userstories_to_csv(project,queryset):
"created_date", "modified_date", "finish_date",
"client_requirement", "team_requirement", "attachments",
"generated_from_issue", "external_reference", "tasks",
"tags","watchers", "voters"]
"tags", "watchers", "voters"]
custom_attrs = project.userstorycustomattributes.all()
for custom_attr in custom_attrs:
@ -230,6 +240,10 @@ def userstories_to_csv(project,queryset):
return csv_data
#####################################################
# Api filter data
#####################################################
def _get_userstories_statuses(project, queryset):
compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None)
queryset_where_tuple = queryset.query.where.as_sql(compiler, connection)
@ -336,7 +350,8 @@ def _get_userstories_owners(project, queryset):
extra_sql = """
WITH counters AS (
SELECT "userstories_userstory"."owner_id" owner_id, count(coalesce("userstories_userstory"."owner_id", -1)) count
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}
@ -350,7 +365,7 @@ def _get_userstories_owners(project, queryset):
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)
WHERE "projects_membership"."project_id" = %s AND "projects_membership"."user_id" IS NOT NULL
-- System users
UNION
@ -379,16 +394,44 @@ def _get_userstories_owners(project, queryset):
return sorted(result, key=itemgetter("full_name"))
def _get_userstories_tags(queryset):
tags = []
for t_list in queryset.values_list("tags", flat=True):
if t_list is None:
continue
tags += list(t_list)
def _get_userstories_tags(project, queryset):
compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None)
queryset_where_tuple = queryset.query.where.as_sql(compiler, connection)
where = queryset_where_tuple[0]
where_params = queryset_where_tuple[1]
tags = [{"name":e, "count":tags.count(e)} for e in set(tags)]
extra_sql = """
WITH userstories_tags AS (
SELECT tag,
COUNT(tag) counter FROM (
SELECT UNNEST(userstories_userstory.tags) tag
FROM userstories_userstory
INNER JOIN projects_project
ON (userstories_userstory.project_id = projects_project.id)
WHERE {where}) tags
GROUP BY tag),
project_tags AS (
SELECT reduce_dim(tags_colors) tag_color
FROM projects_project
WHERE id=%s)
return sorted(tags, key=itemgetter("name"))
SELECT tag_color[1] tag, COALESCE(userstories_tags.counter, 0) counter
FROM project_tags
LEFT JOIN userstories_tags ON project_tags.tag_color[1] = userstories_tags.tag
ORDER BY tag
""".format(where=where)
with closing(connection.cursor()) as cursor:
cursor.execute(extra_sql, where_params + [project.id])
rows = cursor.fetchall()
result = []
for name, count in rows:
result.append({
"name": name,
"count": count,
})
return sorted(result, key=itemgetter("name"))
def get_userstories_filters_data(project, querysets):
@ -400,7 +443,7 @@ def get_userstories_filters_data(project, querysets):
("statuses", _get_userstories_statuses(project, querysets["statuses"])),
("assigned_to", _get_userstories_assigned_to(project, querysets["assigned_to"])),
("owners", _get_userstories_owners(project, querysets["owners"])),
("tags", _get_userstories_tags(querysets["tags"])),
("tags", _get_userstories_tags(project, querysets["tags"])),
])
return data

View File

@ -46,11 +46,39 @@ def attach_role_points(queryset, as_field="role_points_attr"):
:return: Queryset object with the additional `as_field` field.
"""
model = queryset.model
sql = """SELECT json_agg(json_build_object(userstories_rolepoints.role_id,
userstories_rolepoints.points_id))::text
sql = """SELECT json_agg((userstories_rolepoints.role_id, userstories_rolepoints.points_id))
FROM userstories_rolepoints
WHERE userstories_rolepoints.user_story_id = {tbl}.id"""
sql = sql.format(tbl=model._meta.db_table)
queryset = queryset.extra(select={as_field: sql})
return queryset
def attach_tasks(queryset, as_field="tasks_attr"):
"""Attach tasks as json column to each object of the queryset.
:param queryset: A Django user stories queryset object.
:param as_field: Attach the role points as an attribute with this name.
:return: Queryset object with the additional `as_field` field.
"""
model = queryset.model
sql = """SELECT json_agg(row_to_json(t))
FROM(
SELECT
tasks_task.id,
tasks_task.ref,
tasks_task.subject,
tasks_task.status_id,
tasks_task.is_blocked,
tasks_task.is_iocaine,
projects_taskstatus.is_closed
FROM tasks_task
INNER JOIN projects_taskstatus on projects_taskstatus.id = tasks_task.status_id
WHERE user_story_id = {tbl}.id) t"""
sql = sql.format(tbl=model._meta.db_table)
queryset = queryset.extra(select={as_field: sql})
return queryset

View File

@ -229,6 +229,7 @@ def test_api_filter_by_text_6(client):
assert response.status_code == 200
assert number_of_issues == 1
def test_api_filters_data(client):
project = f.ProjectFactory.create()
user1 = f.UserFactory.create(is_superuser=True)
@ -378,8 +379,7 @@ def test_api_filters_data(client):
assert next(filter(lambda i: i['id'] == severity2.id, response.data["severities"]))["count"] == 0
assert next(filter(lambda i: i['id'] == severity3.id, response.data["severities"]))["count"] == 1
with pytest.raises(StopIteration):
assert next(filter(lambda i: i['name'] == tag0, response.data["tags"]))["count"] == 0
assert next(filter(lambda i: i['name'] == tag0, response.data["tags"]))["count"] == 0
assert next(filter(lambda i: i['name'] == tag1, response.data["tags"]))["count"] == 4
assert next(filter(lambda i: i['name'] == tag2, response.data["tags"]))["count"] == 2
assert next(filter(lambda i: i['name'] == tag3, response.data["tags"]))["count"] == 1
@ -415,8 +415,7 @@ def test_api_filters_data(client):
assert next(filter(lambda i: i['id'] == severity2.id, response.data["severities"]))["count"] == 0
assert next(filter(lambda i: i['id'] == severity3.id, response.data["severities"]))["count"] == 1
with pytest.raises(StopIteration):
assert next(filter(lambda i: i['name'] == tag0, response.data["tags"]))["count"] == 0
assert next(filter(lambda i: i['name'] == tag0, response.data["tags"]))["count"] == 0
assert next(filter(lambda i: i['name'] == tag1, response.data["tags"]))["count"] == 2
assert next(filter(lambda i: i['name'] == tag2, response.data["tags"]))["count"] == 2
assert next(filter(lambda i: i['name'] == tag3, response.data["tags"]))["count"] == 1

View File

@ -185,3 +185,153 @@ def test_custom_fields_csv_generation():
assert row[24] == attr.name
row = next(reader)
assert row[24] == "val1"
def test_get_tasks_including_attachments(client):
user = f.UserFactory.create()
project = f.ProjectFactory.create(owner=user)
f.MembershipFactory.create(project=project, user=user, is_admin=True)
task = f.TaskFactory.create(project=project)
f.TaskAttachmentFactory(project=project, content_object=task)
url = reverse("tasks-list")
client.login(project.owner)
response = client.get(url)
assert response.status_code == 200
assert response.data[0].get("attachments") == []
url = reverse("tasks-list") + "?include_attachments=1"
response = client.get(url)
assert response.status_code == 200
assert len(response.data[0].get("attachments")) == 1
def test_api_filters_data(client):
project = f.ProjectFactory.create()
user1 = f.UserFactory.create(is_superuser=True)
f.MembershipFactory.create(user=user1, project=project)
user2 = f.UserFactory.create(is_superuser=True)
f.MembershipFactory.create(user=user2, project=project)
user3 = f.UserFactory.create(is_superuser=True)
f.MembershipFactory.create(user=user3, project=project)
status0 = f.TaskStatusFactory.create(project=project)
status1 = f.TaskStatusFactory.create(project=project)
status2 = f.TaskStatusFactory.create(project=project)
status3 = f.TaskStatusFactory.create(project=project)
tag0 = "test1test2test3"
tag1 = "test1"
tag2 = "test2"
tag3 = "test3"
# ------------------------------------------------------
# | Task | Owner | Assigned To | Tags |
# |-------#--------#-------------#---------------------|
# | 0 | user2 | None | tag1 |
# | 1 | user1 | None | tag2 |
# | 2 | user3 | None | tag1 tag2 |
# | 3 | user2 | None | tag3 |
# | 4 | user1 | user1 | tag1 tag2 tag3 |
# | 5 | user3 | user1 | tag3 |
# | 6 | user2 | user1 | tag1 tag2 |
# | 7 | user1 | user2 | tag3 |
# | 8 | user3 | user2 | tag1 |
# | 9 | user2 | user3 | tag0 |
# ------------------------------------------------------
task0 = f.TaskFactory.create(project=project, owner=user2, assigned_to=None,
status=status3, tags=[tag1])
task1 = f.TaskFactory.create(project=project, owner=user1, assigned_to=None,
status=status3, tags=[tag2])
task2 = f.TaskFactory.create(project=project, owner=user3, assigned_to=None,
status=status1, tags=[tag1, tag2])
task3 = f.TaskFactory.create(project=project, owner=user2, assigned_to=None,
status=status0, tags=[tag3])
task4 = f.TaskFactory.create(project=project, owner=user1, assigned_to=user1,
status=status0, tags=[tag1, tag2, tag3])
task5 = f.TaskFactory.create(project=project, owner=user3, assigned_to=user1,
status=status2, tags=[tag3])
task6 = f.TaskFactory.create(project=project, owner=user2, assigned_to=user1,
status=status3, tags=[tag1, tag2])
task7 = f.TaskFactory.create(project=project, owner=user1, assigned_to=user2,
status=status0, tags=[tag3])
task8 = f.TaskFactory.create(project=project, owner=user3, assigned_to=user2,
status=status3, tags=[tag1])
task9 = f.TaskFactory.create(project=project, owner=user2, assigned_to=user3,
status=status1, tags=[tag0])
url = reverse("tasks-filters-data") + "?project={}".format(project.id)
client.login(user1)
## No filter
response = client.get(url)
assert response.status_code == 200
assert next(filter(lambda i: i['id'] == user1.id, response.data["owners"]))["count"] == 3
assert next(filter(lambda i: i['id'] == user2.id, response.data["owners"]))["count"] == 4
assert next(filter(lambda i: i['id'] == user3.id, response.data["owners"]))["count"] == 3
assert next(filter(lambda i: i['id'] == None, response.data["assigned_to"]))["count"] == 4
assert next(filter(lambda i: i['id'] == user1.id, response.data["assigned_to"]))["count"] == 3
assert next(filter(lambda i: i['id'] == user2.id, response.data["assigned_to"]))["count"] == 2
assert next(filter(lambda i: i['id'] == user3.id, response.data["assigned_to"]))["count"] == 1
assert next(filter(lambda i: i['id'] == status0.id, response.data["statuses"]))["count"] == 3
assert next(filter(lambda i: i['id'] == status1.id, response.data["statuses"]))["count"] == 2
assert next(filter(lambda i: i['id'] == status2.id, response.data["statuses"]))["count"] == 1
assert next(filter(lambda i: i['id'] == status3.id, response.data["statuses"]))["count"] == 4
assert next(filter(lambda i: i['name'] == tag0, response.data["tags"]))["count"] == 1
assert next(filter(lambda i: i['name'] == tag1, response.data["tags"]))["count"] == 5
assert next(filter(lambda i: i['name'] == tag2, response.data["tags"]))["count"] == 4
assert next(filter(lambda i: i['name'] == tag3, response.data["tags"]))["count"] == 4
## Filter ((status0 or status3)
response = client.get(url + "&status={},{}".format(status3.id, status0.id))
assert response.status_code == 200
assert next(filter(lambda i: i['id'] == user1.id, response.data["owners"]))["count"] == 3
assert next(filter(lambda i: i['id'] == user2.id, response.data["owners"]))["count"] == 3
assert next(filter(lambda i: i['id'] == user3.id, response.data["owners"]))["count"] == 1
assert next(filter(lambda i: i['id'] == None, response.data["assigned_to"]))["count"] == 3
assert next(filter(lambda i: i['id'] == user1.id, response.data["assigned_to"]))["count"] == 2
assert next(filter(lambda i: i['id'] == user2.id, response.data["assigned_to"]))["count"] == 2
assert next(filter(lambda i: i['id'] == user3.id, response.data["assigned_to"]))["count"] == 0
assert next(filter(lambda i: i['id'] == status0.id, response.data["statuses"]))["count"] == 3
assert next(filter(lambda i: i['id'] == status1.id, response.data["statuses"]))["count"] == 2
assert next(filter(lambda i: i['id'] == status2.id, response.data["statuses"]))["count"] == 1
assert next(filter(lambda i: i['id'] == status3.id, response.data["statuses"]))["count"] == 4
assert next(filter(lambda i: i['name'] == tag0, response.data["tags"]))["count"] == 0
assert next(filter(lambda i: i['name'] == tag1, response.data["tags"]))["count"] == 4
assert next(filter(lambda i: i['name'] == tag2, response.data["tags"]))["count"] == 3
assert next(filter(lambda i: i['name'] == tag3, response.data["tags"]))["count"] == 3
## Filter ((tag1 and tag2) and (user1 or user2))
response = client.get(url + "&tags={},{}&owner={},{}".format(tag1, tag2, user1.id, user2.id))
assert response.status_code == 200
assert next(filter(lambda i: i['id'] == user1.id, response.data["owners"]))["count"] == 1
assert next(filter(lambda i: i['id'] == user2.id, response.data["owners"]))["count"] == 1
assert next(filter(lambda i: i['id'] == user3.id, response.data["owners"]))["count"] == 1
assert next(filter(lambda i: i['id'] == None, response.data["assigned_to"]))["count"] == 0
assert next(filter(lambda i: i['id'] == user1.id, response.data["assigned_to"]))["count"] == 2
assert next(filter(lambda i: i['id'] == user2.id, response.data["assigned_to"]))["count"] == 0
assert next(filter(lambda i: i['id'] == user3.id, response.data["assigned_to"]))["count"] == 0
assert next(filter(lambda i: i['id'] == status0.id, response.data["statuses"]))["count"] == 1
assert next(filter(lambda i: i['id'] == status1.id, response.data["statuses"]))["count"] == 0
assert next(filter(lambda i: i['id'] == status2.id, response.data["statuses"]))["count"] == 0
assert next(filter(lambda i: i['id'] == status3.id, response.data["statuses"]))["count"] == 1
assert next(filter(lambda i: i['name'] == tag0, response.data["tags"]))["count"] == 0
assert next(filter(lambda i: i['name'] == tag1, response.data["tags"]))["count"] == 2
assert next(filter(lambda i: i['name'] == tag2, response.data["tags"]))["count"] == 2
assert next(filter(lambda i: i['name'] == tag3, response.data["tags"]))["count"] == 1

View File

@ -504,8 +504,7 @@ def test_api_filters_data(client):
assert next(filter(lambda i: i['id'] == status2.id, response.data["statuses"]))["count"] == 1
assert next(filter(lambda i: i['id'] == status3.id, response.data["statuses"]))["count"] == 4
with pytest.raises(StopIteration):
assert next(filter(lambda i: i['name'] == tag0, response.data["tags"]))["count"] == 0
assert next(filter(lambda i: i['name'] == tag0, response.data["tags"]))["count"] == 0
assert next(filter(lambda i: i['name'] == tag1, response.data["tags"]))["count"] == 4
assert next(filter(lambda i: i['name'] == tag2, response.data["tags"]))["count"] == 3
assert next(filter(lambda i: i['name'] == tag3, response.data["tags"]))["count"] == 3
@ -528,8 +527,7 @@ def test_api_filters_data(client):
assert next(filter(lambda i: i['id'] == status2.id, response.data["statuses"]))["count"] == 0
assert next(filter(lambda i: i['id'] == status3.id, response.data["statuses"]))["count"] == 1
with pytest.raises(StopIteration):
assert next(filter(lambda i: i['name'] == tag0, response.data["tags"]))["count"] == 0
assert next(filter(lambda i: i['name'] == tag0, response.data["tags"]))["count"] == 0
assert next(filter(lambda i: i['name'] == tag1, response.data["tags"]))["count"] == 2
assert next(filter(lambda i: i['name'] == tag2, response.data["tags"]))["count"] == 2
assert next(filter(lambda i: i['name'] == tag3, response.data["tags"]))["count"] == 1
@ -644,3 +642,45 @@ def test_update_userstory_update_tribe_gig(client):
assert response.status_code == 200
assert response.data["tribe_gig"] == data["tribe_gig"]
def test_get_user_stories_including_tasks(client):
user = f.UserFactory.create()
project = f.ProjectFactory.create(owner=user)
f.MembershipFactory.create(project=project, user=user, is_admin=True)
user_story = f.UserStoryFactory.create(project=project)
f.TaskFactory.create(user_story=user_story)
url = reverse("userstories-list")
client.login(project.owner)
response = client.get(url)
assert response.status_code == 200
assert response.data[0].get("tasks") == []
url = reverse("userstories-list") + "?include_tasks=1"
response = client.get(url)
assert response.status_code == 200
assert len(response.data[0].get("tasks")) == 1
def test_get_user_stories_including_attachments(client):
user = f.UserFactory.create()
project = f.ProjectFactory.create(owner=user)
f.MembershipFactory.create(project=project, user=user, is_admin=True)
user_story = f.UserStoryFactory.create(project=project)
f.UserStoryAttachmentFactory(project=project, content_object=user_story)
url = reverse("userstories-list")
client.login(project.owner)
response = client.get(url)
assert response.status_code == 200
assert response.data[0].get("attachments") == []
url = reverse("userstories-list") + "?include_attachments=1"
response = client.get(url)
assert response.status_code == 200
assert len(response.data[0].get("attachments")) == 1