diff --git a/taiga/base/fields.py b/taiga/base/fields.py
index 3f6fcf19..8e95801d 100644
--- a/taiga/base/fields.py
+++ b/taiga/base/fields.py
@@ -18,7 +18,7 @@
from django.forms import widgets
from django.utils.translation import ugettext as _
-
+from django.utils.translation import ugettext_lazy
from taiga.base.api import serializers
@@ -99,35 +99,6 @@ class PickledObjectField(serializers.WritableField):
return data
-class TagsField(serializers.WritableField):
- """
- Pickle objects serializer.
- """
- def to_native(self, obj):
- return obj
-
- def from_native(self, data):
- if not data:
- return data
-
- ret = sum([tag.split(",") for tag in data], [])
- return ret
-
-
-class TagsColorsField(serializers.WritableField):
- """
- PgArray objects serializer.
- """
- widget = widgets.Textarea
-
- def to_native(self, obj):
- return dict(obj)
-
- def from_native(self, data):
- return list(data.items())
-
-
-
class WatchersField(serializers.WritableField):
def to_native(self, obj):
return obj
diff --git a/taiga/projects/api.py b/taiga/projects/api.py
index 958ed3b3..77d9a5d1 100644
--- a/taiga/projects/api.py
+++ b/taiga/projects/api.py
@@ -66,9 +66,9 @@ from . import services
######################################################
## Project
######################################################
+
class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin,
BlockeableSaveMixin, BlockeableDeleteMixin, ModelCrudViewSet):
-
queryset = models.Project.objects.all()
serializer_class = serializers.ProjectDetailSerializer
admin_serializer_class = serializers.ProjectDetailAdminSerializer
diff --git a/taiga/projects/apps.py b/taiga/projects/apps.py
index 38295e81..634d56ce 100644
--- a/taiga/projects/apps.py
+++ b/taiga/projects/apps.py
@@ -25,13 +25,14 @@ from django.db.models import signals
def connect_projects_signals():
from . import signals as handlers
+ from .tagging import signals as tagging_handlers
# On project object is created apply template.
signals.post_save.connect(handlers.project_post_save,
sender=apps.get_model("projects", "Project"),
dispatch_uid='project_post_save')
# Tags normalization after save a project
- signals.pre_save.connect(handlers.tags_normalization,
+ signals.pre_save.connect(tagging_handlers.tags_normalization,
sender=apps.get_model("projects", "Project"),
dispatch_uid="tags_normalization_projects")
diff --git a/taiga/projects/issues/api.py b/taiga/projects/issues/api.py
index b368ec8d..20befba7 100644
--- a/taiga/projects/issues/api.py
+++ b/taiga/projects/issues/api.py
@@ -27,11 +27,11 @@ from taiga.base.api import ModelCrudViewSet, ModelListViewSet
from taiga.base.api.mixins import BlockedByProjectMixin
from taiga.base.api.utils import get_object_or_404
+from taiga.projects.history.mixins import HistoryResourceMixin
+from taiga.projects.models import Project, IssueStatus, Severity, Priority, IssueType
from taiga.projects.notifications.mixins import WatchedResourceMixin, WatchersViewSetMixin
from taiga.projects.occ import OCCResourceMixin
-from taiga.projects.history.mixins import HistoryResourceMixin
-
-from taiga.projects.models import Project, IssueStatus, Severity, Priority, IssueType
+from taiga.projects.tagging.mixins import TaggedResourceMixin
from taiga.projects.votes.mixins.viewsets import VotedResourceMixin, VotersViewSetMixin
from . import models
@@ -41,7 +41,7 @@ from . import serializers
class IssueViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, WatchedResourceMixin,
- BlockedByProjectMixin, ModelCrudViewSet):
+ TaggedResourceMixin, BlockedByProjectMixin, ModelCrudViewSet):
queryset = models.Issue.objects.all()
permission_classes = (permissions.IssuePermission, )
filter_backends = (filters.CanViewIssuesFilterBackend,
@@ -196,7 +196,6 @@ class IssueViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, W
owners_filter_backends = (f for f in filter_backends if f != filters.OwnersFilter)
priorities_filter_backends = (f for f in filter_backends if f != filters.PrioritiesFilter)
severities_filter_backends = (f for f in filter_backends if f != filters.SeveritiesFilter)
- tags_filter_backends = (f for f in filter_backends if f != filters.TagsFilter)
queryset = self.get_queryset()
querysets = {
diff --git a/taiga/projects/issues/apps.py b/taiga/projects/issues/apps.py
index 671a45be..ac01491a 100644
--- a/taiga/projects/issues/apps.py
+++ b/taiga/projects/issues/apps.py
@@ -23,6 +23,7 @@ from django.db.models import signals
def connect_issues_signals():
from taiga.projects import signals as generic_handlers
+ from taiga.projects.tagging import signals as tagging_handlers
from . import signals as handlers
# Finished date
@@ -31,7 +32,7 @@ def connect_issues_signals():
dispatch_uid="set_finished_date_when_edit_issue")
# Tags
- signals.pre_save.connect(generic_handlers.tags_normalization,
+ signals.pre_save.connect(tagging_handlers.tags_normalization,
sender=apps.get_model("issues", "Issue"),
dispatch_uid="tags_normalization_issue")
diff --git a/taiga/projects/issues/serializers.py b/taiga/projects/issues/serializers.py
index 505f8463..83557f20 100644
--- a/taiga/projects/issues/serializers.py
+++ b/taiga/projects/issues/serializers.py
@@ -17,15 +17,15 @@
# along with this program. If not, see .
from taiga.base.api import serializers
-from taiga.base.fields import TagsField
from taiga.base.fields import PgArrayField
from taiga.base.neighbors import NeighborsSerializerMixin
-from taiga.mdrender.service import render as mdrender
-from taiga.projects.validators import ProjectExistsValidator
+from taiga.projects.notifications.mixins import EditableWatchedResourceModelSerializer
from taiga.projects.notifications.validators import WatchersValidator
from taiga.projects.serializers import BasicIssueStatusSerializer
-from taiga.projects.notifications.mixins import EditableWatchedResourceModelSerializer
+from taiga.mdrender.service import render as mdrender
+from taiga.projects.tagging.fields import TagsAndTagsColorsField
+from taiga.projects.validators import ProjectExistsValidator
from taiga.projects.votes.mixins.serializers import VoteResourceSerializerMixin
from taiga.users.serializers import UserBasicInfoSerializer
@@ -33,8 +33,9 @@ from taiga.users.serializers import UserBasicInfoSerializer
from . import models
-class IssueSerializer(WatchersValidator, VoteResourceSerializerMixin, EditableWatchedResourceModelSerializer, serializers.ModelSerializer):
- tags = TagsField(required=False)
+class IssueSerializer(WatchersValidator, VoteResourceSerializerMixin, EditableWatchedResourceModelSerializer,
+ serializers.ModelSerializer):
+ tags = TagsAndTagsColorsField(default=[], required=False)
external_reference = PgArrayField(required=False)
is_closed = serializers.Field(source="is_closed")
comment = serializers.SerializerMethodField("get_comment")
@@ -71,7 +72,7 @@ class IssueListSerializer(IssueSerializer):
class Meta:
model = models.Issue
read_only_fields = ('id', 'ref', 'created_date', 'modified_date')
- exclude=("description", "description_html")
+ exclude = ("description", "description_html")
class IssueNeighborsSerializer(NeighborsSerializerMixin, IssueSerializer):
diff --git a/taiga/projects/serializers.py b/taiga/projects/serializers.py
index f7403389..f18bee1d 100644
--- a/taiga/projects/serializers.py
+++ b/taiga/projects/serializers.py
@@ -24,28 +24,27 @@ from django.db.models import Q
from taiga.base.api import serializers
from taiga.base.fields import JsonField
from taiga.base.fields import PgArrayField
-from taiga.base.fields import TagsField
-from taiga.base.fields import TagsColorsField
-from taiga.projects.notifications.choices import NotifyLevel
from taiga.users.services import get_photo_or_gravatar_url
-from taiga.users.serializers import UserSerializer
from taiga.users.serializers import UserBasicInfoSerializer
from taiga.users.serializers import ProjectRoleSerializer
from taiga.users.validators import RoleExistsValidator
from taiga.permissions.services import get_user_project_permissions
from taiga.permissions.services import is_project_admin, is_project_owner
-from taiga.projects.mixins.serializers import ValidateDuplicatedNameInProjectMixin
from . import models
from . import services
-from .notifications.mixins import WatchedResourceModelSerializer
-from .validators import ProjectExistsValidator
from .custom_attributes.serializers import UserStoryCustomAttributeSerializer
from .custom_attributes.serializers import TaskCustomAttributeSerializer
from .custom_attributes.serializers import IssueCustomAttributeSerializer
from .likes.mixins.serializers import FanResourceSerializerMixin
+from .mixins.serializers import ValidateDuplicatedNameInProjectMixin
+from .notifications.choices import NotifyLevel
+from .notifications.mixins import WatchedResourceModelSerializer
+from .tagging.fields import TagsField
+from .tagging.fields import TagsColorsField
+from .validators import ProjectExistsValidator
######################################################
diff --git a/taiga/projects/services/tags.py b/taiga/projects/services/tags.py
index 20e1946a..ea009df7 100644
--- a/taiga/projects/services/tags.py
+++ b/taiga/projects/services/tags.py
@@ -18,6 +18,7 @@
from django.db import connection
+
def tag_exist_for_project_elements(project, tag):
return tag in dict(project.tags_colors).keys()
diff --git a/taiga/projects/signals.py b/taiga/projects/signals.py
index e0196887..b94e5cda 100644
--- a/taiga/projects/signals.py
+++ b/taiga/projects/signals.py
@@ -29,13 +29,6 @@ from easy_thumbnails.files import get_thumbnailer
# Signals over project items
####################################
-## TAGS
-
-def tags_normalization(sender, instance, **kwargs):
- if isinstance(instance.tags, (list, tuple)):
- instance.tags = list(map(str.lower, instance.tags))
-
-
## Membership
def membership_post_delete(sender, instance, using, **kwargs):
diff --git a/taiga/projects/tagging/__init__.py b/taiga/projects/tagging/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/taiga/projects/tagging/fields.py b/taiga/projects/tagging/fields.py
new file mode 100644
index 00000000..b56e3cc1
--- /dev/null
+++ b/taiga/projects/tagging/fields.py
@@ -0,0 +1,99 @@
+# -*- coding: utf-8 -*-
+# Copyright (C) 2014-2016 Andrey Antukh
+# Copyright (C) 2014-2016 Jesús Espino
+# Copyright (C) 2014-2016 David Barragán
+# Copyright (C) 2014-2016 Alejandro Alonso
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+from django.forms import widgets
+from django.utils.translation import ugettext_lazy as _
+from taiga.base.api import serializers
+
+from django.core.exceptions import ValidationError
+
+import re
+
+
+class TagsAndTagsColorsField(serializers.WritableField):
+ """
+ Pickle objects serializer fior stories, tasks and issues tags.
+ """
+ def __init__(self, *args, **kwargs):
+ def _validate_tag_field(value):
+ # Valid field:
+ # - ["tag1", "tag2", "tag3"...]
+ # - ["tag1", ["tag2", None], ["tag3", "#ccc"], [tag4, #cccccc]...]
+ for tag in value:
+ if isinstance(tag, str):
+ continue
+
+ if isinstance(tag, (list, tuple)) and len(tag) == 2:
+ name = tag[0]
+ color = tag[1]
+
+ if isinstance(name, str):
+ if color is None:
+ continue
+
+ if isinstance(color, str) and re.match('^\#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})$', color):
+ continue
+
+ raise ValidationError(_("Invalid tag '{value}'. The color is not a "
+ "valid HEX color or null.").format(value=tag))
+
+ raise ValidationError(_("Invalid tag '{value}'. it must be the name or a pair "
+ "'[\"name\", \"hex color/\" | null]'.").format(value=tag))
+
+ super().__init__(*args, **kwargs)
+ self.validators.append(_validate_tag_field)
+
+ def to_native(self, obj):
+ return obj
+
+ def from_native(self, data):
+ return data
+
+
+class TagsField(serializers.WritableField):
+ """
+ Pickle objects serializer for tags names.
+ """
+ def __init__(self, *args, **kwargs):
+ def _validate_tag_field(value):
+ for tag in value:
+ if isinstance(tag, str):
+ continue
+ raise ValidationError(_("Invalid tag '{value}'. It must be the tag name.").format(value=tag))
+
+ super().__init__(*args, **kwargs)
+ self.validators.append(_validate_tag_field)
+
+ def to_native(self, obj):
+ return obj
+
+ def from_native(self, data):
+ return data
+
+
+class TagsColorsField(serializers.WritableField):
+ """
+ PgArray objects serializer.
+ """
+ widget = widgets.Textarea
+
+ def to_native(self, obj):
+ return dict(obj)
+
+ def from_native(self, data):
+ return list(data.items())
diff --git a/taiga/projects/tagging/mixins.py b/taiga/projects/tagging/mixins.py
new file mode 100644
index 00000000..aa5df99f
--- /dev/null
+++ b/taiga/projects/tagging/mixins.py
@@ -0,0 +1,48 @@
+# -*- coding: utf-8 -*-
+# Copyright (C) 2014-2016 Andrey Antukh
+# Copyright (C) 2014-2016 Jesús Espino
+# Copyright (C) 2014-2016 David Barragán
+# Copyright (C) 2014-2016 Alejandro Alonso
+# 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 .
+
+
+def _pre_save_new_tags_in_project_tagss_colors(obj):
+ current_project_tags = [t[0] for t in obj.project.tags_colors]
+ new_obj_tags = set()
+ new_tags_colors = {}
+
+ for tag in obj.tags:
+ if isinstance(tag, (list, tuple)):
+ name, color = tag
+
+ if color and name not in current_project_tags:
+ new_tags_colors[name] = color
+
+ new_obj_tags.add(name)
+ elif isinstance(tag, str):
+ new_obj_tags.add(tag.lower())
+
+ obj.tags = list(new_obj_tags)
+
+ if new_tags_colors:
+ obj.project.tags_colors += [[k, v] for k,v in new_tags_colors.items()]
+ obj.project.save(update_fields=["tags_colors"])
+
+
+class TaggedResourceMixin:
+ def pre_save(self, obj):
+ if obj.tags:
+ _pre_save_new_tags_in_project_tagss_colors(obj)
+
+ super().pre_save(obj)
diff --git a/taiga/projects/tagging/signals.py b/taiga/projects/tagging/signals.py
new file mode 100644
index 00000000..562fcba5
--- /dev/null
+++ b/taiga/projects/tagging/signals.py
@@ -0,0 +1,23 @@
+# -*- coding: utf-8 -*-
+# Copyright (C) 2014-2016 Andrey Antukh
+# Copyright (C) 2014-2016 Jesús Espino
+# Copyright (C) 2014-2016 David Barragán
+# Copyright (C) 2014-2016 Alejandro Alonso
+# 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 .
+
+
+def tags_normalization(sender, instance, **kwargs):
+ if isinstance(instance.tags, (list, tuple)):
+ instance.tags = list(map(str.lower, instance.tags))
+
diff --git a/taiga/projects/tasks/api.py b/taiga/projects/tasks/api.py
index d991b39b..7b5de5f3 100644
--- a/taiga/projects/tasks/api.py
+++ b/taiga/projects/tasks/api.py
@@ -16,6 +16,7 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
+from django.http import HttpResponse
from django.utils.translation import ugettext as _
from taiga.base.api.utils import get_object_or_404
@@ -24,15 +25,13 @@ 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.models import Project, TaskStatus
-from django.http import HttpResponse
-
-from taiga.projects.notifications.mixins import WatchedResourceMixin, WatchersViewSetMixin
from taiga.projects.history.mixins import HistoryResourceMixin
+from taiga.projects.models import Project, TaskStatus
+from taiga.projects.notifications.mixins import WatchedResourceMixin, WatchersViewSetMixin
from taiga.projects.occ import OCCResourceMixin
+from taiga.projects.tagging.mixins import TaggedResourceMixin
from taiga.projects.votes.mixins.viewsets import VotedResourceMixin, VotersViewSetMixin
-
from . import models
from . import permissions
from . import serializers
@@ -40,13 +39,18 @@ from . import services
class TaskViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, WatchedResourceMixin,
- BlockedByProjectMixin, ModelCrudViewSet):
+ TaggedResourceMixin, BlockedByProjectMixin, ModelCrudViewSet):
queryset = models.Task.objects.all()
permission_classes = (permissions.TaskPermission,)
filter_backends = (filters.CanViewTasksFilterBackend, filters.WatchersFilter)
retrieve_exclude_filters = (filters.WatchersFilter,)
- filter_fields = ["user_story", "milestone", "project", "assigned_to",
- "status__is_closed"]
+ filter_fields = [
+ "user_story",
+ "milestone",
+ "project",
+ "assigned_to",
+ "status__is_closed"
+ ]
def get_serializer_class(self, *args, **kwargs):
if self.action in ["retrieve", "by_ref"]:
diff --git a/taiga/projects/tasks/apps.py b/taiga/projects/tasks/apps.py
index 23cfdfb0..7ae193cc 100644
--- a/taiga/projects/tasks/apps.py
+++ b/taiga/projects/tasks/apps.py
@@ -23,13 +23,15 @@ from django.db.models import signals
def connect_tasks_signals():
from taiga.projects import signals as generic_handlers
+ from taiga.projects.tagging import signals as tagging_handlers
from . import signals as handlers
+
# Finished date
signals.pre_save.connect(handlers.set_finished_date_when_edit_task,
sender=apps.get_model("tasks", "Task"),
dispatch_uid="set_finished_date_when_edit_task")
# Tags
- signals.pre_save.connect(generic_handlers.tags_normalization,
+ signals.pre_save.connect(tagging_handlers.tags_normalization,
sender=apps.get_model("tasks", "Task"),
dispatch_uid="tags_normalization_task")
diff --git a/taiga/projects/tasks/serializers.py b/taiga/projects/tasks/serializers.py
index a7c1c2a8..c0c8334a 100644
--- a/taiga/projects/tasks/serializers.py
+++ b/taiga/projects/tasks/serializers.py
@@ -17,19 +17,18 @@
# along with this program. If not, see .
from taiga.base.api import serializers
-
-from taiga.base.fields import TagsField
from taiga.base.fields import PgArrayField
-
from taiga.base.neighbors import NeighborsSerializerMixin
-from taiga.mdrender.service import render as mdrender
-from taiga.projects.validators import ProjectExistsValidator
+
from taiga.projects.milestones.validators import SprintExistsValidator
-from taiga.projects.tasks.validators import TaskExistsValidator
+from taiga.projects.notifications.mixins import EditableWatchedResourceModelSerializer
from taiga.projects.notifications.validators import WatchersValidator
from taiga.projects.serializers import BasicTaskStatusSerializerSerializer
-from taiga.projects.notifications.mixins import EditableWatchedResourceModelSerializer
+from taiga.mdrender.service import render as mdrender
+from taiga.projects.tagging.fields import TagsAndTagsColorsField
+from taiga.projects.tasks.validators import TaskExistsValidator
+from taiga.projects.validators import ProjectExistsValidator
from taiga.projects.votes.mixins.serializers import VoteResourceSerializerMixin
from taiga.users.serializers import UserBasicInfoSerializer
@@ -37,14 +36,15 @@ from taiga.users.serializers import UserBasicInfoSerializer
from . import models
-class TaskSerializer(WatchersValidator, VoteResourceSerializerMixin, EditableWatchedResourceModelSerializer, serializers.ModelSerializer):
- tags = TagsField(required=False, default=[])
+class TaskSerializer(WatchersValidator, VoteResourceSerializerMixin, EditableWatchedResourceModelSerializer,
+ serializers.ModelSerializer):
+ tags = TagsAndTagsColorsField(default=[], required=False)
external_reference = PgArrayField(required=False)
comment = serializers.SerializerMethodField("get_comment")
milestone_slug = serializers.SerializerMethodField("get_milestone_slug")
blocked_note_html = serializers.SerializerMethodField("get_blocked_note_html")
description_html = serializers.SerializerMethodField("get_description_html")
- is_closed = serializers.SerializerMethodField("get_is_closed")
+ is_closed = serializers.SerializerMethodField("get_is_closed")
status_extra_info = BasicTaskStatusSerializerSerializer(source="status", required=False, read_only=True)
assigned_to_extra_info = UserBasicInfoSerializer(source="assigned_to", required=False, read_only=True)
owner_extra_info = UserBasicInfoSerializer(source="owner", required=False, read_only=True)
@@ -76,7 +76,7 @@ class TaskListSerializer(TaskSerializer):
class Meta:
model = models.Task
read_only_fields = ('id', 'ref', 'created_date', 'modified_date')
- exclude=("description", "description_html")
+ exclude = ("description", "description_html")
class TaskNeighborsSerializer(NeighborsSerializerMixin, TaskSerializer):
@@ -101,6 +101,7 @@ class TasksBulkSerializer(ProjectExistsValidator, SprintExistsValidator,
us_id = serializers.IntegerField(required=False)
bulk_tasks = serializers.CharField()
+
## Order bulk serializers
class _TaskOrderBulkSerializer(TaskExistsValidator, serializers.Serializer):
diff --git a/taiga/projects/userstories/api.py b/taiga/projects/userstories/api.py
index c3858555..028bfe35 100644
--- a/taiga/projects/userstories/api.py
+++ b/taiga/projects/userstories/api.py
@@ -19,7 +19,6 @@
from django.apps import apps
from django.db import transaction
from django.utils.translation import ugettext as _
-from django.core.exceptions import ObjectDoesNotExist
from django.http import HttpResponse
from taiga.base import filters
@@ -31,12 +30,13 @@ from taiga.base.api.mixins import BlockedByProjectMixin
from taiga.base.api import ModelCrudViewSet, ModelListViewSet
from taiga.base.api.utils import get_object_or_404
-from taiga.projects.notifications.mixins import WatchedResourceMixin, WatchersViewSetMixin
from taiga.projects.history.mixins import HistoryResourceMixin
-from taiga.projects.occ import OCCResourceMixin
-from taiga.projects.models import Project, UserStoryStatus
-from taiga.projects.milestones.models import Milestone
from taiga.projects.history.services import take_snapshot
+from taiga.projects.milestones.models import Milestone
+from taiga.projects.models import Project, UserStoryStatus
+from taiga.projects.notifications.mixins import WatchedResourceMixin, WatchersViewSetMixin
+from taiga.projects.occ import OCCResourceMixin
+from taiga.projects.tagging.mixins import TaggedResourceMixin
from taiga.projects.votes.mixins.viewsets import VotedResourceMixin, VotersViewSetMixin
from . import models
@@ -46,7 +46,7 @@ from . import services
class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, WatchedResourceMixin,
- BlockedByProjectMixin, ModelCrudViewSet):
+ TaggedResourceMixin, BlockedByProjectMixin, ModelCrudViewSet):
queryset = models.UserStory.objects.all()
permission_classes = (permissions.UserStoryPermission,)
filter_backends = (filters.CanViewUsFilterBackend,
@@ -113,8 +113,9 @@ class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixi
def pre_save(self, obj):
# This is very ugly hack, but having
# restframework is the only way to do it.
+ #
# NOTE: code moved as is from serializer
- # to api because is not serializer logic.
+ # to api because is not serializer logic.
related_data = getattr(obj, "_related_data", {})
self._role_points = related_data.pop("role_points", None)
@@ -124,7 +125,8 @@ class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixi
super().pre_save(obj)
def post_save(self, obj, created=False):
- # Code related to the hack of pre_save method. Rather, this is the continuation of it.
+ # Code related to the hack of pre_save method.
+ # Rather, this is the continuation of it.
if self._role_points:
Points = apps.get_model("projects", "Points")
RolePoints = apps.get_model("userstories", "RolePoints")
@@ -134,14 +136,16 @@ class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixi
role_points = RolePoints.objects.get(role__id=role_id, user_story_id=obj.pk,
role__computable=True)
except (ValueError, RolePoints.DoesNotExist):
- raise exc.BadRequest({"points": _("Invalid role id '{role_id}'").format(
- role_id=role_id)})
+ raise exc.BadRequest({
+ "points": _("Invalid role id '{role_id}'").format(role_id=role_id)
+ })
try:
role_points.points = Points.objects.get(id=points_id, project_id=obj.project_id)
except (ValueError, Points.DoesNotExist):
- raise exc.BadRequest({"points": _("Invalid points id '{points_id}'").format(
- points_id=points_id)})
+ raise exc.BadRequest({
+ "points": _("Invalid points id '{points_id}'").format(points_id=points_id)
+ })
role_points.save()
@@ -200,7 +204,6 @@ class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixi
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)
- tags_filter_backends = (f for f in filter_backends if f != filters.TagsFilter)
queryset = self.get_queryset()
querysets = {
diff --git a/taiga/projects/userstories/apps.py b/taiga/projects/userstories/apps.py
index 04c7d32d..fca31409 100644
--- a/taiga/projects/userstories/apps.py
+++ b/taiga/projects/userstories/apps.py
@@ -23,6 +23,7 @@ from django.db.models import signals
def connect_userstories_signals():
from taiga.projects import signals as generic_handlers
+ from taiga.projects.tagging import signals as tagging_handlers
from . import signals as handlers
# When deleting user stories we must disable task signals while delating and
@@ -59,7 +60,7 @@ def connect_userstories_signals():
dispatch_uid="try_to_close_milestone_when_delete_us")
# Tags
- signals.pre_save.connect(generic_handlers.tags_normalization,
+ signals.pre_save.connect(tagging_handlers.tags_normalization,
sender=apps.get_model("userstories", "UserStory"),
dispatch_uid="tags_normalization_user_story")
diff --git a/taiga/projects/userstories/serializers.py b/taiga/projects/userstories/serializers.py
index 0d6eab6d..dae58a18 100644
--- a/taiga/projects/userstories/serializers.py
+++ b/taiga/projects/userstories/serializers.py
@@ -16,23 +16,22 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-from django.apps import apps
from taiga.base.api import serializers
from taiga.base.api.utils import get_object_or_404
-from taiga.base.fields import TagsField
from taiga.base.fields import PickledObjectField
from taiga.base.fields import PgArrayField
from taiga.base.neighbors import NeighborsSerializerMixin
from taiga.base.utils import json
-from taiga.mdrender.service import render as mdrender
-from taiga.projects.models import Project
-from taiga.projects.validators import ProjectExistsValidator, UserStoryStatusExistsValidator
from taiga.projects.milestones.validators import SprintExistsValidator
-from taiga.projects.userstories.validators import UserStoryExistsValidator
+from taiga.projects.models import Project
from taiga.projects.notifications.validators import WatchersValidator
-from taiga.projects.serializers import BasicUserStoryStatusSerializer
from taiga.projects.notifications.mixins import EditableWatchedResourceModelSerializer
+from taiga.projects.serializers import BasicUserStoryStatusSerializer
+from taiga.mdrender.service import render as mdrender
+from taiga.projects.tagging.fields import TagsAndTagsColorsField
+from taiga.projects.userstories.validators import UserStoryExistsValidator
+from taiga.projects.validators import ProjectExistsValidator, UserStoryStatusExistsValidator
from taiga.projects.votes.mixins.serializers import VoteResourceSerializerMixin
from taiga.users.serializers import UserBasicInfoSerializer
@@ -50,9 +49,9 @@ class RolePointsField(serializers.WritableField):
return json.loads(obj)
-class UserStorySerializer(WatchersValidator, VoteResourceSerializerMixin, EditableWatchedResourceModelSerializer,
- serializers.ModelSerializer):
- tags = TagsField(default=[], required=False)
+class UserStorySerializer(WatchersValidator, VoteResourceSerializerMixin,
+ EditableWatchedResourceModelSerializer, serializers.ModelSerializer):
+ tags = TagsAndTagsColorsField(default=[], required=False)
external_reference = PgArrayField(required=False)
points = RolePointsField(source="role_points", required=False)
total_points = serializers.SerializerMethodField("get_total_points")
@@ -112,7 +111,7 @@ class UserStoryListSerializer(UserStorySerializer):
model = models.UserStory
depth = 0
read_only_fields = ('created_date', 'modified_date')
- exclude=("description", "description_html")
+ exclude = ("description", "description_html")
class UserStoryNeighborsSerializer(NeighborsSerializerMixin, UserStorySerializer):
@@ -142,7 +141,8 @@ class _UserStoryOrderBulkSerializer(UserStoryExistsValidator, serializers.Serial
order = serializers.IntegerField()
-class UpdateUserStoriesOrderBulkSerializer(ProjectExistsValidator, UserStoryStatusExistsValidator, serializers.Serializer):
+class UpdateUserStoriesOrderBulkSerializer(ProjectExistsValidator, UserStoryStatusExistsValidator,
+ serializers.Serializer):
project_id = serializers.IntegerField()
bulk_stories = _UserStoryOrderBulkSerializer(many=True)
diff --git a/taiga/webhooks/serializers.py b/taiga/webhooks/serializers.py
index 68d6a8eb..ee0d8308 100644
--- a/taiga/webhooks/serializers.py
+++ b/taiga/webhooks/serializers.py
@@ -19,7 +19,7 @@
from django.core.exceptions import ObjectDoesNotExist
from taiga.base.api import serializers
-from taiga.base.fields import TagsField, PgArrayField, JsonField
+from taiga.base.fields import PgArrayField, JsonField
from taiga.front.templatetags.functions import resolve as resolve_front_url
@@ -29,6 +29,7 @@ from taiga.projects.milestones import models as milestone_models
from taiga.projects.notifications.mixins import EditableWatchedResourceModelSerializer
from taiga.projects.services import get_logo_big_thumbnail_url
from taiga.projects.tasks import models as task_models
+from taiga.projects.tagging.fields import TagsField
from taiga.projects.userstories import models as us_models
from taiga.projects.wiki import models as wiki_models
diff --git a/tests/integration/test_issues_tags.py b/tests/integration/test_issues_tags.py
new file mode 100644
index 00000000..5a38bab0
--- /dev/null
+++ b/tests/integration/test_issues_tags.py
@@ -0,0 +1,142 @@
+# -*- coding: utf-8 -*-
+from unittest import mock
+from collections import OrderedDict
+
+from django.core.urlresolvers import reverse
+
+from taiga.base.utils import json
+
+from .. import factories as f
+
+import pytest
+pytestmark = pytest.mark.django_db
+
+
+def test_api_issue_add_new_tags_with_error(client):
+ project = f.ProjectFactory.create()
+ issue = f.create_issue(project=project, status__project=project)
+ f.MembershipFactory.create(project=project, user=issue.owner, is_admin=True)
+ url = reverse("issues-detail", kwargs={"pk": issue.pk})
+ data = {
+ "tags": [],
+ "version": issue.version
+ }
+
+ client.login(issue.owner)
+
+ data["tags"] = [1]
+ response = client.json.patch(url, json.dumps(data))
+ assert response.status_code == 400, response.data
+ assert "tags" in response.data
+
+ data["tags"] = [["back"]]
+ response = client.json.patch(url, json.dumps(data))
+ assert response.status_code == 400, response.data
+ assert "tags" in response.data
+
+ data["tags"] = [["back", "#cccc"]]
+ response = client.json.patch(url, json.dumps(data))
+ assert response.status_code == 400, response.data
+ assert "tags" in response.data
+
+ data["tags"] = [[1, "#ccc"]]
+ response = client.json.patch(url, json.dumps(data))
+ assert response.status_code == 400, response.data
+ assert "tags" in response.data
+
+
+def test_api_issue_add_new_tags_without_colors(client):
+ project = f.ProjectFactory.create()
+ issue = f.create_issue(project=project, status__project=project)
+ f.MembershipFactory.create(project=project, user=issue.owner, is_admin=True)
+ url = reverse("issues-detail", kwargs={"pk": issue.pk})
+ data = {
+ "tags": [
+ ["back", None],
+ ["front", None],
+ ["ux", None]
+ ],
+ "version": issue.version
+ }
+
+ client.login(issue.owner)
+
+ response = client.json.patch(url, json.dumps(data))
+
+ assert response.status_code == 200, response.data
+
+ tags_colors = OrderedDict(project.tags_colors)
+ assert not tags_colors.keys()
+
+ project.refresh_from_db()
+
+ tags_colors = OrderedDict(project.tags_colors)
+ assert "back" in tags_colors and "front" in tags_colors and "ux" in tags_colors
+
+
+def test_api_issue_add_new_tags_with_colors(client):
+ project = f.ProjectFactory.create()
+ issue = f.create_issue(project=project, status__project=project)
+ f.MembershipFactory.create(project=project, user=issue.owner, is_admin=True)
+ url = reverse("issues-detail", kwargs={"pk": issue.pk})
+ data = {
+ "tags": [
+ ["back", "#fff8e7"],
+ ["front", None],
+ ["ux", "#fabada"]
+ ],
+ "version": issue.version
+ }
+
+ client.login(issue.owner)
+
+ response = client.json.patch(url, json.dumps(data))
+ assert response.status_code == 200, response.data
+
+ tags_colors = OrderedDict(project.tags_colors)
+ assert not tags_colors.keys()
+
+ project.refresh_from_db()
+
+ tags_colors = OrderedDict(project.tags_colors)
+ assert "back" in tags_colors and "front" in tags_colors and "ux" in tags_colors
+ assert tags_colors["back"] == "#fff8e7"
+ assert tags_colors["ux"] == "#fabada"
+
+
+def test_api_create_new_issue_with_tags(client):
+ project = f.ProjectFactory.create()
+ status = f.IssueStatusFactory.create(project=project)
+ project.default_issue_status = status
+ project.save()
+ f.MembershipFactory.create(project=project, user=project.owner, is_admin=True)
+ url = reverse("issues-list")
+
+ data = {
+ "subject": "Test user story",
+ "project": project.id,
+ "tags": [
+ ["back", "#fff8e7"],
+ ["front", None],
+ ["ux", "#fabada"]
+ ]
+ }
+
+ client.login(project.owner)
+
+ response = client.json.post(url, json.dumps(data))
+ assert response.status_code == 201, response.data
+
+ assert ("back" in response.data["tags"] and
+ "front" in response.data["tags"] and
+ "ux" in response.data["tags"])
+
+ tags_colors = OrderedDict(project.tags_colors)
+ assert not tags_colors.keys()
+
+ project.refresh_from_db()
+
+ tags_colors = OrderedDict(project.tags_colors)
+ assert "back" in tags_colors and "front" in tags_colors and "ux" in tags_colors
+ assert tags_colors["back"] == "#fff8e7"
+ assert tags_colors["ux"] == "#fabada"
diff --git a/tests/integration/test_tasks.py b/tests/integration/test_tasks.py
index bb077c2f..712fa07e 100644
--- a/tests/integration/test_tasks.py
+++ b/tests/integration/test_tasks.py
@@ -67,19 +67,6 @@ def test_create_task_without_default_values(client):
assert response.data['status'] == None
-def test_api_update_task_tags(client):
- project = f.ProjectFactory.create()
- task = f.create_task(project=project, status__project=project, milestone=None, user_story=None)
- f.MembershipFactory.create(project=project, user=task.owner, is_admin=True)
- url = reverse("tasks-detail", kwargs={"pk": task.pk})
- data = {"tags": ["back", "front"], "version": task.version}
-
- client.login(task.owner)
- response = client.json.patch(url, json.dumps(data))
-
- assert response.status_code == 200, response.data
-
-
def test_api_create_in_bulk_with_status(client):
us = f.create_userstory()
f.MembershipFactory.create(project=us.project, user=us.owner, is_admin=True)
diff --git a/tests/integration/test_tasks_tags.py b/tests/integration/test_tasks_tags.py
new file mode 100644
index 00000000..67a27c0d
--- /dev/null
+++ b/tests/integration/test_tasks_tags.py
@@ -0,0 +1,142 @@
+# -*- coding: utf-8 -*-
+from unittest import mock
+from collections import OrderedDict
+
+from django.core.urlresolvers import reverse
+
+from taiga.base.utils import json
+
+from .. import factories as f
+
+import pytest
+pytestmark = pytest.mark.django_db
+
+
+def test_api_task_add_new_tags_with_error(client):
+ project = f.ProjectFactory.create()
+ task = f.create_task(project=project, status__project=project, milestone=None, user_story=None)
+ f.MembershipFactory.create(project=project, user=task.owner, is_admin=True)
+ url = reverse("tasks-detail", kwargs={"pk": task.pk})
+ data = {
+ "tags": [],
+ "version": task.version
+ }
+
+ client.login(task.owner)
+
+ data["tags"] = [1]
+ response = client.json.patch(url, json.dumps(data))
+ assert response.status_code == 400, response.data
+ assert "tags" in response.data
+
+ data["tags"] = [["back"]]
+ response = client.json.patch(url, json.dumps(data))
+ assert response.status_code == 400, response.data
+ assert "tags" in response.data
+
+ data["tags"] = [["back", "#cccc"]]
+ response = client.json.patch(url, json.dumps(data))
+ assert response.status_code == 400, response.data
+ assert "tags" in response.data
+
+ data["tags"] = [[1, "#ccc"]]
+ response = client.json.patch(url, json.dumps(data))
+ assert response.status_code == 400, response.data
+ assert "tags" in response.data
+
+
+def test_api_task_add_new_tags_without_colors(client):
+ project = f.ProjectFactory.create()
+ task = f.create_task(project=project, status__project=project, milestone=None, user_story=None)
+ f.MembershipFactory.create(project=project, user=task.owner, is_admin=True)
+ url = reverse("tasks-detail", kwargs={"pk": task.pk})
+ data = {
+ "tags": [
+ ["back", None],
+ ["front", None],
+ ["ux", None]
+ ],
+ "version": task.version
+ }
+
+ client.login(task.owner)
+
+ response = client.json.patch(url, json.dumps(data))
+
+ assert response.status_code == 200, response.data
+
+ tags_colors = OrderedDict(project.tags_colors)
+ assert not tags_colors.keys()
+
+ project.refresh_from_db()
+
+ tags_colors = OrderedDict(project.tags_colors)
+ assert "back" in tags_colors and "front" in tags_colors and "ux" in tags_colors
+
+
+def test_api_task_add_new_tags_with_colors(client):
+ project = f.ProjectFactory.create()
+ task = f.create_task(project=project, status__project=project, milestone=None, user_story=None)
+ f.MembershipFactory.create(project=project, user=task.owner, is_admin=True)
+ url = reverse("tasks-detail", kwargs={"pk": task.pk})
+ data = {
+ "tags": [
+ ["back", "#fff8e7"],
+ ["front", None],
+ ["ux", "#fabada"]
+ ],
+ "version": task.version
+ }
+
+ client.login(task.owner)
+
+ response = client.json.patch(url, json.dumps(data))
+ assert response.status_code == 200, response.data
+
+ tags_colors = OrderedDict(project.tags_colors)
+ assert not tags_colors.keys()
+
+ project.refresh_from_db()
+
+ tags_colors = OrderedDict(project.tags_colors)
+ assert "back" in tags_colors and "front" in tags_colors and "ux" in tags_colors
+ assert tags_colors["back"] == "#fff8e7"
+ assert tags_colors["ux"] == "#fabada"
+
+
+def test_api_create_new_task_with_tags(client):
+ project = f.ProjectFactory.create()
+ status = f.TaskStatusFactory.create(project=project)
+ project.default_task_status = status
+ project.save()
+ f.MembershipFactory.create(project=project, user=project.owner, is_admin=True)
+ url = reverse("tasks-list")
+
+ data = {
+ "subject": "Test user story",
+ "project": project.id,
+ "tags": [
+ ["back", "#fff8e7"],
+ ["front", None],
+ ["ux", "#fabada"]
+ ]
+ }
+
+ client.login(project.owner)
+
+ response = client.json.post(url, json.dumps(data))
+ assert response.status_code == 201, response.data
+
+ assert ("back" in response.data["tags"] and
+ "front" in response.data["tags"] and
+ "ux" in response.data["tags"])
+
+ tags_colors = OrderedDict(project.tags_colors)
+ assert not tags_colors.keys()
+
+ project.refresh_from_db()
+
+ tags_colors = OrderedDict(project.tags_colors)
+ assert "back" in tags_colors and "front" in tags_colors and "ux" in tags_colors
+ assert tags_colors["back"] == "#fff8e7"
+ assert tags_colors["ux"] == "#fabada"
diff --git a/tests/integration/test_userstories_tags.py b/tests/integration/test_userstories_tags.py
new file mode 100644
index 00000000..1313dc91
--- /dev/null
+++ b/tests/integration/test_userstories_tags.py
@@ -0,0 +1,142 @@
+# -*- coding: utf-8 -*-
+from unittest import mock
+from collections import OrderedDict
+
+from django.core.urlresolvers import reverse
+
+from taiga.base.utils import json
+
+from .. import factories as f
+
+import pytest
+pytestmark = pytest.mark.django_db
+
+
+def test_api_user_story_add_new_tags_with_error(client):
+ project = f.ProjectFactory.create()
+ user_story = f.create_userstory(project=project, status__project=project)
+ f.MembershipFactory.create(project=project, user=user_story.owner, is_admin=True)
+ url = reverse("userstories-detail", kwargs={"pk": user_story.pk})
+ data = {
+ "tags": [],
+ "version": user_story.version
+ }
+
+ client.login(user_story.owner)
+
+ data["tags"] = [1]
+ response = client.json.patch(url, json.dumps(data))
+ assert response.status_code == 400, response.data
+ assert "tags" in response.data
+
+ data["tags"] = [["back"]]
+ response = client.json.patch(url, json.dumps(data))
+ assert response.status_code == 400, response.data
+ assert "tags" in response.data
+
+ data["tags"] = [["back", "#cccc"]]
+ response = client.json.patch(url, json.dumps(data))
+ assert response.status_code == 400, response.data
+ assert "tags" in response.data
+
+ data["tags"] = [[1, "#ccc"]]
+ response = client.json.patch(url, json.dumps(data))
+ assert response.status_code == 400, response.data
+ assert "tags" in response.data
+
+
+def test_api_user_story_add_new_tags_without_colors(client):
+ project = f.ProjectFactory.create()
+ user_story = f.create_userstory(project=project, status__project=project)
+ f.MembershipFactory.create(project=project, user=user_story.owner, is_admin=True)
+ url = reverse("userstories-detail", kwargs={"pk": user_story.pk})
+ data = {
+ "tags": [
+ ["back", None],
+ ["front", None],
+ ["ux", None]
+ ],
+ "version": user_story.version
+ }
+
+ client.login(user_story.owner)
+
+ response = client.json.patch(url, json.dumps(data))
+
+ assert response.status_code == 200, response.data
+
+ tags_colors = OrderedDict(project.tags_colors)
+ assert not tags_colors.keys()
+
+ project.refresh_from_db()
+
+ tags_colors = OrderedDict(project.tags_colors)
+ assert "back" in tags_colors and "front" in tags_colors and "ux" in tags_colors
+
+
+def test_api_user_story_add_new_tags_with_colors(client):
+ project = f.ProjectFactory.create()
+ user_story = f.create_userstory(project=project, status__project=project)
+ f.MembershipFactory.create(project=project, user=user_story.owner, is_admin=True)
+ url = reverse("userstories-detail", kwargs={"pk": user_story.pk})
+ data = {
+ "tags": [
+ ["back", "#fff8e7"],
+ ["front", None],
+ ["ux", "#fabada"]
+ ],
+ "version": user_story.version
+ }
+
+ client.login(user_story.owner)
+
+ response = client.json.patch(url, json.dumps(data))
+ assert response.status_code == 200, response.data
+
+ tags_colors = OrderedDict(project.tags_colors)
+ assert not tags_colors.keys()
+
+ project.refresh_from_db()
+
+ tags_colors = OrderedDict(project.tags_colors)
+ assert "back" in tags_colors and "front" in tags_colors and "ux" in tags_colors
+ assert tags_colors["back"] == "#fff8e7"
+ assert tags_colors["ux"] == "#fabada"
+
+
+def test_api_create_new_user_story_with_tags(client):
+ project = f.ProjectFactory.create()
+ status = f.UserStoryStatusFactory.create(project=project)
+ project.default_userstory_status = status
+ project.save()
+ f.MembershipFactory.create(project=project, user=project.owner, is_admin=True)
+ url = reverse("userstories-list")
+
+ data = {
+ "subject": "Test user story",
+ "project": project.id,
+ "tags": [
+ ["back", "#fff8e7"],
+ ["front", None],
+ ["ux", "#fabada"]
+ ]
+ }
+
+ client.login(project.owner)
+
+ response = client.json.post(url, json.dumps(data))
+ assert response.status_code == 201, response.data
+
+ assert ("back" in response.data["tags"] and
+ "front" in response.data["tags"] and
+ "ux" in response.data["tags"])
+
+ tags_colors = OrderedDict(project.tags_colors)
+ assert not tags_colors.keys()
+
+ project.refresh_from_db()
+
+ tags_colors = OrderedDict(project.tags_colors)
+ assert "back" in tags_colors and "front" in tags_colors and "ux" in tags_colors
+ assert tags_colors["back"] == "#fff8e7"
+ assert tags_colors["ux"] == "#fabada"