Merge pull request #748 from taigaio/US/2928-cards-ui-refactor
US #2928: Cards UI refactorremotes/origin/issue/4795/notification_even_they_are_disabled
commit
941ab5bd9b
|
@ -14,6 +14,11 @@
|
||||||
- New API endpoints over projects to create, rename, edit, delete and mix tags.
|
- New API endpoints over projects to create, rename, edit, delete and mix tags.
|
||||||
- Tag color assignation is not automatic.
|
- Tag color assignation is not automatic.
|
||||||
- Select a color (or not) to a tag when add it to stories, issues and tasks.
|
- 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
|
### Misc
|
||||||
- [API] Improve performance of some calls over list.
|
- [API] Improve performance of some calls over list.
|
||||||
|
|
|
@ -152,7 +152,7 @@ class PermissionBasedFilterBackend(FilterBackend):
|
||||||
else:
|
else:
|
||||||
qs = qs.filter(project__anon_permissions__contains=[self.permission])
|
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):
|
class CanViewProjectFilterBackend(PermissionBasedFilterBackend):
|
||||||
|
@ -268,7 +268,7 @@ class MembersFilterBackend(PermissionBasedFilterBackend):
|
||||||
|
|
||||||
qs = qs.filter(memberships__project__anon_permissions__contains=[self.permission])
|
qs = qs.filter(memberships__project__anon_permissions__contains=[self.permission])
|
||||||
|
|
||||||
return qs.distinct()
|
return qs
|
||||||
|
|
||||||
|
|
||||||
#####################################################################
|
#####################################################################
|
||||||
|
@ -307,7 +307,7 @@ class IsProjectAdminFilterBackend(FilterBackend, BaseIsProjectAdminFilterBackend
|
||||||
else:
|
else:
|
||||||
queryset = queryset.filter(project_id__in=project_ids)
|
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):
|
class IsProjectAdminFromWebhookLogFilterBackend(FilterBackend, BaseIsProjectAdminFilterBackend):
|
||||||
|
|
|
@ -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')]),
|
||||||
|
),
|
||||||
|
]
|
|
@ -70,6 +70,7 @@ class Attachment(models.Model):
|
||||||
permissions = (
|
permissions = (
|
||||||
("view_attachment", "Can view attachment"),
|
("view_attachment", "Can view attachment"),
|
||||||
)
|
)
|
||||||
|
index_together = [("content_type", "object_id")]
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super(Attachment, self).__init__(*args, **kwargs)
|
super(Attachment, self).__init__(*args, **kwargs)
|
||||||
|
|
|
@ -16,11 +16,17 @@
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# 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/>.
|
# 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.api import serializers
|
||||||
|
from taiga.base.utils.thumbnails import get_thumbnail_url
|
||||||
|
|
||||||
from . import services
|
from . import services
|
||||||
from . import models
|
from . import models
|
||||||
|
|
||||||
|
import json
|
||||||
|
import serpy
|
||||||
|
|
||||||
|
|
||||||
class AttachmentSerializer(serializers.ModelSerializer):
|
class AttachmentSerializer(serializers.ModelSerializer):
|
||||||
url = serializers.SerializerMethodField("get_url")
|
url = serializers.SerializerMethodField("get_url")
|
||||||
|
@ -37,5 +43,31 @@ class AttachmentSerializer(serializers.ModelSerializer):
|
||||||
def get_url(self, obj):
|
def get_url(self, obj):
|
||||||
return obj.attached_file.url
|
return obj.attached_file.url
|
||||||
|
|
||||||
|
|
||||||
def get_thumbnail_card_url(self, obj):
|
def get_thumbnail_card_url(self, obj):
|
||||||
return services.get_card_image_thumbnail_url(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
|
||||||
|
|
|
@ -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
|
|
@ -21,9 +21,9 @@ from taiga.base.fields import PgArrayField
|
||||||
from taiga.base.neighbors import NeighborsSerializerMixin
|
from taiga.base.neighbors import NeighborsSerializerMixin
|
||||||
|
|
||||||
from taiga.mdrender.service import render as mdrender
|
from taiga.mdrender.service import render as mdrender
|
||||||
from taiga.projects.mixins.serializers import OwnerExtraInfoMixin
|
from taiga.projects.mixins.serializers import ListOwnerExtraInfoSerializerMixin
|
||||||
from taiga.projects.mixins.serializers import AssigedToExtraInfoMixin
|
from taiga.projects.mixins.serializers import ListAssignedToExtraInfoSerializerMixin
|
||||||
from taiga.projects.mixins.serializers import StatusExtraInfoMixin
|
from taiga.projects.mixins.serializers import ListStatusExtraInfoSerializerMixin
|
||||||
from taiga.projects.notifications.mixins import EditableWatchedResourceModelSerializer
|
from taiga.projects.notifications.mixins import EditableWatchedResourceModelSerializer
|
||||||
from taiga.projects.notifications.mixins import ListWatchedResourceModelSerializer
|
from taiga.projects.notifications.mixins import ListWatchedResourceModelSerializer
|
||||||
from taiga.projects.notifications.validators import WatchersValidator
|
from taiga.projects.notifications.validators import WatchersValidator
|
||||||
|
@ -39,6 +39,7 @@ from . import models
|
||||||
|
|
||||||
import serpy
|
import serpy
|
||||||
|
|
||||||
|
|
||||||
class IssueSerializer(WatchersValidator, VoteResourceSerializerMixin, EditableWatchedResourceModelSerializer,
|
class IssueSerializer(WatchersValidator, VoteResourceSerializerMixin, EditableWatchedResourceModelSerializer,
|
||||||
serializers.ModelSerializer):
|
serializers.ModelSerializer):
|
||||||
tags = TagsAndTagsColorsField(default=[], required=False)
|
tags = TagsAndTagsColorsField(default=[], required=False)
|
||||||
|
@ -75,8 +76,8 @@ class IssueSerializer(WatchersValidator, VoteResourceSerializerMixin, EditableWa
|
||||||
|
|
||||||
|
|
||||||
class IssueListSerializer(ListVoteResourceSerializerMixin, ListWatchedResourceModelSerializer,
|
class IssueListSerializer(ListVoteResourceSerializerMixin, ListWatchedResourceModelSerializer,
|
||||||
OwnerExtraInfoMixin, AssigedToExtraInfoMixin, StatusExtraInfoMixin,
|
ListOwnerExtraInfoSerializerMixin, ListAssignedToExtraInfoSerializerMixin,
|
||||||
serializers.LightSerializer):
|
ListStatusExtraInfoSerializerMixin, serializers.LightSerializer):
|
||||||
id = serpy.Field()
|
id = serpy.Field()
|
||||||
ref = serpy.Field()
|
ref = serpy.Field()
|
||||||
severity = serpy.Field(attr="severity_id")
|
severity = serpy.Field(attr="severity_id")
|
||||||
|
|
|
@ -35,6 +35,10 @@ from taiga.projects.notifications.utils import attach_watchers_to_queryset
|
||||||
from . import models
|
from . import models
|
||||||
|
|
||||||
|
|
||||||
|
#####################################################
|
||||||
|
# Bulk actions
|
||||||
|
#####################################################
|
||||||
|
|
||||||
def get_issues_from_bulk(bulk_data, **additional_fields):
|
def get_issues_from_bulk(bulk_data, **additional_fields):
|
||||||
"""Convert `bulk_data` into a list of issues.
|
"""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)
|
db.update_in_bulk_with_ids(issue_ids, new_order_values, model=models.Issue)
|
||||||
|
|
||||||
|
|
||||||
|
#####################################################
|
||||||
|
# CSV
|
||||||
|
#####################################################
|
||||||
|
|
||||||
def issues_to_csv(project, queryset):
|
def issues_to_csv(project, queryset):
|
||||||
csv_data = io.StringIO()
|
csv_data = io.StringIO()
|
||||||
fieldnames = ["ref", "subject", "description", "sprint", "sprint_estimated_start",
|
fieldnames = ["ref", "subject", "description", "sprint", "sprint_estimated_start",
|
||||||
|
@ -143,6 +151,10 @@ def issues_to_csv(project, queryset):
|
||||||
return csv_data
|
return csv_data
|
||||||
|
|
||||||
|
|
||||||
|
#####################################################
|
||||||
|
# Api filter data
|
||||||
|
#####################################################
|
||||||
|
|
||||||
def _get_issues_statuses(project, queryset):
|
def _get_issues_statuses(project, queryset):
|
||||||
compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None)
|
compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None)
|
||||||
queryset_where_tuple = queryset.query.where.as_sql(compiler, connection)
|
queryset_where_tuple = queryset.query.where.as_sql(compiler, connection)
|
||||||
|
@ -394,7 +406,7 @@ def _get_issues_owners(project, queryset):
|
||||||
FROM projects_membership
|
FROM projects_membership
|
||||||
LEFT OUTER JOIN counters ON ("projects_membership"."user_id" = "counters"."owner_id")
|
LEFT OUTER JOIN counters ON ("projects_membership"."user_id" = "counters"."owner_id")
|
||||||
INNER JOIN "users_user" ON ("projects_membership"."user_id" = "users_user"."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
|
-- System users
|
||||||
UNION
|
UNION
|
||||||
|
@ -423,16 +435,44 @@ def _get_issues_owners(project, queryset):
|
||||||
return sorted(result, key=itemgetter("full_name"))
|
return sorted(result, key=itemgetter("full_name"))
|
||||||
|
|
||||||
|
|
||||||
def _get_issues_tags(queryset):
|
def _get_issues_tags(project, queryset):
|
||||||
tags = []
|
compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None)
|
||||||
for t_list in queryset.values_list("tags", flat=True):
|
queryset_where_tuple = queryset.query.where.as_sql(compiler, connection)
|
||||||
if t_list is None:
|
where = queryset_where_tuple[0]
|
||||||
continue
|
where_params = queryset_where_tuple[1]
|
||||||
tags += list(t_list)
|
|
||||||
|
|
||||||
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):
|
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"])),
|
("severities", _get_issues_severities(project, querysets["severities"])),
|
||||||
("assigned_to", _get_issues_assigned_to(project, querysets["assigned_to"])),
|
("assigned_to", _get_issues_assigned_to(project, querysets["assigned_to"])),
|
||||||
("owners", _get_issues_owners(project, querysets["owners"])),
|
("owners", _get_issues_owners(project, querysets["owners"])),
|
||||||
("tags", _get_issues_tags(querysets["tags"])),
|
("tags", _get_issues_tags(project, querysets["tags"])),
|
||||||
])
|
])
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
|
@ -35,6 +35,7 @@ class MilestoneSerializer(WatchersValidator, WatchedResourceModelSerializer,
|
||||||
ValidateDuplicatedNameInProjectMixin):
|
ValidateDuplicatedNameInProjectMixin):
|
||||||
total_points = serializers.SerializerMethodField("get_total_points")
|
total_points = serializers.SerializerMethodField("get_total_points")
|
||||||
closed_points = serializers.SerializerMethodField("get_closed_points")
|
closed_points = serializers.SerializerMethodField("get_closed_points")
|
||||||
|
user_stories = serializers.SerializerMethodField("get_user_stories")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Milestone
|
model = models.Milestone
|
||||||
|
@ -46,6 +47,9 @@ class MilestoneSerializer(WatchersValidator, WatchedResourceModelSerializer,
|
||||||
def get_closed_points(self, obj):
|
def get_closed_points(self, obj):
|
||||||
return sum(obj.closed_points.values())
|
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):
|
class MilestoneListSerializer(ListWatchedResourceModelSerializer, serializers.LightSerializer):
|
||||||
id = serpy.Field()
|
id = serpy.Field()
|
||||||
|
@ -62,8 +66,16 @@ class MilestoneListSerializer(ListWatchedResourceModelSerializer, serializers.Li
|
||||||
order = serpy.Field()
|
order = serpy.Field()
|
||||||
watchers = serpy.Field()
|
watchers = serpy.Field()
|
||||||
user_stories = serpy.MethodField("get_user_stories")
|
user_stories = serpy.MethodField("get_user_stories")
|
||||||
total_points = serializers.Field(source="total_points_attr")
|
total_points = serpy.MethodField()
|
||||||
closed_points = serializers.Field(source="closed_points_attr")
|
closed_points = serpy.MethodField()
|
||||||
|
|
||||||
def get_user_stories(self, obj):
|
def get_user_stories(self, obj):
|
||||||
return UserStoryListSerializer(obj.user_stories.all(), many=True).data
|
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
|
||||||
|
|
|
@ -44,7 +44,7 @@ class ValidateDuplicatedNameInProjectMixin(serializers.ModelSerializer):
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
class CachedSerializedUsersMixin(serpy.Serializer):
|
class ListCachedUsersSerializerMixin(serpy.Serializer):
|
||||||
def to_value(self, instance):
|
def to_value(self, instance):
|
||||||
self._serialized_users = {}
|
self._serialized_users = {}
|
||||||
return super().to_value(instance)
|
return super().to_value(instance)
|
||||||
|
@ -61,7 +61,7 @@ class CachedSerializedUsersMixin(serpy.Serializer):
|
||||||
return serialized_user
|
return serialized_user
|
||||||
|
|
||||||
|
|
||||||
class OwnerExtraInfoMixin(CachedSerializedUsersMixin):
|
class ListOwnerExtraInfoSerializerMixin(ListCachedUsersSerializerMixin):
|
||||||
owner = serpy.Field(attr="owner_id")
|
owner = serpy.Field(attr="owner_id")
|
||||||
owner_extra_info = serpy.MethodField()
|
owner_extra_info = serpy.MethodField()
|
||||||
|
|
||||||
|
@ -69,7 +69,7 @@ class OwnerExtraInfoMixin(CachedSerializedUsersMixin):
|
||||||
return self.get_user_extra_info(obj.owner)
|
return self.get_user_extra_info(obj.owner)
|
||||||
|
|
||||||
|
|
||||||
class AssigedToExtraInfoMixin(CachedSerializedUsersMixin):
|
class ListAssignedToExtraInfoSerializerMixin(ListCachedUsersSerializerMixin):
|
||||||
assigned_to = serpy.Field(attr="assigned_to_id")
|
assigned_to = serpy.Field(attr="assigned_to_id")
|
||||||
assigned_to_extra_info = serpy.MethodField()
|
assigned_to_extra_info = serpy.MethodField()
|
||||||
|
|
||||||
|
@ -77,9 +77,10 @@ class AssigedToExtraInfoMixin(CachedSerializedUsersMixin):
|
||||||
return self.get_user_extra_info(obj.assigned_to)
|
return self.get_user_extra_info(obj.assigned_to)
|
||||||
|
|
||||||
|
|
||||||
class StatusExtraInfoMixin(serpy.Serializer):
|
class ListStatusExtraInfoSerializerMixin(serpy.Serializer):
|
||||||
status = serpy.Field(attr="status_id")
|
status = serpy.Field(attr="status_id")
|
||||||
status_extra_info = serpy.MethodField()
|
status_extra_info = serpy.MethodField()
|
||||||
|
|
||||||
def to_value(self, instance):
|
def to_value(self, instance):
|
||||||
self._serialized_status = {}
|
self._serialized_status = {}
|
||||||
return super().to_value(instance)
|
return super().to_value(instance)
|
||||||
|
|
|
@ -25,6 +25,8 @@ from taiga.base import exceptions as exc
|
||||||
from taiga.base.decorators import list_route
|
from taiga.base.decorators import list_route
|
||||||
from taiga.base.api import ModelCrudViewSet, ModelListViewSet
|
from taiga.base.api import ModelCrudViewSet, ModelListViewSet
|
||||||
from taiga.base.api.mixins import BlockedByProjectMixin
|
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.history.mixins import HistoryResourceMixin
|
||||||
from taiga.projects.models import Project, TaskStatus
|
from taiga.projects.models import Project, TaskStatus
|
||||||
from taiga.projects.notifications.mixins import WatchedResourceMixin, WatchersViewSetMixin
|
from taiga.projects.notifications.mixins import WatchedResourceMixin, WatchersViewSetMixin
|
||||||
|
@ -42,8 +44,18 @@ class TaskViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, Wa
|
||||||
TaggedResourceMixin, BlockedByProjectMixin, ModelCrudViewSet):
|
TaggedResourceMixin, BlockedByProjectMixin, ModelCrudViewSet):
|
||||||
queryset = models.Task.objects.all()
|
queryset = models.Task.objects.all()
|
||||||
permission_classes = (permissions.TaskPermission,)
|
permission_classes = (permissions.TaskPermission,)
|
||||||
filter_backends = (filters.CanViewTasksFilterBackend, filters.WatchersFilter)
|
filter_backends = (filters.CanViewTasksFilterBackend,
|
||||||
retrieve_exclude_filters = (filters.WatchersFilter,)
|
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",
|
filter_fields = ["user_story",
|
||||||
"milestone",
|
"milestone",
|
||||||
"project",
|
"project",
|
||||||
|
@ -60,6 +72,44 @@ class TaskViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, Wa
|
||||||
|
|
||||||
return serializers.TaskSerializer
|
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):
|
def update(self, request, *args, **kwargs):
|
||||||
self.object = self.get_object_or_none()
|
self.object = self.get_object_or_none()
|
||||||
project_id = request.DATA.get('project', None)
|
project_id = request.DATA.get('project', None)
|
||||||
|
@ -91,38 +141,24 @@ class TaskViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, Wa
|
||||||
|
|
||||||
return super().update(request, *args, **kwargs)
|
return super().update(request, *args, **kwargs)
|
||||||
|
|
||||||
def get_queryset(self):
|
@list_route(methods=["GET"])
|
||||||
qs = super().get_queryset()
|
def filters_data(self, request, *args, **kwargs):
|
||||||
qs = self.attach_votes_attrs_to_queryset(qs)
|
project_id = request.QUERY_PARAMS.get("project", None)
|
||||||
qs = qs.select_related("milestone",
|
project = get_object_or_404(Project, id=project_id)
|
||||||
"owner",
|
|
||||||
"assigned_to",
|
|
||||||
"status",
|
|
||||||
"project")
|
|
||||||
|
|
||||||
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):
|
queryset = self.get_queryset()
|
||||||
if obj.user_story:
|
querysets = {
|
||||||
obj.milestone = obj.user_story.milestone
|
"statuses": self.filter_queryset(queryset, filter_backends=statuses_filter_backends),
|
||||||
if not obj.id:
|
"assigned_to": self.filter_queryset(queryset, filter_backends=assigned_to_filter_backends),
|
||||||
obj.owner = self.request.user
|
"owners": self.filter_queryset(queryset, filter_backends=owners_filter_backends),
|
||||||
super().pre_save(obj)
|
"tags": self.filter_queryset(queryset)
|
||||||
|
}
|
||||||
def pre_conditions_on_save(self, obj):
|
return response.Ok(services.get_tasks_filters_data(project, querysets))
|
||||||
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."))
|
|
||||||
|
|
||||||
@list_route(methods=["GET"])
|
@list_route(methods=["GET"])
|
||||||
def by_ref(self, request):
|
def by_ref(self, request):
|
||||||
|
|
|
@ -31,6 +31,7 @@ class TaskPermission(TaigaResourcePermission):
|
||||||
partial_update_perms = CommentAndOrUpdatePerm('modify_task', 'comment_task')
|
partial_update_perms = CommentAndOrUpdatePerm('modify_task', 'comment_task')
|
||||||
destroy_perms = HasProjectPerm('delete_task')
|
destroy_perms = HasProjectPerm('delete_task')
|
||||||
list_perms = AllowAny()
|
list_perms = AllowAny()
|
||||||
|
filters_data_perms = AllowAny()
|
||||||
csv_perms = AllowAny()
|
csv_perms = AllowAny()
|
||||||
bulk_create_perms = HasProjectPerm('add_task')
|
bulk_create_perms = HasProjectPerm('add_task')
|
||||||
bulk_update_order_perms = HasProjectPerm('modify_task')
|
bulk_update_order_perms = HasProjectPerm('modify_task')
|
||||||
|
|
|
@ -23,10 +23,12 @@ from taiga.base.api import serializers
|
||||||
from taiga.base.fields import PgArrayField
|
from taiga.base.fields import PgArrayField
|
||||||
from taiga.base.neighbors import NeighborsSerializerMixin
|
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.milestones.validators import SprintExistsValidator
|
||||||
from taiga.projects.mixins.serializers import OwnerExtraInfoMixin
|
from taiga.projects.mixins.serializers import ListOwnerExtraInfoSerializerMixin
|
||||||
from taiga.projects.mixins.serializers import AssigedToExtraInfoMixin
|
from taiga.projects.mixins.serializers import ListAssignedToExtraInfoSerializerMixin
|
||||||
from taiga.projects.mixins.serializers import StatusExtraInfoMixin
|
from taiga.projects.mixins.serializers import ListStatusExtraInfoSerializerMixin
|
||||||
from taiga.projects.notifications.mixins import EditableWatchedResourceModelSerializer
|
from taiga.projects.notifications.mixins import EditableWatchedResourceModelSerializer
|
||||||
from taiga.projects.notifications.mixins import ListWatchedResourceModelSerializer
|
from taiga.projects.notifications.mixins import ListWatchedResourceModelSerializer
|
||||||
from taiga.projects.notifications.validators import WatchersValidator
|
from taiga.projects.notifications.validators import WatchersValidator
|
||||||
|
@ -46,8 +48,10 @@ from . import models
|
||||||
|
|
||||||
import serpy
|
import serpy
|
||||||
|
|
||||||
|
|
||||||
class TaskSerializer(WatchersValidator, VoteResourceSerializerMixin, EditableWatchedResourceModelSerializer,
|
class TaskSerializer(WatchersValidator, VoteResourceSerializerMixin, EditableWatchedResourceModelSerializer,
|
||||||
serializers.ModelSerializer):
|
serializers.ModelSerializer):
|
||||||
|
|
||||||
tags = TagsAndTagsColorsField(default=[], required=False)
|
tags = TagsAndTagsColorsField(default=[], required=False)
|
||||||
external_reference = PgArrayField(required=False)
|
external_reference = PgArrayField(required=False)
|
||||||
comment = serializers.SerializerMethodField("get_comment")
|
comment = serializers.SerializerMethodField("get_comment")
|
||||||
|
@ -83,8 +87,10 @@ class TaskSerializer(WatchersValidator, VoteResourceSerializerMixin, EditableWat
|
||||||
|
|
||||||
|
|
||||||
class TaskListSerializer(ListVoteResourceSerializerMixin, ListWatchedResourceModelSerializer,
|
class TaskListSerializer(ListVoteResourceSerializerMixin, ListWatchedResourceModelSerializer,
|
||||||
OwnerExtraInfoMixin, AssigedToExtraInfoMixin, StatusExtraInfoMixin,
|
ListOwnerExtraInfoSerializerMixin, ListAssignedToExtraInfoSerializerMixin,
|
||||||
|
ListStatusExtraInfoSerializerMixin, ListBasicAttachmentsInfoSerializerMixin,
|
||||||
serializers.LightSerializer):
|
serializers.LightSerializer):
|
||||||
|
|
||||||
id = serpy.Field()
|
id = serpy.Field()
|
||||||
user_story = serpy.Field(attr="user_story_id")
|
user_story = serpy.Field(attr="user_story_id")
|
||||||
ref = serpy.Field()
|
ref = serpy.Field()
|
||||||
|
|
|
@ -16,14 +16,19 @@
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# 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/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
import io
|
|
||||||
import csv
|
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.base.utils import db, text
|
||||||
from taiga.projects.history.services import take_snapshot
|
from taiga.projects.history.services import take_snapshot
|
||||||
from taiga.projects.tasks.apps import (
|
from taiga.projects.tasks.apps import connect_tasks_signals
|
||||||
connect_tasks_signals,
|
from taiga.projects.tasks.apps import disconnect_tasks_signals
|
||||||
disconnect_tasks_signals)
|
|
||||||
from taiga.events import events
|
from taiga.events import events
|
||||||
from taiga.projects.votes.utils import attach_total_voters_to_queryset
|
from taiga.projects.votes.utils import attach_total_voters_to_queryset
|
||||||
from taiga.projects.notifications.utils import attach_watchers_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
|
from . import models
|
||||||
|
|
||||||
|
|
||||||
|
#####################################################
|
||||||
|
# Bulk actions
|
||||||
|
#####################################################
|
||||||
|
|
||||||
def get_tasks_from_bulk(bulk_data, **additional_fields):
|
def get_tasks_from_bulk(bulk_data, **additional_fields):
|
||||||
"""Convert `bulk_data` into a list of tasks.
|
"""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
|
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.
|
Update the order of some tasks.
|
||||||
`bulk_data` should be a list of tuples with the following format:
|
`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):
|
def snapshot_tasks_in_bulk(bulk_data, user):
|
||||||
task_ids = []
|
|
||||||
for task_data in bulk_data:
|
for task_data in bulk_data:
|
||||||
try:
|
try:
|
||||||
task = models.Task.objects.get(pk=task_data['task_id'])
|
task = models.Task.objects.get(pk=task_data['task_id'])
|
||||||
|
@ -94,6 +102,10 @@ def snapshot_tasks_in_bulk(bulk_data, user):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
#####################################################
|
||||||
|
# CSV
|
||||||
|
#####################################################
|
||||||
|
|
||||||
def tasks_to_csv(project, queryset):
|
def tasks_to_csv(project, queryset):
|
||||||
csv_data = io.StringIO()
|
csv_data = io.StringIO()
|
||||||
fieldnames = ["ref", "subject", "description", "user_story", "sprint", "sprint_estimated_start",
|
fieldnames = ["ref", "subject", "description", "user_story", "sprint", "sprint_estimated_start",
|
||||||
|
@ -144,7 +156,7 @@ def tasks_to_csv(project, queryset):
|
||||||
"voters": task.total_voters,
|
"voters": task.total_voters,
|
||||||
"created_date": task.created_date,
|
"created_date": task.created_date,
|
||||||
"modified_date": task.modified_date,
|
"modified_date": task.modified_date,
|
||||||
"finished_date": task.finished_date,
|
"finished_date": task.finished_date,
|
||||||
}
|
}
|
||||||
for custom_attr in custom_attrs:
|
for custom_attr in custom_attrs:
|
||||||
value = task.custom_attributes_values.attributes_values.get(str(custom_attr.id), None)
|
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)
|
writer.writerow(task_data)
|
||||||
|
|
||||||
return csv_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
|
||||||
|
|
|
@ -36,6 +36,7 @@ from taiga.base.api import ModelCrudViewSet
|
||||||
from taiga.base.api import ModelListViewSet
|
from taiga.base.api import ModelListViewSet
|
||||||
from taiga.base.api.utils import get_object_or_404
|
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.mixins import HistoryResourceMixin
|
||||||
from taiga.projects.history.services import take_snapshot
|
from taiga.projects.history.services import take_snapshot
|
||||||
from taiga.projects.milestones.models import Milestone
|
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.votes.mixins.viewsets import VotersViewSetMixin
|
||||||
from taiga.projects.userstories.utils import attach_total_points
|
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_role_points
|
||||||
|
from taiga.projects.userstories.utils import attach_tasks
|
||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
from . import permissions
|
from . import permissions
|
||||||
|
@ -85,9 +87,6 @@ class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixi
|
||||||
"kanban_order",
|
"kanban_order",
|
||||||
"total_voters"]
|
"total_voters"]
|
||||||
|
|
||||||
# Specific filter used for filtering neighbor user stories
|
|
||||||
_neighbor_tags_filter = filters.TagsFilter('neighbor_tags')
|
|
||||||
|
|
||||||
def get_serializer_class(self, *args, **kwargs):
|
def get_serializer_class(self, *args, **kwargs):
|
||||||
if self.action in ["retrieve", "by_ref"]:
|
if self.action in ["retrieve", "by_ref"]:
|
||||||
return serializers.UserStoryNeighborsSerializer
|
return serializers.UserStoryNeighborsSerializer
|
||||||
|
@ -105,10 +104,20 @@ class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixi
|
||||||
"owner",
|
"owner",
|
||||||
"assigned_to",
|
"assigned_to",
|
||||||
"generated_from_issue")
|
"generated_from_issue")
|
||||||
|
|
||||||
qs = self.attach_votes_attrs_to_queryset(qs)
|
qs = self.attach_votes_attrs_to_queryset(qs)
|
||||||
qs = self.attach_watchers_attrs_to_queryset(qs)
|
qs = self.attach_watchers_attrs_to_queryset(qs)
|
||||||
qs = attach_total_points(qs)
|
qs = attach_total_points(qs)
|
||||||
qs = attach_role_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
|
return qs
|
||||||
|
|
||||||
def pre_conditions_on_save(self, obj):
|
def pre_conditions_on_save(self, obj):
|
||||||
|
|
|
@ -29,20 +29,19 @@ from taiga.base.neighbors import NeighborsSerializerMixin
|
||||||
from taiga.base.utils import json
|
from taiga.base.utils import json
|
||||||
|
|
||||||
from taiga.mdrender.service import render as mdrender
|
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.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.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 EditableWatchedResourceModelSerializer
|
||||||
from taiga.projects.notifications.mixins import ListWatchedResourceModelSerializer
|
from taiga.projects.notifications.mixins import ListWatchedResourceModelSerializer
|
||||||
from taiga.projects.notifications.validators import WatchersValidator
|
from taiga.projects.notifications.validators import WatchersValidator
|
||||||
from taiga.projects.serializers import BasicUserStoryStatusSerializer
|
from taiga.projects.serializers import BasicUserStoryStatusSerializer
|
||||||
from taiga.projects.tagging.fields import TagsAndTagsColorsField
|
from taiga.projects.tagging.fields import TagsAndTagsColorsField
|
||||||
from taiga.projects.userstories.validators import UserStoryExistsValidator
|
from taiga.projects.userstories.validators import UserStoryExistsValidator
|
||||||
from taiga.projects.validators import ProjectExistsValidator
|
from taiga.projects.validators import ProjectExistsValidator, UserStoryStatusExistsValidator
|
||||||
from taiga.projects.validators import UserStoryStatusExistsValidator
|
|
||||||
from taiga.projects.votes.mixins.serializers import VoteResourceSerializerMixin
|
from taiga.projects.votes.mixins.serializers import VoteResourceSerializerMixin
|
||||||
from taiga.projects.votes.mixins.serializers import ListVoteResourceSerializerMixin
|
from taiga.projects.votes.mixins.serializers import ListVoteResourceSerializerMixin
|
||||||
|
|
||||||
|
@ -136,7 +135,9 @@ class ListOriginIssueSerializer(serializers.LightSerializer):
|
||||||
|
|
||||||
|
|
||||||
class UserStoryListSerializer(ListVoteResourceSerializerMixin, ListWatchedResourceModelSerializer,
|
class UserStoryListSerializer(ListVoteResourceSerializerMixin, ListWatchedResourceModelSerializer,
|
||||||
OwnerExtraInfoMixin, AssigedToExtraInfoMixin, StatusExtraInfoMixin, serializers.LightSerializer):
|
ListOwnerExtraInfoSerializerMixin, ListAssignedToExtraInfoSerializerMixin,
|
||||||
|
ListStatusExtraInfoSerializerMixin, ListBasicAttachmentsInfoSerializerMixin,
|
||||||
|
serializers.LightSerializer):
|
||||||
|
|
||||||
id = serpy.Field()
|
id = serpy.Field()
|
||||||
ref = serpy.Field()
|
ref = serpy.Field()
|
||||||
|
@ -163,13 +164,11 @@ class UserStoryListSerializer(ListVoteResourceSerializerMixin, ListWatchedResour
|
||||||
is_blocked = serpy.Field()
|
is_blocked = serpy.Field()
|
||||||
blocked_note = serpy.Field()
|
blocked_note = serpy.Field()
|
||||||
tags = serpy.Field()
|
tags = serpy.Field()
|
||||||
total_points = serpy.Field("total_points_attr")
|
total_points = serpy.MethodField()
|
||||||
comment = serpy.MethodField("get_comment")
|
comment = serpy.MethodField("get_comment")
|
||||||
origin_issue = ListOriginIssueSerializer(attr="generated_from_issue")
|
origin_issue = ListOriginIssueSerializer(attr="generated_from_issue")
|
||||||
|
|
||||||
def to_value(self, instance):
|
tasks = serpy.MethodField()
|
||||||
self._serialized_status = {}
|
|
||||||
return super().to_value(instance)
|
|
||||||
|
|
||||||
def get_milestone_slug(self, obj):
|
def get_milestone_slug(self, obj):
|
||||||
return obj.milestone.slug if obj.milestone else None
|
return obj.milestone.slug if obj.milestone else None
|
||||||
|
@ -177,15 +176,31 @@ class UserStoryListSerializer(ListVoteResourceSerializerMixin, ListWatchedResour
|
||||||
def get_milestone_name(self, obj):
|
def get_milestone_name(self, obj):
|
||||||
return obj.milestone.name if obj.milestone else None
|
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):
|
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:
|
if obj.role_points_attr is None:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
return dict(ChainMap(*json.loads(obj.role_points_attr)))
|
return dict(ChainMap(*obj.role_points_attr))
|
||||||
|
|
||||||
def get_comment(self, obj):
|
def get_comment(self, obj):
|
||||||
return ""
|
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):
|
class UserStoryNeighborsSerializer(NeighborsSerializerMixin, UserStorySerializer):
|
||||||
def serialize_neighbor(self, neighbor):
|
def serialize_neighbor(self, neighbor):
|
||||||
|
|
|
@ -28,9 +28,8 @@ from django.utils.translation import ugettext as _
|
||||||
|
|
||||||
from taiga.base.utils import db, text
|
from taiga.base.utils import db, text
|
||||||
from taiga.projects.history.services import take_snapshot
|
from taiga.projects.history.services import take_snapshot
|
||||||
from taiga.projects.userstories.apps import (
|
from taiga.projects.userstories.apps import connect_userstories_signals
|
||||||
connect_userstories_signals,
|
from taiga.projects.userstories.apps import disconnect_userstories_signals
|
||||||
disconnect_userstories_signals)
|
|
||||||
|
|
||||||
from taiga.events import events
|
from taiga.events import events
|
||||||
from taiga.projects.votes.utils import attach_total_voters_to_queryset
|
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
|
from . import models
|
||||||
|
|
||||||
|
|
||||||
|
#####################################################
|
||||||
|
# Bulk actions
|
||||||
|
#####################################################
|
||||||
|
|
||||||
def get_userstories_from_bulk(bulk_data, **additional_fields):
|
def get_userstories_from_bulk(bulk_data, **additional_fields):
|
||||||
"""Convert `bulk_data` into a list of user stories.
|
"""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
|
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.
|
Update the order of some user stories.
|
||||||
`bulk_data` should be a list of tuples with the following format:
|
`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)
|
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.
|
Update the milestone of some user stories.
|
||||||
`bulk_data` should be a list of user story ids:
|
`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):
|
def snapshot_userstories_in_bulk(bulk_data, user):
|
||||||
user_story_ids = []
|
|
||||||
for us_data in bulk_data:
|
for us_data in bulk_data:
|
||||||
try:
|
try:
|
||||||
us = models.UserStory.objects.get(pk=us_data['us_id'])
|
us = models.UserStory.objects.get(pk=us_data['us_id'])
|
||||||
|
@ -117,6 +119,10 @@ def snapshot_userstories_in_bulk(bulk_data, user):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
#####################################################
|
||||||
|
# Open/Close calcs
|
||||||
|
#####################################################
|
||||||
|
|
||||||
def calculate_userstory_is_closed(user_story):
|
def calculate_userstory_is_closed(user_story):
|
||||||
if user_story.status is None:
|
if user_story.status is None:
|
||||||
return False
|
return False
|
||||||
|
@ -144,7 +150,11 @@ def open_userstory(us):
|
||||||
us.save(update_fields=["is_closed", "finish_date"])
|
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()
|
csv_data = io.StringIO()
|
||||||
fieldnames = ["ref", "subject", "description", "sprint", "sprint_estimated_start",
|
fieldnames = ["ref", "subject", "description", "sprint", "sprint_estimated_start",
|
||||||
"sprint_estimated_finish", "owner", "owner_full_name", "assigned_to",
|
"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",
|
"created_date", "modified_date", "finish_date",
|
||||||
"client_requirement", "team_requirement", "attachments",
|
"client_requirement", "team_requirement", "attachments",
|
||||||
"generated_from_issue", "external_reference", "tasks",
|
"generated_from_issue", "external_reference", "tasks",
|
||||||
"tags","watchers", "voters"]
|
"tags", "watchers", "voters"]
|
||||||
|
|
||||||
custom_attrs = project.userstorycustomattributes.all()
|
custom_attrs = project.userstorycustomattributes.all()
|
||||||
for custom_attr in custom_attrs:
|
for custom_attr in custom_attrs:
|
||||||
|
@ -230,6 +240,10 @@ def userstories_to_csv(project,queryset):
|
||||||
return csv_data
|
return csv_data
|
||||||
|
|
||||||
|
|
||||||
|
#####################################################
|
||||||
|
# Api filter data
|
||||||
|
#####################################################
|
||||||
|
|
||||||
def _get_userstories_statuses(project, queryset):
|
def _get_userstories_statuses(project, queryset):
|
||||||
compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None)
|
compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None)
|
||||||
queryset_where_tuple = queryset.query.where.as_sql(compiler, connection)
|
queryset_where_tuple = queryset.query.where.as_sql(compiler, connection)
|
||||||
|
@ -336,7 +350,8 @@ def _get_userstories_owners(project, queryset):
|
||||||
|
|
||||||
extra_sql = """
|
extra_sql = """
|
||||||
WITH counters AS (
|
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"
|
FROM "userstories_userstory"
|
||||||
INNER JOIN "projects_project" ON ("userstories_userstory"."project_id" = "projects_project"."id")
|
INNER JOIN "projects_project" ON ("userstories_userstory"."project_id" = "projects_project"."id")
|
||||||
WHERE {where}
|
WHERE {where}
|
||||||
|
@ -350,7 +365,7 @@ def _get_userstories_owners(project, queryset):
|
||||||
FROM projects_membership
|
FROM projects_membership
|
||||||
LEFT OUTER JOIN counters ON ("projects_membership"."user_id" = "counters"."owner_id")
|
LEFT OUTER JOIN counters ON ("projects_membership"."user_id" = "counters"."owner_id")
|
||||||
INNER JOIN "users_user" ON ("projects_membership"."user_id" = "users_user"."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
|
-- System users
|
||||||
UNION
|
UNION
|
||||||
|
@ -379,16 +394,44 @@ def _get_userstories_owners(project, queryset):
|
||||||
return sorted(result, key=itemgetter("full_name"))
|
return sorted(result, key=itemgetter("full_name"))
|
||||||
|
|
||||||
|
|
||||||
def _get_userstories_tags(queryset):
|
def _get_userstories_tags(project, queryset):
|
||||||
tags = []
|
compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None)
|
||||||
for t_list in queryset.values_list("tags", flat=True):
|
queryset_where_tuple = queryset.query.where.as_sql(compiler, connection)
|
||||||
if t_list is None:
|
where = queryset_where_tuple[0]
|
||||||
continue
|
where_params = queryset_where_tuple[1]
|
||||||
tags += list(t_list)
|
|
||||||
|
|
||||||
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):
|
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"])),
|
("statuses", _get_userstories_statuses(project, querysets["statuses"])),
|
||||||
("assigned_to", _get_userstories_assigned_to(project, querysets["assigned_to"])),
|
("assigned_to", _get_userstories_assigned_to(project, querysets["assigned_to"])),
|
||||||
("owners", _get_userstories_owners(project, querysets["owners"])),
|
("owners", _get_userstories_owners(project, querysets["owners"])),
|
||||||
("tags", _get_userstories_tags(querysets["tags"])),
|
("tags", _get_userstories_tags(project, querysets["tags"])),
|
||||||
])
|
])
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
|
@ -46,11 +46,39 @@ def attach_role_points(queryset, as_field="role_points_attr"):
|
||||||
:return: Queryset object with the additional `as_field` field.
|
:return: Queryset object with the additional `as_field` field.
|
||||||
"""
|
"""
|
||||||
model = queryset.model
|
model = queryset.model
|
||||||
sql = """SELECT json_agg(json_build_object(userstories_rolepoints.role_id,
|
sql = """SELECT json_agg((userstories_rolepoints.role_id, userstories_rolepoints.points_id))
|
||||||
userstories_rolepoints.points_id))::text
|
|
||||||
FROM userstories_rolepoints
|
FROM userstories_rolepoints
|
||||||
WHERE userstories_rolepoints.user_story_id = {tbl}.id"""
|
WHERE userstories_rolepoints.user_story_id = {tbl}.id"""
|
||||||
|
|
||||||
sql = sql.format(tbl=model._meta.db_table)
|
sql = sql.format(tbl=model._meta.db_table)
|
||||||
queryset = queryset.extra(select={as_field: sql})
|
queryset = queryset.extra(select={as_field: sql})
|
||||||
return queryset
|
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
|
||||||
|
|
|
@ -229,6 +229,7 @@ def test_api_filter_by_text_6(client):
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert number_of_issues == 1
|
assert number_of_issues == 1
|
||||||
|
|
||||||
|
|
||||||
def test_api_filters_data(client):
|
def test_api_filters_data(client):
|
||||||
project = f.ProjectFactory.create()
|
project = f.ProjectFactory.create()
|
||||||
user1 = f.UserFactory.create(is_superuser=True)
|
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'] == severity2.id, response.data["severities"]))["count"] == 0
|
||||||
assert next(filter(lambda i: i['id'] == severity3.id, response.data["severities"]))["count"] == 1
|
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'] == 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'] == tag2, response.data["tags"]))["count"] == 2
|
||||||
assert next(filter(lambda i: i['name'] == tag3, response.data["tags"]))["count"] == 1
|
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'] == severity2.id, response.data["severities"]))["count"] == 0
|
||||||
assert next(filter(lambda i: i['id'] == severity3.id, response.data["severities"]))["count"] == 1
|
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'] == 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'] == tag2, response.data["tags"]))["count"] == 2
|
||||||
assert next(filter(lambda i: i['name'] == tag3, response.data["tags"]))["count"] == 1
|
assert next(filter(lambda i: i['name'] == tag3, response.data["tags"]))["count"] == 1
|
||||||
|
|
|
@ -185,3 +185,153 @@ def test_custom_fields_csv_generation():
|
||||||
assert row[24] == attr.name
|
assert row[24] == attr.name
|
||||||
row = next(reader)
|
row = next(reader)
|
||||||
assert row[24] == "val1"
|
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
|
||||||
|
|
|
@ -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'] == 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['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'] == 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'] == tag2, response.data["tags"]))["count"] == 3
|
||||||
assert next(filter(lambda i: i['name'] == tag3, 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'] == 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['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'] == 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'] == tag2, response.data["tags"]))["count"] == 2
|
||||||
assert next(filter(lambda i: i['name'] == tag3, response.data["tags"]))["count"] == 1
|
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.status_code == 200
|
||||||
assert response.data["tribe_gig"] == data["tribe_gig"]
|
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
|
||||||
|
|
Loading…
Reference in New Issue