From 8c45033f18d9208c1a1d2de3942210f8058c4874 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Wed, 8 Jun 2016 21:26:09 +0200 Subject: [PATCH] Assign a color when create a new tag --- taiga/base/fields.py | 31 +---- taiga/projects/api.py | 2 +- taiga/projects/apps.py | 3 +- taiga/projects/issues/api.py | 9 +- taiga/projects/issues/apps.py | 3 +- taiga/projects/issues/serializers.py | 15 ++- taiga/projects/serializers.py | 13 +- taiga/projects/services/tags.py | 1 + taiga/projects/signals.py | 7 - taiga/projects/tagging/__init__.py | 0 taiga/projects/tagging/fields.py | 99 ++++++++++++++ taiga/projects/tagging/mixins.py | 48 +++++++ taiga/projects/tagging/signals.py | 23 ++++ taiga/projects/tasks/api.py | 20 +-- taiga/projects/tasks/apps.py | 4 +- taiga/projects/tasks/serializers.py | 23 ++-- taiga/projects/userstories/api.py | 29 +++-- taiga/projects/userstories/apps.py | 3 +- taiga/projects/userstories/serializers.py | 24 ++-- taiga/webhooks/serializers.py | 3 +- tests/integration/test_issues_tags.py | 142 +++++++++++++++++++++ tests/integration/test_tasks.py | 13 -- tests/integration/test_tasks_tags.py | 142 +++++++++++++++++++++ tests/integration/test_userstories_tags.py | 142 +++++++++++++++++++++ 24 files changed, 680 insertions(+), 119 deletions(-) create mode 100644 taiga/projects/tagging/__init__.py create mode 100644 taiga/projects/tagging/fields.py create mode 100644 taiga/projects/tagging/mixins.py create mode 100644 taiga/projects/tagging/signals.py create mode 100644 tests/integration/test_issues_tags.py create mode 100644 tests/integration/test_tasks_tags.py create mode 100644 tests/integration/test_userstories_tags.py 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"