Merge pull request #751 from taigaio/us/4302/improve_tagging_system_v2

US #4302 - Improve tagging system (v2)
remotes/origin/issue/4795/notification_even_they_are_disabled
David Barragán Merino 2016-06-15 17:07:21 +02:00 committed by GitHub
commit ef0b7dfe64
49 changed files with 1866 additions and 350 deletions

View File

@ -3,10 +3,15 @@
## 2.2.0 ??? (unreleased) ## 2.2.0 ??? (unreleased)
### Features ### Features
- Now comment owners and project admins can edit existing comments with the history Entry endpoint. - Include created, modified and finished dates for tasks in CSV reports.
- 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
- Add gravatar url to Users API endpoint. - 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 ### Misc
- Lots of small and not so small bugfixes. - Lots of small and not so small bugfixes.

View File

@ -10,7 +10,7 @@ six==1.10.0
amqp==1.4.9 amqp==1.4.9
djmail==0.12.0.post1 djmail==0.12.0.post1
django-pgjson==0.3.1 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 django-jinja==2.1.2
jinja2==2.8 jinja2==2.8
pygments==2.0.2 pygments==2.0.2
@ -28,7 +28,7 @@ raven==5.10.2
bleach==1.4.2 bleach==1.4.2
django-ipware==1.1.3 django-ipware==1.1.3
premailer==2.9.7 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 lxml==3.5.0
git+https://github.com/Xof/django-pglocks.git@dbb8d7375066859f897604132bd437832d2014ea git+https://github.com/Xof/django-pglocks.git@dbb8d7375066859f897604132bd437832d2014ea
pyjwkest==1.1.5 pyjwkest==1.1.5

View File

@ -18,7 +18,6 @@
from django.forms import widgets from django.forms import widgets
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from taiga.base.api import serializers from taiga.base.api import serializers
@ -99,35 +98,6 @@ class PickledObjectField(serializers.WritableField):
return data 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): class WatchersField(serializers.WritableField):
def to_native(self, obj): def to_native(self, obj):
return obj return obj

View File

@ -0,0 +1,80 @@
# -*- coding: utf-8 -*-
# Copyright (C) 2014-2016 Andrey Antukh <niwi@niwi.nz>
# Copyright (C) 2014-2016 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014-2016 David Barragán <bameda@dbarragan.com>
# Copyright (C) 2014-2016 Alejandro Alonso <alejandro.alonso@kaleidos.net>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
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)

View File

@ -22,38 +22,38 @@ from dateutil.relativedelta import relativedelta
from django.apps import apps from django.apps import apps
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError
from django.db.models import signals, Prefetch from django.db.models import signals, Prefetch
from django.db.models import Value as V from django.db.models import Value as V
from django.db.models.functions import Coalesce 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.translation import ugettext as _
from django.utils import timezone from django.utils import timezone
from django.http import Http404
from taiga.base import filters from taiga.base import filters
from taiga.base import response
from taiga.base import exceptions as exc from taiga.base import exceptions as exc
from taiga.base.decorators import list_route from taiga.base import response
from taiga.base.decorators import detail_route
from taiga.base.api import ModelCrudViewSet, ModelListViewSet from taiga.base.api import ModelCrudViewSet, ModelListViewSet
from taiga.base.api.mixins import BlockedByProjectMixin, BlockeableSaveMixin, BlockeableDeleteMixin from taiga.base.api.mixins import BlockedByProjectMixin, BlockeableSaveMixin, BlockeableDeleteMixin
from taiga.base.api.permissions import AllowAnyPermission from taiga.base.api.permissions import AllowAnyPermission
from taiga.base.api.utils import get_object_or_404 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.base.utils.slug import slugify_uniquely
from taiga.permissions import services as permissions_services
from taiga.projects.history.mixins import HistoryResourceMixin 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.models import NotifyPolicy
from taiga.projects.notifications.mixins import WatchedResourceMixin, WatchersViewSetMixin from taiga.projects.notifications.mixins import WatchedResourceMixin, WatchersViewSetMixin
from taiga.projects.notifications.choices import NotifyLevel 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.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.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 taiga.users import services as users_services
from . import filters as project_filters from . import filters as project_filters
@ -66,9 +66,9 @@ from . import services
###################################################### ######################################################
## Project ## Project
###################################################### ######################################################
class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin,
BlockeableSaveMixin, BlockeableDeleteMixin, ModelCrudViewSet):
class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, BlockeableSaveMixin, BlockeableDeleteMixin,
TagsColorsResourceMixin, ModelCrudViewSet):
queryset = models.Project.objects.all() queryset = models.Project.objects.all()
serializer_class = serializers.ProjectDetailSerializer serializer_class = serializers.ProjectDetailSerializer
admin_serializer_class = serializers.ProjectDetailAdminSerializer admin_serializer_class = serializers.ProjectDetailAdminSerializer
@ -327,12 +327,6 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin,
self.check_permissions(request, "issues_stats", project) self.check_permissions(request, "issues_stats", project)
return response.Ok(services.get_stats_for_project_issues(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"]) @detail_route(methods=["POST"])
def transfer_validate_token(self, request, pk=None): def transfer_validate_token(self, request, pk=None):
project = self.get_object() project = self.get_object()
@ -405,6 +399,10 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin,
services.reject_project_transfer(project, request.user, token, reason) services.reject_project_transfer(project, request.user, token, reason)
return response.Ok() 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): def _set_base_permissions(self, obj):
update_permissions = False update_permissions = False
if not obj.id: if not obj.id:

View File

@ -25,18 +25,16 @@ from django.db.models import signals
def connect_projects_signals(): def connect_projects_signals():
from . import signals as handlers from . import signals as handlers
from .tagging import signals as tagging_handlers
# On project object is created apply template. # On project object is created apply template.
signals.post_save.connect(handlers.project_post_save, signals.post_save.connect(handlers.project_post_save,
sender=apps.get_model("projects", "Project"), sender=apps.get_model("projects", "Project"),
dispatch_uid='project_post_save') dispatch_uid='project_post_save')
# Tags normalization after save a project # 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"), sender=apps.get_model("projects", "Project"),
dispatch_uid="tags_normalization_projects") 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(): def disconnect_projects_signals():
@ -44,8 +42,6 @@ def disconnect_projects_signals():
dispatch_uid='project_post_save') dispatch_uid='project_post_save')
signals.pre_save.disconnect(sender=apps.get_model("projects", "Project"), signals.pre_save.disconnect(sender=apps.get_model("projects", "Project"),
dispatch_uid="tags_normalization_projects") 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 ## Memberships Signals

View File

@ -27,11 +27,11 @@ from taiga.base.api import ModelCrudViewSet, ModelListViewSet
from taiga.base.api.mixins import BlockedByProjectMixin from taiga.base.api.mixins import BlockedByProjectMixin
from taiga.base.api.utils import get_object_or_404 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.notifications.mixins import WatchedResourceMixin, WatchersViewSetMixin
from taiga.projects.occ import OCCResourceMixin from taiga.projects.occ import OCCResourceMixin
from taiga.projects.history.mixins import HistoryResourceMixin from taiga.projects.tagging.api import TaggedResourceMixin
from taiga.projects.models import Project, IssueStatus, Severity, Priority, IssueType
from taiga.projects.votes.mixins.viewsets import VotedResourceMixin, VotersViewSetMixin from taiga.projects.votes.mixins.viewsets import VotedResourceMixin, VotersViewSetMixin
from . import models from . import models
@ -41,7 +41,7 @@ from . import serializers
class IssueViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, WatchedResourceMixin, class IssueViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, WatchedResourceMixin,
BlockedByProjectMixin, ModelCrudViewSet): TaggedResourceMixin, BlockedByProjectMixin, ModelCrudViewSet):
queryset = models.Issue.objects.all() queryset = models.Issue.objects.all()
permission_classes = (permissions.IssuePermission, ) permission_classes = (permissions.IssuePermission, )
filter_backends = (filters.CanViewIssuesFilterBackend, 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) 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) 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) 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() queryset = self.get_queryset()
querysets = { querysets = {

View File

@ -23,6 +23,7 @@ from django.db.models import signals
def connect_issues_signals(): def connect_issues_signals():
from taiga.projects import signals as generic_handlers from taiga.projects import signals as generic_handlers
from taiga.projects.tagging import signals as tagging_handlers
from . import signals as handlers from . import signals as handlers
# Finished date # Finished date
@ -31,15 +32,9 @@ def connect_issues_signals():
dispatch_uid="set_finished_date_when_edit_issue") dispatch_uid="set_finished_date_when_edit_issue")
# Tags # Tags
signals.pre_save.connect(generic_handlers.tags_normalization, signals.pre_save.connect(tagging_handlers.tags_normalization,
sender=apps.get_model("issues", "Issue"), sender=apps.get_model("issues", "Issue"),
dispatch_uid="tags_normalization_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(): def connect_issues_custom_attributes_signals():
@ -56,14 +51,15 @@ def connect_all_issues_signals():
def disconnect_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"),
signals.pre_save.disconnect(sender=apps.get_model("issues", "Issue"), dispatch_uid="tags_normalization_issue") dispatch_uid="set_finished_date_when_edit_issue")
signals.post_save.disconnect(sender=apps.get_model("issues", "Issue"), dispatch_uid="update_project_tags_when_create_or_edit_taggable_item_issue") signals.pre_save.disconnect(sender=apps.get_model("issues", "Issue"),
signals.post_delete.disconnect(sender=apps.get_model("issues", "Issue"), dispatch_uid="update_project_tags_when_delete_taggable_item_issue") dispatch_uid="tags_normalization_issue")
def disconnect_issues_custom_attributes_signals(): 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(): def disconnect_all_issues_signals():

View File

@ -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'),
),
]

View File

@ -18,19 +18,16 @@
from django.db import models from django.db import models
from django.contrib.contenttypes.fields import GenericRelation from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.postgres.fields import ArrayField
from django.conf import settings from django.conf import settings
from django.utils import timezone from django.utils import timezone
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from djorm_pgarray.fields import TextArrayField
from taiga.projects.occ import OCCModelMixin from taiga.projects.occ import OCCModelMixin
from taiga.projects.notifications.mixins import WatchedModelMixin from taiga.projects.notifications.mixins import WatchedModelMixin
from taiga.projects.mixins.blocked import BlockedMixin from taiga.projects.mixins.blocked import BlockedMixin
from taiga.base.tags import TaggedMixin from taiga.projects.tagging.models 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): class Issue(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, models.Model):
@ -65,7 +62,8 @@ class Issue(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, models.
default=None, related_name="issues_assigned_to_me", default=None, related_name="issues_assigned_to_me",
verbose_name=_("assigned to")) verbose_name=_("assigned to"))
attachments = GenericRelation("attachments.Attachment") 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 _importing = None
class Meta: class Meta:

View File

@ -17,15 +17,15 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from taiga.base.api import serializers from taiga.base.api import serializers
from taiga.base.fields import TagsField
from taiga.base.fields import PgArrayField from taiga.base.fields import PgArrayField
from taiga.base.neighbors import NeighborsSerializerMixin from taiga.base.neighbors import NeighborsSerializerMixin
from taiga.mdrender.service import render as mdrender from taiga.projects.notifications.mixins import EditableWatchedResourceModelSerializer
from taiga.projects.validators import ProjectExistsValidator
from taiga.projects.notifications.validators import WatchersValidator from taiga.projects.notifications.validators import WatchersValidator
from taiga.projects.serializers import BasicIssueStatusSerializer 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.projects.votes.mixins.serializers import VoteResourceSerializerMixin
from taiga.users.serializers import UserBasicInfoSerializer from taiga.users.serializers import UserBasicInfoSerializer
@ -33,8 +33,9 @@ from taiga.users.serializers import UserBasicInfoSerializer
from . import models from . import models
class IssueSerializer(WatchersValidator, VoteResourceSerializerMixin, EditableWatchedResourceModelSerializer, serializers.ModelSerializer): class IssueSerializer(WatchersValidator, VoteResourceSerializerMixin, EditableWatchedResourceModelSerializer,
tags = TagsField(required=False) serializers.ModelSerializer):
tags = TagsAndTagsColorsField(default=[], required=False)
external_reference = PgArrayField(required=False) external_reference = PgArrayField(required=False)
is_closed = serializers.Field(source="is_closed") is_closed = serializers.Field(source="is_closed")
comment = serializers.SerializerMethodField("get_comment") comment = serializers.SerializerMethodField("get_comment")
@ -71,7 +72,7 @@ class IssueListSerializer(IssueSerializer):
class Meta: class Meta:
model = models.Issue model = models.Issue
read_only_fields = ('id', 'ref', 'created_date', 'modified_date') read_only_fields = ('id', 'ref', 'created_date', 'modified_date')
exclude=("description", "description_html") exclude = ("description", "description_html")
class IssueNeighborsSerializer(NeighborsSerializerMixin, IssueSerializer): class IssueNeighborsSerializer(NeighborsSerializerMixin, IssueSerializer):

View File

@ -19,6 +19,7 @@
import random import random
import datetime import datetime
from os import path from os import path
from hashlib import sha1
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
@ -256,6 +257,13 @@ class Command(BaseCommand):
self.create_wiki_page(project, wiki_link.href) 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 # Set a value to total_story_points to show the deadline in the backlog
project_stats = get_stats_for_project(project) project_stats = get_stats_for_project(project)
defined_points = project_stats["defined_points"] defined_points = project_stats["defined_points"]
@ -264,7 +272,6 @@ class Command(BaseCommand):
self.create_likes(project) self.create_likes(project)
def create_attachment(self, obj, order): def create_attachment(self, obj, order):
attached_file = self.sd.file_from_directory(*ATTACHMENT_SAMPLE_DATA) attached_file = self.sd.file_from_directory(*ATTACHMENT_SAMPLE_DATA)
membership = self.sd.db_object_from_queryset(obj.project.memberships membership = self.sd.db_object_from_queryset(obj.project.memberships
@ -551,3 +558,8 @@ class Command(BaseCommand):
obj.add_watcher(user) obj.add_watcher(user)
else: else:
obj.add_watcher(user, notify_level) obj.add_watcher(user, notify_level)
def generate_color(self, tag):
color = sha1(tag.encode("utf-8")).hexdigest()[0:6]
return "#{}".format(color)

View File

@ -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();
"""
),
]

View File

@ -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'),
),
]

View File

@ -20,21 +20,22 @@ import itertools
import uuid import uuid
from django.conf import settings 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.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.db.models import signals, Q from django.db.models import signals, Q
from django.apps import apps from django.apps import apps
from django.conf import settings from django.conf import settings
from django.dispatch import receiver from django.dispatch import receiver
from django.contrib.auth import get_user_model
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.utils import timezone from django.utils import timezone
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django_pgjson.fields import JsonField 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.dicts import dict_sum
from taiga.base.utils.files import get_file_path from taiga.base.utils.files import get_file_path
from taiga.base.utils.sequence import arithmetic_progression from taiga.base.utils.sequence import arithmetic_progression
@ -141,7 +142,7 @@ class ProjectDefaults(models.Model):
abstract = True 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, name = models.CharField(max_length=250, null=False, blank=False,
verbose_name=_("name")) verbose_name=_("name"))
slug = models.SlugField(max_length=250, unique=True, null=False, blank=True, 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, blank=True, default=None,
verbose_name=_("creation template")) 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, is_private = models.BooleanField(default=True, null=False, blank=True,
verbose_name=_("is private")) 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, is_featured = models.BooleanField(default=False, null=False, blank=True,
verbose_name=_("is featured")) verbose_name=_("is featured"))
@ -214,9 +211,6 @@ class Project(ProjectDefaults, TaggedMixin, models.Model):
null=True, blank=True, default=None, null=True, blank=True, default=None,
db_index=True) 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, transfer_token = models.CharField(max_length=255, null=True, blank=True, default=None,
verbose_name=_("project transfer token")) verbose_name=_("project transfer token"))

View File

@ -78,6 +78,10 @@ class ProjectPermission(TaigaResourcePermission):
transfer_start_perms = IsObjectOwner() transfer_start_perms = IsObjectOwner()
transfer_reject_perms = IsAuthenticated() & HasProjectPerm('view_project') transfer_reject_perms = IsAuthenticated() & HasProjectPerm('view_project')
transfer_accept_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): class ProjectFansPermission(TaigaResourcePermission):

View File

@ -16,35 +16,33 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.db.models import Q from django.db.models import Q
from taiga.base.api import serializers from taiga.base.api import serializers
from taiga.base.fields import JsonField from taiga.base.fields import JsonField
from taiga.base.fields import PgArrayField 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.services import get_photo_or_gravatar_url
from taiga.users.serializers import UserSerializer
from taiga.users.serializers import UserBasicInfoSerializer from taiga.users.serializers import UserBasicInfoSerializer
from taiga.users.serializers import ProjectRoleSerializer from taiga.users.serializers import ProjectRoleSerializer
from taiga.users.validators import RoleExistsValidator from taiga.users.validators import RoleExistsValidator
from taiga.permissions.services import get_user_project_permissions from taiga.permissions.services import get_user_project_permissions
from taiga.permissions.services import is_project_admin, is_project_owner from taiga.permissions.services import is_project_admin, is_project_owner
from taiga.projects.mixins.serializers import ValidateDuplicatedNameInProjectMixin
from . import models from . import models
from . import services from . import services
from .notifications.mixins import WatchedResourceModelSerializer
from .validators import ProjectExistsValidator
from .custom_attributes.serializers import UserStoryCustomAttributeSerializer from .custom_attributes.serializers import UserStoryCustomAttributeSerializer
from .custom_attributes.serializers import TaskCustomAttributeSerializer from .custom_attributes.serializers import TaskCustomAttributeSerializer
from .custom_attributes.serializers import IssueCustomAttributeSerializer from .custom_attributes.serializers import IssueCustomAttributeSerializer
from .likes.mixins.serializers import FanResourceSerializerMixin 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
###################################################### ######################################################
@ -256,7 +254,7 @@ class ProjectSerializer(FanResourceSerializerMixin, WatchedResourceModelSerializ
i_am_member = serializers.SerializerMethodField("get_i_am_member") i_am_member = serializers.SerializerMethodField("get_i_am_member")
tags = TagsField(default=[], required=False) 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") notify_level = serializers.SerializerMethodField("get_notify_level")
total_closed_milestones = serializers.SerializerMethodField("get_total_closed_milestones") total_closed_milestones = serializers.SerializerMethodField("get_total_closed_milestones")

View File

@ -55,7 +55,5 @@ from .stats import get_stats_for_project_issues
from .stats import get_stats_for_project from .stats import get_stats_for_project
from .stats import get_member_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 request_project_transfer, start_project_transfer
from .transfer import accept_project_transfer, reject_project_transfer from .transfer import accept_project_transfer, reject_project_transfer

View File

@ -1,62 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright (C) 2014-2016 Andrey Antukh <niwi@niwi.nz>
# Copyright (C) 2014-2016 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014-2016 David Barragán <bameda@dbarragan.com>
# Copyright (C) 2014-2016 Alejandro Alonso <alejandro.alonso@kaleidos.net>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.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()

View File

@ -19,7 +19,6 @@
from django.apps import apps from django.apps import apps
from django.conf import settings 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.projects.notifications.services import create_notify_policy_if_not_exists
from taiga.base.utils.db import get_typename_for_model_class from taiga.base.utils.db import get_typename_for_model_class
@ -30,20 +29,7 @@ from easy_thumbnails.files import get_thumbnailer
# Signals over project items # Signals over project items
#################################### ####################################
## TAGS ## Membership
def tags_normalization(sender, instance, **kwargs):
if isinstance(instance.tags, (list, tuple)):
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()
def membership_post_delete(sender, instance, using, **kwargs): def membership_post_delete(sender, instance, using, **kwargs):
instance.project.update_role_points() instance.project.update_role_points()

View File

View File

@ -0,0 +1,125 @@
# -*- coding: utf-8 -*-
# Copyright (C) 2014-2016 Andrey Antukh <niwi@niwi.nz>
# Copyright (C) 2014-2016 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014-2016 David Barragán <bameda@dbarragan.com>
# Copyright (C) 2014-2016 Alejandro Alonso <alejandro.alonso@kaleidos.net>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from taiga.base import response
from taiga.base.decorators import detail_route
from taiga.base.utils.collections import OrderedSet
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 = OrderedSet()
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)

View File

@ -0,0 +1,99 @@
# -*- coding: utf-8 -*-
# Copyright (C) 2014-2016 Andrey Antukh <niwi@niwi.nz>
# Copyright (C) 2014-2016 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014-2016 David Barragán <bameda@dbarragan.com>
# Copyright (C) 2014-2016 Alejandro Alonso <alejandro.alonso@kaleidos.net>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.core.exceptions import ValidationError
from django.forms import widgets
from django.utils.translation import ugettext_lazy as _
from taiga.base.api import serializers
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())

View File

@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
# Copyright (C) 2014-2016 Andrey Antukh <niwi@niwi.nz>
# Copyright (C) 2014-2016 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014-2016 David Barragán <bameda@dbarragan.com>
# Copyright (C) 2014-2016 Alejandro Alonso <alejandro.alonso@kaleidos.net>
# Copyright (C) 2014-2016 Anler Hernández <hello@anler.me>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.db import models
from django.contrib.postgres.fields import ArrayField
from django.utils.translation import ugettext_lazy as _
class TaggedMixin(models.Model):
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

View File

@ -0,0 +1,112 @@
# -*- coding: utf-8 -*-
# Copyright (C) 2014-2016 Andrey Antukh <niwi@niwi.nz>
# Copyright (C) 2014-2016 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014-2016 David Barragán <bameda@dbarragan.com>
# Copyright (C) 2014-2016 Alejandro Alonso <alejandro.alonso@kaleidos.net>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.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

View File

@ -0,0 +1,122 @@
# -*- coding: utf-8 -*-
# Copyright (C) 2014-2016 Andrey Antukh <niwi@niwi.nz>
# Copyright (C) 2014-2016 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014-2016 David Barragán <bameda@dbarragan.com>
# Copyright (C) 2014-2016 Alejandro Alonso <alejandro.alonso@kaleidos.net>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.db import connection
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(update_fields=["tags_colors"])
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(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};
"""
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.pop(from_tag)
tags_colors[to_tag] = color
project.tags_colors = list(tags_colors.items())
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};
"""
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(update_fields=["tags_colors"])
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, color)

View File

@ -3,7 +3,6 @@
# Copyright (C) 2014-2016 Jesús Espino <jespinog@gmail.com> # Copyright (C) 2014-2016 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014-2016 David Barragán <bameda@dbarragan.com> # Copyright (C) 2014-2016 David Barragán <bameda@dbarragan.com>
# Copyright (C) 2014-2016 Alejandro Alonso <alejandro.alonso@kaleidos.net> # Copyright (C) 2014-2016 Alejandro Alonso <alejandro.alonso@kaleidos.net>
# Copyright (C) 2014-2016 Anler Hernández <hello@anler.me>
# This program is free software: you can redistribute it and/or modify # This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as # it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the # published by the Free Software Foundation, either version 3 of the
@ -17,14 +16,7 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.db import models
from django.utils.translation import ugettext_lazy as _
from djorm_pgarray.fields import TextArrayField def tags_normalization(sender, instance, **kwargs):
if isinstance(instance.tags, (list, tuple)):
instance.tags = list(map(str.lower, instance.tags))
class TaggedMixin(models.Model):
tags = TextArrayField(default=None, verbose_name=_("tags"))
class Meta:
abstract = True

View File

@ -16,6 +16,7 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.http import HttpResponse
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from taiga.base.api.utils import get_object_or_404 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.decorators import list_route
from taiga.base.api import ModelCrudViewSet, ModelListViewSet from taiga.base.api import ModelCrudViewSet, ModelListViewSet
from taiga.base.api.mixins import BlockedByProjectMixin from taiga.base.api.mixins import BlockedByProjectMixin
from taiga.projects.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.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.occ import OCCResourceMixin
from taiga.projects.tagging.api import TaggedResourceMixin
from taiga.projects.votes.mixins.viewsets import VotedResourceMixin, VotersViewSetMixin from taiga.projects.votes.mixins.viewsets import VotedResourceMixin, VotersViewSetMixin
from . import models from . import models
from . import permissions from . import permissions
from . import serializers from . import serializers
@ -40,13 +39,18 @@ from . import services
class TaskViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, WatchedResourceMixin, class TaskViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, WatchedResourceMixin,
BlockedByProjectMixin, ModelCrudViewSet): TaggedResourceMixin, BlockedByProjectMixin, ModelCrudViewSet):
queryset = models.Task.objects.all() queryset = models.Task.objects.all()
permission_classes = (permissions.TaskPermission,) permission_classes = (permissions.TaskPermission,)
filter_backends = (filters.CanViewTasksFilterBackend, filters.WatchersFilter) filter_backends = (filters.CanViewTasksFilterBackend, filters.WatchersFilter)
retrieve_exclude_filters = (filters.WatchersFilter,) retrieve_exclude_filters = (filters.WatchersFilter,)
filter_fields = ["user_story", "milestone", "project", "assigned_to", filter_fields = [
"status__is_closed"] "user_story",
"milestone",
"project",
"assigned_to",
"status__is_closed"
]
def get_serializer_class(self, *args, **kwargs): def get_serializer_class(self, *args, **kwargs):
if self.action in ["retrieve", "by_ref"]: if self.action in ["retrieve", "by_ref"]:

View File

@ -23,21 +23,18 @@ from django.db.models import signals
def connect_tasks_signals(): def connect_tasks_signals():
from taiga.projects import signals as generic_handlers from taiga.projects import signals as generic_handlers
from taiga.projects.tagging import signals as tagging_handlers
from . import signals as handlers from . import signals as handlers
# Finished date # Finished date
signals.pre_save.connect(handlers.set_finished_date_when_edit_task, signals.pre_save.connect(handlers.set_finished_date_when_edit_task,
sender=apps.get_model("tasks", "Task"), sender=apps.get_model("tasks", "Task"),
dispatch_uid="set_finished_date_when_edit_task") dispatch_uid="set_finished_date_when_edit_task")
# Tags # Tags
signals.pre_save.connect(generic_handlers.tags_normalization, signals.pre_save.connect(tagging_handlers.tags_normalization,
sender=apps.get_model("tasks", "Task"), sender=apps.get_model("tasks", "Task"),
dispatch_uid="tags_normalization_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(): def connect_tasks_close_or_open_us_and_milestone_signals():
from . import signals as handlers from . import signals as handlers
@ -67,19 +64,24 @@ def connect_all_tasks_signals():
def disconnect_tasks_signals(): def disconnect_tasks_signals():
signals.pre_save.disconnect(sender=apps.get_model("tasks", "Task"), dispatch_uid="tags_normalization") signals.pre_save.disconnect(sender=apps.get_model("tasks", "Task"),
signals.post_save.disconnect(sender=apps.get_model("tasks", "Task"), dispatch_uid="update_project_tags_when_create_or_edit_tagglabe_item") dispatch_uid="set_finished_date_when_edit_task")
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="tags_normalization")
def disconnect_tasks_close_or_open_us_and_milestone_signals(): 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.pre_save.disconnect(sender=apps.get_model("tasks", "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") dispatch_uid="cached_prev_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.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(): 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(): def disconnect_all_tasks_signals():

View File

@ -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'),
),
]

View File

@ -18,16 +18,15 @@
from django.db import models from django.db import models
from django.contrib.contenttypes.fields import GenericRelation from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.postgres.fields import ArrayField
from django.conf import settings from django.conf import settings
from django.utils import timezone from django.utils import timezone
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from djorm_pgarray.fields import TextArrayField
from taiga.projects.occ import OCCModelMixin from taiga.projects.occ import OCCModelMixin
from taiga.projects.notifications.mixins import WatchedModelMixin from taiga.projects.notifications.mixins import WatchedModelMixin
from taiga.projects.mixins.blocked import BlockedMixin 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): class Task(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, models.Model):
@ -66,7 +65,8 @@ class Task(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, models.M
attachments = GenericRelation("attachments.Attachment") attachments = GenericRelation("attachments.Attachment")
is_iocaine = models.BooleanField(default=False, null=False, blank=True, is_iocaine = models.BooleanField(default=False, null=False, blank=True,
verbose_name=_("is iocaine")) 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 _importing = None
class Meta: class Meta:

View File

@ -17,19 +17,18 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from taiga.base.api import serializers from taiga.base.api import serializers
from taiga.base.fields import TagsField
from taiga.base.fields import PgArrayField from taiga.base.fields import PgArrayField
from taiga.base.neighbors import NeighborsSerializerMixin from taiga.base.neighbors import NeighborsSerializerMixin
from taiga.mdrender.service import render as mdrender
from taiga.projects.validators import ProjectExistsValidator
from taiga.projects.milestones.validators import SprintExistsValidator 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.notifications.validators import WatchersValidator
from taiga.projects.serializers import BasicTaskStatusSerializerSerializer 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.projects.votes.mixins.serializers import VoteResourceSerializerMixin
from taiga.users.serializers import UserBasicInfoSerializer from taiga.users.serializers import UserBasicInfoSerializer
@ -37,14 +36,15 @@ from taiga.users.serializers import UserBasicInfoSerializer
from . import models from . import models
class TaskSerializer(WatchersValidator, VoteResourceSerializerMixin, EditableWatchedResourceModelSerializer, serializers.ModelSerializer): class TaskSerializer(WatchersValidator, VoteResourceSerializerMixin, EditableWatchedResourceModelSerializer,
tags = TagsField(required=False, default=[]) serializers.ModelSerializer):
tags = TagsAndTagsColorsField(default=[], required=False)
external_reference = PgArrayField(required=False) external_reference = PgArrayField(required=False)
comment = serializers.SerializerMethodField("get_comment") comment = serializers.SerializerMethodField("get_comment")
milestone_slug = serializers.SerializerMethodField("get_milestone_slug") milestone_slug = serializers.SerializerMethodField("get_milestone_slug")
blocked_note_html = serializers.SerializerMethodField("get_blocked_note_html") blocked_note_html = serializers.SerializerMethodField("get_blocked_note_html")
description_html = serializers.SerializerMethodField("get_description_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) status_extra_info = BasicTaskStatusSerializerSerializer(source="status", required=False, read_only=True)
assigned_to_extra_info = UserBasicInfoSerializer(source="assigned_to", 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) owner_extra_info = UserBasicInfoSerializer(source="owner", required=False, read_only=True)
@ -76,7 +76,7 @@ class TaskListSerializer(TaskSerializer):
class Meta: class Meta:
model = models.Task model = models.Task
read_only_fields = ('id', 'ref', 'created_date', 'modified_date') read_only_fields = ('id', 'ref', 'created_date', 'modified_date')
exclude=("description", "description_html") exclude = ("description", "description_html")
class TaskNeighborsSerializer(NeighborsSerializerMixin, TaskSerializer): class TaskNeighborsSerializer(NeighborsSerializerMixin, TaskSerializer):
@ -101,6 +101,7 @@ class TasksBulkSerializer(ProjectExistsValidator, SprintExistsValidator,
us_id = serializers.IntegerField(required=False) us_id = serializers.IntegerField(required=False)
bulk_tasks = serializers.CharField() bulk_tasks = serializers.CharField()
## Order bulk serializers ## Order bulk serializers
class _TaskOrderBulkSerializer(TaskExistsValidator, serializers.Serializer): class _TaskOrderBulkSerializer(TaskExistsValidator, serializers.Serializer):

View File

@ -19,7 +19,6 @@
from django.apps import apps from django.apps import apps
from django.db import transaction from django.db import transaction
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.core.exceptions import ObjectDoesNotExist
from django.http import HttpResponse from django.http import HttpResponse
from taiga.base import filters 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 import ModelCrudViewSet, ModelListViewSet
from taiga.base.api.utils import get_object_or_404 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.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.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.api import TaggedResourceMixin
from taiga.projects.votes.mixins.viewsets import VotedResourceMixin, VotersViewSetMixin from taiga.projects.votes.mixins.viewsets import VotedResourceMixin, VotersViewSetMixin
from . import models from . import models
@ -46,7 +46,7 @@ from . import services
class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, WatchedResourceMixin, class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, WatchedResourceMixin,
BlockedByProjectMixin, ModelCrudViewSet): TaggedResourceMixin, BlockedByProjectMixin, ModelCrudViewSet):
queryset = models.UserStory.objects.all() queryset = models.UserStory.objects.all()
permission_classes = (permissions.UserStoryPermission,) permission_classes = (permissions.UserStoryPermission,)
filter_backends = (filters.CanViewUsFilterBackend, filter_backends = (filters.CanViewUsFilterBackend,
@ -113,8 +113,9 @@ class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixi
def pre_save(self, obj): def pre_save(self, obj):
# This is very ugly hack, but having # This is very ugly hack, but having
# restframework is the only way to do it. # restframework is the only way to do it.
#
# NOTE: code moved as is from serializer # 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", {}) related_data = getattr(obj, "_related_data", {})
self._role_points = related_data.pop("role_points", None) self._role_points = related_data.pop("role_points", None)
@ -124,7 +125,8 @@ class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixi
super().pre_save(obj) super().pre_save(obj)
def post_save(self, obj, created=False): 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: if self._role_points:
Points = apps.get_model("projects", "Points") Points = apps.get_model("projects", "Points")
RolePoints = apps.get_model("userstories", "RolePoints") 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_points = RolePoints.objects.get(role__id=role_id, user_story_id=obj.pk,
role__computable=True) role__computable=True)
except (ValueError, RolePoints.DoesNotExist): except (ValueError, RolePoints.DoesNotExist):
raise exc.BadRequest({"points": _("Invalid role id '{role_id}'").format( raise exc.BadRequest({
role_id=role_id)}) "points": _("Invalid role id '{role_id}'").format(role_id=role_id)
})
try: try:
role_points.points = Points.objects.get(id=points_id, project_id=obj.project_id) role_points.points = Points.objects.get(id=points_id, project_id=obj.project_id)
except (ValueError, Points.DoesNotExist): except (ValueError, Points.DoesNotExist):
raise exc.BadRequest({"points": _("Invalid points id '{points_id}'").format( raise exc.BadRequest({
points_id=points_id)}) "points": _("Invalid points id '{points_id}'").format(points_id=points_id)
})
role_points.save() 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) 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) 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) 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() queryset = self.get_queryset()
querysets = { querysets = {

View File

@ -23,6 +23,7 @@ from django.db.models import signals
def connect_userstories_signals(): def connect_userstories_signals():
from taiga.projects import signals as generic_handlers from taiga.projects import signals as generic_handlers
from taiga.projects.tagging import signals as tagging_handlers
from . import signals as handlers from . import signals as handlers
# When deleting user stories we must disable task signals while delating and # When deleting user stories we must disable task signals while delating and
@ -59,15 +60,9 @@ def connect_userstories_signals():
dispatch_uid="try_to_close_milestone_when_delete_us") dispatch_uid="try_to_close_milestone_when_delete_us")
# Tags # Tags
signals.pre_save.connect(generic_handlers.tags_normalization, signals.pre_save.connect(tagging_handlers.tags_normalization,
sender=apps.get_model("userstories", "UserStory"), sender=apps.get_model("userstories", "UserStory"),
dispatch_uid="tags_normalization_user_story") 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(): def connect_userstories_custom_attributes_signals():
@ -83,18 +78,27 @@ def connect_all_userstories_signals():
def disconnect_userstories_signals(): def disconnect_userstories_signals():
signals.pre_save.disconnect(sender=apps.get_model("userstories", "UserStory"), dispatch_uid="cached_prev_us") signals.pre_save.disconnect(sender=apps.get_model("userstories", "UserStory"),
signals.post_save.disconnect(sender=apps.get_model("userstories", "UserStory"), dispatch_uid="update_role_points_when_create_or_edit_us") dispatch_uid="cached_prev_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_save.disconnect(sender=apps.get_model("userstories", "UserStory"),
signals.post_delete.disconnect(sender=apps.get_model("userstories", "UserStory"), dispatch_uid="try_to_close_milestone_when_delete_us") dispatch_uid="update_role_points_when_create_or_edit_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_save.disconnect(sender=apps.get_model("userstories", "UserStory"),
signals.post_delete.disconnect(sender=apps.get_model("userstories", "UserStory"), dispatch_uid="update_project_tags_when_delete_taggable_item_user_story") 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(): 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(): def disconnect_all_userstories_signals():

View File

@ -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'),
),
]

View File

@ -18,14 +18,14 @@
from django.db import models from django.db import models
from django.contrib.contenttypes.fields import GenericRelation from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.postgres.fields import ArrayField
from django.conf import settings from django.conf import settings
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.utils import timezone from django.utils import timezone
from djorm_pgarray.fields import TextArrayField
from picklefield.fields import PickledObjectField 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.occ import OCCModelMixin
from taiga.projects.notifications.mixins import WatchedModelMixin from taiga.projects.notifications.mixins import WatchedModelMixin
from taiga.projects.mixins.blocked import BlockedMixin from taiga.projects.mixins.blocked import BlockedMixin
@ -103,7 +103,8 @@ class UserStory(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, mod
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
related_name="generated_user_stories", related_name="generated_user_stories",
verbose_name=_("generated from issue")) 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, tribe_gig = PickledObjectField(null=True, blank=True, default=None,
verbose_name="taiga tribe gig") verbose_name="taiga tribe gig")

View File

@ -16,23 +16,22 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.apps import apps
from taiga.base.api import serializers from taiga.base.api import serializers
from taiga.base.api.utils import get_object_or_404 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 PickledObjectField
from taiga.base.fields import PgArrayField from taiga.base.fields import PgArrayField
from taiga.base.neighbors import NeighborsSerializerMixin from taiga.base.neighbors import NeighborsSerializerMixin
from taiga.base.utils import json 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.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.notifications.validators import WatchersValidator
from taiga.projects.serializers import BasicUserStoryStatusSerializer
from taiga.projects.notifications.mixins import EditableWatchedResourceModelSerializer 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.projects.votes.mixins.serializers import VoteResourceSerializerMixin
from taiga.users.serializers import UserBasicInfoSerializer from taiga.users.serializers import UserBasicInfoSerializer
@ -50,9 +49,9 @@ class RolePointsField(serializers.WritableField):
return json.loads(obj) return json.loads(obj)
class UserStorySerializer(WatchersValidator, VoteResourceSerializerMixin, EditableWatchedResourceModelSerializer, class UserStorySerializer(WatchersValidator, VoteResourceSerializerMixin,
serializers.ModelSerializer): EditableWatchedResourceModelSerializer, serializers.ModelSerializer):
tags = TagsField(default=[], required=False) tags = TagsAndTagsColorsField(default=[], required=False)
external_reference = PgArrayField(required=False) external_reference = PgArrayField(required=False)
points = RolePointsField(source="role_points", required=False) points = RolePointsField(source="role_points", required=False)
total_points = serializers.SerializerMethodField("get_total_points") total_points = serializers.SerializerMethodField("get_total_points")
@ -112,7 +111,7 @@ class UserStoryListSerializer(UserStorySerializer):
model = models.UserStory model = models.UserStory
depth = 0 depth = 0
read_only_fields = ('created_date', 'modified_date') read_only_fields = ('created_date', 'modified_date')
exclude=("description", "description_html") exclude = ("description", "description_html")
class UserStoryNeighborsSerializer(NeighborsSerializerMixin, UserStorySerializer): class UserStoryNeighborsSerializer(NeighborsSerializerMixin, UserStorySerializer):
@ -142,7 +141,8 @@ class _UserStoryOrderBulkSerializer(UserStoryExistsValidator, serializers.Serial
order = serializers.IntegerField() order = serializers.IntegerField()
class UpdateUserStoriesOrderBulkSerializer(ProjectExistsValidator, UserStoryStatusExistsValidator, serializers.Serializer): class UpdateUserStoriesOrderBulkSerializer(ProjectExistsValidator, UserStoryStatusExistsValidator,
serializers.Serializer):
project_id = serializers.IntegerField() project_id = serializers.IntegerField()
bulk_stories = _UserStoryOrderBulkSerializer(many=True) bulk_stories = _UserStoryOrderBulkSerializer(many=True)

View File

@ -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'),
),
]

View File

@ -26,6 +26,7 @@ from django.apps.config import MODELS_MODULE_NAME
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import UserManager, AbstractBaseUser from django.contrib.auth.models import UserManager, AbstractBaseUser
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import ArrayField
from django.core import validators from django.core import validators
from django.core.exceptions import AppRegistryNotReady from django.core.exceptions import AppRegistryNotReady
from django.db import models from django.db import models
@ -34,7 +35,6 @@ from django.utils import timezone
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django_pgjson.fields import JsonField from django_pgjson.fields import JsonField
from djorm_pgarray.fields import TextArrayField
from taiga.auth.tokens import get_token_for_user from taiga.auth.tokens import get_token_for_user
from taiga.base.utils.slug import slugify_uniquely from taiga.base.utils.slug import slugify_uniquely
@ -53,8 +53,8 @@ def get_user_model_safe():
registry not being ready yet. registry not being ready yet.
Raises LookupError if model isn't found. 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 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 Ongoing Django issue: https://code.djangoproject.com/ticket/22872
""" """
user_app, user_model = settings.AUTH_USER_MODEL.split('.') user_app, user_model = settings.AUTH_USER_MODEL.split('.')
@ -293,10 +293,8 @@ class Role(models.Model):
verbose_name=_("name")) verbose_name=_("name"))
slug = models.SlugField(max_length=250, null=False, blank=True, slug = models.SlugField(max_length=250, null=False, blank=True,
verbose_name=_("slug")) verbose_name=_("slug"))
permissions = TextArrayField(blank=True, null=True, permissions = ArrayField(models.TextField(null=False, blank=False, choices=MEMBERS_PERMISSIONS),
default=[], null=True, blank=True, default=[], verbose_name=_("permissions"))
verbose_name=_("permissions"),
choices=MEMBERS_PERMISSIONS)
order = models.IntegerField(default=10, null=False, blank=False, order = models.IntegerField(default=10, null=False, blank=False,
verbose_name=_("order")) verbose_name=_("order"))
# null=True is for make work django 1.7 migrations. project # null=True is for make work django 1.7 migrations. project

View File

@ -19,7 +19,7 @@
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from taiga.base.api import serializers 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 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.notifications.mixins import EditableWatchedResourceModelSerializer
from taiga.projects.services import get_logo_big_thumbnail_url from taiga.projects.services import get_logo_big_thumbnail_url
from taiga.projects.tasks import models as task_models 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.userstories import models as us_models
from taiga.projects.wiki import models as wiki_models from taiga.projects.wiki import models as wiki_models

View File

@ -27,20 +27,24 @@ def data():
m.public_project = f.ProjectFactory(is_private=False, m.public_project = f.ProjectFactory(is_private=False,
anon_permissions=['view_project'], anon_permissions=['view_project'],
public_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, m.private_project1 = f.ProjectFactory(is_private=True,
anon_permissions=['view_project'], anon_permissions=['view_project'],
public_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, m.private_project2 = f.ProjectFactory(is_private=True,
anon_permissions=[], anon_permissions=[],
public_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, m.blocked_project = f.ProjectFactory(is_private=True,
anon_permissions=[], anon_permissions=[],
public_permissions=[], public_permissions=[],
owner=m.project_owner, 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, m.public_membership = f.MembershipFactory(project=m.public_project,
user=m.project_member_with_perms, 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) results = helper_test_http_method(client, 'patch', url, '{"name": "Test"}', users)
assert results == [401, 403, 200] 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]

View File

@ -68,7 +68,7 @@ def test_valid_project_import_without_extra_data(client):
} }
response = client.json.post(url, json.dumps(data)) response = client.json.post(url, json.dumps(data))
assert response.status_code == 201 assert response.status_code == 201, response.data
must_empty_children = [ must_empty_children = [
"issues", "user_stories", "us_statuses", "wiki_pages", "priorities", "issues", "user_stories", "us_statuses", "wiki_pages", "priorities",
"severities", "milestones", "points", "issue_types", "task_statuses", "severities", "milestones", "points", "issue_types", "task_statuses",

View File

@ -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"

View File

@ -10,6 +10,9 @@ from taiga.projects.services import stats as stats_services
from taiga.projects.history.services import take_snapshot from taiga.projects.history.services import take_snapshot
from taiga.permissions.choices import ANON_PERMISSIONS from taiga.permissions.choices import ANON_PERMISSIONS
from taiga.projects.models import Project 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 taiga.projects.choices import BLOCKED_BY_DELETING
from .. import factories as f from .. import factories as f
@ -1852,3 +1855,209 @@ def test_delete_project_with_celery_disabled(client, settings):
response = client.json.delete(url) response = client.json.delete(url)
assert response.status_code == 204 assert response.status_code == 204
assert Project.objects.filter(id=project.id).count() == 0 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)
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"]]

View File

@ -67,19 +67,6 @@ def test_create_task_without_default_values(client):
assert response.data['status'] == None 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): def test_api_create_in_bulk_with_status(client):
us = f.create_userstory() us = f.create_userstory()
f.MembershipFactory.create(project=us.project, user=us.owner, is_admin=True) f.MembershipFactory.create(project=us.project, user=us.owner, is_admin=True)

View File

@ -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"

View File

@ -481,7 +481,7 @@ def test_get_watched_list_valid_info_for_project():
fav_user = f.UserFactory() fav_user = f.UserFactory()
viewer_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"]) role = f.RoleFactory(project=project, permissions=["view_project", "view_us", "view_tasks", "view_issues"])
project.add_watcher(fav_user) 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["assigned_to"] == None
assert project_watch_info["status"] == None assert project_watch_info["status"] == None
assert project_watch_info["status_color"] == 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["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["logo_small_url"] == get_thumbnail_url(project.logo, settings.THN_LOGO_SMALL)
assert project_watch_info["is_fan"] == False assert project_watch_info["is_fan"] == False
@ -540,7 +535,7 @@ def test_get_liked_list_valid_info():
fan_user = f.UserFactory() fan_user = f.UserFactory()
viewer_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) content_type = ContentType.objects.get_for_model(project)
like = f.LikeFactory(content_type=content_type, object_id=project.id, user=fan_user) like = f.LikeFactory(content_type=content_type, object_id=project.id, user=fan_user)
project.refresh_totals() 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["assigned_to"] == None
assert project_like_info["status"] == None assert project_like_info["status"] == None
assert project_like_info["status_color"] == 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["is_private"] == project.is_private
assert project_like_info["logo_small_url"] == get_thumbnail_url(project.logo, settings.THN_LOGO_SMALL) assert project_like_info["logo_small_url"] == get_thumbnail_url(project.logo, settings.THN_LOGO_SMALL)

View File

@ -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"

View File

@ -1,26 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright (C) 2014-2016 Andrey Antukh <niwi@niwi.nz>
# Copyright (C) 2014-2016 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014-2016 David Barragán <bameda@dbarragan.com>
# Copyright (C) 2014-2016 Alejandro Alonso <alejandro.alonso@kaleidos.net>
# Copyright (C) 2014-2016 Anler Hernández <hello@anler.me>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.db import models
from taiga.base import tags
class TaggedModel(tags.TaggedMixin, models.Model):
class Meta:
app_label = "tests"