From 20c350ea63b987e7f82207f4f80c919c05748389 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Mon, 6 Jun 2016 15:11:48 +0200 Subject: [PATCH 01/11] Remove obsolete code --- taiga/projects/apps.py | 5 --- taiga/projects/issues/apps.py | 17 +++---- taiga/projects/issues/models.py | 2 - taiga/projects/services/__init__.py | 2 - taiga/projects/services/tags_colors.py | 62 -------------------------- taiga/projects/signals.py | 9 +--- taiga/projects/tasks/apps.py | 26 +++++------ taiga/projects/userstories/apps.py | 33 +++++++------- 8 files changed, 38 insertions(+), 118 deletions(-) delete mode 100644 taiga/projects/services/tags_colors.py diff --git a/taiga/projects/apps.py b/taiga/projects/apps.py index a390b5f5..38295e81 100644 --- a/taiga/projects/apps.py +++ b/taiga/projects/apps.py @@ -34,9 +34,6 @@ def connect_projects_signals(): signals.pre_save.connect(handlers.tags_normalization, sender=apps.get_model("projects", "Project"), dispatch_uid="tags_normalization_projects") - signals.pre_save.connect(handlers.update_project_tags_when_create_or_edit_taggable_item, - sender=apps.get_model("projects", "Project"), - dispatch_uid="update_project_tags_when_create_or_edit_taggable_item_projects") def disconnect_projects_signals(): @@ -44,8 +41,6 @@ def disconnect_projects_signals(): dispatch_uid='project_post_save') signals.pre_save.disconnect(sender=apps.get_model("projects", "Project"), dispatch_uid="tags_normalization_projects") - signals.pre_save.disconnect(sender=apps.get_model("projects", "Project"), - dispatch_uid="update_project_tags_when_create_or_edit_taggable_item_projects") ## Memberships Signals diff --git a/taiga/projects/issues/apps.py b/taiga/projects/issues/apps.py index 4d0bca19..671a45be 100644 --- a/taiga/projects/issues/apps.py +++ b/taiga/projects/issues/apps.py @@ -34,12 +34,6 @@ def connect_issues_signals(): signals.pre_save.connect(generic_handlers.tags_normalization, sender=apps.get_model("issues", "Issue"), dispatch_uid="tags_normalization_issue") - signals.post_save.connect(generic_handlers.update_project_tags_when_create_or_edit_taggable_item, - sender=apps.get_model("issues", "Issue"), - dispatch_uid="update_project_tags_when_create_or_edit_taggable_item_issue") - signals.post_delete.connect(generic_handlers.update_project_tags_when_delete_taggable_item, - sender=apps.get_model("issues", "Issue"), - dispatch_uid="update_project_tags_when_delete_taggable_item_issue") def connect_issues_custom_attributes_signals(): @@ -56,14 +50,15 @@ def connect_all_issues_signals(): def disconnect_issues_signals(): - signals.pre_save.disconnect(sender=apps.get_model("issues", "Issue"), dispatch_uid="set_finished_date_when_edit_issue") - signals.pre_save.disconnect(sender=apps.get_model("issues", "Issue"), dispatch_uid="tags_normalization_issue") - signals.post_save.disconnect(sender=apps.get_model("issues", "Issue"), dispatch_uid="update_project_tags_when_create_or_edit_taggable_item_issue") - signals.post_delete.disconnect(sender=apps.get_model("issues", "Issue"), dispatch_uid="update_project_tags_when_delete_taggable_item_issue") + signals.pre_save.disconnect(sender=apps.get_model("issues", "Issue"), + dispatch_uid="set_finished_date_when_edit_issue") + signals.pre_save.disconnect(sender=apps.get_model("issues", "Issue"), + dispatch_uid="tags_normalization_issue") def disconnect_issues_custom_attributes_signals(): - signals.post_save.disconnect(sender=apps.get_model("issues", "Issue"), dispatch_uid="create_custom_attribute_value_when_create_issue") + signals.post_save.disconnect(sender=apps.get_model("issues", "Issue"), + dispatch_uid="create_custom_attribute_value_when_create_issue") def disconnect_all_issues_signals(): diff --git a/taiga/projects/issues/models.py b/taiga/projects/issues/models.py index 89a78051..cd962e08 100644 --- a/taiga/projects/issues/models.py +++ b/taiga/projects/issues/models.py @@ -30,8 +30,6 @@ from taiga.projects.notifications.mixins import WatchedModelMixin from taiga.projects.mixins.blocked import BlockedMixin from taiga.base.tags import TaggedMixin -from taiga.projects.services.tags_colors import update_project_tags_colors_handler, remove_unused_tags - class Issue(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, models.Model): ref = models.BigIntegerField(db_index=True, null=True, blank=True, default=None, diff --git a/taiga/projects/services/__init__.py b/taiga/projects/services/__init__.py index fb3cb9c5..a115275b 100644 --- a/taiga/projects/services/__init__.py +++ b/taiga/projects/services/__init__.py @@ -55,7 +55,5 @@ from .stats import get_stats_for_project_issues from .stats import get_stats_for_project from .stats import get_member_stats_for_project -from .tags_colors import update_project_tags_colors_handler - from .transfer import request_project_transfer, start_project_transfer from .transfer import accept_project_transfer, reject_project_transfer diff --git a/taiga/projects/services/tags_colors.py b/taiga/projects/services/tags_colors.py deleted file mode 100644 index 9b9aa962..00000000 --- a/taiga/projects/services/tags_colors.py +++ /dev/null @@ -1,62 +0,0 @@ -# -*- 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.conf import settings - -from taiga.projects.services.filters import get_all_tags -from taiga.projects.models import Project - -from hashlib import sha1 - - -def _generate_color(tag): - color = sha1(tag.encode("utf-8")).hexdigest()[0:6] - return "#{}".format(color) - - -def _get_new_color(tag, predefined_colors, exclude=[]): - colors = list(set(predefined_colors) - set(exclude)) - if colors: - return colors[0] - return _generate_color(tag) - - -def remove_unused_tags(project): - current_tags = get_all_tags(project) - project.tags_colors = list(filter(lambda x: x[0] in current_tags, project.tags_colors)) - - -def update_project_tags_colors_handler(instance): - if instance.tags is None: - instance.tags = [] - - if not isinstance(instance.project.tags_colors, list): - instance.project.tags_colors = [] - - for tag in instance.tags: - defined_tags = map(lambda x: x[0], instance.project.tags_colors) - if tag not in defined_tags: - used_colors = map(lambda x: x[1], instance.project.tags_colors) - new_color = _get_new_color(tag, settings.TAGS_PREDEFINED_COLORS, - exclude=used_colors) - instance.project.tags_colors.append([tag, new_color]) - - remove_unused_tags(instance.project) - - if not isinstance(instance, Project): - instance.project.save() diff --git a/taiga/projects/signals.py b/taiga/projects/signals.py index ca5d7094..e0196887 100644 --- a/taiga/projects/signals.py +++ b/taiga/projects/signals.py @@ -19,7 +19,6 @@ from django.apps import apps from django.conf import settings -from taiga.projects.services.tags_colors import update_project_tags_colors_handler, remove_unused_tags from taiga.projects.notifications.services import create_notify_policy_if_not_exists from taiga.base.utils.db import get_typename_for_model_class @@ -37,13 +36,7 @@ def tags_normalization(sender, instance, **kwargs): instance.tags = list(map(str.lower, instance.tags)) -def update_project_tags_when_create_or_edit_taggable_item(sender, instance, **kwargs): - update_project_tags_colors_handler(instance) - - -def update_project_tags_when_delete_taggable_item(sender, instance, **kwargs): - remove_unused_tags(instance.project) - instance.project.save() +## Membership def membership_post_delete(sender, instance, using, **kwargs): instance.project.update_role_points() diff --git a/taiga/projects/tasks/apps.py b/taiga/projects/tasks/apps.py index 616854f6..23cfdfb0 100644 --- a/taiga/projects/tasks/apps.py +++ b/taiga/projects/tasks/apps.py @@ -32,12 +32,7 @@ def connect_tasks_signals(): signals.pre_save.connect(generic_handlers.tags_normalization, sender=apps.get_model("tasks", "Task"), dispatch_uid="tags_normalization_task") - signals.post_save.connect(generic_handlers.update_project_tags_when_create_or_edit_taggable_item, - sender=apps.get_model("tasks", "Task"), - dispatch_uid="update_project_tags_when_create_or_edit_tagglabe_item_task") - signals.post_delete.connect(generic_handlers.update_project_tags_when_delete_taggable_item, - sender=apps.get_model("tasks", "Task"), - dispatch_uid="update_project_tags_when_delete_tagglabe_item_task") + def connect_tasks_close_or_open_us_and_milestone_signals(): from . import signals as handlers @@ -67,19 +62,24 @@ def connect_all_tasks_signals(): def disconnect_tasks_signals(): - signals.pre_save.disconnect(sender=apps.get_model("tasks", "Task"), dispatch_uid="tags_normalization") - signals.post_save.disconnect(sender=apps.get_model("tasks", "Task"), dispatch_uid="update_project_tags_when_create_or_edit_tagglabe_item") - signals.post_delete.disconnect(sender=apps.get_model("tasks", "Task"), dispatch_uid="update_project_tags_when_delete_tagglabe_item") + signals.pre_save.disconnect(sender=apps.get_model("tasks", "Task"), + dispatch_uid="set_finished_date_when_edit_task") + signals.pre_save.disconnect(sender=apps.get_model("tasks", "Task"), + dispatch_uid="tags_normalization") def disconnect_tasks_close_or_open_us_and_milestone_signals(): - signals.pre_save.disconnect(sender=apps.get_model("tasks", "Task"), dispatch_uid="cached_prev_task") - signals.post_save.disconnect(sender=apps.get_model("tasks", "Task"), dispatch_uid="try_to_close_or_open_us_and_milestone_when_create_or_edit_task") - signals.post_delete.disconnect(sender=apps.get_model("tasks", "Task"), dispatch_uid="try_to_close_or_open_us_and_milestone_when_delete_task") + signals.pre_save.disconnect(sender=apps.get_model("tasks", "Task"), + dispatch_uid="cached_prev_task") + signals.post_save.disconnect(sender=apps.get_model("tasks", "Task"), + dispatch_uid="try_to_close_or_open_us_and_milestone_when_create_or_edit_task") + signals.post_delete.disconnect(sender=apps.get_model("tasks", "Task"), + dispatch_uid="try_to_close_or_open_us_and_milestone_when_delete_task") def disconnect_tasks_custom_attributes_signals(): - signals.post_save.disconnect(sender=apps.get_model("tasks", "Task"), dispatch_uid="create_custom_attribute_value_when_create_task") + signals.post_save.disconnect(sender=apps.get_model("tasks", "Task"), + dispatch_uid="create_custom_attribute_value_when_create_task") def disconnect_all_tasks_signals(): diff --git a/taiga/projects/userstories/apps.py b/taiga/projects/userstories/apps.py index ef3d5df5..04c7d32d 100644 --- a/taiga/projects/userstories/apps.py +++ b/taiga/projects/userstories/apps.py @@ -62,12 +62,6 @@ def connect_userstories_signals(): signals.pre_save.connect(generic_handlers.tags_normalization, sender=apps.get_model("userstories", "UserStory"), dispatch_uid="tags_normalization_user_story") - signals.post_save.connect(generic_handlers.update_project_tags_when_create_or_edit_taggable_item, - sender=apps.get_model("userstories", "UserStory"), - dispatch_uid="update_project_tags_when_create_or_edit_taggable_item_user_story") - signals.post_delete.connect(generic_handlers.update_project_tags_when_delete_taggable_item, - sender=apps.get_model("userstories", "UserStory"), - dispatch_uid="update_project_tags_when_delete_taggable_item_user_story") def connect_userstories_custom_attributes_signals(): @@ -83,18 +77,27 @@ def connect_all_userstories_signals(): def disconnect_userstories_signals(): - signals.pre_save.disconnect(sender=apps.get_model("userstories", "UserStory"), dispatch_uid="cached_prev_us") - signals.post_save.disconnect(sender=apps.get_model("userstories", "UserStory"), dispatch_uid="update_role_points_when_create_or_edit_us") - signals.post_save.disconnect(sender=apps.get_model("userstories", "UserStory"), dispatch_uid="update_milestone_of_tasks_when_edit_us") - signals.post_save.disconnect(sender=apps.get_model("userstories", "UserStory"), dispatch_uid="try_to_close_or_open_us_and_milestone_when_create_or_edit_us") - signals.post_delete.disconnect(sender=apps.get_model("userstories", "UserStory"), dispatch_uid="try_to_close_milestone_when_delete_us") - signals.pre_save.disconnect(sender=apps.get_model("userstories", "UserStory"), dispatch_uid="tags_normalization_user_story") - signals.post_save.disconnect(sender=apps.get_model("userstories", "UserStory"), dispatch_uid="update_project_tags_when_create_or_edit_taggable_item_user_story") - signals.post_delete.disconnect(sender=apps.get_model("userstories", "UserStory"), dispatch_uid="update_project_tags_when_delete_taggable_item_user_story") + signals.pre_save.disconnect(sender=apps.get_model("userstories", "UserStory"), + dispatch_uid="cached_prev_us") + + signals.post_save.disconnect(sender=apps.get_model("userstories", "UserStory"), + dispatch_uid="update_role_points_when_create_or_edit_us") + + signals.post_save.disconnect(sender=apps.get_model("userstories", "UserStory"), + dispatch_uid="update_milestone_of_tasks_when_edit_us") + + signals.post_save.disconnect(sender=apps.get_model("userstories", "UserStory"), + dispatch_uid="try_to_close_or_open_us_and_milestone_when_create_or_edit_us") + signals.post_delete.disconnect(sender=apps.get_model("userstories", "UserStory"), + dispatch_uid="try_to_close_milestone_when_delete_us") + + signals.pre_save.disconnect(sender=apps.get_model("userstories", "UserStory"), + dispatch_uid="tags_normalization_user_story") def disconnect_userstories_custom_attributes_signals(): - signals.post_save.disconnect(sender=apps.get_model("userstories", "UserStory"), dispatch_uid="create_custom_attribute_value_when_create_user_story") + signals.post_save.disconnect(sender=apps.get_model("userstories", "UserStory"), + dispatch_uid="create_custom_attribute_value_when_create_user_story") def disconnect_all_userstories_signals(): From 7134d04262512a65b59493ff2a009893fddc917d Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 7 Jun 2016 09:13:38 +0200 Subject: [PATCH 02/11] Adding migrations --- .../0046_triggers_to_update_tags_colors.py | 192 ++++++++++++++++++ tests/integration/test_projects.py | 24 +++ 2 files changed, 216 insertions(+) create mode 100644 taiga/projects/migrations/0046_triggers_to_update_tags_colors.py diff --git a/taiga/projects/migrations/0046_triggers_to_update_tags_colors.py b/taiga/projects/migrations/0046_triggers_to_update_tags_colors.py new file mode 100644 index 00000000..28296036 --- /dev/null +++ b/taiga/projects/migrations/0046_triggers_to_update_tags_colors.py @@ -0,0 +1,192 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2016-06-07 06:19 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0045_merge'), + ] + + operations = [ + # Function: Reduce a multidimensional array only on its first level + migrations.RunSQL( + """ + CREATE OR REPLACE FUNCTION public.reduce_dim(anyarray) + RETURNS SETOF anyarray + AS $function$ + DECLARE + s $1%TYPE; + BEGIN + FOREACH s SLICE 1 IN ARRAY $1 LOOP + RETURN NEXT s; + END LOOP; + RETURN; + END; + $function$ + LANGUAGE plpgsql IMMUTABLE; + """ + ), + # Function: aggregates multi dimensional arrays + migrations.RunSQL( + """ + DROP AGGREGATE IF EXISTS array_agg_mult (anyarray); + CREATE AGGREGATE array_agg_mult (anyarray) ( + SFUNC = array_cat + ,STYPE = anyarray + ,INITCOND = '{}' + ); + """ + ), + # Function: array_distinct + migrations.RunSQL( + """ + CREATE OR REPLACE FUNCTION array_distinct(anyarray) + RETURNS anyarray AS $$ + SELECT ARRAY(SELECT DISTINCT unnest($1)) + $$ LANGUAGE sql; + """ + ), + # Rebuild the color tags so it's consisten in any project + migrations.RunSQL( + """ + WITH + tags_colors AS ( + SELECT id project_id, reduce_dim(tags_colors) tags_colors + FROM projects_project + WHERE tags_colors != '{}' + ), + tags AS ( + SELECT unnest(tags) tag, NULL color, project_id FROM userstories_userstory + UNION + SELECT unnest(tags) tag, NULL color, project_id FROM tasks_task + UNION + SELECT unnest(tags) tag, NULL color, project_id FROM issues_issue + UNION + SELECT unnest(tags) tag, NULL color, id project_id FROM projects_project + ), + rebuilt_tags_colors AS ( + SELECT tags.project_id project_id, + array_agg_mult(ARRAY[[tags.tag, tags_colors.tags_colors[2]]]) tags_colors + FROM tags + LEFT JOIN tags_colors ON + tags_colors.project_id = tags.project_id AND + tags_colors[1] = tags.tag + GROUP BY tags.project_id + ) + UPDATE projects_project + SET tags_colors = rebuilt_tags_colors.tags_colors + FROM rebuilt_tags_colors + WHERE rebuilt_tags_colors.project_id = projects_project.id; + """ + ), + # Trigger for auto updating projects_project.tags_colors + migrations.RunSQL( + """ + CREATE OR REPLACE FUNCTION update_project_tags_colors() + RETURNS trigger AS $update_project_tags_colors$ + DECLARE + tags text[]; + project_tags_colors text[]; + tag_color text[]; + project_tags text[]; + tag text; + project_id integer; + BEGIN + tags := NEW.tags::text[]; + project_id := NEW.project_id::integer; + project_tags := '{}'; + + -- Read project tags_colors into project_tags_colors + SELECT projects_project.tags_colors INTO project_tags_colors + FROM projects_project + WHERE id = project_id; + + -- Extract just the project tags to project_tags_colors + IF project_tags_colors != ARRAY[]::text[] THEN + FOREACH tag_color SLICE 1 in ARRAY project_tags_colors + LOOP + project_tags := array_append(project_tags, tag_color[1]); + END LOOP; + END IF; + + -- Add to project_tags_colors the new tags + IF tags IS NOT NULL THEN + FOREACH tag in ARRAY tags + LOOP + IF tag != ALL(project_tags) THEN + project_tags_colors := array_cat(project_tags_colors, + ARRAY[ARRAY[tag, NULL]]); + END IF; + END LOOP; + END IF; + + -- Save the result in the tags_colors column + UPDATE projects_project + SET tags_colors = project_tags_colors + WHERE id = project_id; + + RETURN NULL; + END; $update_project_tags_colors$ + LANGUAGE plpgsql; + """ + ), + + # Execute trigger after user_story update + migrations.RunSQL( + """ + DROP TRIGGER IF EXISTS update_project_tags_colors_on_userstory_update ON userstories_userstory; + CREATE TRIGGER update_project_tags_colors_on_userstory_update + AFTER UPDATE ON userstories_userstory + FOR EACH ROW EXECUTE PROCEDURE update_project_tags_colors(); + """ + ), + # Execute trigger after user_story insert + migrations.RunSQL( + """ + DROP TRIGGER IF EXISTS update_project_tags_colors_on_userstory_insert ON userstories_userstory; + CREATE TRIGGER update_project_tags_colors_on_userstory_insert + AFTER INSERT ON userstories_userstory + FOR EACH ROW EXECUTE PROCEDURE update_project_tags_colors(); + """ + ), + # Execute trigger after task update + migrations.RunSQL( + """ + DROP TRIGGER IF EXISTS update_project_tags_colors_on_task_update ON tasks_task; + CREATE TRIGGER update_project_tags_colors_on_task_update + AFTER UPDATE ON tasks_task + FOR EACH ROW EXECUTE PROCEDURE update_project_tags_colors(); + """ + ), + # Execute trigger after task insert + migrations.RunSQL( + """ + DROP TRIGGER IF EXISTS update_project_tags_colors_on_task_insert ON tasks_task; + CREATE TRIGGER update_project_tags_colors_on_task_insert + AFTER INSERT ON tasks_task + FOR EACH ROW EXECUTE PROCEDURE update_project_tags_colors(); + """ + ), + # Execute trigger after issue update + migrations.RunSQL( + """ + DROP TRIGGER IF EXISTS update_project_tags_colors_on_issue_update ON issues_issue; + CREATE TRIGGER update_project_tags_colors_on_issue_update + AFTER UPDATE ON issues_issue + FOR EACH ROW EXECUTE PROCEDURE update_project_tags_colors(); + """ + ), + # Execute trigger after issue insert + migrations.RunSQL( + """ + DROP TRIGGER IF EXISTS update_project_tags_colors_on_issue_insert ON issues_issue; + CREATE TRIGGER update_project_tags_colors_on_issue_insert + AFTER INSERT ON issues_issue + FOR EACH ROW EXECUTE PROCEDURE update_project_tags_colors(); + """ + ), + ] diff --git a/tests/integration/test_projects.py b/tests/integration/test_projects.py index 9f91cd21..4c2e121e 100644 --- a/tests/integration/test_projects.py +++ b/tests/integration/test_projects.py @@ -1852,3 +1852,27 @@ def test_delete_project_with_celery_disabled(client, settings): response = client.json.delete(url) assert response.status_code == 204 assert Project.objects.filter(id=project.id).count() == 0 + + +def test_color_tags_project_fired_on_element_create(): + user_story = f.UserStoryFactory.create(tags=["tag"]) + project = Project.objects.get(id=user_story.project.id) + assert project.tags_colors == [["tag", None]] + + +def test_color_tags_project_fired_on_element_update(): + user_story = f.UserStoryFactory.create() + user_story.tags = ["tag"] + user_story.save() + project = Project.objects.get(id=user_story.project.id) + assert project.tags_colors == [["tag", None]] + + +def test_color_tags_project_fired_on_element_update_respecting_color(): + project = f.ProjectFactory.create(tags_colors=[["tag", "#123123"]]) + user_story = f.UserStoryFactory.create(project=project) + user_story.tags = ["tag"] + user_story.save() + project = Project.objects.get(id=user_story.project.id) + assert project.tags_colors == [["tag", "#123123"]] +>>>>>>> d64d158... WIP: migrations, removing automatic color generation From 3e555de7c4d1d1c511ef7d87da76ac557030e84d Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 7 Jun 2016 13:26:28 +0200 Subject: [PATCH 03/11] API calls to create, rename, edit color, delete and mix tags --- taiga/projects/api.py | 61 ++++++ taiga/projects/permissions.py | 4 + taiga/projects/serializers.py | 94 ++++++++- taiga/projects/services/__init__.py | 3 + taiga/projects/services/tags.py | 90 +++++++++ .../test_projects_choices_resources.py | 136 ++++++++++++- tests/integration/test_projects.py | 187 +++++++++++++++++- tests/integration/test_users.py | 14 +- 8 files changed, 571 insertions(+), 18 deletions(-) create mode 100644 taiga/projects/services/tags.py diff --git a/taiga/projects/api.py b/taiga/projects/api.py index 9c00901c..958ed3b3 100644 --- a/taiga/projects/api.py +++ b/taiga/projects/api.py @@ -405,6 +405,67 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, services.reject_project_transfer(project, request.user, token, reason) return response.Ok() + @detail_route(methods=["POST"]) + def create_tag(self, request, pk=None): + project = self.get_object() + self.check_permissions(request, "create_tag", project) + self._raise_if_blocked(project) + serializer = serializers.CreateTagSerializer(data=request.DATA, project=project) + if not serializer.is_valid(): + return response.BadRequest(serializer.errors) + + data = serializer.data + services.create_tag(project, data.get("tag"), data.get("color")) + return response.Ok() + + + @detail_route(methods=["POST"]) + def edit_tag(self, request, pk=None): + project = self.get_object() + self.check_permissions(request, "edit_tag", project) + self._raise_if_blocked(project) + serializer = serializers.EditTagTagSerializer(data=request.DATA, project=project) + if not serializer.is_valid(): + return response.BadRequest(serializer.errors) + + data = serializer.data + services.edit_tag(project, data.get("from_tag"), + to_tag=data.get("to_tag", None), + color=data.get("color", None)) + + return response.Ok() + + + @detail_route(methods=["POST"]) + def delete_tag(self, request, pk=None): + project = self.get_object() + self.check_permissions(request, "delete_tag", project) + self._raise_if_blocked(project) + serializer = serializers.DeleteTagSerializer(data=request.DATA, project=project) + if not serializer.is_valid(): + return response.BadRequest(serializer.errors) + + data = serializer.data + services.delete_tag(project, data.get("tag")) + return response.Ok() + + @detail_route(methods=["POST"]) + def mix_tags(self, request, pk=None): + project = self.get_object() + self.check_permissions(request, "mix_tags", project) + self._raise_if_blocked(project) + serializer = serializers.MixTagsSerializer(data=request.DATA, project=project) + if not serializer.is_valid(): + return response.BadRequest(serializer.errors) + + data = serializer.data + services.mix_tags(project, data.get("from_tags"), data.get("to_tag")) + return response.Ok() + + def _raise_if_blocked(self, project): + if self.is_blocked(project): + raise exc.Blocked(_("Blocked element")) + def _set_base_permissions(self, obj): update_permissions = False if not obj.id: diff --git a/taiga/projects/permissions.py b/taiga/projects/permissions.py index c43e842f..0cc95427 100644 --- a/taiga/projects/permissions.py +++ b/taiga/projects/permissions.py @@ -78,6 +78,10 @@ class ProjectPermission(TaigaResourcePermission): transfer_start_perms = IsObjectOwner() transfer_reject_perms = IsAuthenticated() & HasProjectPerm('view_project') transfer_accept_perms = IsAuthenticated() & HasProjectPerm('view_project') + create_tag_perms = IsProjectAdmin() + edit_tag_perms = IsProjectAdmin() + delete_tag_perms = IsProjectAdmin() + mix_tags_perms = IsProjectAdmin() class ProjectFansPermission(TaigaResourcePermission): diff --git a/taiga/projects/serializers.py b/taiga/projects/serializers.py index 1b271590..f7403389 100644 --- a/taiga/projects/serializers.py +++ b/taiga/projects/serializers.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 . +import re from django.utils.translation import ugettext as _ from django.db.models import Q @@ -256,7 +257,7 @@ class ProjectSerializer(FanResourceSerializerMixin, WatchedResourceModelSerializ i_am_member = serializers.SerializerMethodField("get_i_am_member") tags = TagsField(default=[], required=False) - tags_colors = TagsColorsField(required=False) + tags_colors = TagsColorsField(required=False, read_only=True) notify_level = serializers.SerializerMethodField("get_notify_level") total_closed_milestones = serializers.SerializerMethodField("get_total_closed_milestones") @@ -416,3 +417,94 @@ class ProjectTemplateSerializer(serializers.ModelSerializer): class UpdateProjectOrderBulkSerializer(ProjectExistsValidator, serializers.Serializer): project_id = serializers.IntegerField() order = serializers.IntegerField() + + +###################################################### +## Project tags serializers +###################################################### + + +class ProjectTagSerializer(serializers.Serializer): + def __init__(self, *args, **kwargs): + # Don't pass the extra project arg + self.project = kwargs.pop("project") + + # Instantiate the superclass normally + super().__init__(*args, **kwargs) + + +class CreateTagSerializer(ProjectTagSerializer): + tag = serializers.CharField() + color = serializers.CharField(required=False) + + def validate_tag(self, attrs, source): + tag = attrs.get(source, None) + if services.tag_exist_for_project_elements(self.project, tag): + raise serializers.ValidationError(_("The tag exists.")) + + return attrs + + def validate_color(self, attrs, source): + color = attrs.get(source, None) + if not re.match('^\#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})$', color): + raise serializers.ValidationError(_("The color is not a valid HEX color.")) + + return attrs + + +class EditTagTagSerializer(ProjectTagSerializer): + from_tag = serializers.CharField() + to_tag = serializers.CharField(required=False) + color = serializers.CharField(required=False) + + def validate_from_tag(self, attrs, source): + tag = attrs.get(source, None) + if not services.tag_exist_for_project_elements(self.project, tag): + raise serializers.ValidationError(_("The tag doesn't exist.")) + + return attrs + + def validate_to_tag(self, attrs, source): + tag = attrs.get(source, None) + if services.tag_exist_for_project_elements(self.project, tag): + raise serializers.ValidationError(_("The tag exists yet")) + + return attrs + + def validate_color(self, attrs, source): + color = attrs.get(source, None) + if len(color) != 7 or not re.match('^\#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})$', color): + raise serializers.ValidationError(_("The color is not a valid HEX color.")) + + return attrs + + +class DeleteTagSerializer(ProjectTagSerializer): + tag = serializers.CharField() + + def validate_tag(self, attrs, source): + tag = attrs.get(source, None) + if not services.tag_exist_for_project_elements(self.project, tag): + raise serializers.ValidationError(_("The tag doesn't exist.")) + + return attrs + + +class MixTagsSerializer(ProjectTagSerializer): + from_tags = TagsField() + to_tag = serializers.CharField() + + def validate_from_tags(self, attrs, source): + tags = attrs.get(source, None) + for tag in tags: + if not services.tag_exist_for_project_elements(self.project, tag): + raise serializers.ValidationError(_("The tag doesn't exist.")) + + return attrs + + def validate_to_tag(self, attrs, source): + tag = attrs.get(source, None) + if not services.tag_exist_for_project_elements(self.project, tag): + raise serializers.ValidationError(_("The tag doesn't exist.")) + + return attrs diff --git a/taiga/projects/services/__init__.py b/taiga/projects/services/__init__.py index a115275b..f2fcc3c0 100644 --- a/taiga/projects/services/__init__.py +++ b/taiga/projects/services/__init__.py @@ -57,3 +57,6 @@ from .stats import get_member_stats_for_project from .transfer import request_project_transfer, start_project_transfer from .transfer import accept_project_transfer, reject_project_transfer + +from .tags import tag_exist_for_project_elements, create_tag +from .tags import edit_tag, delete_tag, mix_tags diff --git a/taiga/projects/services/tags.py b/taiga/projects/services/tags.py new file mode 100644 index 00000000..20e1946a --- /dev/null +++ b/taiga/projects/services/tags.py @@ -0,0 +1,90 @@ +# -*- 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.db import connection + +def tag_exist_for_project_elements(project, tag): + return tag in dict(project.tags_colors).keys() + + +def create_tag(project, tag, color): + project.tags_colors.append([tag, color]) + project.save() + + +def edit_tag(project, from_tag, to_tag=None, color=None): + tags_colors = dict(project.tags_colors) + + if color is not None: + tags_colors = dict(project.tags_colors) + tags_colors[from_tag] = color + + if to_tag is not None: + color = dict(project.tags_colors)[from_tag] + sql = """ + UPDATE userstories_userstory SET tags = array_distinct(array_replace(tags, '{from_tag}', '{to_tag}')) WHERE project_id={project_id}; + UPDATE tasks_task SET tags = array_distinct(array_replace(tags, '{from_tag}', '{to_tag}')) WHERE project_id={project_id}; + UPDATE issues_issue SET tags = array_distinct(array_replace(tags, '{from_tag}', '{to_tag}')) WHERE project_id={project_id}; + """ + sql = sql.format(project_id=project.id, from_tag=from_tag, to_tag=to_tag) + cursor = connection.cursor() + cursor.execute(sql) + + tags_colors[to_tag] = tags_colors.pop(from_tag) + + + project.tags_colors = list(tags_colors.items()) + project.save() + + +def rename_tag(project, from_tag, to_tag): + color = dict(project.tags_colors)[from_tag] + sql = """ + UPDATE userstories_userstory SET tags = array_distinct(array_replace(tags, '{from_tag}', '{to_tag}')) WHERE project_id={project_id}; + UPDATE tasks_task SET tags = array_distinct(array_replace(tags, '{from_tag}', '{to_tag}')) WHERE project_id={project_id}; + UPDATE issues_issue SET tags = array_distinct(array_replace(tags, '{from_tag}', '{to_tag}')) WHERE project_id={project_id}; + """ + sql = sql.format(project_id=project.id, from_tag=from_tag, to_tag=to_tag, color=color) + cursor = connection.cursor() + cursor.execute(sql) + + tags_colors = dict(project.tags_colors) + tags_colors[to_tag] = tags_colors.pop(from_tag) + project.tags_colors = list(tags_colors.items()) + project.save() + + +def delete_tag(project, tag): + sql = """ + UPDATE userstories_userstory SET tags = array_remove(tags, '{tag}') WHERE project_id={project_id}; + UPDATE tasks_task SET tags = array_remove(tags, '{tag}') WHERE project_id={project_id}; + UPDATE issues_issue SET tags = array_remove(tags, '{tag}') WHERE project_id={project_id}; + """ + sql = sql.format(project_id=project.id, tag=tag) + cursor = connection.cursor() + cursor.execute(sql) + + tags_colors = dict(project.tags_colors) + del tags_colors[tag] + project.tags_colors = list(tags_colors.items()) + project.save() + + +def mix_tags(project, from_tags, to_tag): + for from_tag in from_tags: + rename_tag(project, from_tag, to_tag) diff --git a/tests/integration/resources_permissions/test_projects_choices_resources.py b/tests/integration/resources_permissions/test_projects_choices_resources.py index 0115143a..2e95f731 100644 --- a/tests/integration/resources_permissions/test_projects_choices_resources.py +++ b/tests/integration/resources_permissions/test_projects_choices_resources.py @@ -27,20 +27,24 @@ def data(): m.public_project = f.ProjectFactory(is_private=False, anon_permissions=['view_project'], public_permissions=['view_project'], - owner=m.project_owner) + owner=m.project_owner, + tags_colors = [("tag1", "#123123"), ("tag2", "#456456"), ("tag3", "#111222")]) m.private_project1 = f.ProjectFactory(is_private=True, anon_permissions=['view_project'], public_permissions=['view_project'], - owner=m.project_owner) + owner=m.project_owner, + tags_colors = [("tag1", "#123123"), ("tag2", "#456456"), ("tag3", "#111222")]) m.private_project2 = f.ProjectFactory(is_private=True, anon_permissions=[], public_permissions=[], - owner=m.project_owner) + owner=m.project_owner, + tags_colors = [("tag1", "#123123"), ("tag2", "#456456"), ("tag3", "#111222")]) m.blocked_project = f.ProjectFactory(is_private=True, anon_permissions=[], public_permissions=[], owner=m.project_owner, - blocked_code=project_choices.BLOCKED_BY_STAFF) + blocked_code=project_choices.BLOCKED_BY_STAFF, + tags_colors = [("tag1", "#123123"), ("tag2", "#456456"), ("tag3", "#111222")]) m.public_membership = f.MembershipFactory(project=m.public_project, user=m.project_member_with_perms, @@ -1911,3 +1915,127 @@ def test_project_template_patch(client, data): results = helper_test_http_method(client, 'patch', url, '{"name": "Test"}', users) assert results == [401, 403, 200] + + +def test_create_tag(client, data): + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + post_data = json.dumps({ + "tag": "testtest", + "color": "#123123" + }) + + url = reverse('projects-create-tag', kwargs={"pk": data.public_project.pk}) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 200] + + url = reverse('projects-create-tag', kwargs={"pk": data.private_project1.pk}) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 200] + + url = reverse('projects-create-tag', kwargs={"pk": data.private_project2.pk}) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [404, 404, 404, 403, 200] + + url = reverse('projects-create-tag', kwargs={"pk": data.blocked_project.pk}) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [404, 404, 404, 403, 451] + + +def test_edit_tag(client, data): + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + post_data = json.dumps({ + "from_tag": "tag1", + "to_tag": "renamedtag1", + "color": "#123123" + }) + + url = reverse('projects-edit-tag', kwargs={"pk": data.public_project.pk}) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 200] + + url = reverse('projects-edit-tag', kwargs={"pk": data.private_project1.pk}) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 200] + + url = reverse('projects-edit-tag', kwargs={"pk": data.private_project2.pk}) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [404, 404, 404, 403, 200] + + url = reverse('projects-edit-tag', kwargs={"pk": data.blocked_project.pk}) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [404, 404, 404, 403, 451] + + +def test_delete_tag(client, data): + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + post_data = json.dumps({ + "tag": "tag2", + }) + + url = reverse('projects-delete-tag', kwargs={"pk": data.public_project.pk}) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 200] + + url = reverse('projects-delete-tag', kwargs={"pk": data.private_project1.pk}) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 200] + + url = reverse('projects-delete-tag', kwargs={"pk": data.private_project2.pk}) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [404, 404, 404, 403, 200] + + url = reverse('projects-delete-tag', kwargs={"pk": data.blocked_project.pk}) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [404, 404, 404, 403, 451] + + +def test_mix_tags(client, data): + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + post_data = json.dumps({ + "from_tags": ["tag1"], + "to_tag": "tag3" + }) + + url = reverse('projects-mix-tags', kwargs={"pk": data.public_project.pk}) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 200] + + url = reverse('projects-mix-tags', kwargs={"pk": data.private_project1.pk}) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 200] + + url = reverse('projects-mix-tags', kwargs={"pk": data.private_project2.pk}) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [404, 404, 404, 403, 200] + + url = reverse('projects-mix-tags', kwargs={"pk": data.blocked_project.pk}) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [404, 404, 404, 403, 451] diff --git a/tests/integration/test_projects.py b/tests/integration/test_projects.py index 4c2e121e..29d57c50 100644 --- a/tests/integration/test_projects.py +++ b/tests/integration/test_projects.py @@ -10,6 +10,9 @@ from taiga.projects.services import stats as stats_services from taiga.projects.history.services import take_snapshot from taiga.permissions.choices import ANON_PERMISSIONS from taiga.projects.models import Project +from taiga.projects.userstories.models import UserStory +from taiga.projects.tasks.models import Task +from taiga.projects.issues.models import Issue from taiga.projects.choices import BLOCKED_BY_DELETING from .. import factories as f @@ -1854,6 +1857,189 @@ def test_delete_project_with_celery_disabled(client, settings): assert Project.objects.filter(id=project.id).count() == 0 +def test_create_tag(client, settings): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + role = f.RoleFactory.create(project=project, permissions=["view_project"]) + membership = f.MembershipFactory.create(project=project, user=user, role=role, is_admin=True) + url = reverse("projects-create-tag", args=(project.id,)) + client.login(user) + data = { + "tag": "newtag", + "color": "#123123" + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 200 + project = Project.objects.get(id=project.pk) + assert project.tags_colors == [["newtag", "#123123"]] + + +def test_create_tag_without_color(client, settings): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + role = f.RoleFactory.create(project=project, permissions=["view_project"]) + membership = f.MembershipFactory.create(project=project, user=user, role=role, is_admin=True) + url = reverse("projects-create-tag", args=(project.id,)) + client.login(user) + data = { + "tag": "newtag", + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 200 + project = Project.objects.get(id=project.pk) + assert project.tags_colors[0][0] == "newtag" + + +def test_edit_tag_only_name(client, settings): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user, tags_colors=[("tag", "#123123")]) + user_story = f.UserStoryFactory.create(project=project, tags=["tag"]) + task = f.TaskFactory.create(project=project, tags=["tag"]) + issue = f.IssueFactory.create(project=project, tags=["tag"]) + + role = f.RoleFactory.create(project=project, permissions=["view_project"]) + membership = f.MembershipFactory.create(project=project, user=user, role=role, is_admin=True) + url = reverse("projects-edit-tag", args=(project.id,)) + client.login(user) + data = { + "from_tag": "tag", + "to_tag": "renamed_tag" + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + print(response.data) + assert response.status_code == 200 + project = Project.objects.get(id=project.pk) + assert project.tags_colors == [["renamed_tag", "#123123"]] + user_story = UserStory.objects.get(id=user_story.pk) + assert user_story.tags == ["renamed_tag"] + task = Task.objects.get(id=task.pk) + assert task.tags == ["renamed_tag"] + issue = Issue.objects.get(id=issue.pk) + assert issue.tags == ["renamed_tag"] + + +def test_edit_tag_only_color(client, settings): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user, tags_colors=[("tag", "#123123")]) + user_story = f.UserStoryFactory.create(project=project, tags=["tag"]) + task = f.TaskFactory.create(project=project, tags=["tag"]) + issue = f.IssueFactory.create(project=project, tags=["tag"]) + + role = f.RoleFactory.create(project=project, permissions=["view_project"]) + membership = f.MembershipFactory.create(project=project, user=user, role=role, is_admin=True) + url = reverse("projects-edit-tag", args=(project.id,)) + client.login(user) + data = { + "from_tag": "tag", + "color": "#AAABBB" + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 200 + project = Project.objects.get(id=project.pk) + assert project.tags_colors == [["tag", "#AAABBB"]] + user_story = UserStory.objects.get(id=user_story.pk) + assert user_story.tags == ["tag"] + task = Task.objects.get(id=task.pk) + assert task.tags == ["tag"] + issue = Issue.objects.get(id=issue.pk) + assert issue.tags == ["tag"] + + +def test_edit_tag(client, settings): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user, tags_colors=[("tag", "#123123")]) + user_story = f.UserStoryFactory.create(project=project, tags=["tag"]) + task = f.TaskFactory.create(project=project, tags=["tag"]) + issue = f.IssueFactory.create(project=project, tags=["tag"]) + + role = f.RoleFactory.create(project=project, permissions=["view_project"]) + membership = f.MembershipFactory.create(project=project, user=user, role=role, is_admin=True) + url = reverse("projects-edit-tag", args=(project.id,)) + client.login(user) + data = { + "from_tag": "tag", + "to_tag": "renamed_tag", + "color": "#AAABBB" + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 200 + project = Project.objects.get(id=project.pk) + assert project.tags_colors == [["renamed_tag", "#AAABBB"]] + user_story = UserStory.objects.get(id=user_story.pk) + assert user_story.tags == ["renamed_tag"] + task = Task.objects.get(id=task.pk) + assert task.tags == ["renamed_tag"] + issue = Issue.objects.get(id=issue.pk) + assert issue.tags == ["renamed_tag"] + + +def test_delete_tag(client, settings): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user, tags_colors=[("tag", "#123123")]) + user_story = f.UserStoryFactory.create(project=project, tags=["tag"]) + task = f.TaskFactory.create(project=project, tags=["tag"]) + issue = f.IssueFactory.create(project=project, tags=["tag"]) + + role = f.RoleFactory.create(project=project, permissions=["view_project"]) + membership = f.MembershipFactory.create(project=project, user=user, role=role, is_admin=True) + url = reverse("projects-delete-tag", args=(project.id,)) + client.login(user) + data = { + "tag": "tag" + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 200 + project = Project.objects.get(id=project.pk) + assert project.tags_colors == [] + user_story = UserStory.objects.get(id=user_story.pk) + assert user_story.tags == [] + task = Task.objects.get(id=task.pk) + assert task.tags == [] + issue = Issue.objects.get(id=issue.pk) + assert issue.tags == [] + + +def test_mix_tags(client, settings): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user, tags_colors=[("tag1", "#123123"), ("tag2", "#123123"), ("tag3", "#123123")]) + user_story = f.UserStoryFactory.create(project=project, tags=["tag1", "tag3"]) + task = f.TaskFactory.create(project=project, tags=["tag2", "tag3"]) + issue = f.IssueFactory.create(project=project, tags=["tag1", "tag2", "tag3"]) + + role = f.RoleFactory.create(project=project, permissions=["view_project"]) + membership = f.MembershipFactory.create(project=project, user=user, role=role, is_admin=True) + url = reverse("projects-mix-tags", args=(project.id,)) + client.login(user) + data = { + "from_tags": ["tag1", "tag2"], + "to_tag": "tag2" + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 200 + project = Project.objects.get(id=project.pk) + assert set(["tag2", "tag3"]) == set(dict(project.tags_colors).keys()) + user_story = UserStory.objects.get(id=user_story.pk) + assert set(user_story.tags) == set(["tag2", "tag3"]) + task = Task.objects.get(id=task.pk) + assert set(task.tags) == set(["tag2", "tag3"]) + issue = Issue.objects.get(id=issue.pk) + assert set(issue.tags) == set(["tag2", "tag3"]) + + def test_color_tags_project_fired_on_element_create(): user_story = f.UserStoryFactory.create(tags=["tag"]) project = Project.objects.get(id=user_story.project.id) @@ -1875,4 +2061,3 @@ def test_color_tags_project_fired_on_element_update_respecting_color(): user_story.save() project = Project.objects.get(id=user_story.project.id) assert project.tags_colors == [["tag", "#123123"]] ->>>>>>> d64d158... WIP: migrations, removing automatic color generation diff --git a/tests/integration/test_users.py b/tests/integration/test_users.py index d65b2451..2c25b29d 100644 --- a/tests/integration/test_users.py +++ b/tests/integration/test_users.py @@ -481,7 +481,7 @@ def test_get_watched_list_valid_info_for_project(): fav_user = f.UserFactory() viewer_user = f.UserFactory() - project = f.ProjectFactory(is_private=False, name="Testing project", tags=['test', 'tag']) + project = f.ProjectFactory(is_private=False, name="Testing project") role = f.RoleFactory(project=project, permissions=["view_project", "view_us", "view_tasks", "view_issues"]) project.add_watcher(fav_user) @@ -499,11 +499,6 @@ def test_get_watched_list_valid_info_for_project(): assert project_watch_info["assigned_to"] == None assert project_watch_info["status"] == None assert project_watch_info["status_color"] == None - - tags_colors = {tc["name"]:tc["color"] for tc in project_watch_info["tags_colors"]} - assert "test" in tags_colors - assert "tag" in tags_colors - assert project_watch_info["is_private"] == project.is_private assert project_watch_info["logo_small_url"] == get_thumbnail_url(project.logo, settings.THN_LOGO_SMALL) assert project_watch_info["is_fan"] == False @@ -540,7 +535,7 @@ def test_get_liked_list_valid_info(): fan_user = f.UserFactory() viewer_user = f.UserFactory() - project = f.ProjectFactory(is_private=False, name="Testing project", tags=['test', 'tag']) + project = f.ProjectFactory(is_private=False, name="Testing project") content_type = ContentType.objects.get_for_model(project) like = f.LikeFactory(content_type=content_type, object_id=project.id, user=fan_user) project.refresh_totals() @@ -558,11 +553,6 @@ def test_get_liked_list_valid_info(): assert project_like_info["assigned_to"] == None assert project_like_info["status"] == None assert project_like_info["status_color"] == None - - tags_colors = {tc["name"]:tc["color"] for tc in project_like_info["tags_colors"]} - assert "test" in tags_colors - assert "tag" in tags_colors - assert project_like_info["is_private"] == project.is_private assert project_like_info["logo_small_url"] == get_thumbnail_url(project.logo, settings.THN_LOGO_SMALL) 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 04/11] 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" From 2b82bb056f17d788ebda8fbd7e35df2d33f1c388 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 9 Jun 2016 07:41:17 +0200 Subject: [PATCH 05/11] Updating CHANGELOG --- CHANGELOG.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d4a82a19..67c30c98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,10 +3,15 @@ ## 2.2.0 ??? (unreleased) ### Features -- Now comment owners and project admins can edit existing comments with the history Entry endpoint. -- Add a new permissions to allow add comments instead of use the existent modify permission for this purpose. -- Include created, modified and finished dates for tasks in CSV reports +- Include created, modified and finished dates for tasks in CSV reports. - Add gravatar url to Users API endpoint. +- Comments: + - Now comment owners and project admins can edit existing comments with the history Entry endpoint. + - Add a new permissions to allow add comments instead of use the existent modify permission for this purpose. +- Tags: + - New API endpoints over projects to create, rename, edit, delete and mix tags. + - Tag color assignation is not automatic. + - Select a color (or not) to a tag when add it to stories, issues and tasks. ### Misc - Lots of small and not so small bugfixes. From b4c81f9c9db84ec29fda84e99bf9e8a245d96f9a Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 9 Jun 2016 14:39:13 +0200 Subject: [PATCH 06/11] Fixing sample_data --- taiga/projects/management/commands/sample_data.py | 1 + 1 file changed, 1 insertion(+) diff --git a/taiga/projects/management/commands/sample_data.py b/taiga/projects/management/commands/sample_data.py index db7a696c..937b11d7 100644 --- a/taiga/projects/management/commands/sample_data.py +++ b/taiga/projects/management/commands/sample_data.py @@ -260,6 +260,7 @@ class Command(BaseCommand): project_stats = get_stats_for_project(project) defined_points = project_stats["defined_points"] project.total_story_points = int(defined_points * self.sd.int(5,12) / 10) + project.refresh_from_db() project.save() self.create_likes(project) From fde98473c4b0f3ed18d18cf6fd0c6833870166fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Espino?= Date: Thu, 9 Jun 2016 19:18:52 +0200 Subject: [PATCH 07/11] Keep the to_tag color on mix_tags service function --- taiga/projects/services/tags.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/taiga/projects/services/tags.py b/taiga/projects/services/tags.py index ea009df7..010a23fb 100644 --- a/taiga/projects/services/tags.py +++ b/taiga/projects/services/tags.py @@ -53,8 +53,8 @@ def edit_tag(project, from_tag, to_tag=None, color=None): project.save() -def rename_tag(project, from_tag, to_tag): - color = dict(project.tags_colors)[from_tag] +def rename_tag(project, from_tag, to_tag, color=None): + color = color or dict(project.tags_colors)[from_tag] sql = """ UPDATE userstories_userstory SET tags = array_distinct(array_replace(tags, '{from_tag}', '{to_tag}')) WHERE project_id={project_id}; UPDATE tasks_task SET tags = array_distinct(array_replace(tags, '{from_tag}', '{to_tag}')) WHERE project_id={project_id}; @@ -65,7 +65,8 @@ def rename_tag(project, from_tag, to_tag): cursor.execute(sql) tags_colors = dict(project.tags_colors) - tags_colors[to_tag] = tags_colors.pop(from_tag) + tags_colors.pop(from_tag) + tags_colors[to_tag] = color project.tags_colors = list(tags_colors.items()) project.save() @@ -87,5 +88,6 @@ def delete_tag(project, tag): def mix_tags(project, from_tags, to_tag): + color = dict(project.tags_colors)[to_tag] for from_tag in from_tags: - rename_tag(project, from_tag, to_tag) + rename_tag(project, from_tag, to_tag, color) From 13ef1b9af512ac403650adc4cc45de9446692c39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Fri, 10 Jun 2016 18:39:28 +0200 Subject: [PATCH 08/11] Move all tags code to projects.tags --- taiga/projects/api.py | 89 ++----------- taiga/projects/issues/api.py | 2 +- taiga/projects/serializers.py | 93 ------------- taiga/projects/services/__init__.py | 3 - taiga/projects/tagging/api.py | 125 ++++++++++++++++++ taiga/projects/tagging/fields.py | 4 +- taiga/projects/tagging/mixins.py | 48 ------- taiga/projects/tagging/serializers.py | 112 ++++++++++++++++ .../{services/tags.py => tagging/services.py} | 55 ++++++-- taiga/projects/tagging/signals.py | 1 - taiga/projects/tasks/api.py | 2 +- taiga/projects/userstories/api.py | 2 +- 12 files changed, 297 insertions(+), 239 deletions(-) create mode 100644 taiga/projects/tagging/api.py delete mode 100644 taiga/projects/tagging/mixins.py create mode 100644 taiga/projects/tagging/serializers.py rename taiga/projects/{services/tags.py => tagging/services.py} (60%) diff --git a/taiga/projects/api.py b/taiga/projects/api.py index 77d9a5d1..6c7a4ec9 100644 --- a/taiga/projects/api.py +++ b/taiga/projects/api.py @@ -22,38 +22,38 @@ from dateutil.relativedelta import relativedelta from django.apps import apps from django.conf import settings +from django.core.exceptions import ValidationError from django.db.models import signals, Prefetch from django.db.models import Value as V from django.db.models.functions import Coalesce -from django.core.exceptions import ValidationError +from django.http import Http404 from django.utils.translation import ugettext as _ from django.utils import timezone -from django.http import Http404 from taiga.base import filters -from taiga.base import response from taiga.base import exceptions as exc -from taiga.base.decorators import list_route -from taiga.base.decorators import detail_route +from taiga.base import response from taiga.base.api import ModelCrudViewSet, ModelListViewSet from taiga.base.api.mixins import BlockedByProjectMixin, BlockeableSaveMixin, BlockeableDeleteMixin from taiga.base.api.permissions import AllowAnyPermission from taiga.base.api.utils import get_object_or_404 +from taiga.base.decorators import list_route +from taiga.base.decorators import detail_route from taiga.base.utils.slug import slugify_uniquely +from taiga.permissions import services as permissions_services from taiga.projects.history.mixins import HistoryResourceMixin +from taiga.projects.issues.models import Issue +from taiga.projects.likes.mixins.viewsets import LikedResourceMixin, FansViewSetMixin from taiga.projects.notifications.models import NotifyPolicy from taiga.projects.notifications.mixins import WatchedResourceMixin, WatchersViewSetMixin from taiga.projects.notifications.choices import NotifyLevel - -from taiga.projects.mixins.ordering import BulkUpdateOrderMixin from taiga.projects.mixins.on_destroy import MoveOnDestroyMixin +from taiga.projects.mixins.ordering import BulkUpdateOrderMixin +from taiga.projects.tasks.models import Task +from taiga.projects.tagging.api import TagsColorsResourceMixin from taiga.projects.userstories.models import UserStory, RolePoints -from taiga.projects.tasks.models import Task -from taiga.projects.issues.models import Issue -from taiga.projects.likes.mixins.viewsets import LikedResourceMixin, FansViewSetMixin -from taiga.permissions import services as permissions_services from taiga.users import services as users_services from . import filters as project_filters @@ -67,8 +67,8 @@ from . import services ## Project ###################################################### -class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, - BlockeableSaveMixin, BlockeableDeleteMixin, ModelCrudViewSet): +class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, BlockeableSaveMixin, BlockeableDeleteMixin, + TagsColorsResourceMixin, ModelCrudViewSet): queryset = models.Project.objects.all() serializer_class = serializers.ProjectDetailSerializer admin_serializer_class = serializers.ProjectDetailAdminSerializer @@ -327,12 +327,6 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, self.check_permissions(request, "issues_stats", project) return response.Ok(services.get_stats_for_project_issues(project)) - @detail_route(methods=["GET"]) - def tags_colors(self, request, pk=None): - project = self.get_object() - self.check_permissions(request, "tags_colors", project) - return response.Ok(dict(project.tags_colors)) - @detail_route(methods=["POST"]) def transfer_validate_token(self, request, pk=None): project = self.get_object() @@ -405,63 +399,6 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, services.reject_project_transfer(project, request.user, token, reason) return response.Ok() - @detail_route(methods=["POST"]) - def create_tag(self, request, pk=None): - project = self.get_object() - self.check_permissions(request, "create_tag", project) - self._raise_if_blocked(project) - serializer = serializers.CreateTagSerializer(data=request.DATA, project=project) - if not serializer.is_valid(): - return response.BadRequest(serializer.errors) - - data = serializer.data - services.create_tag(project, data.get("tag"), data.get("color")) - return response.Ok() - - - @detail_route(methods=["POST"]) - def edit_tag(self, request, pk=None): - project = self.get_object() - self.check_permissions(request, "edit_tag", project) - self._raise_if_blocked(project) - serializer = serializers.EditTagTagSerializer(data=request.DATA, project=project) - if not serializer.is_valid(): - return response.BadRequest(serializer.errors) - - data = serializer.data - services.edit_tag(project, data.get("from_tag"), - to_tag=data.get("to_tag", None), - color=data.get("color", None)) - - return response.Ok() - - - @detail_route(methods=["POST"]) - def delete_tag(self, request, pk=None): - project = self.get_object() - self.check_permissions(request, "delete_tag", project) - self._raise_if_blocked(project) - serializer = serializers.DeleteTagSerializer(data=request.DATA, project=project) - if not serializer.is_valid(): - return response.BadRequest(serializer.errors) - - data = serializer.data - services.delete_tag(project, data.get("tag")) - return response.Ok() - - @detail_route(methods=["POST"]) - def mix_tags(self, request, pk=None): - project = self.get_object() - self.check_permissions(request, "mix_tags", project) - self._raise_if_blocked(project) - serializer = serializers.MixTagsSerializer(data=request.DATA, project=project) - if not serializer.is_valid(): - return response.BadRequest(serializer.errors) - - data = serializer.data - services.mix_tags(project, data.get("from_tags"), data.get("to_tag")) - return response.Ok() - def _raise_if_blocked(self, project): if self.is_blocked(project): raise exc.Blocked(_("Blocked element")) diff --git a/taiga/projects/issues/api.py b/taiga/projects/issues/api.py index 20befba7..cae23be3 100644 --- a/taiga/projects/issues/api.py +++ b/taiga/projects/issues/api.py @@ -31,7 +31,7 @@ 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.tagging.mixins import TaggedResourceMixin +from taiga.projects.tagging.api import TaggedResourceMixin from taiga.projects.votes.mixins.viewsets import VotedResourceMixin, VotersViewSetMixin from . import models diff --git a/taiga/projects/serializers.py b/taiga/projects/serializers.py index f18bee1d..f388528f 100644 --- a/taiga/projects/serializers.py +++ b/taiga/projects/serializers.py @@ -16,8 +16,6 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -import re - from django.utils.translation import ugettext as _ from django.db.models import Q @@ -416,94 +414,3 @@ class ProjectTemplateSerializer(serializers.ModelSerializer): class UpdateProjectOrderBulkSerializer(ProjectExistsValidator, serializers.Serializer): project_id = serializers.IntegerField() order = serializers.IntegerField() - - -###################################################### -## Project tags serializers -###################################################### - - -class ProjectTagSerializer(serializers.Serializer): - def __init__(self, *args, **kwargs): - # Don't pass the extra project arg - self.project = kwargs.pop("project") - - # Instantiate the superclass normally - super().__init__(*args, **kwargs) - - -class CreateTagSerializer(ProjectTagSerializer): - tag = serializers.CharField() - color = serializers.CharField(required=False) - - def validate_tag(self, attrs, source): - tag = attrs.get(source, None) - if services.tag_exist_for_project_elements(self.project, tag): - raise serializers.ValidationError(_("The tag exists.")) - - return attrs - - def validate_color(self, attrs, source): - color = attrs.get(source, None) - if not re.match('^\#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})$', color): - raise serializers.ValidationError(_("The color is not a valid HEX color.")) - - return attrs - - -class EditTagTagSerializer(ProjectTagSerializer): - from_tag = serializers.CharField() - to_tag = serializers.CharField(required=False) - color = serializers.CharField(required=False) - - def validate_from_tag(self, attrs, source): - tag = attrs.get(source, None) - if not services.tag_exist_for_project_elements(self.project, tag): - raise serializers.ValidationError(_("The tag doesn't exist.")) - - return attrs - - def validate_to_tag(self, attrs, source): - tag = attrs.get(source, None) - if services.tag_exist_for_project_elements(self.project, tag): - raise serializers.ValidationError(_("The tag exists yet")) - - return attrs - - def validate_color(self, attrs, source): - color = attrs.get(source, None) - if len(color) != 7 or not re.match('^\#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})$', color): - raise serializers.ValidationError(_("The color is not a valid HEX color.")) - - return attrs - - -class DeleteTagSerializer(ProjectTagSerializer): - tag = serializers.CharField() - - def validate_tag(self, attrs, source): - tag = attrs.get(source, None) - if not services.tag_exist_for_project_elements(self.project, tag): - raise serializers.ValidationError(_("The tag doesn't exist.")) - - return attrs - - -class MixTagsSerializer(ProjectTagSerializer): - from_tags = TagsField() - to_tag = serializers.CharField() - - def validate_from_tags(self, attrs, source): - tags = attrs.get(source, None) - for tag in tags: - if not services.tag_exist_for_project_elements(self.project, tag): - raise serializers.ValidationError(_("The tag doesn't exist.")) - - return attrs - - def validate_to_tag(self, attrs, source): - tag = attrs.get(source, None) - if not services.tag_exist_for_project_elements(self.project, tag): - raise serializers.ValidationError(_("The tag doesn't exist.")) - - return attrs diff --git a/taiga/projects/services/__init__.py b/taiga/projects/services/__init__.py index f2fcc3c0..a115275b 100644 --- a/taiga/projects/services/__init__.py +++ b/taiga/projects/services/__init__.py @@ -57,6 +57,3 @@ from .stats import get_member_stats_for_project from .transfer import request_project_transfer, start_project_transfer from .transfer import accept_project_transfer, reject_project_transfer - -from .tags import tag_exist_for_project_elements, create_tag -from .tags import edit_tag, delete_tag, mix_tags diff --git a/taiga/projects/tagging/api.py b/taiga/projects/tagging/api.py new file mode 100644 index 00000000..d93ebe72 --- /dev/null +++ b/taiga/projects/tagging/api.py @@ -0,0 +1,125 @@ +# -*- 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 taiga.base import response +from taiga.base.decorators import detail_route + +from . import services +from . import serializers + + +class TagsColorsResourceMixin: + @detail_route(methods=["GET"]) + def tags_colors(self, request, pk=None): + project = self.get_object() + self.check_permissions(request, "tags_colors", project) + + return response.Ok(dict(project.tags_colors)) + + @detail_route(methods=["POST"]) + def create_tag(self, request, pk=None): + project = self.get_object() + self.check_permissions(request, "create_tag", project) + self._raise_if_blocked(project) + + serializer = serializers.CreateTagSerializer(data=request.DATA, project=project) + if not serializer.is_valid(): + return response.BadRequest(serializer.errors) + + data = serializer.data + services.create_tag(project, data.get("tag"), data.get("color")) + + return response.Ok() + + + @detail_route(methods=["POST"]) + def edit_tag(self, request, pk=None): + project = self.get_object() + self.check_permissions(request, "edit_tag", project) + self._raise_if_blocked(project) + + serializer = serializers.EditTagTagSerializer(data=request.DATA, project=project) + if not serializer.is_valid(): + return response.BadRequest(serializer.errors) + + data = serializer.data + services.edit_tag(project, + data.get("from_tag"), + to_tag=data.get("to_tag", None), + color=data.get("color", None)) + + return response.Ok() + + + @detail_route(methods=["POST"]) + def delete_tag(self, request, pk=None): + project = self.get_object() + self.check_permissions(request, "delete_tag", project) + self._raise_if_blocked(project) + + serializer = serializers.DeleteTagSerializer(data=request.DATA, project=project) + if not serializer.is_valid(): + return response.BadRequest(serializer.errors) + + data = serializer.data + services.delete_tag(project, data.get("tag")) + + return response.Ok() + + @detail_route(methods=["POST"]) + def mix_tags(self, request, pk=None): + project = self.get_object() + self.check_permissions(request, "mix_tags", project) + self._raise_if_blocked(project) + + serializer = serializers.MixTagsSerializer(data=request.DATA, project=project) + if not serializer.is_valid(): + return response.BadRequest(serializer.errors) + + data = serializer.data + services.mix_tags(project, data.get("from_tags"), data.get("to_tag")) + + return response.Ok() + + +class TaggedResourceMixin: + def pre_save(self, obj): + if obj.tags: + self._pre_save_new_tags_in_project_tagss_colors(obj) + + super().pre_save(obj) + + def _pre_save_new_tags_in_project_tagss_colors(self, obj): + new_obj_tags = set() + new_tags_colors = {} + + for tag in obj.tags: + if isinstance(tag, (list, tuple)): + name, color = tag + + if color and not services.tag_exist_for_project_elements(obj.project, name): + 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: + services.create_tags(obj.project, new_tags_colors) diff --git a/taiga/projects/tagging/fields.py b/taiga/projects/tagging/fields.py index b56e3cc1..24f92f23 100644 --- a/taiga/projects/tagging/fields.py +++ b/taiga/projects/tagging/fields.py @@ -16,11 +16,11 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +from django.core.exceptions import ValidationError 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 +from taiga.base.api import serializers import re diff --git a/taiga/projects/tagging/mixins.py b/taiga/projects/tagging/mixins.py deleted file mode 100644 index aa5df99f..00000000 --- a/taiga/projects/tagging/mixins.py +++ /dev/null @@ -1,48 +0,0 @@ -# -*- 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/serializers.py b/taiga/projects/tagging/serializers.py new file mode 100644 index 00000000..dc25b73a --- /dev/null +++ b/taiga/projects/tagging/serializers.py @@ -0,0 +1,112 @@ +# -*- 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.utils.translation import ugettext as _ + +from taiga.base.api import serializers + +from . import services +from . import fields + +import re + + +class ProjectTagSerializer(serializers.Serializer): + def __init__(self, *args, **kwargs): + # Don't pass the extra project arg + self.project = kwargs.pop("project") + + # Instantiate the superclass normally + super().__init__(*args, **kwargs) + + +class CreateTagSerializer(ProjectTagSerializer): + tag = serializers.CharField() + color = serializers.CharField(required=False) + + def validate_tag(self, attrs, source): + tag = attrs.get(source, None) + if services.tag_exist_for_project_elements(self.project, tag): + raise serializers.ValidationError(_("The tag exists.")) + + return attrs + + def validate_color(self, attrs, source): + color = attrs.get(source, None) + if not re.match('^\#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})$', color): + raise serializers.ValidationError(_("The color is not a valid HEX color.")) + + return attrs + + +class EditTagTagSerializer(ProjectTagSerializer): + from_tag = serializers.CharField() + to_tag = serializers.CharField(required=False) + color = serializers.CharField(required=False) + + def validate_from_tag(self, attrs, source): + tag = attrs.get(source, None) + if not services.tag_exist_for_project_elements(self.project, tag): + raise serializers.ValidationError(_("The tag doesn't exist.")) + + return attrs + + def validate_to_tag(self, attrs, source): + tag = attrs.get(source, None) + if services.tag_exist_for_project_elements(self.project, tag): + raise serializers.ValidationError(_("The tag exists yet")) + + return attrs + + def validate_color(self, attrs, source): + color = attrs.get(source, None) + if not re.match('^\#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})$', color): + raise serializers.ValidationError(_("The color is not a valid HEX color.")) + + return attrs + + +class DeleteTagSerializer(ProjectTagSerializer): + tag = serializers.CharField() + + def validate_tag(self, attrs, source): + tag = attrs.get(source, None) + if not services.tag_exist_for_project_elements(self.project, tag): + raise serializers.ValidationError(_("The tag doesn't exist.")) + + return attrs + + +class MixTagsSerializer(ProjectTagSerializer): + from_tags = fields.TagsField() + to_tag = serializers.CharField() + + def validate_from_tags(self, attrs, source): + tags = attrs.get(source, None) + for tag in tags: + if not services.tag_exist_for_project_elements(self.project, tag): + raise serializers.ValidationError(_("The tag doesn't exist.")) + + return attrs + + def validate_to_tag(self, attrs, source): + tag = attrs.get(source, None) + if not services.tag_exist_for_project_elements(self.project, tag): + raise serializers.ValidationError(_("The tag doesn't exist.")) + + return attrs diff --git a/taiga/projects/services/tags.py b/taiga/projects/tagging/services.py similarity index 60% rename from taiga/projects/services/tags.py rename to taiga/projects/tagging/services.py index 010a23fb..30e9f9dc 100644 --- a/taiga/projects/services/tags.py +++ b/taiga/projects/tagging/services.py @@ -23,9 +23,14 @@ def tag_exist_for_project_elements(project, tag): return tag in dict(project.tags_colors).keys() +def create_tags(project, new_tags_colors): + project.tags_colors += [[k, v] for k,v in new_tags_colors.items()] + project.save(update_fields=["tags_colors"]) + + def create_tag(project, tag, color): project.tags_colors.append([tag, color]) - project.save() + project.save(update_fields=["tags_colors"]) def edit_tag(project, from_tag, to_tag=None, color=None): @@ -38,9 +43,17 @@ def edit_tag(project, from_tag, to_tag=None, color=None): if to_tag is not None: color = dict(project.tags_colors)[from_tag] sql = """ - UPDATE userstories_userstory SET tags = array_distinct(array_replace(tags, '{from_tag}', '{to_tag}')) WHERE project_id={project_id}; - UPDATE tasks_task SET tags = array_distinct(array_replace(tags, '{from_tag}', '{to_tag}')) WHERE project_id={project_id}; - UPDATE issues_issue SET tags = array_distinct(array_replace(tags, '{from_tag}', '{to_tag}')) WHERE project_id={project_id}; + UPDATE userstories_userstory + SET tags = array_distinct(array_replace(tags, '{from_tag}', '{to_tag}')) + WHERE project_id = {project_id}; + + UPDATE tasks_task + SET tags = array_distinct(array_replace(tags, '{from_tag}', '{to_tag}')) + WHERE project_id = {project_id}; + + UPDATE issues_issue + SET tags = array_distinct(array_replace(tags, '{from_tag}', '{to_tag}')) + WHERE project_id = {project_id}; """ sql = sql.format(project_id=project.id, from_tag=from_tag, to_tag=to_tag) cursor = connection.cursor() @@ -50,15 +63,23 @@ def edit_tag(project, from_tag, to_tag=None, color=None): project.tags_colors = list(tags_colors.items()) - project.save() + project.save(update_fields=["tags_colors"]) def rename_tag(project, from_tag, to_tag, color=None): color = color or dict(project.tags_colors)[from_tag] sql = """ - UPDATE userstories_userstory SET tags = array_distinct(array_replace(tags, '{from_tag}', '{to_tag}')) WHERE project_id={project_id}; - UPDATE tasks_task SET tags = array_distinct(array_replace(tags, '{from_tag}', '{to_tag}')) WHERE project_id={project_id}; - UPDATE issues_issue SET tags = array_distinct(array_replace(tags, '{from_tag}', '{to_tag}')) WHERE project_id={project_id}; + UPDATE userstories_userstory + SET tags = array_distinct(array_replace(tags, '{from_tag}', '{to_tag}')) + WHERE project_id = {project_id}; + + UPDATE tasks_task + SET tags = array_distinct(array_replace(tags, '{from_tag}', '{to_tag}')) + WHERE project_id = {project_id}; + + UPDATE issues_issue + SET tags = array_distinct(array_replace(tags, '{from_tag}', '{to_tag}')) + WHERE project_id = {project_id}; """ sql = sql.format(project_id=project.id, from_tag=from_tag, to_tag=to_tag, color=color) cursor = connection.cursor() @@ -68,14 +89,22 @@ def rename_tag(project, from_tag, to_tag, color=None): tags_colors.pop(from_tag) tags_colors[to_tag] = color project.tags_colors = list(tags_colors.items()) - project.save() + project.save(update_fields=["tags_colors"]) def delete_tag(project, tag): sql = """ - UPDATE userstories_userstory SET tags = array_remove(tags, '{tag}') WHERE project_id={project_id}; - UPDATE tasks_task SET tags = array_remove(tags, '{tag}') WHERE project_id={project_id}; - UPDATE issues_issue SET tags = array_remove(tags, '{tag}') WHERE project_id={project_id}; + UPDATE userstories_userstory + SET tags = array_remove(tags, '{tag}') + WHERE project_id = {project_id}; + + UPDATE tasks_task + SET tags = array_remove(tags, '{tag}') + WHERE project_id = {project_id}; + + UPDATE issues_issue + SET tags = array_remove(tags, '{tag}') + WHERE project_id = {project_id}; """ sql = sql.format(project_id=project.id, tag=tag) cursor = connection.cursor() @@ -84,7 +113,7 @@ def delete_tag(project, tag): tags_colors = dict(project.tags_colors) del tags_colors[tag] project.tags_colors = list(tags_colors.items()) - project.save() + project.save(update_fields=["tags_colors"]) def mix_tags(project, from_tags, to_tag): diff --git a/taiga/projects/tagging/signals.py b/taiga/projects/tagging/signals.py index 562fcba5..cc94461a 100644 --- a/taiga/projects/tagging/signals.py +++ b/taiga/projects/tagging/signals.py @@ -20,4 +20,3 @@ 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 7b5de5f3..9ebf6dfe 100644 --- a/taiga/projects/tasks/api.py +++ b/taiga/projects/tasks/api.py @@ -29,7 +29,7 @@ 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.tagging.api import TaggedResourceMixin from taiga.projects.votes.mixins.viewsets import VotedResourceMixin, VotersViewSetMixin from . import models diff --git a/taiga/projects/userstories/api.py b/taiga/projects/userstories/api.py index 028bfe35..b6693c36 100644 --- a/taiga/projects/userstories/api.py +++ b/taiga/projects/userstories/api.py @@ -36,7 +36,7 @@ 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.tagging.api import TaggedResourceMixin from taiga.projects.votes.mixins.viewsets import VotedResourceMixin, VotersViewSetMixin from . import models From 93e30ceffa39da42dc302be7826c9fa7a37a8ff0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Mon, 13 Jun 2016 18:02:27 +0200 Subject: [PATCH 09/11] Fix sampledata command to generate tags with and without color --- taiga/projects/management/commands/sample_data.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/taiga/projects/management/commands/sample_data.py b/taiga/projects/management/commands/sample_data.py index 937b11d7..23bbf598 100644 --- a/taiga/projects/management/commands/sample_data.py +++ b/taiga/projects/management/commands/sample_data.py @@ -19,6 +19,7 @@ import random import datetime from os import path +from hashlib import sha1 from django.core.management.base import BaseCommand @@ -256,16 +257,21 @@ class Command(BaseCommand): self.create_wiki_page(project, wiki_link.href) + project.refresh_from_db() + + # Set color for some tags: + for tag in project.tags_colors: + if self.sd.boolean(): + tag[1] = self.generate_color(tag[0]) + # Set a value to total_story_points to show the deadline in the backlog project_stats = get_stats_for_project(project) defined_points = project_stats["defined_points"] project.total_story_points = int(defined_points * self.sd.int(5,12) / 10) - project.refresh_from_db() project.save() self.create_likes(project) - def create_attachment(self, obj, order): attached_file = self.sd.file_from_directory(*ATTACHMENT_SAMPLE_DATA) membership = self.sd.db_object_from_queryset(obj.project.memberships @@ -552,3 +558,8 @@ class Command(BaseCommand): obj.add_watcher(user) else: obj.add_watcher(user, notify_level) + + def generate_color(self, tag): + color = sha1(tag.encode("utf-8")).hexdigest()[0:6] + return "#{}".format(color) + From bdd0f0b8334e2ec28883482c0bb9cc4cfcac1f70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Mon, 13 Jun 2016 18:04:47 +0200 Subject: [PATCH 10/11] Fix to add tags in the same order --- taiga/base/utils/collections.py | 80 +++++++++++++++++++++++++++++++++ taiga/projects/tagging/api.py | 4 +- 2 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 taiga/base/utils/collections.py diff --git a/taiga/base/utils/collections.py b/taiga/base/utils/collections.py new file mode 100644 index 00000000..c5ca3c59 --- /dev/null +++ b/taiga/base/utils/collections.py @@ -0,0 +1,80 @@ +# -*- 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 . + +import collections + + +class OrderedSet(collections.MutableSet): + # Extract from: + # - https://docs.python.org/3/library/collections.abc.html?highlight=orderedset + # - https://code.activestate.com/recipes/576694/ + def __init__(self, iterable=None): + self.end = end = [] + end += [None, end, end] # sentinel node for doubly linked list + self.map = {} # key --> [key, prev, next] + if iterable is not None: + self |= iterable + + def __len__(self): + return len(self.map) + + def __contains__(self, key): + return key in self.map + + def add(self, key): + if key not in self.map: + end = self.end + curr = end[1] + curr[2] = end[1] = self.map[key] = [key, curr, end] + + def discard(self, key): + if key in self.map: + key, prev, next = self.map.pop(key) + prev[2] = next + next[1] = prev + + def __iter__(self): + end = self.end + curr = end[2] + while curr is not end: + yield curr[0] + curr = curr[2] + + def __reversed__(self): + end = self.end + curr = end[1] + while curr is not end: + yield curr[0] + curr = curr[1] + + def pop(self, last=True): + if not self: + raise KeyError('set is empty') + key = self.end[1][0] if last else self.end[2][0] + self.discard(key) + return key + + def __repr__(self): + if not self: + return '%s()' % (self.__class__.__name__,) + return '%s(%r)' % (self.__class__.__name__, list(self)) + + def __eq__(self, other): + if isinstance(other, OrderedSet): + return len(self) == len(other) and list(self) == list(other) + return set(self) == set(other) diff --git a/taiga/projects/tagging/api.py b/taiga/projects/tagging/api.py index d93ebe72..c2dbd38a 100644 --- a/taiga/projects/tagging/api.py +++ b/taiga/projects/tagging/api.py @@ -18,6 +18,7 @@ from taiga.base import response from taiga.base.decorators import detail_route +from taiga.base.utils.collections import OrderedSet from . import services from . import serializers @@ -101,11 +102,10 @@ class TaggedResourceMixin: def pre_save(self, obj): if obj.tags: self._pre_save_new_tags_in_project_tagss_colors(obj) - super().pre_save(obj) def _pre_save_new_tags_in_project_tagss_colors(self, obj): - new_obj_tags = set() + new_obj_tags = OrderedSet() new_tags_colors = {} for tag in obj.tags: From 63cc560dabfa6ed205089191907275fff2ba72c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Mon, 13 Jun 2016 21:02:56 +0200 Subject: [PATCH 11/11] Change TextArrayField to ArrayField for tags, permisions and more fields. --- requirements.txt | 4 +-- taiga/base/fields.py | 1 - .../migrations/0007_auto_20160614_1201.py | 26 ++++++++++++++ taiga/projects/issues/models.py | 8 ++--- .../migrations/0047_auto_20160614_1201.py | 36 +++++++++++++++++++ taiga/projects/models.py | 24 +++++-------- .../tags.py => projects/tagging/models.py} | 14 ++++++-- .../migrations/0010_auto_20160614_1201.py | 26 ++++++++++++++ taiga/projects/tasks/models.py | 8 ++--- .../migrations/0012_auto_20160614_1201.py | 26 ++++++++++++++ taiga/projects/userstories/models.py | 7 ++-- .../migrations/0021_auto_20160614_1201.py | 21 +++++++++++ taiga/users/models.py | 12 +++---- tests/integration/test_importer_api.py | 2 +- tests/models.py | 26 -------------- 15 files changed, 175 insertions(+), 66 deletions(-) create mode 100644 taiga/projects/issues/migrations/0007_auto_20160614_1201.py create mode 100644 taiga/projects/migrations/0047_auto_20160614_1201.py rename taiga/{base/tags.py => projects/tagging/models.py} (72%) create mode 100644 taiga/projects/tasks/migrations/0010_auto_20160614_1201.py create mode 100644 taiga/projects/userstories/migrations/0012_auto_20160614_1201.py create mode 100644 taiga/users/migrations/0021_auto_20160614_1201.py diff --git a/requirements.txt b/requirements.txt index 181e863f..93d225f0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,7 @@ six==1.10.0 amqp==1.4.9 djmail==0.12.0.post1 django-pgjson==0.3.1 -djorm-pgarray==1.2 +djorm-pgarray==1.2 # Use until Taiga 2.1. Keep compatibility with old migrations django-jinja==2.1.2 jinja2==2.8 pygments==2.0.2 @@ -28,7 +28,7 @@ raven==5.10.2 bleach==1.4.2 django-ipware==1.1.3 premailer==2.9.7 -cssutils==1.0.1 # Compatible with python 3.5 +cssutils==1.0.1 # Compatible with python 3.5 lxml==3.5.0 git+https://github.com/Xof/django-pglocks.git@dbb8d7375066859f897604132bd437832d2014ea pyjwkest==1.1.5 diff --git a/taiga/base/fields.py b/taiga/base/fields.py index 8e95801d..5e5c4b5a 100644 --- a/taiga/base/fields.py +++ b/taiga/base/fields.py @@ -18,7 +18,6 @@ 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 diff --git a/taiga/projects/issues/migrations/0007_auto_20160614_1201.py b/taiga/projects/issues/migrations/0007_auto_20160614_1201.py new file mode 100644 index 00000000..5cf43d30 --- /dev/null +++ b/taiga/projects/issues/migrations/0007_auto_20160614_1201.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2016-06-14 12:01 +from __future__ import unicode_literals + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('issues', '0006_remove_issue_watchers'), + ] + + operations = [ + migrations.AlterField( + model_name='issue', + name='external_reference', + field=django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(blank=True, null=True), size=2), blank=True, default=[], null=True, size=None, verbose_name='external reference'), + ), + migrations.AlterField( + model_name='issue', + name='tags', + field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), blank=True, default=[], null=True, size=None, verbose_name='tags'), + ), + ] diff --git a/taiga/projects/issues/models.py b/taiga/projects/issues/models.py index cd962e08..7c9f6b2e 100644 --- a/taiga/projects/issues/models.py +++ b/taiga/projects/issues/models.py @@ -18,17 +18,16 @@ from django.db import models from django.contrib.contenttypes.fields import GenericRelation +from django.contrib.postgres.fields import ArrayField from django.conf import settings from django.utils import timezone from django.dispatch import receiver from django.utils.translation import ugettext_lazy as _ -from djorm_pgarray.fields import TextArrayField - from taiga.projects.occ import OCCModelMixin from taiga.projects.notifications.mixins import WatchedModelMixin from taiga.projects.mixins.blocked import BlockedMixin -from taiga.base.tags import TaggedMixin +from taiga.projects.tagging.models import TaggedMixin class Issue(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, models.Model): @@ -63,7 +62,8 @@ class Issue(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, models. default=None, related_name="issues_assigned_to_me", verbose_name=_("assigned to")) attachments = GenericRelation("attachments.Attachment") - external_reference = TextArrayField(default=None, verbose_name=_("external reference")) + external_reference = ArrayField(ArrayField(models.TextField(null=True, blank=True), size=2), + null=True, blank=True, default=[], verbose_name=_("external reference")) _importing = None class Meta: diff --git a/taiga/projects/migrations/0047_auto_20160614_1201.py b/taiga/projects/migrations/0047_auto_20160614_1201.py new file mode 100644 index 00000000..eccd1f46 --- /dev/null +++ b/taiga/projects/migrations/0047_auto_20160614_1201.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2016-06-14 12:01 +from __future__ import unicode_literals + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0046_triggers_to_update_tags_colors'), + ] + + operations = [ + migrations.AlterField( + model_name='project', + name='anon_permissions', + field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(choices=[('view_project', 'View project'), ('view_milestones', 'View milestones'), ('view_us', 'View user stories'), ('view_tasks', 'View tasks'), ('view_issues', 'View issues'), ('view_wiki_pages', 'View wiki pages'), ('view_wiki_links', 'View wiki links')]), blank=True, default=[], null=True, size=None, verbose_name='anonymous permissions'), + ), + migrations.AlterField( + model_name='project', + name='public_permissions', + field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(choices=[('view_project', 'View project'), ('view_milestones', 'View milestones'), ('add_milestone', 'Add milestone'), ('modify_milestone', 'Modify milestone'), ('delete_milestone', 'Delete milestone'), ('view_us', 'View user story'), ('add_us', 'Add user story'), ('modify_us', 'Modify user story'), ('comment_us', 'Comment user story'), ('delete_us', 'Delete user story'), ('view_tasks', 'View tasks'), ('add_task', 'Add task'), ('modify_task', 'Modify task'), ('comment_task', 'Comment task'), ('delete_task', 'Delete task'), ('view_issues', 'View issues'), ('add_issue', 'Add issue'), ('modify_issue', 'Modify issue'), ('comment_issue', 'Comment issue'), ('delete_issue', 'Delete issue'), ('view_wiki_pages', 'View wiki pages'), ('add_wiki_page', 'Add wiki page'), ('modify_wiki_page', 'Modify wiki page'), ('comment_wiki_page', 'Comment wiki page'), ('delete_wiki_page', 'Delete wiki page'), ('view_wiki_links', 'View wiki links'), ('add_wiki_link', 'Add wiki link'), ('modify_wiki_link', 'Modify wiki link'), ('delete_wiki_link', 'Delete wiki link')]), blank=True, default=[], null=True, size=None, verbose_name='user permissions'), + ), + migrations.AlterField( + model_name='project', + name='tags', + field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), blank=True, default=[], null=True, size=None, verbose_name='tags'), + ), + migrations.AlterField( + model_name='project', + name='tags_colors', + field=django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(blank=True, null=True), size=2), blank=True, default=[], null=True, size=None, verbose_name='tags colors'), + ), + ] diff --git a/taiga/projects/models.py b/taiga/projects/models.py index 4a369c8d..0c6bcc09 100644 --- a/taiga/projects/models.py +++ b/taiga/projects/models.py @@ -20,21 +20,22 @@ import itertools import uuid from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.postgres.fields import ArrayField from django.core.exceptions import ValidationError from django.db import models from django.db.models import signals, Q from django.apps import apps from django.conf import settings from django.dispatch import receiver -from django.contrib.auth import get_user_model from django.utils.translation import ugettext_lazy as _ from django.utils import timezone from django.utils.functional import cached_property from django_pgjson.fields import JsonField -from djorm_pgarray.fields import TextArrayField -from taiga.base.tags import TaggedMixin +from taiga.projects.tagging.models import TaggedMixin +from taiga.projects.tagging.models import TagsColorsdMixin from taiga.base.utils.dicts import dict_sum from taiga.base.utils.files import get_file_path from taiga.base.utils.sequence import arithmetic_progression @@ -141,7 +142,7 @@ class ProjectDefaults(models.Model): abstract = True -class Project(ProjectDefaults, TaggedMixin, models.Model): +class Project(ProjectDefaults, TaggedMixin, TagsColorsdMixin, models.Model): name = models.CharField(max_length=250, null=False, blank=False, verbose_name=_("name")) slug = models.SlugField(max_length=250, unique=True, null=False, blank=True, @@ -186,16 +187,12 @@ class Project(ProjectDefaults, TaggedMixin, models.Model): blank=True, default=None, verbose_name=_("creation template")) - anon_permissions = TextArrayField(blank=True, null=True, - default=[], - verbose_name=_("anonymous permissions"), - choices=ANON_PERMISSIONS) - public_permissions = TextArrayField(blank=True, null=True, - default=[], - verbose_name=_("user permissions"), - choices=MEMBERS_PERMISSIONS) is_private = models.BooleanField(default=True, null=False, blank=True, verbose_name=_("is private")) + anon_permissions = ArrayField(models.TextField(null=False, blank=False, choices=ANON_PERMISSIONS), + null=True, blank=True, default=[], verbose_name=_("anonymous permissions")) + public_permissions = ArrayField(models.TextField(null=False, blank=False, choices=MEMBERS_PERMISSIONS), + null=True, blank=True, default=[], verbose_name=_("user permissions")) is_featured = models.BooleanField(default=False, null=False, blank=True, verbose_name=_("is featured")) @@ -214,9 +211,6 @@ class Project(ProjectDefaults, TaggedMixin, models.Model): null=True, blank=True, default=None, db_index=True) - tags_colors = TextArrayField(dimension=2, default=[], null=False, blank=True, - verbose_name=_("tags colors")) - transfer_token = models.CharField(max_length=255, null=True, blank=True, default=None, verbose_name=_("project transfer token")) diff --git a/taiga/base/tags.py b/taiga/projects/tagging/models.py similarity index 72% rename from taiga/base/tags.py rename to taiga/projects/tagging/models.py index 0e1cd866..970dae40 100644 --- a/taiga/base/tags.py +++ b/taiga/projects/tagging/models.py @@ -18,13 +18,21 @@ # along with this program. If not, see . from django.db import models +from django.contrib.postgres.fields import ArrayField from django.utils.translation import ugettext_lazy as _ -from djorm_pgarray.fields import TextArrayField - class TaggedMixin(models.Model): - tags = TextArrayField(default=None, verbose_name=_("tags")) + tags = ArrayField(models.TextField(), + null=True, blank=True, default=[], verbose_name=_("tags")) + + class Meta: + abstract = True + + +class TagsColorsdMixin(models.Model): + tags_colors = ArrayField(ArrayField(models.TextField(null=True, blank=True), size=2), + null=True, blank=True, default=[], verbose_name=_("tags colors")) class Meta: abstract = True diff --git a/taiga/projects/tasks/migrations/0010_auto_20160614_1201.py b/taiga/projects/tasks/migrations/0010_auto_20160614_1201.py new file mode 100644 index 00000000..4c3968fa --- /dev/null +++ b/taiga/projects/tasks/migrations/0010_auto_20160614_1201.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2016-06-14 12:01 +from __future__ import unicode_literals + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tasks', '0009_auto_20151104_1131'), + ] + + operations = [ + migrations.AlterField( + model_name='task', + name='external_reference', + field=django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(blank=True, null=True), size=2), blank=True, default=[], null=True, size=None, verbose_name='external reference'), + ), + migrations.AlterField( + model_name='task', + name='tags', + field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), blank=True, default=[], null=True, size=None, verbose_name='tags'), + ), + ] diff --git a/taiga/projects/tasks/models.py b/taiga/projects/tasks/models.py index 30406387..18ff5750 100644 --- a/taiga/projects/tasks/models.py +++ b/taiga/projects/tasks/models.py @@ -18,16 +18,15 @@ from django.db import models from django.contrib.contenttypes.fields import GenericRelation +from django.contrib.postgres.fields import ArrayField from django.conf import settings from django.utils import timezone from django.utils.translation import ugettext_lazy as _ -from djorm_pgarray.fields import TextArrayField - from taiga.projects.occ import OCCModelMixin from taiga.projects.notifications.mixins import WatchedModelMixin from taiga.projects.mixins.blocked import BlockedMixin -from taiga.base.tags import TaggedMixin +from taiga.projects.tagging.models import TaggedMixin class Task(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, models.Model): @@ -66,7 +65,8 @@ class Task(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, models.M attachments = GenericRelation("attachments.Attachment") is_iocaine = models.BooleanField(default=False, null=False, blank=True, verbose_name=_("is iocaine")) - external_reference = TextArrayField(default=None, verbose_name=_("external reference")) + external_reference = ArrayField(ArrayField(models.TextField(null=True, blank=True), size=2), + null=True, blank=True, default=[], verbose_name=_("external reference")) _importing = None class Meta: diff --git a/taiga/projects/userstories/migrations/0012_auto_20160614_1201.py b/taiga/projects/userstories/migrations/0012_auto_20160614_1201.py new file mode 100644 index 00000000..1e9830e5 --- /dev/null +++ b/taiga/projects/userstories/migrations/0012_auto_20160614_1201.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2016-06-14 12:01 +from __future__ import unicode_literals + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('userstories', '0011_userstory_tribe_gig'), + ] + + operations = [ + migrations.AlterField( + model_name='userstory', + name='external_reference', + field=django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(blank=True, null=True), size=2), blank=True, default=[], null=True, size=None, verbose_name='external reference'), + ), + migrations.AlterField( + model_name='userstory', + name='tags', + field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), blank=True, default=[], null=True, size=None, verbose_name='tags'), + ), + ] diff --git a/taiga/projects/userstories/models.py b/taiga/projects/userstories/models.py index 86332b46..47019ed3 100644 --- a/taiga/projects/userstories/models.py +++ b/taiga/projects/userstories/models.py @@ -18,14 +18,14 @@ from django.db import models from django.contrib.contenttypes.fields import GenericRelation +from django.contrib.postgres.fields import ArrayField from django.conf import settings from django.utils.translation import ugettext_lazy as _ from django.utils import timezone -from djorm_pgarray.fields import TextArrayField from picklefield.fields import PickledObjectField -from taiga.base.tags import TaggedMixin +from taiga.projects.tagging.models import TaggedMixin from taiga.projects.occ import OCCModelMixin from taiga.projects.notifications.mixins import WatchedModelMixin from taiga.projects.mixins.blocked import BlockedMixin @@ -103,7 +103,8 @@ class UserStory(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, mod on_delete=models.SET_NULL, related_name="generated_user_stories", verbose_name=_("generated from issue")) - external_reference = TextArrayField(default=None, verbose_name=_("external reference")) + external_reference = ArrayField(ArrayField(models.TextField(null=True, blank=True), size=2), + null=True, blank=True, default=[], verbose_name=_("external reference")) tribe_gig = PickledObjectField(null=True, blank=True, default=None, verbose_name="taiga tribe gig") diff --git a/taiga/users/migrations/0021_auto_20160614_1201.py b/taiga/users/migrations/0021_auto_20160614_1201.py new file mode 100644 index 00000000..a9f1bb98 --- /dev/null +++ b/taiga/users/migrations/0021_auto_20160614_1201.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2016-06-14 12:01 +from __future__ import unicode_literals + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0020_auto_20160525_1229'), + ] + + operations = [ + migrations.AlterField( + model_name='role', + name='permissions', + field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(choices=[('view_project', 'View project'), ('view_milestones', 'View milestones'), ('add_milestone', 'Add milestone'), ('modify_milestone', 'Modify milestone'), ('delete_milestone', 'Delete milestone'), ('view_us', 'View user story'), ('add_us', 'Add user story'), ('modify_us', 'Modify user story'), ('comment_us', 'Comment user story'), ('delete_us', 'Delete user story'), ('view_tasks', 'View tasks'), ('add_task', 'Add task'), ('modify_task', 'Modify task'), ('comment_task', 'Comment task'), ('delete_task', 'Delete task'), ('view_issues', 'View issues'), ('add_issue', 'Add issue'), ('modify_issue', 'Modify issue'), ('comment_issue', 'Comment issue'), ('delete_issue', 'Delete issue'), ('view_wiki_pages', 'View wiki pages'), ('add_wiki_page', 'Add wiki page'), ('modify_wiki_page', 'Modify wiki page'), ('comment_wiki_page', 'Comment wiki page'), ('delete_wiki_page', 'Delete wiki page'), ('view_wiki_links', 'View wiki links'), ('add_wiki_link', 'Add wiki link'), ('modify_wiki_link', 'Modify wiki link'), ('delete_wiki_link', 'Delete wiki link')]), blank=True, default=[], null=True, size=None, verbose_name='permissions'), + ), + ] diff --git a/taiga/users/models.py b/taiga/users/models.py index 2d8fcb33..264d1539 100644 --- a/taiga/users/models.py +++ b/taiga/users/models.py @@ -26,6 +26,7 @@ from django.apps.config import MODELS_MODULE_NAME from django.conf import settings from django.contrib.auth.models import UserManager, AbstractBaseUser from django.contrib.contenttypes.models import ContentType +from django.contrib.postgres.fields import ArrayField from django.core import validators from django.core.exceptions import AppRegistryNotReady from django.db import models @@ -34,7 +35,6 @@ from django.utils import timezone from django.utils.translation import ugettext_lazy as _ from django_pgjson.fields import JsonField -from djorm_pgarray.fields import TextArrayField from taiga.auth.tokens import get_token_for_user from taiga.base.utils.slug import slugify_uniquely @@ -53,8 +53,8 @@ def get_user_model_safe(): registry not being ready yet. Raises LookupError if model isn't found. - Based on: https://github.com/django-oscar/django-oscar/blob/1.0/oscar/core/loading.py#L310-L340 - Ongoing Django issue: https://code.djangoproject.com/ticket/22872 + Based on: https://github.com/django-oscar/django-oscar/blob/1.0/oscar/core/loading.py#L310-L340 + Ongoing Django issue: https://code.djangoproject.com/ticket/22872 """ user_app, user_model = settings.AUTH_USER_MODEL.split('.') @@ -293,10 +293,8 @@ class Role(models.Model): verbose_name=_("name")) slug = models.SlugField(max_length=250, null=False, blank=True, verbose_name=_("slug")) - permissions = TextArrayField(blank=True, null=True, - default=[], - verbose_name=_("permissions"), - choices=MEMBERS_PERMISSIONS) + permissions = ArrayField(models.TextField(null=False, blank=False, choices=MEMBERS_PERMISSIONS), + null=True, blank=True, default=[], verbose_name=_("permissions")) order = models.IntegerField(default=10, null=False, blank=False, verbose_name=_("order")) # null=True is for make work django 1.7 migrations. project diff --git a/tests/integration/test_importer_api.py b/tests/integration/test_importer_api.py index 490ba83b..6a2e7883 100644 --- a/tests/integration/test_importer_api.py +++ b/tests/integration/test_importer_api.py @@ -68,7 +68,7 @@ def test_valid_project_import_without_extra_data(client): } response = client.json.post(url, json.dumps(data)) - assert response.status_code == 201 + assert response.status_code == 201, response.data must_empty_children = [ "issues", "user_stories", "us_statuses", "wiki_pages", "priorities", "severities", "milestones", "points", "issue_types", "task_statuses", diff --git a/tests/models.py b/tests/models.py index 9583b8c0..e69de29b 100644 --- a/tests/models.py +++ b/tests/models.py @@ -1,26 +0,0 @@ -# -*- 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 -# Copyright (C) 2014-2016 Anler Hernández -# 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.db import models -from taiga.base import tags - - -class TaggedModel(tags.TaggedMixin, models.Model): - class Meta: - app_label = "tests"