From 0f9ae58fb20c6391fcfa81e42855bc89aaae6098 Mon Sep 17 00:00:00 2001
From: Chris Wilson
Date: Mon, 8 Jun 2015 16:22:15 +0100
Subject: [PATCH 001/190] Adding field_type to custom attributes model, with
associated migration
---
.../0006_add_customattribute_field_type.py | 33 +++++++++++++++++++
taiga/projects/custom_attributes/models.py | 6 ++++
2 files changed, 39 insertions(+)
create mode 100644 taiga/projects/custom_attributes/migrations/0006_add_customattribute_field_type.py
diff --git a/taiga/projects/custom_attributes/migrations/0006_add_customattribute_field_type.py b/taiga/projects/custom_attributes/migrations/0006_add_customattribute_field_type.py
new file mode 100644
index 00000000..ff3a3844
--- /dev/null
+++ b/taiga/projects/custom_attributes/migrations/0006_add_customattribute_field_type.py
@@ -0,0 +1,33 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('custom_attributes', '0005_auto_20150505_1639'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='issuecustomattribute',
+ name='field_type',
+ field=models.CharField(default='TEXT', max_length=5, verbose_name='attribute field type'),
+ preserve_default=True,
+ ),
+ migrations.AddField(
+ model_name='taskcustomattribute',
+ name='field_type',
+ field=models.CharField(default='TEXT', max_length=5, verbose_name='attribute field type'),
+ preserve_default=True,
+ ),
+ migrations.AddField(
+ model_name='userstorycustomattribute',
+ name='field_type',
+ field=models.CharField(default='TEXT', max_length=5, verbose_name='attribute field type'),
+ preserve_default=True,
+ ),
+
+ ]
diff --git a/taiga/projects/custom_attributes/models.py b/taiga/projects/custom_attributes/models.py
index 6f82244d..bde84bc0 100644
--- a/taiga/projects/custom_attributes/models.py
+++ b/taiga/projects/custom_attributes/models.py
@@ -27,9 +27,15 @@ from taiga.projects.occ.mixins import OCCModelMixin
# Custom Attribute Models
#######################################################
+
class AbstractCustomAttribute(models.Model):
+ FIELD_TYPES = (
+ ('TEXT', 'Text'),
+ ('MULTI', 'Multi-Line Text')
+ )
name = models.CharField(null=False, blank=False, max_length=64, verbose_name=_("name"))
description = models.TextField(null=False, blank=True, verbose_name=_("description"))
+ field_type = models.CharField(null=False, blank=False, choices=FIELD_TYPES, max_length=5, verbose_name=_("type"))
order = models.IntegerField(null=False, blank=False, default=10000, verbose_name=_("order"))
project = models.ForeignKey("projects.Project", null=False, blank=False, related_name="%(class)ss",
verbose_name=_("project"))
From 85b24322af8783ef2c1fdfac815e08552c997669 Mon Sep 17 00:00:00 2001
From: Chris Wilson
Date: Thu, 18 Jun 2015 16:10:26 +0100
Subject: [PATCH 002/190] Add a default value of TEXT to the new field_type
attribute on the custom attribute model
---
taiga/projects/custom_attributes/models.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/taiga/projects/custom_attributes/models.py b/taiga/projects/custom_attributes/models.py
index bde84bc0..b9ea807d 100644
--- a/taiga/projects/custom_attributes/models.py
+++ b/taiga/projects/custom_attributes/models.py
@@ -35,7 +35,7 @@ class AbstractCustomAttribute(models.Model):
)
name = models.CharField(null=False, blank=False, max_length=64, verbose_name=_("name"))
description = models.TextField(null=False, blank=True, verbose_name=_("description"))
- field_type = models.CharField(null=False, blank=False, choices=FIELD_TYPES, max_length=5, verbose_name=_("type"))
+ field_type = models.CharField(null=False, blank=False, choices=FIELD_TYPES, max_length=5, default='TEXT', verbose_name=_("type"))
order = models.IntegerField(null=False, blank=False, default=10000, verbose_name=_("order"))
project = models.ForeignKey("projects.Project", null=False, blank=False, related_name="%(class)ss",
verbose_name=_("project"))
From debf6417675167ce4a7f1f680a9429bdcbb205fc Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?=
Date: Thu, 18 Jun 2015 17:45:07 +0200
Subject: [PATCH 003/190] Make minor improvements over Membership and Project
serializers
---
taiga/projects/issues/serializers.py | 2 +-
taiga/projects/serializers.py | 19 ++++++++-----------
taiga/projects/tasks/serializers.py | 2 +-
taiga/projects/userstories/serializers.py | 2 +-
taiga/users/serializers.py | 3 ++-
5 files changed, 13 insertions(+), 15 deletions(-)
diff --git a/taiga/projects/issues/serializers.py b/taiga/projects/issues/serializers.py
index fc43a8f8..dd0d4ef5 100644
--- a/taiga/projects/issues/serializers.py
+++ b/taiga/projects/issues/serializers.py
@@ -24,7 +24,7 @@ from taiga.mdrender.service import render as mdrender
from taiga.projects.validators import ProjectExistsValidator
from taiga.projects.notifications.validators import WatchersValidator
from taiga.projects.serializers import BasicIssueStatusSerializer
-from taiga.users.serializers import BasicInfoSerializer as UserBasicInfoSerializer
+from taiga.users.serializers import UserBasicInfoSerializer
from . import models
diff --git a/taiga/projects/serializers.py b/taiga/projects/serializers.py
index 5aa7c019..5ca3e7f4 100644
--- a/taiga/projects/serializers.py
+++ b/taiga/projects/serializers.py
@@ -27,6 +27,7 @@ from taiga.base.fields import TagsColorsField
from taiga.users.services import get_photo_or_gravatar_url
from taiga.users.serializers import UserSerializer
+from taiga.users.serializers import UserBasicInfoSerializer
from taiga.users.serializers import ProjectRoleSerializer
from taiga.users.validators import RoleExistsValidator
@@ -197,7 +198,7 @@ class MembershipSerializer(serializers.ModelSerializer):
photo = serializers.SerializerMethodField("get_photo")
project_name = serializers.SerializerMethodField("get_project_name")
project_slug = serializers.SerializerMethodField("get_project_slug")
- invited_by = UserSerializer(read_only=True)
+ invited_by = UserBasicInfoSerializer(read_only=True)
class Meta:
model = models.Membership
@@ -346,11 +347,11 @@ class ProjectSerializer(serializers.ModelSerializer):
class ProjectDetailSerializer(ProjectSerializer):
- roles = serializers.SerializerMethodField("get_roles")
- memberships = serializers.SerializerMethodField("get_memberships")
us_statuses = UserStoryStatusSerializer(many=True, required=False) # User Stories
points = PointsSerializer(many=True, required=False)
+
task_statuses = TaskStatusSerializer(many=True, required=False) # Tasks
+
issue_statuses = IssueStatusSerializer(many=True, required=False)
issue_types = IssueTypeSerializer(many=True, required=False)
priorities = PrioritySerializer(many=True, required=False) # Issues
@@ -362,7 +363,10 @@ class ProjectDetailSerializer(ProjectSerializer):
many=True, required=False)
issue_custom_attributes = IssueCustomAttributeSerializer(source="issuecustomattributes",
many=True, required=False)
- users = serializers.SerializerMethodField("get_users")
+
+ roles = ProjectRoleSerializer(source="roles", many=True, read_only=True)
+ users = UserSerializer(source="members", many=True, read_only=True)
+ memberships = serializers.SerializerMethodField(method_name="get_memberships")
def get_memberships(self, obj):
qs = obj.memberships.filter(user__isnull=False)
@@ -372,13 +376,6 @@ class ProjectDetailSerializer(ProjectSerializer):
serializer = ProjectMembershipSerializer(qs, many=True)
return serializer.data
- def get_roles(self, obj):
- serializer = ProjectRoleSerializer(obj.roles.all(), many=True)
- return serializer.data
-
- def get_users(self, obj):
- return UserSerializer(obj.members.all(), many=True).data
-
class ProjectDetailAdminSerializer(ProjectDetailSerializer):
class Meta:
diff --git a/taiga/projects/tasks/serializers.py b/taiga/projects/tasks/serializers.py
index 0c8e436d..30a63d1b 100644
--- a/taiga/projects/tasks/serializers.py
+++ b/taiga/projects/tasks/serializers.py
@@ -27,7 +27,7 @@ from taiga.projects.milestones.validators import SprintExistsValidator
from taiga.projects.tasks.validators import TaskExistsValidator
from taiga.projects.notifications.validators import WatchersValidator
from taiga.projects.serializers import BasicTaskStatusSerializerSerializer
-from taiga.users.serializers import BasicInfoSerializer as UserBasicInfoSerializer
+from taiga.users.serializers import UserBasicInfoSerializer
from . import models
diff --git a/taiga/projects/userstories/serializers.py b/taiga/projects/userstories/serializers.py
index 3978381e..30129e7b 100644
--- a/taiga/projects/userstories/serializers.py
+++ b/taiga/projects/userstories/serializers.py
@@ -27,7 +27,7 @@ from taiga.projects.validators import UserStoryStatusExistsValidator
from taiga.projects.userstories.validators import UserStoryExistsValidator
from taiga.projects.notifications.validators import WatchersValidator
from taiga.projects.serializers import BasicUserStoryStatusSerializer
-from taiga.users.serializers import BasicInfoSerializer as UserBasicInfoSerializer
+from taiga.users.serializers import UserBasicInfoSerializer
from . import models
diff --git a/taiga/users/serializers.py b/taiga/users/serializers.py
index 934c0677..1a22ff70 100644
--- a/taiga/users/serializers.py
+++ b/taiga/users/serializers.py
@@ -108,11 +108,12 @@ class UserAdminSerializer(UserSerializer):
read_only_fields = ("id", "email")
-class BasicInfoSerializer(UserSerializer):
+class UserBasicInfoSerializer(UserSerializer):
class Meta:
model = User
fields = ("username", "full_name_display","photo", "big_photo")
+
class RecoverySerializer(serializers.Serializer):
token = serializers.CharField(max_length=200)
password = serializers.CharField(min_length=6)
From aff9a7d63704c4e45b5f0c1b5136d46894a1a6d9 Mon Sep 17 00:00:00 2001
From: Alejandro Alonso
Date: Fri, 19 Jun 2015 10:42:03 +0200
Subject: [PATCH 004/190] Fixing created datetime for timeline entries
---
.../management/commands/rebuild_timeline.py | 13 ++++------
.../rebuild_timeline_for_user_creation.py | 7 +++--
taiga/timeline/service.py | 15 ++++++-----
taiga/timeline/signals.py | 25 +++++++++++-------
tests/integration/test_timeline.py | 26 +++++++++----------
tests/unit/test_timeline.py | 16 ++++++------
6 files changed, 52 insertions(+), 50 deletions(-)
diff --git a/taiga/timeline/management/commands/rebuild_timeline.py b/taiga/timeline/management/commands/rebuild_timeline.py
index 0a234d38..2c462ce0 100644
--- a/taiga/timeline/management/commands/rebuild_timeline.py
+++ b/taiga/timeline/management/commands/rebuild_timeline.py
@@ -61,7 +61,7 @@ class BulkCreator(object):
bulk_creator = BulkCreator()
-def custom_add_to_object_timeline(obj:object, instance:object, event_type:str, namespace:str="default", extra_data:dict={}):
+def custom_add_to_object_timeline(obj:object, instance:object, event_type:str, created_datetime:object, namespace:str="default", extra_data:dict={}):
assert isinstance(obj, Model), "obj must be a instance of Model"
assert isinstance(instance, Model), "instance must be a instance of Model"
event_type_key = _get_impl_key_from_model(instance.__class__, event_type)
@@ -73,8 +73,8 @@ def custom_add_to_object_timeline(obj:object, instance:object, event_type:str, n
event_type=event_type_key,
project=instance.project,
data=impl(instance, extra_data=extra_data),
- data_content_type = ContentType.objects.get_for_model(instance.__class__),
- created = bulk_creator.created,
+ data_content_type=ContentType.objects.get_for_model(instance.__class__),
+ created=created_datetime,
))
@@ -116,23 +116,20 @@ def generate_timeline(initial_date, final_date, project_id):
#Memberships
for membership in project.memberships.exclude(user=None).exclude(user=project.owner):
- bulk_creator.created = membership.created_at
- _push_to_timelines(project, membership.user, membership, "create")
+ _push_to_timelines(project, membership.user, membership, "create", membership.created_at)
for project in projects.iterator():
- bulk_creator.created = project.created_date
print("Project:", bulk_creator.created)
extra_data = {
"values_diff": {},
"user": extract_user_info(project.owner),
}
- _push_to_timelines(project, project.owner, project, "create", extra_data=extra_data)
+ _push_to_timelines(project, project.owner, project, "create", project.created_date, extra_data=extra_data)
del extra_data
for historyEntry in history_entries.iterator():
print("History entry:", historyEntry.created_at)
try:
- bulk_creator.created = historyEntry.created_at
on_new_history_entry(None, historyEntry, None)
except ObjectDoesNotExist as e:
print("Ignoring")
diff --git a/taiga/timeline/management/commands/rebuild_timeline_for_user_creation.py b/taiga/timeline/management/commands/rebuild_timeline_for_user_creation.py
index d4f99ed5..2982969a 100644
--- a/taiga/timeline/management/commands/rebuild_timeline_for_user_creation.py
+++ b/taiga/timeline/management/commands/rebuild_timeline_for_user_creation.py
@@ -53,7 +53,7 @@ class BulkCreator(object):
bulk_creator = BulkCreator()
-def custom_add_to_object_timeline(obj:object, instance:object, event_type:str, namespace:str="default", extra_data:dict={}):
+def custom_add_to_object_timeline(obj:object, instance:object, event_type:str, created_datetime:object, namespace:str="default", extra_data:dict={}):
assert isinstance(obj, Model), "obj must be a instance of Model"
assert isinstance(instance, Model), "instance must be a instance of Model"
event_type_key = _get_impl_key_from_model(instance.__class__, event_type)
@@ -66,7 +66,7 @@ def custom_add_to_object_timeline(obj:object, instance:object, event_type:str, n
project=None,
data=impl(instance, extra_data=extra_data),
data_content_type = ContentType.objects.get_for_model(instance.__class__),
- created = bulk_creator.created,
+ created=created_datetime,
))
@@ -75,13 +75,12 @@ def generate_timeline():
# Users api wasn't a HistoryResourceMixin so we can't interate on the HistoryEntries in this case
users = User.objects.order_by("date_joined")
for user in users.iterator():
- bulk_creator.created = user.date_joined
print("User:", user.date_joined)
extra_data = {
"values_diff": {},
"user": extract_user_info(user),
}
- _push_to_timelines(None, user, user, "create", extra_data=extra_data)
+ _push_to_timelines(None, user, user, "create", user.date_joined, extra_data=extra_data)
del extra_data
bulk_creator.flush()
diff --git a/taiga/timeline/service.py b/taiga/timeline/service.py
index 5317942f..6fde769c 100644
--- a/taiga/timeline/service.py
+++ b/taiga/timeline/service.py
@@ -50,7 +50,7 @@ def build_project_namespace(project:object):
return "{0}:{1}".format("project", project.id)
-def _add_to_object_timeline(obj:object, instance:object, event_type:str, namespace:str="default", extra_data:dict={}):
+def _add_to_object_timeline(obj:object, instance:object, event_type:str, created_datetime:object, namespace:str="default", extra_data:dict={}):
assert isinstance(obj, Model), "obj must be a instance of Model"
assert isinstance(instance, Model), "instance must be a instance of Model"
from .models import Timeline
@@ -67,21 +67,22 @@ def _add_to_object_timeline(obj:object, instance:object, event_type:str, namespa
event_type=event_type_key,
project=project,
data=impl(instance, extra_data=extra_data),
- data_content_type = ContentType.objects.get_for_model(instance.__class__),
+ data_content_type=ContentType.objects.get_for_model(instance.__class__),
+ created=created_datetime,
)
-def _add_to_objects_timeline(objects, instance:object, event_type:str, namespace:str="default", extra_data:dict={}):
+def _add_to_objects_timeline(objects, instance:object, event_type:str, created_datetime:object, namespace:str="default", extra_data:dict={}):
for obj in objects:
- _add_to_object_timeline(obj, instance, event_type, namespace, extra_data)
+ _add_to_object_timeline(obj, instance, event_type, created_datetime, namespace, extra_data)
@app.task
-def push_to_timeline(objects, instance:object, event_type:str, namespace:str="default", extra_data:dict={}):
+def push_to_timeline(objects, instance:object, event_type:str, created_datetime:object, namespace:str="default", extra_data:dict={}):
if isinstance(objects, Model):
- _add_to_object_timeline(objects, instance, event_type, namespace, extra_data)
+ _add_to_object_timeline(objects, instance, event_type, created_datetime, namespace, extra_data)
elif isinstance(objects, QuerySet) or isinstance(objects, list):
- _add_to_objects_timeline(objects, instance, event_type, namespace, extra_data)
+ _add_to_objects_timeline(objects, instance, event_type, created_datetime, namespace, extra_data)
else:
raise Exception("Invalid objects parameter")
diff --git a/taiga/timeline/signals.py b/taiga/timeline/signals.py
index 03d8b9f5..b0787946 100644
--- a/taiga/timeline/signals.py
+++ b/taiga/timeline/signals.py
@@ -15,6 +15,7 @@
# along with this program. If not, see .
from django.conf import settings
+from django.utils import timezone
from taiga.projects.history import services as history_services
from taiga.projects.models import Project
@@ -33,15 +34,15 @@ def _push_to_timeline(*args, **kwargs):
push_to_timeline(*args, **kwargs)
-def _push_to_timelines(project, user, obj, event_type, extra_data={}):
+def _push_to_timelines(project, user, obj, event_type, created_datetime, extra_data={}):
if project is not None:
# Project timeline
- _push_to_timeline(project, obj, event_type,
+ _push_to_timeline(project, obj, event_type, created_datetime,
namespace=build_project_namespace(project),
extra_data=extra_data)
# User timeline
- _push_to_timeline(user, obj, event_type,
+ _push_to_timeline(user, obj, event_type, created_datetime,
namespace=build_user_namespace(user),
extra_data=extra_data)
@@ -64,7 +65,7 @@ def _push_to_timelines(project, user, obj, event_type, extra_data={}):
related_people |= team
related_people = related_people.distinct()
- _push_to_timeline(related_people, obj, event_type,
+ _push_to_timeline(related_people, obj, event_type, created_datetime,
namespace=build_user_namespace(user),
extra_data=extra_data)
@@ -103,7 +104,8 @@ def on_new_history_entry(sender, instance, created, **kwargs):
if instance.delete_comment_date:
extra_data["comment_deleted"] = True
- _push_to_timelines(project, user, obj, event_type, extra_data=extra_data)
+ created_datetime = instance.created_at
+ _push_to_timelines(project, user, obj, event_type, created_datetime, extra_data=extra_data)
def create_membership_push_to_timeline(sender, instance, **kwargs):
@@ -111,26 +113,29 @@ def create_membership_push_to_timeline(sender, instance, **kwargs):
# If the user is the project owner we don't do anything because that info will
# be shown in created project timeline entry
if not instance.pk and instance.user and instance.user != instance.project.owner:
- _push_to_timelines(instance.project, instance.user, instance, "create")
+ created_datetime = instance.created_at
+ _push_to_timelines(instance.project, instance.user, instance, "create", created_datetime)
#Updating existing membership
elif instance.pk:
prev_instance = sender.objects.get(pk=instance.pk)
if instance.user != prev_instance.user:
+ created_datetime = timezone.now()
# The new member
- _push_to_timelines(instance.project, instance.user, instance, "create")
+ _push_to_timelines(instance.project, instance.user, instance, "create", created_datetime)
# If we are updating the old user is removed from project
if prev_instance.user:
- _push_to_timelines(instance.project, prev_instance.user, prev_instance, "delete")
+ _push_to_timelines(instance.project, prev_instance.user, prev_instance, "delete", created_datetime)
def delete_membership_push_to_timeline(sender, instance, **kwargs):
if instance.user:
- _push_to_timelines(instance.project, instance.user, instance, "delete")
+ created_datetime = timezone.now()
+ _push_to_timelines(instance.project, instance.user, instance, "delete", created_datetime)
def create_user_push_to_timeline(sender, instance, created, **kwargs):
if created:
project = None
user = instance
- _push_to_timelines(project, user, user, "create")
+ _push_to_timelines(project, user, user, "create", created_datetime=user.date_joined)
diff --git a/tests/integration/test_timeline.py b/tests/integration/test_timeline.py
index 326a7b85..05c649b7 100644
--- a/tests/integration/test_timeline.py
+++ b/tests/integration/test_timeline.py
@@ -34,7 +34,7 @@ def test_add_to_object_timeline():
service.register_timeline_implementation("tasks.task", "test", lambda x, extra_data=None: str(id(x)))
- service._add_to_object_timeline(user1, task, "test")
+ service._add_to_object_timeline(user1, task, "test", task.created_date)
assert Timeline.objects.filter(object_id=user1.id).count() == 2
assert Timeline.objects.order_by("-id")[0].data == id(task)
@@ -53,11 +53,11 @@ def test_get_timeline():
service.register_timeline_implementation("tasks.task", "test", lambda x, extra_data=None: str(id(x)))
- service._add_to_object_timeline(user1, task1, "test")
- service._add_to_object_timeline(user1, task2, "test")
- service._add_to_object_timeline(user1, task3, "test")
- service._add_to_object_timeline(user1, task4, "test")
- service._add_to_object_timeline(user2, task1, "test")
+ service._add_to_object_timeline(user1, task1, "test", task1.created_date)
+ service._add_to_object_timeline(user1, task2, "test", task2.created_date)
+ service._add_to_object_timeline(user1, task3, "test", task3.created_date)
+ service._add_to_object_timeline(user1, task4, "test", task4.created_date)
+ service._add_to_object_timeline(user2, task1, "test", task1.created_date)
assert Timeline.objects.filter(object_id=user1.id).count() == 5
assert Timeline.objects.filter(object_id=user2.id).count() == 2
@@ -71,7 +71,7 @@ def test_filter_timeline_no_privileges():
task1= factories.TaskFactory()
service.register_timeline_implementation("tasks.task", "test", lambda x, extra_data=None: str(id(x)))
- service._add_to_object_timeline(user1, task1, "test")
+ service._add_to_object_timeline(user1, task1, "test", task1.created_date)
timeline = Timeline.objects.exclude(event_type="users.user.create")
timeline = service.filter_timeline_for_user(timeline, user2)
assert timeline.count() == 0
@@ -86,8 +86,8 @@ def test_filter_timeline_public_project():
task2= factories.TaskFactory.create(project=project)
service.register_timeline_implementation("tasks.task", "test", lambda x, extra_data=None: str(id(x)))
- service._add_to_object_timeline(user1, task1, "test")
- service._add_to_object_timeline(user1, task2, "test")
+ service._add_to_object_timeline(user1, task1, "test", task1.created_date)
+ service._add_to_object_timeline(user1, task2, "test", task2.created_date)
timeline = Timeline.objects.exclude(event_type="users.user.create")
timeline = service.filter_timeline_for_user(timeline, user2)
assert timeline.count() == 1
@@ -102,8 +102,8 @@ def test_filter_timeline_private_project_anon_permissions():
task2= factories.TaskFactory.create(project=project)
service.register_timeline_implementation("tasks.task", "test", lambda x, extra_data=None: str(id(x)))
- service._add_to_object_timeline(user1, task1, "test")
- service._add_to_object_timeline(user1, task2, "test")
+ service._add_to_object_timeline(user1, task1, "test", task1.created_date)
+ service._add_to_object_timeline(user1, task2, "test", task2.created_date)
timeline = Timeline.objects.exclude(event_type="users.user.create")
timeline = service.filter_timeline_for_user(timeline, user2)
assert timeline.count() == 1
@@ -121,8 +121,8 @@ def test_filter_timeline_private_project_member_permissions():
task2= factories.TaskFactory.create(project=project)
service.register_timeline_implementation("tasks.task", "test", lambda x, extra_data=None: str(id(x)))
- service._add_to_object_timeline(user1, task1, "test")
- service._add_to_object_timeline(user1, task2, "test")
+ service._add_to_object_timeline(user1, task1, "test", task1.created_date)
+ service._add_to_object_timeline(user1, task2, "test", task2.created_date)
timeline = Timeline.objects.exclude(event_type="users.user.create")
timeline = service.filter_timeline_for_user(timeline, user2)
assert timeline.count() == 3
diff --git a/tests/unit/test_timeline.py b/tests/unit/test_timeline.py
index c580404e..377e9728 100644
--- a/tests/unit/test_timeline.py
+++ b/tests/unit/test_timeline.py
@@ -31,12 +31,12 @@ def test_push_to_timeline_many_objects():
with patch("taiga.timeline.service._add_to_object_timeline") as mock:
users = [User(), User(), User()]
project = Project()
- service.push_to_timeline(users, project, "test")
+ service.push_to_timeline(users, project, "test", project.created_date)
assert mock.call_count == 3
assert mock.mock_calls == [
- call(users[0], project, "test", "default", {}),
- call(users[1], project, "test", "default", {}),
- call(users[2], project, "test", "default", {}),
+ call(users[0], project, "test", project.created_date, "default", {}),
+ call(users[1], project, "test", project.created_date, "default", {}),
+ call(users[2], project, "test", project.created_date, "default", {}),
]
with pytest.raises(Exception):
service.push_to_timeline(None, project, "test")
@@ -46,12 +46,12 @@ def test_add_to_objects_timeline():
with patch("taiga.timeline.service._add_to_object_timeline") as mock:
users = [User(), User(), User()]
project = Project()
- service._add_to_objects_timeline(users, project, "test")
+ service._add_to_objects_timeline(users, project, "test", project.created_date)
assert mock.call_count == 3
assert mock.mock_calls == [
- call(users[0], project, "test", "default", {}),
- call(users[1], project, "test", "default", {}),
- call(users[2], project, "test", "default", {}),
+ call(users[0], project, "test", project.created_date, "default", {}),
+ call(users[1], project, "test", project.created_date, "default", {}),
+ call(users[2], project, "test", project.created_date, "default", {}),
]
with pytest.raises(Exception):
service.push_to_timeline(None, project, "test")
From 15af8ef3ccbeddbf3c17de24678f6d4bc11c2790 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?=
Date: Sat, 20 Jun 2015 22:24:48 +0200
Subject: [PATCH 005/190] Update changelog
---
CHANGELOG.md | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index abbd0695..50f94187 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,14 @@
# Changelog #
+## 1.9.0 ??? (unreleased)
+### Features
+- ...
+
+### Misc
+- Lots of small and not so small bugfixes.
+
+
## 1.8.0 Saracenia Purpurea (2015-06-18)
### Features
From 1bd2a6c0de6bcb993928195bb2209979e1560198 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?=
Date: Sat, 20 Jun 2015 22:29:22 +0200
Subject: [PATCH 006/190] [i18n] Update locales
---
taiga/locale/de/LC_MESSAGES/django.po | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/taiga/locale/de/LC_MESSAGES/django.po b/taiga/locale/de/LC_MESSAGES/django.po
index 6ea5110a..cf2bc68f 100644
--- a/taiga/locale/de/LC_MESSAGES/django.po
+++ b/taiga/locale/de/LC_MESSAGES/django.po
@@ -14,8 +14,8 @@ msgstr ""
"Project-Id-Version: taiga-back\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2015-06-15 12:34+0200\n"
-"PO-Revision-Date: 2015-06-17 08:05+0000\n"
-"Last-Translator: Regina \n"
+"PO-Revision-Date: 2015-06-20 14:15+0000\n"
+"Last-Translator: Hans Raaf\n"
"Language-Team: German (http://www.transifex.com/projects/p/taiga-back/"
"language/de/)\n"
"MIME-Version: 1.0\n"
@@ -1441,7 +1441,7 @@ msgstr "Es gibt keinen Sprint mit dieser id"
#: taiga/projects/mixins/blocked.py:29
msgid "is blocked"
-msgstr "ist gesperrt"
+msgstr "wird blockiert"
#: taiga/projects/mixins/ordering.py:47
#, python-brace-format
From 68181b8c5f18948730e86774b4a2b6e0e0299fe6 Mon Sep 17 00:00:00 2001
From: Alejandro Alonso
Date: Tue, 23 Jun 2015 10:19:59 +0200
Subject: [PATCH 007/190] Fixing I18NJsonField
---
taiga/base/fields.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/taiga/base/fields.py b/taiga/base/fields.py
index 07898ea2..a1a8c56c 100644
--- a/taiga/base/fields.py
+++ b/taiga/base/fields.py
@@ -58,7 +58,7 @@ class I18NJsonField(JsonField):
if key in self.i18n_fields:
if isinstance(value, list):
- i18n_d[key] = [e is not None and _(e) or e for e in value]
+ i18n_d[key] = [e is not None and _(str(e)) or e for e in value]
if isinstance(value, str):
i18n_d[key] = value is not None and _(value) or value
else:
From f94f52b8acb95d13b054a22e35357418aa9356e2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?=
Date: Tue, 23 Jun 2015 18:57:50 +0200
Subject: [PATCH 008/190] Update CHANGELOG
---
CHANGELOG.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 50f94187..5ec53387 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,7 +3,7 @@
## 1.9.0 ??? (unreleased)
### Features
-- ...
+- Add a "field type" property for custom fields: 'text' and 'multiline text' right now (thanks to [@artlepool](https://github.com/artlepool))
### Misc
- Lots of small and not so small bugfixes.
From 67bf755771ad798d8bbe77fec751782795264e51 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?=
Date: Tue, 23 Jun 2015 19:56:26 +0200
Subject: [PATCH 009/190] Fix migrations of custom_attributes
---
.../migrations/0006_add_customattribute_field_type.py | 7 +++----
taiga/projects/custom_attributes/models.py | 6 +++---
2 files changed, 6 insertions(+), 7 deletions(-)
diff --git a/taiga/projects/custom_attributes/migrations/0006_add_customattribute_field_type.py b/taiga/projects/custom_attributes/migrations/0006_add_customattribute_field_type.py
index ff3a3844..8a4aebb8 100644
--- a/taiga/projects/custom_attributes/migrations/0006_add_customattribute_field_type.py
+++ b/taiga/projects/custom_attributes/migrations/0006_add_customattribute_field_type.py
@@ -14,20 +14,19 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='issuecustomattribute',
name='field_type',
- field=models.CharField(default='TEXT', max_length=5, verbose_name='attribute field type'),
+ field=models.CharField(max_length=5, verbose_name='type', choices=[('TEXT', 'Text'), ('MULTI', 'Multi-Line Text')], default='TEXT'),
preserve_default=True,
),
migrations.AddField(
model_name='taskcustomattribute',
name='field_type',
- field=models.CharField(default='TEXT', max_length=5, verbose_name='attribute field type'),
+ field=models.CharField(max_length=5, verbose_name='type', choices=[('TEXT', 'Text'), ('MULTI', 'Multi-Line Text')], default='TEXT'),
preserve_default=True,
),
migrations.AddField(
model_name='userstorycustomattribute',
name='field_type',
- field=models.CharField(default='TEXT', max_length=5, verbose_name='attribute field type'),
+ field=models.CharField(max_length=5, verbose_name='type', choices=[('TEXT', 'Text'), ('MULTI', 'Multi-Line Text')], default='TEXT'),
preserve_default=True,
),
-
]
diff --git a/taiga/projects/custom_attributes/models.py b/taiga/projects/custom_attributes/models.py
index b9ea807d..6d05121c 100644
--- a/taiga/projects/custom_attributes/models.py
+++ b/taiga/projects/custom_attributes/models.py
@@ -30,12 +30,12 @@ from taiga.projects.occ.mixins import OCCModelMixin
class AbstractCustomAttribute(models.Model):
FIELD_TYPES = (
- ('TEXT', 'Text'),
- ('MULTI', 'Multi-Line Text')
+ ("TEXT", _("Text")),
+ ("MULTI", _("Multi-Line Text"))
)
name = models.CharField(null=False, blank=False, max_length=64, verbose_name=_("name"))
description = models.TextField(null=False, blank=True, verbose_name=_("description"))
- field_type = models.CharField(null=False, blank=False, choices=FIELD_TYPES, max_length=5, default='TEXT', verbose_name=_("type"))
+ field_type = models.CharField(null=False, blank=False, choices=FIELD_TYPES, max_length=5, default="TEXT", verbose_name=_("type"))
order = models.IntegerField(null=False, blank=False, default=10000, verbose_name=_("order"))
project = models.ForeignKey("projects.Project", null=False, blank=False, related_name="%(class)ss",
verbose_name=_("project"))
From 8efeb406fd43a3ee80b9a9dff4c7d0a09d448e66 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jes=C3=BAs=20Espino?=
Date: Tue, 23 Jun 2015 21:24:40 +0200
Subject: [PATCH 010/190] Adding more deterministic timeline ordering, by time
and id
---
taiga/timeline/service.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/taiga/timeline/service.py b/taiga/timeline/service.py
index 6fde769c..28327297 100644
--- a/taiga/timeline/service.py
+++ b/taiga/timeline/service.py
@@ -96,7 +96,7 @@ def get_timeline(obj, namespace=None):
if namespace is not None:
timeline = timeline.filter(namespace=namespace)
- timeline = timeline.order_by("-created")
+ timeline = timeline.order_by("-created", "-id")
return timeline
From 91c9056823b5ecccea1c81def7f714a9a320e5cb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jes=C3=BAs=20Espino?=
Date: Sat, 20 Jun 2015 09:12:46 +0200
Subject: [PATCH 011/190] Fix import and deletion of projects
---
taiga/events/events.py | 3 ++
taiga/projects/api.py | 44 ++++++++++++---------
taiga/projects/apps.py | 35 +++++++++++++----
taiga/projects/issues/apps.py | 48 +++++++++++++++--------
taiga/projects/tasks/apps.py | 62 +++++++++++++++++++-----------
taiga/projects/userstories/apps.py | 49 ++++++++++++++++-------
6 files changed, 163 insertions(+), 78 deletions(-)
diff --git a/taiga/events/events.py b/taiga/events/events.py
index 8e32dcf7..26343694 100644
--- a/taiga/events/events.py
+++ b/taiga/events/events.py
@@ -53,6 +53,9 @@ def emit_event_for_model(obj, *, type:str="change", channel:str="events",
Sends a model change event.
"""
+ if obj._importing:
+ return None
+
assert type in set(["create", "change", "delete"])
assert hasattr(obj, "project_id")
diff --git a/taiga/projects/api.py b/taiga/projects/api.py
index e0761db2..975dd4ea 100644
--- a/taiga/projects/api.py
+++ b/taiga/projects/api.py
@@ -255,31 +255,37 @@ class ProjectViewSet(HistoryResourceMixin, ModelCrudViewSet):
super().pre_save(obj)
def destroy(self, request, *args, **kwargs):
+ from taiga.events.apps import connect_events_signals, disconnect_events_signals
+ from taiga.projects.tasks.apps import connect_tasks_signals, disconnect_tasks_signals
+ from taiga.projects.userstories.apps import connect_userstories_signals, disconnect_userstories_signals
+ from taiga.projects.issues.apps import connect_issues_signals, disconnect_issues_signals
+ from taiga.projects.apps import connect_memberships_signals, disconnect_memberships_signals
+
obj = self.get_object_or_none()
self.check_permissions(request, 'destroy', obj)
- signals.post_delete.disconnect(sender=UserStory,
- dispatch_uid="user_story_update_project_colors_on_delete")
- signals.post_delete.disconnect(sender=Issue,
- dispatch_uid="issue_update_project_colors_on_delete")
- signals.post_delete.disconnect(sender=Task,
- dispatch_uid="tasks_milestone_close_handler_on_delete")
- signals.post_delete.disconnect(sender=Task,
- dispatch_uid="tasks_us_close_handler_on_delete")
- signals.post_delete.disconnect(sender=Task,
- dispatch_uid="task_update_project_colors_on_delete")
- signals.post_delete.disconnect(dispatch_uid="refprojdel")
- signals.post_delete.disconnect(dispatch_uid='update_watchers_on_membership_post_delete')
-
- obj.tasks.all().delete()
- obj.user_stories.all().delete()
- obj.issues.all().delete()
- obj.memberships.all().delete()
- obj.roles.all().delete()
-
if obj is None:
raise Http404
+ disconnect_events_signals()
+ disconnect_issues_signals()
+ disconnect_tasks_signals()
+ disconnect_userstories_signals()
+ disconnect_memberships_signals()
+
+ try:
+ obj.tasks.all().delete()
+ obj.user_stories.all().delete()
+ obj.issues.all().delete()
+ obj.memberships.all().delete()
+ obj.roles.all().delete()
+ finally:
+ connect_events_signals()
+ connect_issues_signals()
+ connect_tasks_signals()
+ connect_userstories_signals()
+ connect_memberships_signals()
+
self.pre_delete(obj)
self.pre_conditions_on_delete(obj)
obj.delete()
diff --git a/taiga/projects/apps.py b/taiga/projects/apps.py
index b59c0137..a57f2f5a 100644
--- a/taiga/projects/apps.py
+++ b/taiga/projects/apps.py
@@ -21,11 +21,7 @@ from django.db.models import signals
from . import signals as handlers
-class ProjectsAppConfig(AppConfig):
- name = "taiga.projects"
- verbose_name = "Projects"
-
- def ready(self):
+def connect_memberships_signals():
# On membership object is deleted, update role-points relation.
signals.pre_delete.connect(handlers.membership_post_delete,
sender=apps.get_model("projects", "Membership"),
@@ -41,6 +37,8 @@ class ProjectsAppConfig(AppConfig):
sender=apps.get_model("projects", "Membership"),
dispatch_uid='create-notify-policy')
+
+def connect_projects_signals():
# On project object is created apply template.
signals.post_save.connect(handlers.project_post_save,
sender=apps.get_model("projects", "Project"),
@@ -48,6 +46,29 @@ class ProjectsAppConfig(AppConfig):
# Tags
signals.pre_save.connect(handlers.tags_normalization,
- sender=apps.get_model("projects", "Project"))
+ sender=apps.get_model("projects", "Project"),
+ dispatch_uid="tags_normalization_projects")
signals.pre_save.connect(handlers.update_project_tags_when_create_or_edit_taggable_item,
- sender=apps.get_model("projects", "Project"))
+ sender=apps.get_model("projects", "Project"),
+ dispatch_uid="update_project_tags_when_create_or_edit_taggable_item_projects")
+
+
+def disconnect_memberships_signals():
+ signals.pre_delete.disconnect(dispatch_uid='membership_pre_delete')
+ signals.post_delete.disconnect(dispatch_uid='update_watchers_on_membership_post_delete')
+ signals.post_save.disconnect(dispatch_uid='create-notify-policy')
+
+
+def disconnect_projects_signals():
+ signals.post_save.disconnect(dispatch_uid='project_post_save')
+ signals.pre_save.disconnect(dispatch_uid="tags_normalization_projects")
+ signals.pre_save.disconnect(dispatch_uid="update_project_tags_when_create_or_edit_taggable_item_projects")
+
+
+class ProjectsAppConfig(AppConfig):
+ name = "taiga.projects"
+ verbose_name = "Projects"
+
+ def ready(self):
+ connect_memberships_signals()
+ connect_projects_signals()
diff --git a/taiga/projects/issues/apps.py b/taiga/projects/issues/apps.py
index 3972ef48..7333d934 100644
--- a/taiga/projects/issues/apps.py
+++ b/taiga/projects/issues/apps.py
@@ -23,25 +23,39 @@ from taiga.projects.custom_attributes import signals as custom_attributes_handle
from . import signals as handlers
+def connect_issues_signals():
+ # Finished date
+ signals.pre_save.connect(handlers.set_finished_date_when_edit_issue,
+ sender=apps.get_model("issues", "Issue"),
+ dispatch_uid="set_finished_date_when_edit_issue")
+
+ # Tags
+ signals.pre_save.connect(generic_handlers.tags_normalization,
+ sender=apps.get_model("issues", "Issue"),
+ dispatch_uid="tags_normalization_issue")
+ signals.post_save.connect(generic_handlers.update_project_tags_when_create_or_edit_taggable_item,
+ sender=apps.get_model("issues", "Issue"),
+ dispatch_uid="update_project_tags_when_create_or_edit_taggable_item_issue")
+ signals.post_delete.connect(generic_handlers.update_project_tags_when_delete_taggable_item,
+ sender=apps.get_model("issues", "Issue"),
+ dispatch_uid="update_project_tags_when_delete_taggable_item_issue")
+
+ # Custom Attributes
+ signals.post_save.connect(custom_attributes_handlers.create_custom_attribute_value_when_create_issue,
+ sender=apps.get_model("issues", "Issue"),
+ dispatch_uid="create_custom_attribute_value_when_create_issue")
+
+def disconnect_issues_signals():
+ signals.pre_save.disconnect(dispatch_uid="set_finished_date_when_edit_issue")
+ signals.pre_save.disconnect(dispatch_uid="tags_normalization_issue")
+ signals.post_save.disconnect(dispatch_uid="update_project_tags_when_create_or_edit_taggable_item_issue")
+ signals.post_delete.disconnect(dispatch_uid="update_project_tags_when_delete_taggable_item_issue")
+ signals.post_save.disconnect(dispatch_uid="create_custom_attribute_value_when_create_issue")
+
+
class IssuesAppConfig(AppConfig):
name = "taiga.projects.issues"
verbose_name = "Issues"
def ready(self):
- # Finished date
- signals.pre_save.connect(handlers.set_finished_date_when_edit_issue,
- sender=apps.get_model("issues", "Issue"),
- dispatch_uid="set_finished_date_when_edit_issue")
-
- # Tags
- signals.pre_save.connect(generic_handlers.tags_normalization,
- sender=apps.get_model("issues", "Issue"))
- signals.post_save.connect(generic_handlers.update_project_tags_when_create_or_edit_taggable_item,
- sender=apps.get_model("issues", "Issue"))
- signals.post_delete.connect(generic_handlers.update_project_tags_when_delete_taggable_item,
- sender=apps.get_model("issues", "Issue"))
-
- # Custom Attributes
- signals.post_save.connect(custom_attributes_handlers.create_custom_attribute_value_when_create_issue,
- sender=apps.get_model("issues", "Issue"),
- dispatch_uid="create_custom_attribute_value_when_create_issue")
+ connect_issues_signals()
diff --git a/taiga/projects/tasks/apps.py b/taiga/projects/tasks/apps.py
index a6597339..f8426dcc 100644
--- a/taiga/projects/tasks/apps.py
+++ b/taiga/projects/tasks/apps.py
@@ -22,31 +22,49 @@ from taiga.projects import signals as generic_handlers
from taiga.projects.custom_attributes import signals as custom_attributes_handlers
from . import signals as handlers
+def connect_tasks_signals():
+ # Cached prev object version
+ signals.pre_save.connect(handlers.cached_prev_task,
+ sender=apps.get_model("tasks", "Task"),
+ dispatch_uid="cached_prev_task")
+
+ # Open/Close US and Milestone
+ signals.post_save.connect(handlers.try_to_close_or_open_us_and_milestone_when_create_or_edit_task,
+ sender=apps.get_model("tasks", "Task"),
+ dispatch_uid="try_to_close_or_open_us_and_milestone_when_create_or_edit_task")
+ signals.post_delete.connect(handlers.try_to_close_or_open_us_and_milestone_when_delete_task,
+ sender=apps.get_model("tasks", "Task"),
+ dispatch_uid="try_to_close_or_open_us_and_milestone_when_delete_task")
+
+ # Tags
+ signals.pre_save.connect(generic_handlers.tags_normalization,
+ sender=apps.get_model("tasks", "Task"),
+ dispatch_uid="tags_normalization_task")
+ signals.post_save.connect(generic_handlers.update_project_tags_when_create_or_edit_taggable_item,
+ sender=apps.get_model("tasks", "Task"),
+ dispatch_uid="update_project_tags_when_create_or_edit_tagglabe_item_task")
+ signals.post_delete.connect(generic_handlers.update_project_tags_when_delete_taggable_item,
+ sender=apps.get_model("tasks", "Task"),
+ dispatch_uid="update_project_tags_when_delete_tagglabe_item_task")
+
+ # Custom Attributes
+ signals.post_save.connect(custom_attributes_handlers.create_custom_attribute_value_when_create_task,
+ sender=apps.get_model("tasks", "Task"),
+ dispatch_uid="create_custom_attribute_value_when_create_task")
+
+def disconnect_tasks_signals():
+ signals.pre_save.disconnect(dispatch_uid="cached_prev_task")
+ signals.post_save.disconnect(dispatch_uid="try_to_close_or_open_us_and_milestone_when_create_or_edit_task")
+ signals.post_delete.disconnect(dispatch_uid="try_to_close_or_open_us_and_milestone_when_delete_task")
+ signals.pre_save.disconnect(dispatch_uid="tags_normalization")
+ signals.post_save.disconnect(dispatch_uid="update_project_tags_when_create_or_edit_tagglabe_item")
+ signals.post_delete.disconnect(dispatch_uid="update_project_tags_when_delete_tagglabe_item")
+ signals.post_save.disconnect(dispatch_uid="create_custom_attribute_value_when_create_task")
+
class TasksAppConfig(AppConfig):
name = "taiga.projects.tasks"
verbose_name = "Tasks"
def ready(self):
- # Cached prev object version
- signals.pre_save.connect(handlers.cached_prev_task,
- sender=apps.get_model("tasks", "Task"))
-
- # Open/Close US and Milestone
- signals.post_save.connect(handlers.try_to_close_or_open_us_and_milestone_when_create_or_edit_task,
- sender=apps.get_model("tasks", "Task"))
- signals.post_delete.connect(handlers.try_to_close_or_open_us_and_milestone_when_delete_task,
- sender=apps.get_model("tasks", "Task"))
-
- # Tags
- signals.pre_save.connect(generic_handlers.tags_normalization,
- sender=apps.get_model("tasks", "Task"))
- signals.post_save.connect(generic_handlers.update_project_tags_when_create_or_edit_taggable_item,
- sender=apps.get_model("tasks", "Task"))
- signals.post_delete.connect(generic_handlers.update_project_tags_when_delete_taggable_item,
- sender=apps.get_model("tasks", "Task"))
-
- # Custom Attributes
- signals.post_save.connect(custom_attributes_handlers.create_custom_attribute_value_when_create_task,
- sender=apps.get_model("tasks", "Task"),
- dispatch_uid="create_custom_attribute_value_when_create_task")
+ connect_tasks_signals()
diff --git a/taiga/projects/userstories/apps.py b/taiga/projects/userstories/apps.py
index f6b1bb77..868074c1 100644
--- a/taiga/projects/userstories/apps.py
+++ b/taiga/projects/userstories/apps.py
@@ -23,38 +23,61 @@ from taiga.projects.custom_attributes import signals as custom_attributes_handle
from . import signals as handlers
-class UserStoriesAppConfig(AppConfig):
- name = "taiga.projects.userstories"
- verbose_name = "User Stories"
-
- def ready(self):
+def connect_userstories_signals():
# Cached prev object version
signals.pre_save.connect(handlers.cached_prev_us,
- sender=apps.get_model("userstories", "UserStory"))
+ sender=apps.get_model("userstories", "UserStory"),
+ dispatch_uid="cached_prev_us")
# Role Points
signals.post_save.connect(handlers.update_role_points_when_create_or_edit_us,
- sender=apps.get_model("userstories", "UserStory"))
+ sender=apps.get_model("userstories", "UserStory"),
+ dispatch_uid="update_role_points_when_create_or_edit_us")
# Tasks
signals.post_save.connect(handlers.update_milestone_of_tasks_when_edit_us,
- sender=apps.get_model("userstories", "UserStory"))
+ sender=apps.get_model("userstories", "UserStory"),
+ dispatch_uid="update_milestone_of_tasks_when_edit_us")
# Open/Close US and Milestone
signals.post_save.connect(handlers.try_to_close_or_open_us_and_milestone_when_create_or_edit_us,
- sender=apps.get_model("userstories", "UserStory"))
+ sender=apps.get_model("userstories", "UserStory"),
+ dispatch_uid="try_to_close_or_open_us_and_milestone_when_create_or_edit_us")
signals.post_delete.connect(handlers.try_to_close_milestone_when_delete_us,
- sender=apps.get_model("userstories", "UserStory"))
+ sender=apps.get_model("userstories", "UserStory"),
+ dispatch_uid="try_to_close_milestone_when_delete_us")
# Tags
signals.pre_save.connect(generic_handlers.tags_normalization,
- sender=apps.get_model("userstories", "UserStory"))
+ sender=apps.get_model("userstories", "UserStory"),
+ dispatch_uid="tags_normalization_user_story")
signals.post_save.connect(generic_handlers.update_project_tags_when_create_or_edit_taggable_item,
- sender=apps.get_model("userstories", "UserStory"))
+ 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"))
+ sender=apps.get_model("userstories", "UserStory"),
+ dispatch_uid="update_project_tags_when_delete_taggable_item_user_story")
# Custom Attributes
signals.post_save.connect(custom_attributes_handlers.create_custom_attribute_value_when_create_user_story,
sender=apps.get_model("userstories", "UserStory"),
dispatch_uid="create_custom_attribute_value_when_create_user_story")
+
+def disconnect_userstories_signals():
+ signals.pre_save.disconnect(dispatch_uid="cached_prev_us")
+ signals.post_save.disconnect(dispatch_uid="update_role_points_when_create_or_edit_us")
+ signals.post_save.disconnect(dispatch_uid="update_milestone_of_tasks_when_edit_us")
+ signals.post_save.disconnect(dispatch_uid="try_to_close_or_open_us_and_milestone_when_create_or_edit_us")
+ signals.post_delete.disconnect(dispatch_uid="try_to_close_milestone_when_delete_us")
+ signals.pre_save.disconnect(dispatch_uid="tags_normalization_user_story")
+ signals.post_save.disconnect(dispatch_uid="update_project_tags_when_create_or_edit_taggable_item_user_story")
+ signals.post_delete.disconnect(dispatch_uid="update_project_tags_when_delete_taggable_item_user_story")
+ signals.post_save.disconnect(dispatch_uid="create_custom_attribute_value_when_create_user_story")
+
+
+class UserStoriesAppConfig(AppConfig):
+ name = "taiga.projects.userstories"
+ verbose_name = "User Stories"
+
+ def ready(self):
+ connect_userstories_signals()
From 9cbecd9b7edf82749761361454827575a0a19586 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jes=C3=BAs=20Espino?=
Date: Tue, 23 Jun 2015 18:33:11 +0200
Subject: [PATCH 012/190] Add support for partial words on global searches
---
taiga/searches/services.py | 12 ++++++++----
tests/integration/test_searches.py | 20 +++++++++++++++++---
2 files changed, 25 insertions(+), 7 deletions(-)
diff --git a/taiga/searches/services.py b/taiga/searches/services.py
index 6786df83..1cc2374e 100644
--- a/taiga/searches/services.py
+++ b/taiga/searches/services.py
@@ -25,9 +25,10 @@ def search_user_stories(project, text):
model_cls = apps.get_model("userstories", "UserStory")
where_clause = ("to_tsvector(coalesce(userstories_userstory.subject) || ' ' || "
"coalesce(userstories_userstory.ref) || ' ' || "
- "coalesce(userstories_userstory.description)) @@ plainto_tsquery(%s)")
+ "coalesce(userstories_userstory.description)) @@ to_tsquery(%s)")
if text:
+ text += ":*"
return (model_cls.objects.extra(where=[where_clause], params=[text])
.filter(project_id=project.pk)[:MAX_RESULTS])
@@ -38,9 +39,10 @@ def search_tasks(project, text):
model_cls = apps.get_model("tasks", "Task")
where_clause = ("to_tsvector(coalesce(tasks_task.subject, '') || ' ' || "
"coalesce(tasks_task.ref) || ' ' || "
- "coalesce(tasks_task.description, '')) @@ plainto_tsquery(%s)")
+ "coalesce(tasks_task.description, '')) @@ to_tsquery(%s)")
if text:
+ text += ":*"
return (model_cls.objects.extra(where=[where_clause], params=[text])
.filter(project_id=project.pk)[:MAX_RESULTS])
@@ -51,9 +53,10 @@ def search_issues(project, text):
model_cls = apps.get_model("issues", "Issue")
where_clause = ("to_tsvector(coalesce(issues_issue.subject) || ' ' || "
"coalesce(issues_issue.ref) || ' ' || "
- "coalesce(issues_issue.description)) @@ plainto_tsquery(%s)")
+ "coalesce(issues_issue.description)) @@ to_tsquery(%s)")
if text:
+ text += ":*"
return (model_cls.objects.extra(where=[where_clause], params=[text])
.filter(project_id=project.pk)[:MAX_RESULTS])
@@ -63,9 +66,10 @@ def search_issues(project, text):
def search_wiki_pages(project, text):
model_cls = apps.get_model("wiki", "WikiPage")
where_clause = ("to_tsvector(coalesce(wiki_wikipage.slug) || ' ' || coalesce(wiki_wikipage.content)) "
- "@@ plainto_tsquery(%s)")
+ "@@ to_tsquery(%s)")
if text:
+ text += ":*"
return (model_cls.objects.extra(where=[where_clause], params=[text])
.filter(project_id=project.pk)[:MAX_RESULTS])
diff --git a/tests/integration/test_searches.py b/tests/integration/test_searches.py
index 606f3304..0837c4c5 100644
--- a/tests/integration/test_searches.py
+++ b/tests/integration/test_searches.py
@@ -74,11 +74,11 @@ def searches_initial_data():
m.tsk2 = f.TaskFactory.create(project=m.project1)
m.tsk3 = f.TaskFactory.create(project=m.project1, subject="Back to the future")
- m.iss1 = f.IssueFactory.create(project=m.project1, subject="Backend and Frontend")
+ m.iss1 = f.IssueFactory.create(project=m.project1, subject="Design and Frontend")
m.iss2 = f.IssueFactory.create(project=m.project2)
- m.iss3 = f.IssueFactory.create(project=m.project1)
+ m.iss3 = f.IssueFactory.create(project=m.project1, subject="Green Frog")
- m.wiki1 = f.WikiPageFactory.create(project=m.project1)
+ m.wiki1 = f.WikiPageFactory.create(project=m.project1, content="Final Frontier")
m.wiki2 = f.WikiPageFactory.create(project=m.project1, content="Frontend, future")
m.wiki3 = f.WikiPageFactory.create(project=m.project2)
@@ -131,6 +131,20 @@ def test_search_text_query_in_my_project(client, searches_initial_data):
assert len(response.data["wikipages"]) == 0
+def test_search_partial_text_query_in_my_project(client, searches_initial_data):
+ data = searches_initial_data
+
+ client.login(data.member1.user)
+
+ response = client.get(reverse("search-list"), {"project": data.project1.id, "text": "fron"})
+ assert response.status_code == 200
+ assert response.data["count"] == 3
+ assert len(response.data["userstories"]) == 0
+ assert len(response.data["tasks"]) == 0
+ assert len(response.data["issues"]) == 1
+ assert len(response.data["wikipages"]) == 2
+
+
def test_search_text_query_with_an_invalid_project_id(client, searches_initial_data):
data = searches_initial_data
From 79c079d2cb7c8d455a5a4015c455d3d2e5b12d0e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jes=C3=BAs=20Espino?=
Date: Tue, 23 Jun 2015 19:22:47 +0200
Subject: [PATCH 013/190] Allow to include id attributes in the elements (to
allow anchor links)
---
taiga/mdrender/service.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/taiga/mdrender/service.py b/taiga/mdrender/service.py
index 5b9207c8..077c0c63 100644
--- a/taiga/mdrender/service.py
+++ b/taiga/mdrender/service.py
@@ -61,7 +61,7 @@ bleach.ALLOWED_STYLES.append("background")
bleach.ALLOWED_ATTRIBUTES["a"] = ["href", "title", "alt", "target"]
bleach.ALLOWED_ATTRIBUTES["img"] = ["alt", "src"]
-bleach.ALLOWED_ATTRIBUTES["*"] = ["class", "style"]
+bleach.ALLOWED_ATTRIBUTES["*"] = ["class", "style", "id"]
def _make_extensions_list(project=None):
From 9f253394a455731f57438933886ee670616683fd Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?=
Date: Wed, 24 Jun 2015 00:41:32 +0200
Subject: [PATCH 014/190] Refactored some tets
---
tests/integration/test_exporter_api.py | 4 +-
tests/integration/test_hooks_bitbucket.py | 4 +-
tests/integration/test_hooks_github.py | 4 +-
tests/integration/test_hooks_gitlab.py | 4 +-
tests/integration/test_importer_api.py | 64 +++++++++++------------
tests/integration/test_notifications.py | 2 +-
tests/integration/test_occ.py | 24 ++++-----
tests/integration/test_projects.py | 8 +--
tests/integration/test_users.py | 8 +--
tests/integration/test_userstories.py | 8 +--
10 files changed, 65 insertions(+), 65 deletions(-)
diff --git a/tests/integration/test_exporter_api.py b/tests/integration/test_exporter_api.py
index 7758fdf6..dee2f5bb 100644
--- a/tests/integration/test_exporter_api.py
+++ b/tests/integration/test_exporter_api.py
@@ -47,7 +47,7 @@ def test_valid_project_export_with_celery_disabled(client, settings):
response = client.get(url, content_type="application/json")
assert response.status_code == 200
- response_data = json.loads(response.content.decode("utf-8"))
+ response_data = response.data
assert "url" in response_data
@@ -63,7 +63,7 @@ def test_valid_project_export_with_celery_enabled(client, settings):
response = client.get(url, content_type="application/json")
assert response.status_code == 202
- response_data = json.loads(response.content.decode("utf-8"))
+ response_data = response.data
assert "export_id" in response_data
diff --git a/tests/integration/test_hooks_bitbucket.py b/tests/integration/test_hooks_bitbucket.py
index 4cf3bab8..c8a8670c 100644
--- a/tests/integration/test_hooks_bitbucket.py
+++ b/tests/integration/test_hooks_bitbucket.py
@@ -32,7 +32,7 @@ def test_bad_signature(client):
url = "{}?project={}&key={}".format(url, project.id, "badbadbad")
data = {}
response = client.post(url, urllib.parse.urlencode(data, True), content_type="application/x-www-form-urlencoded")
- response_content = json.loads(response.content.decode("utf-8"))
+ response_content = response.data
assert response.status_code == 400
assert "Bad signature" in response_content["_error_message"]
@@ -232,7 +232,7 @@ def test_api_get_project_modules(client):
client.login(project.owner)
response = client.get(url)
assert response.status_code == 200
- content = json.loads(response.content.decode("utf-8"))
+ content = response.data
assert "bitbucket" in content
assert content["bitbucket"]["secret"] != ""
assert content["bitbucket"]["webhooks_url"] != ""
diff --git a/tests/integration/test_hooks_github.py b/tests/integration/test_hooks_github.py
index d9861f89..39994f71 100644
--- a/tests/integration/test_hooks_github.py
+++ b/tests/integration/test_hooks_github.py
@@ -30,7 +30,7 @@ def test_bad_signature(client):
response = client.post(url, json.dumps(data),
HTTP_X_HUB_SIGNATURE="sha1=badbadbad",
content_type="application/json")
- response_content = json.loads(response.content.decode("utf-8"))
+ response_content = response.data
assert response.status_code == 400
assert "Bad signature" in response_content["_error_message"]
@@ -421,7 +421,7 @@ def test_api_get_project_modules(client):
client.login(project.owner)
response = client.get(url)
assert response.status_code == 200
- content = json.loads(response.content.decode("utf-8"))
+ content = response.data
assert "github" in content
assert content["github"]["secret"] != ""
assert content["github"]["webhooks_url"] != ""
diff --git a/tests/integration/test_hooks_gitlab.py b/tests/integration/test_hooks_gitlab.py
index 10935c46..64c4765c 100644
--- a/tests/integration/test_hooks_gitlab.py
+++ b/tests/integration/test_hooks_gitlab.py
@@ -33,7 +33,7 @@ def test_bad_signature(client):
url = "{}?project={}&key={}".format(url, project.id, "badbadbad")
data = {}
response = client.post(url, json.dumps(data), content_type="application/json")
- response_content = json.loads(response.content.decode("utf-8"))
+ response_content = response.data
assert response.status_code == 400
assert "Bad signature" in response_content["_error_message"]
@@ -349,7 +349,7 @@ def test_api_get_project_modules(client):
client.login(project.owner)
response = client.get(url)
assert response.status_code == 200
- content = json.loads(response.content.decode("utf-8"))
+ content = response.data
assert "gitlab" in content
assert content["gitlab"]["secret"] != ""
assert content["gitlab"]["webhooks_url"] != ""
diff --git a/tests/integration/test_importer_api.py b/tests/integration/test_importer_api.py
index 94b72561..96143112 100644
--- a/tests/integration/test_importer_api.py
+++ b/tests/integration/test_importer_api.py
@@ -58,7 +58,7 @@ def test_valid_project_import_without_extra_data(client):
response = client.post(url, json.dumps(data), content_type="application/json")
assert response.status_code == 201
- response_data = json.loads(response.content.decode("utf-8"))
+ response_data = response.data
must_empty_children = [
"issues", "user_stories", "us_statuses", "wiki_pages", "priorities",
"severities", "milestones", "points", "issue_types", "task_statuses",
@@ -85,7 +85,7 @@ def test_valid_project_import_with_not_existing_memberships(client):
response = client.post(url, json.dumps(data), content_type="application/json")
assert response.status_code == 201
- response_data = json.loads(response.content.decode("utf-8"))
+ response_data = response.data
# The new membership and the owner membership
assert len(response_data["memberships"]) == 2
@@ -108,7 +108,7 @@ def test_valid_project_import_with_membership_uuid_rewrite(client):
response = client.post(url, json.dumps(data), content_type="application/json")
assert response.status_code == 201
- response_data = json.loads(response.content.decode("utf-8"))
+ response_data = response.data
assert Membership.objects.filter(email="with-uuid@email.com", token="123").count() == 0
@@ -149,7 +149,7 @@ def test_valid_project_import_with_extra_data(client):
response = client.post(url, json.dumps(data), content_type="application/json")
assert response.status_code == 201
- response_data = json.loads(response.content.decode("utf-8"))
+ response_data = response.data
must_empty_children = [
"issues", "user_stories", "wiki_pages", "milestones",
"wiki_links",
@@ -178,7 +178,7 @@ def test_invalid_project_import_without_roles(client):
response = client.post(url, json.dumps(data), content_type="application/json")
assert response.status_code == 400
- response_data = json.loads(response.content.decode("utf-8"))
+ response_data = response.data
assert len(response_data) == 2
assert Project.objects.filter(slug="imported-project").count() == 0
@@ -205,7 +205,7 @@ def test_invalid_project_import_with_extra_data(client):
response = client.post(url, json.dumps(data), content_type="application/json")
assert response.status_code == 400
- response_data = json.loads(response.content.decode("utf-8"))
+ response_data = response.data
assert len(response_data) == 7
assert Project.objects.filter(slug="imported-project").count() == 0
@@ -302,7 +302,7 @@ def test_valid_user_story_import(client):
response = client.post(url, json.dumps(data), content_type="application/json")
assert response.status_code == 201
- response_data = json.loads(response.content.decode("utf-8"))
+ response_data = response.data
assert response_data["subject"] == "Imported issue"
assert response_data["finish_date"] == "2014-10-24T00:00:00+0000"
@@ -349,7 +349,7 @@ def test_valid_issue_import_without_extra_data(client):
response = client.post(url, json.dumps(data), content_type="application/json")
assert response.status_code == 201
- response_data = json.loads(response.content.decode("utf-8"))
+ response_data = response.data
assert response_data["owner"] == user.email
assert response_data["ref"] is not None
@@ -408,7 +408,7 @@ def test_valid_issue_import_with_extra_data(client):
response = client.post(url, json.dumps(data), content_type="application/json")
assert response.status_code == 201
- response_data = json.loads(response.content.decode("utf-8"))
+ response_data = response.data
assert len(response_data["attachments"]) == 1
assert response_data["owner"] == user.email
assert response_data["ref"] is not None
@@ -435,7 +435,7 @@ def test_invalid_issue_import_with_extra_data(client):
response = client.post(url, json.dumps(data), content_type="application/json")
assert response.status_code == 400
- response_data = json.loads(response.content.decode("utf-8"))
+ response_data = response.data
assert len(response_data) == 1
assert Issue.objects.filter(subject="Imported issue").count() == 0
@@ -460,7 +460,7 @@ def test_invalid_issue_import_with_bad_choices(client):
response = client.post(url, json.dumps(data), content_type="application/json")
assert response.status_code == 400
- response_data = json.loads(response.content.decode("utf-8"))
+ response_data = response.data
assert len(response_data) == 1
url = reverse("importer-issue", args=[project.pk])
@@ -472,7 +472,7 @@ def test_invalid_issue_import_with_bad_choices(client):
response = client.post(url, json.dumps(data), content_type="application/json")
assert response.status_code == 400
- response_data = json.loads(response.content.decode("utf-8"))
+ response_data = response.data
assert len(response_data) == 1
url = reverse("importer-issue", args=[project.pk])
@@ -484,7 +484,7 @@ def test_invalid_issue_import_with_bad_choices(client):
response = client.post(url, json.dumps(data), content_type="application/json")
assert response.status_code == 400
- response_data = json.loads(response.content.decode("utf-8"))
+ response_data = response.data
assert len(response_data) == 1
url = reverse("importer-issue", args=[project.pk])
@@ -496,7 +496,7 @@ def test_invalid_issue_import_with_bad_choices(client):
response = client.post(url, json.dumps(data), content_type="application/json")
assert response.status_code == 400
- response_data = json.loads(response.content.decode("utf-8"))
+ response_data = response.data
assert len(response_data) == 1
@@ -528,7 +528,7 @@ def test_valid_us_import_without_extra_data(client):
response = client.post(url, json.dumps(data), content_type="application/json")
assert response.status_code == 201
- response_data = json.loads(response.content.decode("utf-8"))
+ response_data = response.data
assert response_data["owner"] == user.email
assert response_data["ref"] is not None
@@ -556,7 +556,7 @@ def test_valid_us_import_with_extra_data(client):
response = client.post(url, json.dumps(data), content_type="application/json")
assert response.status_code == 201
- response_data = json.loads(response.content.decode("utf-8"))
+ response_data = response.data
assert len(response_data["attachments"]) == 1
assert response_data["owner"] == user.email
assert response_data["ref"] is not None
@@ -579,7 +579,7 @@ def test_invalid_us_import_with_extra_data(client):
response = client.post(url, json.dumps(data), content_type="application/json")
assert response.status_code == 400
- response_data = json.loads(response.content.decode("utf-8"))
+ response_data = response.data
assert len(response_data) == 1
assert UserStory.objects.filter(subject="Imported us").count() == 0
@@ -601,7 +601,7 @@ def test_invalid_us_import_with_bad_choices(client):
response = client.post(url, json.dumps(data), content_type="application/json")
assert response.status_code == 400
- response_data = json.loads(response.content.decode("utf-8"))
+ response_data = response.data
assert len(response_data) == 1
@@ -633,7 +633,7 @@ def test_valid_task_import_without_extra_data(client):
response = client.post(url, json.dumps(data), content_type="application/json")
assert response.status_code == 201
- response_data = json.loads(response.content.decode("utf-8"))
+ response_data = response.data
assert response_data["owner"] == user.email
assert response_data["ref"] is not None
@@ -685,7 +685,7 @@ def test_valid_task_import_with_extra_data(client):
response = client.post(url, json.dumps(data), content_type="application/json")
assert response.status_code == 201
- response_data = json.loads(response.content.decode("utf-8"))
+ response_data = response.data
assert len(response_data["attachments"]) == 1
assert response_data["owner"] == user.email
assert response_data["ref"] is not None
@@ -708,7 +708,7 @@ def test_invalid_task_import_with_extra_data(client):
response = client.post(url, json.dumps(data), content_type="application/json")
assert response.status_code == 400
- response_data = json.loads(response.content.decode("utf-8"))
+ response_data = response.data
assert len(response_data) == 1
assert Task.objects.filter(subject="Imported task").count() == 0
@@ -730,7 +730,7 @@ def test_invalid_task_import_with_bad_choices(client):
response = client.post(url, json.dumps(data), content_type="application/json")
assert response.status_code == 400
- response_data = json.loads(response.content.decode("utf-8"))
+ response_data = response.data
assert len(response_data) == 1
@@ -781,7 +781,7 @@ def test_valid_wiki_page_import_without_extra_data(client):
response = client.post(url, json.dumps(data), content_type="application/json")
assert response.status_code == 201
- response_data = json.loads(response.content.decode("utf-8"))
+ response_data = response.data
assert response_data["owner"] == user.email
@@ -806,7 +806,7 @@ def test_valid_wiki_page_import_with_extra_data(client):
response = client.post(url, json.dumps(data), content_type="application/json")
assert response.status_code == 201
- response_data = json.loads(response.content.decode("utf-8"))
+ response_data = response.data
assert len(response_data["attachments"]) == 1
assert response_data["owner"] == user.email
@@ -826,7 +826,7 @@ def test_invalid_wiki_page_import_with_extra_data(client):
response = client.post(url, json.dumps(data), content_type="application/json")
assert response.status_code == 400
- response_data = json.loads(response.content.decode("utf-8"))
+ response_data = response.data
assert len(response_data) == 1
assert WikiPage.objects.filter(slug="imported-wiki-page").count() == 0
@@ -858,7 +858,7 @@ def test_valid_wiki_link_import(client):
response = client.post(url, json.dumps(data), content_type="application/json")
assert response.status_code == 201
- json.loads(response.content.decode("utf-8"))
+ response.data
@@ -890,7 +890,7 @@ def test_valid_milestone_import(client):
response = client.post(url, json.dumps(data), content_type="application/json")
assert response.status_code == 201
- json.loads(response.content.decode("utf-8"))
+ response.data
@@ -910,7 +910,7 @@ def test_milestone_import_duplicated_milestone(client):
response = client.post(url, json.dumps(data), content_type="application/json")
response = client.post(url, json.dumps(data), content_type="application/json")
assert response.status_code == 400
- response_data = json.loads(response.content.decode("utf-8"))
+ response_data = response.data
assert response_data["milestones"][0]["name"][0] == "Name duplicated for the project"
@@ -925,7 +925,7 @@ def test_invalid_dump_import(client):
response = client.post(url, {'dump': data})
assert response.status_code == 400
- response_data = json.loads(response.content.decode("utf-8"))
+ response_data = response.data
assert response_data["_error_message"] == "Invalid dump format"
@@ -946,7 +946,7 @@ def test_valid_dump_import_with_celery_disabled(client, settings):
response = client.post(url, {'dump': data})
assert response.status_code == 201
- response_data = json.loads(response.content.decode("utf-8"))
+ response_data = response.data
assert "id" in response_data
assert response_data["name"] == "Valid project"
@@ -968,7 +968,7 @@ def test_valid_dump_import_with_celery_enabled(client, settings):
response = client.post(url, {'dump': data})
assert response.status_code == 202
- response_data = json.loads(response.content.decode("utf-8"))
+ response_data = response.data
assert "import_id" in response_data
@@ -988,7 +988,7 @@ def test_dump_import_duplicated_project(client):
response = client.post(url, {'dump': data})
assert response.status_code == 201
- response_data = json.loads(response.content.decode("utf-8"))
+ response_data = response.data
assert response_data["name"] == "Test import"
assert response_data["slug"] == "{}-test-import".format(user.username)
diff --git a/tests/integration/test_notifications.py b/tests/integration/test_notifications.py
index 7da6469d..d3d64ddb 100644
--- a/tests/integration/test_notifications.py
+++ b/tests/integration/test_notifications.py
@@ -463,4 +463,4 @@ def test_retrieve_notify_policies_by_anonymous_user(client):
url = reverse("notifications-detail", args=[policy.pk])
response = client.get(url, content_type="application/json")
assert response.status_code == 404, response.status_code
- assert json.loads(response.content.decode("utf-8"))["_error_message"] == "No NotifyPolicy matches the given query.", response.content
+ assert response.data["_error_message"] == "No NotifyPolicy matches the given query.", response.content
diff --git a/tests/integration/test_occ.py b/tests/integration/test_occ.py
index 9826cf0e..2244750d 100644
--- a/tests/integration/test_occ.py
+++ b/tests/integration/test_occ.py
@@ -61,7 +61,7 @@ def test_invalid_concurrent_save_for_issue(client):
response = client.json.post(url, json.dumps(data))
assert response.status_code == 201, response.content
- issue_id = json.loads(response.content)["id"]
+ issue_id = response.data["id"]
url = reverse("issues-detail", args=(issue_id,))
data = {"version": 1, "subject": "test 1"}
response = client.patch(url, json.dumps(data), content_type="application/json")
@@ -90,7 +90,7 @@ def test_valid_concurrent_save_for_issue_different_versions(client):
response = client.json.post(url, json.dumps(data))
assert response.status_code == 201, response.content
- issue_id = json.loads(response.content)["id"]
+ issue_id = response.data["id"]
url = reverse("issues-detail", args=(issue_id,))
data = {"version": 1, "subject": "test 1"}
response = client.patch(url, json.dumps(data), content_type="application/json")
@@ -119,7 +119,7 @@ def test_valid_concurrent_save_for_issue_different_fields(client):
response = client.json.post(url, json.dumps(data))
assert response.status_code == 201, response.content
- issue_id = json.loads(response.content)["id"]
+ issue_id = response.data["id"]
url = reverse("issues-detail", args=(issue_id,))
data = {"version": 1, "subject": "test 1"}
response = client.patch(url, json.dumps(data), content_type="application/json")
@@ -143,7 +143,7 @@ def test_invalid_concurrent_save_for_wiki_page(client):
response = client.json.post(url, json.dumps(data))
assert response.status_code == 201, response.content
- wiki_id = json.loads(response.content)["id"]
+ wiki_id = response.data["id"]
url = reverse("wiki-detail", args=(wiki_id,))
data = {"version": 1, "content": "test 1"}
response = client.patch(url, json.dumps(data), content_type="application/json")
@@ -167,7 +167,7 @@ def test_valid_concurrent_save_for_wiki_page_different_versions(client):
response = client.json.post(url, json.dumps(data))
assert response.status_code == 201, response.content
- wiki_id = json.loads(response.content)["id"]
+ wiki_id = response.data["id"]
url = reverse("wiki-detail", args=(wiki_id,))
data = {"version": 1, "content": "test 1"}
response = client.patch(url, json.dumps(data), content_type="application/json")
@@ -194,7 +194,7 @@ def test_invalid_concurrent_save_for_us(client):
response = client.json.post(url, json.dumps(data))
assert response.status_code == 201
- userstory_id = json.loads(response.content)["id"]
+ userstory_id = response.data["id"]
url = reverse("userstories-detail", args=(userstory_id,))
data = {"version": 1, "subject": "test 1"}
response = client.patch(url, json.dumps(data), content_type="application/json")
@@ -220,7 +220,7 @@ def test_valid_concurrent_save_for_us_different_versions(client):
response = client.json.post(url, json.dumps(data))
assert response.status_code == 201
- userstory_id = json.loads(response.content)["id"]
+ userstory_id = response.data["id"]
url = reverse("userstories-detail", args=(userstory_id,))
data = {"version": 1, "subject": "test 1"}
response = client.patch(url, json.dumps(data), content_type="application/json")
@@ -246,7 +246,7 @@ def test_valid_concurrent_save_for_us_different_fields(client):
response = client.json.post(url, json.dumps(data))
assert response.status_code == 201
- userstory_id = json.loads(response.content)["id"]
+ userstory_id = response.data["id"]
url = reverse("userstories-detail", args=(userstory_id,))
data = {"version": 1, "subject": "test 1"}
response = client.patch(url, json.dumps(data), content_type="application/json")
@@ -272,7 +272,7 @@ def test_invalid_concurrent_save_for_task(client):
response = client.json.post(url, json.dumps(data))
assert response.status_code == 201
- task_id = json.loads(response.content)["id"]
+ task_id = response.data["id"]
url = reverse("tasks-detail", args=(task_id,))
data = {"version": 1, "subject": "test 1"}
response = client.patch(url, json.dumps(data), content_type="application/json")
@@ -298,7 +298,7 @@ def test_valid_concurrent_save_for_task_different_versions(client):
response = client.json.post(url, json.dumps(data))
assert response.status_code == 201
- task_id = json.loads(response.content)["id"]
+ task_id = response.data["id"]
url = reverse("tasks-detail", args=(task_id,))
data = {"version": 1, "subject": "test 1"}
response = client.patch(url, json.dumps(data), content_type="application/json")
@@ -324,7 +324,7 @@ def test_valid_concurrent_save_for_task_different_fields(client):
response = client.json.post(url, json.dumps(data))
assert response.status_code == 201
- task_id = json.loads(response.content)["id"]
+ task_id = response.data["id"]
url = reverse("tasks-detail", args=(task_id,))
data = {"version": 1, "subject": "test 1"}
response = client.patch(url, json.dumps(data), content_type="application/json")
@@ -351,7 +351,7 @@ def test_invalid_save_without_version_parameter(client):
response = client.json.post(url, json.dumps(data))
assert response.status_code == 201
- task_id = json.loads(response.content)["id"]
+ task_id = response.data["id"]
url = reverse("tasks-detail", args=(task_id,))
data = {"subject": "test 1"}
response = client.patch(url, json.dumps(data), content_type="application/json")
diff --git a/tests/integration/test_projects.py b/tests/integration/test_projects.py
index 44de3056..a539dd09 100644
--- a/tests/integration/test_projects.py
+++ b/tests/integration/test_projects.py
@@ -200,7 +200,7 @@ def test_leave_project_valid_membership_only_owner(client):
url = reverse("projects-leave", args=(project.id,))
response = client.post(url)
assert response.status_code == 403
- assert json.loads(response.content)["_error_message"] == "You can't leave the project if there are no more owners"
+ assert response.data["_error_message"] == "You can't leave the project if there are no more owners"
def test_leave_project_invalid_membership(client):
@@ -221,7 +221,7 @@ def test_delete_membership_only_owner(client):
url = reverse("memberships-detail", args=(membership.id,))
response = client.delete(url)
assert response.status_code == 400
- assert json.loads(response.content)["_error_message"] == "At least one of the user must be an active admin"
+ assert response.data["_error_message"] == "At least one of the user must be an active admin"
def test_edit_membership_only_owner(client):
@@ -339,7 +339,7 @@ def test_projects_user_order(client):
url = reverse("projects-list")
url = "%s?member=%s" % (url, user.id)
response = client.json.get(url)
- response_content = json.loads(response.content.decode("utf-8"))
+ response_content = response.data
assert response.status_code == 200
assert(response_content[0]["id"] == project_1.id)
@@ -347,6 +347,6 @@ def test_projects_user_order(client):
url = reverse("projects-list")
url = "%s?member=%s&order_by=memberships__user_order" % (url, user.id)
response = client.json.get(url)
- response_content = json.loads(response.content.decode("utf-8"))
+ response_content = response.data
assert response.status_code == 200
assert(response_content[0]["id"] == project_2.id)
diff --git a/tests/integration/test_users.py b/tests/integration/test_users.py
index 1898aea8..2d3f1664 100644
--- a/tests/integration/test_users.py
+++ b/tests/integration/test_users.py
@@ -202,13 +202,13 @@ def test_list_contacts_private_projects(client):
url = reverse('users-contacts', kwargs={"pk": user_1.pk})
response = client.get(url, content_type="application/json")
assert response.status_code == 200
- response_content = json.loads(response.content.decode("utf-8"))
+ response_content = response.data
assert len(response_content) == 0
client.login(user_1)
response = client.get(url, content_type="application/json")
assert response.status_code == 200
- response_content = json.loads(response.content.decode("utf-8"))
+ response_content = response.data
assert len(response_content) == 1
assert response_content[0]["id"] == user_2.id
@@ -227,7 +227,7 @@ def test_list_contacts_no_projects(client):
response = client.get(url, content_type="application/json")
assert response.status_code == 200
- response_content = json.loads(response.content.decode("utf-8"))
+ response_content = response.data
assert len(response_content) == 0
@@ -246,6 +246,6 @@ def test_list_contacts_public_projects(client):
response = client.get(url, content_type="application/json")
assert response.status_code == 200
- response_content = json.loads(response.content.decode("utf-8"))
+ response_content = response.data
assert len(response_content) == 1
assert response_content[0]["id"] == user_2.id
diff --git a/tests/integration/test_userstories.py b/tests/integration/test_userstories.py
index 9819d413..91bee3b0 100644
--- a/tests/integration/test_userstories.py
+++ b/tests/integration/test_userstories.py
@@ -203,15 +203,15 @@ def test_archived_filter(client):
data = {}
response = client.get(url, data)
- assert len(json.loads(response.content)) == 2
+ assert len(response.data) == 2
data = {"status__is_archived": 0}
response = client.get(url, data)
- assert len(json.loads(response.content)) == 1
+ assert len(response.data) == 1
data = {"status__is_archived": 1}
response = client.get(url, data)
- assert len(json.loads(response.content)) == 1
+ assert len(response.data) == 1
def test_filter_by_multiple_status(client):
@@ -230,7 +230,7 @@ def test_filter_by_multiple_status(client):
data = {}
response = client.get(url, data)
- assert len(json.loads(response.content)) == 2
+ assert len(response.data) == 2
def test_get_total_points(client):
From 4ae37167f5dd13c88bcc0bdfa69a7b755220cd50 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jes=C3=BAs=20Espino?=
Date: Tue, 23 Jun 2015 18:08:09 +0200
Subject: [PATCH 015/190] Allow multiple message actions on commit
---
CHANGELOG.md | 1 +
taiga/hooks/bitbucket/event_hooks.py | 3 +--
taiga/hooks/github/event_hooks.py | 3 +--
taiga/hooks/gitlab/event_hooks.py | 3 +--
tests/integration/test_hooks_bitbucket.py | 20 +++++++++++++++++++
tests/integration/test_hooks_github.py | 24 +++++++++++++++++++++++
tests/integration/test_hooks_gitlab.py | 24 +++++++++++++++++++++++
7 files changed, 72 insertions(+), 6 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5ec53387..68349c38 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,7 @@
- Add a "field type" property for custom fields: 'text' and 'multiline text' right now (thanks to [@artlepool](https://github.com/artlepool))
### Misc
+- Allow multiple actions in the commit messages.
- Lots of small and not so small bugfixes.
diff --git a/taiga/hooks/bitbucket/event_hooks.py b/taiga/hooks/bitbucket/event_hooks.py
index 7461eabb..969dae64 100644
--- a/taiga/hooks/bitbucket/event_hooks.py
+++ b/taiga/hooks/bitbucket/event_hooks.py
@@ -61,8 +61,7 @@ class PushEventHook(BaseEventHook):
return
p = re.compile("tg-(\d+) +#([-\w]+)")
- m = p.search(message.lower())
- if m:
+ for m in p.finditer(message.lower()):
ref = m.group(1)
status_slug = m.group(2)
self._change_status(ref, status_slug, bitbucket_user)
diff --git a/taiga/hooks/github/event_hooks.py b/taiga/hooks/github/event_hooks.py
index e34c1a2f..3dbd0417 100644
--- a/taiga/hooks/github/event_hooks.py
+++ b/taiga/hooks/github/event_hooks.py
@@ -56,8 +56,7 @@ class PushEventHook(BaseEventHook):
return
p = re.compile("tg-(\d+) +#([-\w]+)")
- m = p.search(message.lower())
- if m:
+ for m in p.finditer(message.lower()):
ref = m.group(1)
status_slug = m.group(2)
self._change_status(ref, status_slug, github_user, commit)
diff --git a/taiga/hooks/gitlab/event_hooks.py b/taiga/hooks/gitlab/event_hooks.py
index 8776d8c7..84079121 100644
--- a/taiga/hooks/gitlab/event_hooks.py
+++ b/taiga/hooks/gitlab/event_hooks.py
@@ -54,8 +54,7 @@ class PushEventHook(BaseEventHook):
return
p = re.compile("tg-(\d+) +#([-\w]+)")
- m = p.search(message.lower())
- if m:
+ for m in p.finditer(message.lower()):
ref = m.group(1)
status_slug = m.group(2)
self._change_status(ref, status_slug, gitlab_user)
diff --git a/tests/integration/test_hooks_bitbucket.py b/tests/integration/test_hooks_bitbucket.py
index 4cf3bab8..83a67d3a 100644
--- a/tests/integration/test_hooks_bitbucket.py
+++ b/tests/integration/test_hooks_bitbucket.py
@@ -160,6 +160,26 @@ def test_push_event_user_story_processing(client):
assert len(mail.outbox) == 1
+def test_push_event_multiple_actions(client):
+ creation_status = f.IssueStatusFactory()
+ role = f.RoleFactory(project=creation_status.project, permissions=["view_issues"])
+ f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner)
+ new_status = f.IssueStatusFactory(project=creation_status.project)
+ issue1 = f.IssueFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner)
+ issue2 = f.IssueFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner)
+ payload = [
+ '{"commits": [{"message": "test message test TG-%s #%s ok test TG-%s #%s ok bye!"}]}' % (issue1.ref, new_status.slug, issue2.ref, new_status.slug)
+ ]
+ mail.outbox = []
+ ev_hook1 = event_hooks.PushEventHook(issue1.project, payload)
+ ev_hook1.process_event()
+ issue1 = Issue.objects.get(id=issue1.id)
+ issue2 = Issue.objects.get(id=issue2.id)
+ assert issue1.status.id == new_status.id
+ assert issue2.status.id == new_status.id
+ assert len(mail.outbox) == 2
+
+
def test_push_event_processing_case_insensitive(client):
creation_status = f.TaskStatusFactory()
role = f.RoleFactory(project=creation_status.project, permissions=["view_tasks"])
diff --git a/tests/integration/test_hooks_github.py b/tests/integration/test_hooks_github.py
index d9861f89..43b092f5 100644
--- a/tests/integration/test_hooks_github.py
+++ b/tests/integration/test_hooks_github.py
@@ -134,6 +134,30 @@ def test_push_event_user_story_processing(client):
assert len(mail.outbox) == 1
+def test_push_event_multiple_actions(client):
+ creation_status = f.IssueStatusFactory()
+ role = f.RoleFactory(project=creation_status.project, permissions=["view_issues"])
+ f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner)
+ new_status = f.IssueStatusFactory(project=creation_status.project)
+ issue1 = f.IssueFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner)
+ issue2 = f.IssueFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner)
+ payload = {"commits": [
+ {"message": """test message
+ test TG-%s #%s ok
+ test TG-%s #%s ok
+ bye!
+ """ % (issue1.ref, new_status.slug, issue2.ref, new_status.slug)},
+ ]}
+ mail.outbox = []
+ ev_hook1 = event_hooks.PushEventHook(issue1.project, payload)
+ ev_hook1.process_event()
+ issue1 = Issue.objects.get(id=issue1.id)
+ issue2 = Issue.objects.get(id=issue2.id)
+ assert issue1.status.id == new_status.id
+ assert issue2.status.id == new_status.id
+ assert len(mail.outbox) == 2
+
+
def test_push_event_processing_case_insensitive(client):
creation_status = f.TaskStatusFactory()
role = f.RoleFactory(project=creation_status.project, permissions=["view_tasks"])
diff --git a/tests/integration/test_hooks_gitlab.py b/tests/integration/test_hooks_gitlab.py
index 10935c46..fc02a4c2 100644
--- a/tests/integration/test_hooks_gitlab.py
+++ b/tests/integration/test_hooks_gitlab.py
@@ -179,6 +179,30 @@ def test_push_event_user_story_processing(client):
assert len(mail.outbox) == 1
+def test_push_event_multiple_actions(client):
+ creation_status = f.IssueStatusFactory()
+ role = f.RoleFactory(project=creation_status.project, permissions=["view_issues"])
+ f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner)
+ new_status = f.IssueStatusFactory(project=creation_status.project)
+ issue1 = f.IssueFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner)
+ issue2 = f.IssueFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner)
+ payload = {"commits": [
+ {"message": """test message
+ test TG-%s #%s ok
+ test TG-%s #%s ok
+ bye!
+ """ % (issue1.ref, new_status.slug, issue2.ref, new_status.slug)},
+ ]}
+ mail.outbox = []
+ ev_hook1 = event_hooks.PushEventHook(issue1.project, payload)
+ ev_hook1.process_event()
+ issue1 = Issue.objects.get(id=issue1.id)
+ issue2 = Issue.objects.get(id=issue2.id)
+ assert issue1.status.id == new_status.id
+ assert issue2.status.id == new_status.id
+ assert len(mail.outbox) == 2
+
+
def test_push_event_processing_case_insensitive(client):
creation_status = f.TaskStatusFactory()
role = f.RoleFactory(project=creation_status.project, permissions=["view_tasks"])
From c32d505b96fb875d7da421246f793e9b23a5f976 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jes=C3=BAs=20Espino?=
Date: Tue, 23 Jun 2015 21:23:54 +0200
Subject: [PATCH 016/190] Allow default status, priority, severity and type in
issues and tasks
---
.../migrations/0005_auto_20150623_1923.py | 38 +++++++++++++++++++
taiga/projects/issues/models.py | 8 ++--
.../migrations/0006_auto_20150623_1923.py | 20 ++++++++++
taiga/projects/tasks/models.py | 2 +-
tests/integration/test_issues.py | 25 ++++++++++++
tests/integration/test_tasks.py | 17 +++++++++
tests/integration/test_userstories.py | 17 +++++++++
7 files changed, 122 insertions(+), 5 deletions(-)
create mode 100644 taiga/projects/issues/migrations/0005_auto_20150623_1923.py
create mode 100644 taiga/projects/tasks/migrations/0006_auto_20150623_1923.py
diff --git a/taiga/projects/issues/migrations/0005_auto_20150623_1923.py b/taiga/projects/issues/migrations/0005_auto_20150623_1923.py
new file mode 100644
index 00000000..f97057cd
--- /dev/null
+++ b/taiga/projects/issues/migrations/0005_auto_20150623_1923.py
@@ -0,0 +1,38 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('issues', '0004_auto_20150114_0954'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='issue',
+ name='priority',
+ field=models.ForeignKey(blank=True, null=True, to='projects.Priority', related_name='issues', verbose_name='priority'),
+ preserve_default=True,
+ ),
+ migrations.AlterField(
+ model_name='issue',
+ name='severity',
+ field=models.ForeignKey(blank=True, null=True, to='projects.Severity', related_name='issues', verbose_name='severity'),
+ preserve_default=True,
+ ),
+ migrations.AlterField(
+ model_name='issue',
+ name='status',
+ field=models.ForeignKey(blank=True, null=True, to='projects.IssueStatus', related_name='issues', verbose_name='status'),
+ preserve_default=True,
+ ),
+ migrations.AlterField(
+ model_name='issue',
+ name='type',
+ field=models.ForeignKey(blank=True, null=True, to='projects.IssueType', related_name='issues', verbose_name='type'),
+ preserve_default=True,
+ ),
+ ]
diff --git a/taiga/projects/issues/models.py b/taiga/projects/issues/models.py
index 397e03db..943509b3 100644
--- a/taiga/projects/issues/models.py
+++ b/taiga/projects/issues/models.py
@@ -36,13 +36,13 @@ class Issue(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, models.
verbose_name=_("ref"))
owner = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, default=None,
related_name="owned_issues", verbose_name=_("owner"))
- status = models.ForeignKey("projects.IssueStatus", null=False, blank=False,
+ status = models.ForeignKey("projects.IssueStatus", null=True, blank=True,
related_name="issues", verbose_name=_("status"))
- severity = models.ForeignKey("projects.Severity", null=False, blank=False,
+ severity = models.ForeignKey("projects.Severity", null=True, blank=True,
related_name="issues", verbose_name=_("severity"))
- priority = models.ForeignKey("projects.Priority", null=False, blank=False,
+ priority = models.ForeignKey("projects.Priority", null=True, blank=True,
related_name="issues", verbose_name=_("priority"))
- type = models.ForeignKey("projects.IssueType", null=False, blank=False,
+ type = models.ForeignKey("projects.IssueType", null=True, blank=True,
related_name="issues", verbose_name=_("type"))
milestone = models.ForeignKey("milestones.Milestone", null=True, blank=True,
default=None, related_name="issues",
diff --git a/taiga/projects/tasks/migrations/0006_auto_20150623_1923.py b/taiga/projects/tasks/migrations/0006_auto_20150623_1923.py
new file mode 100644
index 00000000..077fefe8
--- /dev/null
+++ b/taiga/projects/tasks/migrations/0006_auto_20150623_1923.py
@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('tasks', '0005_auto_20150114_0954'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='task',
+ name='status',
+ field=models.ForeignKey(blank=True, null=True, to='projects.TaskStatus', related_name='tasks', verbose_name='status'),
+ preserve_default=True,
+ ),
+ ]
diff --git a/taiga/projects/tasks/models.py b/taiga/projects/tasks/models.py
index c35de144..699321c0 100644
--- a/taiga/projects/tasks/models.py
+++ b/taiga/projects/tasks/models.py
@@ -35,7 +35,7 @@ class Task(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, models.M
verbose_name=_("ref"))
owner = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, default=None,
related_name="owned_tasks", verbose_name=_("owner"))
- status = models.ForeignKey("projects.TaskStatus", null=False, blank=False,
+ status = models.ForeignKey("projects.TaskStatus", null=True, blank=True,
related_name="tasks", verbose_name=_("status"))
project = models.ForeignKey("projects.Project", null=False, blank=False,
related_name="tasks", verbose_name=_("project"))
diff --git a/tests/integration/test_issues.py b/tests/integration/test_issues.py
index 43ac01e0..a3aa9db0 100644
--- a/tests/integration/test_issues.py
+++ b/tests/integration/test_issues.py
@@ -46,6 +46,31 @@ def test_update_issues_order_in_bulk():
model=models.Issue)
+def test_create_issue_without_status(client):
+ user = f.UserFactory.create()
+ project = f.ProjectFactory.create(owner=user)
+ status = f.IssueStatusFactory.create(project=project)
+ priority = f.PriorityFactory.create(project=project)
+ severity = f.SeverityFactory.create(project=project)
+ type = f.IssueTypeFactory.create(project=project)
+ project.default_issue_status = status
+ project.default_priority = priority
+ project.default_severity = severity
+ project.default_issue_type = type
+ project.save()
+ f.MembershipFactory.create(project=project, user=user, is_owner=True)
+ url = reverse("issues-list")
+
+ data = {"subject": "Test user story", "project": project.id}
+ client.login(user)
+ response = client.json.post(url, json.dumps(data))
+ assert response.status_code == 201
+ assert response.data['status'] == project.default_issue_status.id
+ assert response.data['severity'] == project.default_severity.id
+ assert response.data['priority'] == project.default_priority.id
+ assert response.data['type'] == project.default_issue_type.id
+
+
def test_api_create_issues_in_bulk(client):
project = f.create_project()
f.MembershipFactory(project=project, user=project.owner, is_owner=True)
diff --git a/tests/integration/test_tasks.py b/tests/integration/test_tasks.py
index 6f8d3206..61d1954a 100644
--- a/tests/integration/test_tasks.py
+++ b/tests/integration/test_tasks.py
@@ -36,6 +36,23 @@ Task #2
db.save_in_bulk.assert_called_once_with(tasks, None, None)
+def test_create_task_without_status(client):
+ user = f.UserFactory.create()
+ project = f.ProjectFactory.create(owner=user)
+ status = f.TaskStatusFactory.create(project=project)
+ project.default_task_status = status
+ project.save()
+
+ f.MembershipFactory.create(project=project, user=user, is_owner=True)
+ url = reverse("tasks-list")
+
+ data = {"subject": "Test user story", "project": project.id}
+ client.login(user)
+ response = client.json.post(url, json.dumps(data))
+ assert response.status_code == 201
+ assert response.data['status'] == project.default_task_status.id
+
+
def test_api_update_task_tags(client):
task = f.create_task()
f.MembershipFactory.create(project=task.project, user=task.owner, is_owner=True)
diff --git a/tests/integration/test_userstories.py b/tests/integration/test_userstories.py
index 91bee3b0..e86f1dad 100644
--- a/tests/integration/test_userstories.py
+++ b/tests/integration/test_userstories.py
@@ -45,6 +45,23 @@ def test_update_userstories_order_in_bulk():
model=models.UserStory)
+def test_create_userstory_without_status(client):
+ user = f.UserFactory.create()
+ project = f.ProjectFactory.create(owner=user)
+ status = f.UserStoryStatusFactory.create(project=project)
+ project.default_us_status = status
+ project.save()
+
+ f.MembershipFactory.create(project=project, user=user, is_owner=True)
+ url = reverse("userstories-list")
+
+ data = {"subject": "Test user story", "project": project.id}
+ client.login(user)
+ response = client.json.post(url, json.dumps(data))
+ assert response.status_code == 201
+ assert response.data['status'] == project.default_us_status.id
+
+
def test_api_delete_userstory(client):
us = f.UserStoryFactory.create()
f.MembershipFactory.create(project=us.project, user=us.owner, is_owner=True)
From d70800d3e66a212c9f1c84d689d06947dd2b26ac Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?=
Date: Tue, 23 Jun 2015 17:14:07 +0200
Subject: [PATCH 017/190] US #2911: Mixin 'users', 'members' and 'memberships'
in ProjectDetailSerializer
---
CHANGELOG.md | 4 +++-
taiga/projects/serializers.py | 39 ++++++++++++++++++-----------------
2 files changed, 23 insertions(+), 20 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 68349c38..2b5fdcda 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,11 +2,13 @@
## 1.9.0 ??? (unreleased)
+
### Features
- Add a "field type" property for custom fields: 'text' and 'multiline text' right now (thanks to [@artlepool](https://github.com/artlepool))
+- Allow multiple actions in the commit messages.
### Misc
-- Allow multiple actions in the commit messages.
+- API: Mixin fields 'users', 'members' and 'memberships' in ProjectDetailSerializer
- Lots of small and not so small bugfixes.
diff --git a/taiga/projects/serializers.py b/taiga/projects/serializers.py
index 5ca3e7f4..d34d5939 100644
--- a/taiga/projects/serializers.py
+++ b/taiga/projects/serializers.py
@@ -272,21 +272,6 @@ class MembershipAdminSerializer(MembershipSerializer):
exclude = ("token",)
-class ProjectMembershipSerializer(serializers.ModelSerializer):
- role_name = serializers.CharField(source='role.name', required=False, i18n=True)
- full_name = serializers.CharField(source='user.get_full_name', required=False)
- username = serializers.CharField(source='user.username', required=False)
- color = serializers.CharField(source='user.color', required=False)
- is_active = serializers.BooleanField(source='user.is_active', required=False)
- photo = serializers.SerializerMethodField("get_photo")
-
- class Meta:
- model = models.Membership
-
- def get_photo(self, project):
- return get_photo_or_gravatar_url(project.user)
-
-
class MemberBulkSerializer(RoleExistsValidator, serializers.Serializer):
email = serializers.EmailField()
role_id = serializers.IntegerField()
@@ -298,6 +283,23 @@ class MembersBulkSerializer(ProjectExistsValidator, serializers.Serializer):
invitation_extra_text = serializers.CharField(required=False, max_length=255)
+class ProjectMemberSerializer(serializers.ModelSerializer):
+ id = serializers.IntegerField(source="user.id", read_only=True)
+ username = serializers.CharField(source='user.username', read_only=True)
+ full_name = serializers.CharField(source='user.full_name', read_only=True)
+ full_name_display = serializers.CharField(source='user.get_full_name', read_only=True)
+ color = serializers.CharField(source='user.color', read_only=True)
+ photo = serializers.SerializerMethodField("get_photo")
+ is_active = serializers.BooleanField(source='user.is_active', read_only=True)
+ role_name = serializers.CharField(source='role.name', read_only=True, i18n=True)
+
+ class Meta:
+ model = models.Membership
+
+ def get_photo(self, membership):
+ return get_photo_or_gravatar_url(membership.user)
+
+
######################################################
## Projects
######################################################
@@ -365,15 +367,14 @@ class ProjectDetailSerializer(ProjectSerializer):
many=True, required=False)
roles = ProjectRoleSerializer(source="roles", many=True, read_only=True)
- users = UserSerializer(source="members", many=True, read_only=True)
- memberships = serializers.SerializerMethodField(method_name="get_memberships")
+ members = serializers.SerializerMethodField(method_name="get_members")
- def get_memberships(self, obj):
+ def get_members(self, obj):
qs = obj.memberships.filter(user__isnull=False)
qs = qs.extra(select={"complete_user_name":"concat(full_name, username)"})
qs = qs.order_by("complete_user_name")
qs = qs.select_related("role", "user")
- serializer = ProjectMembershipSerializer(qs, many=True)
+ serializer = ProjectMemberSerializer(qs, many=True)
return serializer.data
From 6a0b4687d8e908006124ec5784c91553b1b00758 Mon Sep 17 00:00:00 2001
From: Alejandro Alonso
Date: Tue, 23 Jun 2015 12:49:36 +0200
Subject: [PATCH 018/190] Issue #2916: When leaving a project I lose all my
watched stuff (even from different projects)
---
taiga/projects/signals.py | 9 ++++++++-
tests/integration/test_projects.py | 16 ++++++++++++++++
2 files changed, 24 insertions(+), 1 deletion(-)
diff --git a/taiga/projects/signals.py b/taiga/projects/signals.py
index e4366d8d..61dd0709 100644
--- a/taiga/projects/signals.py
+++ b/taiga/projects/signals.py
@@ -19,6 +19,7 @@ from django.conf import settings
from taiga.projects.services.tags_colors import update_project_tags_colors_handler, remove_unused_tags
from taiga.projects.notifications.services import create_notify_policy_if_not_exists
+from taiga.base.utils.db import get_typename_for_model_class
####################################
@@ -53,7 +54,13 @@ def update_watchers_on_membership_post_delete(sender, instance, using, **kwargs)
# instance.user can contain pointer to now
# removed object from a database.
for model in models:
- model.watchers.through.objects.filter(user_id=instance.user_id).delete()
+ #filter(project=instance.project)
+ filter = {
+ "user_id": instance.user_id,
+ "%s__project"%(model._meta.model_name): instance.project,
+ }
+
+ model.watchers.through.objects.filter(**filter).delete()
def create_notify_policy(sender, instance, using, **kwargs):
diff --git a/tests/integration/test_projects.py b/tests/integration/test_projects.py
index a539dd09..527b0d0e 100644
--- a/tests/integration/test_projects.py
+++ b/tests/integration/test_projects.py
@@ -212,6 +212,22 @@ def test_leave_project_invalid_membership(client):
assert response.status_code == 404
+def test_leave_project_respect_watching_items(client):
+ user = f.UserFactory.create()
+ project = f.ProjectFactory.create()
+ role = f.RoleFactory.create(project=project, permissions=["view_project"])
+ f.MembershipFactory.create(project=project, user=user, role=role)
+ issue = f.IssueFactory(owner=user)
+ issue.watchers=[user]
+ issue.save()
+
+ client.login(user)
+ url = reverse("projects-leave", args=(project.id,))
+ response = client.post(url)
+ assert response.status_code == 200
+ assert list(issue.watchers.all()) == [user]
+
+
def test_delete_membership_only_owner(client):
user = f.UserFactory.create()
project = f.ProjectFactory.create()
From 816743982879b1681c6f3dbd3373823f31e99d09 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?=
Date: Wed, 24 Jun 2015 13:31:46 +0200
Subject: [PATCH 019/190] [i18n] Update locales
---
taiga/locale/de/LC_MESSAGES/django.po | 138 +++++++++++++++++++++-----
1 file changed, 115 insertions(+), 23 deletions(-)
diff --git a/taiga/locale/de/LC_MESSAGES/django.po b/taiga/locale/de/LC_MESSAGES/django.po
index cf2bc68f..9147444a 100644
--- a/taiga/locale/de/LC_MESSAGES/django.po
+++ b/taiga/locale/de/LC_MESSAGES/django.po
@@ -14,8 +14,8 @@ msgstr ""
"Project-Id-Version: taiga-back\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2015-06-15 12:34+0200\n"
-"PO-Revision-Date: 2015-06-20 14:15+0000\n"
-"Last-Translator: Hans Raaf\n"
+"PO-Revision-Date: 2015-06-24 10:12+0000\n"
+"Last-Translator: Regina \n"
"Language-Team: German (http://www.transifex.com/projects/p/taiga-back/"
"language/de/)\n"
"MIME-Version: 1.0\n"
@@ -65,7 +65,7 @@ msgstr "Der Benutzer ist schon registriert."
#: taiga/auth/services.py:146
msgid "Membership with user is already exists."
-msgstr "Der Benutzer für diese Mitgliedschaft, existiert bereits."
+msgstr "Der Benutzer für diese Mitgliedschaft existiert bereits."
#: taiga/auth/services.py:172
msgid "Error on creating new user."
@@ -265,7 +265,7 @@ msgstr "Es gab keine Eingabe"
#: taiga/base/api/serializers.py:548
msgid "Cannot create a new item, only existing items may be updated."
msgstr ""
-"Es können nur existierende Einträge aktualisiertwerden. Eine Neuerstellung "
+"Es können nur existierende Einträge aktualisiert werden. Eine Neuerstellung "
"ist nicht möglich."
#: taiga/base/api/serializers.py:559
@@ -337,7 +337,7 @@ msgstr "Nicht gefunden."
#: taiga/base/exceptions.py:130
msgid "Method not supported for this endpoint."
-msgstr ""
+msgstr "Methode wird für diesen Endpunkt nicht unterstützt. "
#: taiga/base/exceptions.py:138 taiga/base/exceptions.py:146
msgid "Wrong arguments."
@@ -357,7 +357,7 @@ msgstr "Voraussetzungsfehler"
#: taiga/base/filters.py:74
msgid "Error in filter params types."
-msgstr ""
+msgstr "Fehler in Filter Parameter Typen."
#: taiga/base/filters.py:121 taiga/base/filters.py:210
#: taiga/base/filters.py:259
@@ -470,6 +470,10 @@ msgid ""
"%(comment)s
\n"
" "
msgstr ""
+"\n"
+"Kommentar:
\n"
+"%(comment)s
\n"
+" "
#: taiga/base/templates/emails/updates-body-text.jinja:6
#, python-format
@@ -536,7 +540,7 @@ msgstr "Fehler beim Importieren der Tickets"
#: taiga/export_import/dump_service.py:169
msgid "error importing user stories"
-msgstr "Fehler beim Importieren der User Stories"
+msgstr "Fehler beim Importieren der User-Stories"
#: taiga/export_import/dump_service.py:174
msgid "error importing tasks"
@@ -569,7 +573,7 @@ msgstr "Enthält ungültige Benutzerfelder."
#: taiga/projects/serializers.py:66 taiga/projects/serializers.py:92
#: taiga/projects/serializers.py:122 taiga/projects/serializers.py:164
msgid "Name duplicated for the project"
-msgstr ""
+msgstr "Der Name für das Projekt ist doppelt vergeben"
#: taiga/export_import/tasks.py:49 taiga/export_import/tasks.py:50
msgid "Error generating project dump"
@@ -781,6 +785,10 @@ msgid ""
" %(comment)s
\n"
" "
msgstr ""
+"\n"
+"Kommentar
\n"
+"%(comment)s
\n"
+" "
#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:18
#: taiga/users/admin.py:51
@@ -797,6 +805,12 @@ msgid ""
"%(comment)s\n"
"---------"
msgstr ""
+"---------\n"
+"- Von: %(full_name)s <%(email)s>\n"
+"---------\n"
+"- Kommentar:\n"
+"%(comment)s\n"
+"---------"
#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:8
msgid "- Extra info:"
@@ -808,6 +822,9 @@ msgid ""
"\n"
"[Taiga] Feedback from %(full_name)s <%(email)s>\n"
msgstr ""
+"\n"
+"[Taiga] Feedback von %(full_name)s <%(email)s>\n"
+" \n"
#: taiga/hooks/api.py:52
msgid "The payload is not a valid json"
@@ -905,6 +922,9 @@ msgid ""
"\n"
"{message}"
msgstr ""
+"Kommentar von GitHub:\n"
+"\n"
+"{message}"
#: taiga/hooks/gitlab/event_hooks.py:87
msgid "Status changed from GitLab commit"
@@ -926,7 +946,7 @@ msgstr "Meilensteine ansehen"
#: taiga/permissions/permissions.py:23 taiga/permissions/permissions.py:33
msgid "View user stories"
-msgstr "User Stories ansehen. "
+msgstr "User-Stories ansehen. "
#: taiga/permissions/permissions.py:24 taiga/permissions/permissions.py:36
#: taiga/permissions/permissions.py:64
@@ -958,11 +978,11 @@ msgstr "Mitgliedschaft beantragen"
#: taiga/permissions/permissions.py:40
msgid "Add user story to project"
-msgstr "User Story zu Projekt hinzufügen"
+msgstr "User-Story zu Projekt hinzufügen"
#: taiga/permissions/permissions.py:41
msgid "Add comments to user stories"
-msgstr "Kommentar zu User Stories hinzufügen"
+msgstr "Kommentar zu User-Stories hinzufügen"
#: taiga/permissions/permissions.py:42
msgid "Add comments to tasks"
@@ -1006,19 +1026,19 @@ msgstr "Meilenstein löschen"
#: taiga/permissions/permissions.py:59
msgid "View user story"
-msgstr "User Story ansehen"
+msgstr "User-Story ansehen"
#: taiga/permissions/permissions.py:60
msgid "Add user story"
-msgstr "User Story hinzufügen"
+msgstr "User-Story hinzufügen"
#: taiga/permissions/permissions.py:61
msgid "Modify user story"
-msgstr "User Story ändern"
+msgstr "User-Story ändern"
#: taiga/permissions/permissions.py:62
msgid "Delete user story"
-msgstr "User Story löschen"
+msgstr "User-Story löschen"
#: taiga/permissions/permissions.py:65
msgid "Add task"
@@ -1482,7 +1502,7 @@ msgstr "voreingestellte Punkte"
#: taiga/projects/models.py:97
msgid "default US status"
-msgstr "voreingesteller User Story Status "
+msgstr "voreingesteller User-Story Status "
#: taiga/projects/models.py:101
msgid "default task status"
@@ -1593,7 +1613,7 @@ msgstr "Vorgabe Optionen"
#: taiga/projects/models.py:584
msgid "us statuses"
-msgstr "User Story Status "
+msgstr "User-Story Status "
#: taiga/projects/models.py:585 taiga/projects/userstories/models.py:40
#: taiga/projects/userstories/models.py:72
@@ -1682,6 +1702,11 @@ msgid ""
"Hello %(user)s, %(changer)s has updated an issue on %(project)s\n"
"See issue #%(ref)s %(subject)s at %(url)s\n"
msgstr ""
+"\n"
+"Ticket aktualisiert\n"
+"Hallo %(user)s, %(changer)s hat ein Ticket aktualisiert in %(project)s\n"
+"Ticket ansehen #%(ref)s %(subject)s auf %(url)s \n"
+" \n"
#: taiga/projects/notifications/templates/emails/issues/issue-change-subject.jinja:1
#, python-format
@@ -1689,6 +1714,9 @@ msgid ""
"\n"
"[%(project)s] Updated the issue #%(ref)s \"%(subject)s\"\n"
msgstr ""
+"\n"
+"[%(project)s] Aktualisierte das Ticket #%(ref)s \"%(subject)s\"\n"
+" \n"
#: taiga/projects/notifications/templates/emails/issues/issue-create-body-html.jinja:4
#, python-format
@@ -1715,6 +1743,13 @@ msgid ""
"---\n"
"The Taiga Team\n"
msgstr ""
+"\n"
+"Neues Ticket wurde erstellt\n"
+"Hallo %(user)s, %(changer)s hat ein neues Ticket erstellt in %(project)s\n"
+"Ticket ansehen #%(ref)s %(subject)s auf %(url)s\n"
+"\n"
+"---\n"
+"Das Taiga Team\n"
#: taiga/projects/notifications/templates/emails/issues/issue-create-subject.jinja:1
#, python-format
@@ -1722,6 +1757,9 @@ msgid ""
"\n"
"[%(project)s] Created the issue #%(ref)s \"%(subject)s\"\n"
msgstr ""
+"\n"
+"[%(project)s] Erstellte das Ticket #%(ref)s \"%(subject)s\"\n"
+"\n"
#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-html.jinja:4
#, python-format
@@ -1746,6 +1784,14 @@ msgid ""
"---\n"
"The Taiga Team\n"
msgstr ""
+"\n"
+"Ticket gelöscht\n"
+"Hallo %(user)s, %(changer)s hat ein Ticket gelöscht in %(project)s\n"
+"Ticket #%(ref)s %(subject)s\n"
+"\n"
+"---\n"
+"Das Taiga Team\n"
+"\n"
#: taiga/projects/notifications/templates/emails/issues/issue-delete-subject.jinja:1
#, python-format
@@ -1753,6 +1799,10 @@ msgid ""
"\n"
"[%(project)s] Deleted the issue #%(ref)s \"%(subject)s\"\n"
msgstr ""
+"\n"
+"[%(project)s] Löschte das Ticket #%(ref)s \"%(subject)s\"\n"
+" \n"
+"\n"
#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-html.jinja:4
#, python-format
@@ -1782,6 +1832,9 @@ msgid ""
"\n"
"[%(project)s] Updated the sprint \"%(milestone)s\"\n"
msgstr ""
+"\n"
+"[%(project)s] Aktualisierte den Sprint \"%(milestone)s\"\n"
+" \n"
#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-html.jinja:4
#, python-format
@@ -1815,6 +1868,9 @@ msgid ""
"\n"
"[%(project)s] Created the sprint \"%(milestone)s\"\n"
msgstr ""
+"\n"
+"[%(project)s] Erstellte den Sprint \"%(milestone)s\"\n"
+"\n"
#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-html.jinja:4
#, python-format
@@ -1846,6 +1902,9 @@ msgid ""
"\n"
"[%(project)s] Deleted the Sprint \"%(milestone)s\"\n"
msgstr ""
+"\n"
+"[%(project)s] Löschte den Sprint \"%(milestone)s\"\n"
+" \n"
#: taiga/projects/notifications/templates/emails/tasks/task-change-body-html.jinja:4
#, python-format
@@ -1859,6 +1918,15 @@ msgid ""
"%(subject)s in Taiga\">See task\n"
" "
msgstr ""
+"\n"
+"Aufgabe aktualisiert
\n"
+"Hallo %(user)s,
%(changer)s hat eine Aufgabe aktualisiert in "
+"%(project)s
\n"
+"Aufgabe #%(ref)s %(subject)s
\n"
+"Aufgabe ansehen \n"
+"\n"
+" "
#: taiga/projects/notifications/templates/emails/tasks/task-change-body-text.jinja:3
#, python-format
@@ -1868,6 +1936,11 @@ msgid ""
"Hello %(user)s, %(changer)s has updated a task on %(project)s\n"
"See task #%(ref)s %(subject)s at %(url)s\n"
msgstr ""
+"\n"
+"Aufgabe aktualisiert\n"
+"Hallo %(user)s, %(changer)s hat eine Aufgabe aktualisiert in %(project)s\n"
+"Aufgabe ansehen #%(ref)s %(subject)s auf %(url)s\n"
+" \n"
#: taiga/projects/notifications/templates/emails/tasks/task-change-subject.jinja:1
#, python-format
@@ -1875,6 +1948,8 @@ msgid ""
"\n"
"[%(project)s] Updated the task #%(ref)s \"%(subject)s\"\n"
msgstr ""
+"\n"
+"[%(project)s] Aktualisierte die Aufgabe #%(ref)s \"%(subject)s\"\n"
#: taiga/projects/notifications/templates/emails/tasks/task-create-body-html.jinja:4
#, python-format
@@ -1901,6 +1976,15 @@ msgid ""
"---\n"
"The Taiga Team\n"
msgstr ""
+"\n"
+"Neue Aufgabe wurde erstellt\n"
+"Hallo %(user)s, %(changer)s hat eine neue Aufgabe erstellt in %(project)s\n"
+"Aufgabe ansehen #%(ref)s %(subject)s auf %(url)s\n"
+"\n"
+"\n"
+"---\n"
+"Das Taiga Team\n"
+"\n"
#: taiga/projects/notifications/templates/emails/tasks/task-create-subject.jinja:1
#, python-format
@@ -1908,6 +1992,9 @@ msgid ""
"\n"
"[%(project)s] Created the task #%(ref)s \"%(subject)s\"\n"
msgstr ""
+"\n"
+"[%(project)s] Erstellte die Aufgabe #%(ref)s \"%(subject)s\"\n"
+"\n"
#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-html.jinja:4
#, python-format
@@ -2142,7 +2229,7 @@ msgstr "Die Watcher beinhalten einen ungültigen Benutzer"
#: taiga/projects/occ/mixins.py:58
msgid "The version parameter is not valid"
-msgstr ""
+msgstr "Der Versionsparameter ist ungültig"
#: taiga/projects/occ/mixins.py:74
msgid "The version doesn't match with the current one"
@@ -2308,6 +2395,11 @@ msgid ""
msgstr ""
"\n"
"\n"
+"---\n"
+"\n"
+"Das Taiga Team\n"
+" \n"
+"\n"
#: taiga/projects/templates/emails/membership_invitation-subject.jinja:1
#, python-format
@@ -2554,7 +2646,7 @@ msgstr "Kritisch"
#. Translators: User role
#: taiga/projects/translations.py:170
msgid "UX"
-msgstr ""
+msgstr "UX"
#. Translators: User role
#: taiga/projects/translations.py:172
@@ -2621,7 +2713,7 @@ msgstr "erzeugt von Ticket"
#: taiga/projects/userstories/validators.py:28
msgid "There's no user story with that id"
-msgstr "Es gibt keine User Story mit dieser id"
+msgstr "Es gibt keine User-Story mit dieser id"
#: taiga/projects/validators.py:28
msgid "There's no project with that id"
@@ -2650,11 +2742,11 @@ msgstr "Stimme"
#: taiga/projects/wiki/api.py:60
msgid "'content' parameter is mandatory"
-msgstr ""
+msgstr "'content' Parameter ist erforderlich"
#: taiga/projects/wiki/api.py:63
msgid "'project_id' parameter is mandatory"
-msgstr ""
+msgstr "'project_id' Parameter ist erforderlich"
#: taiga/projects/wiki/models.py:36
msgid "last modifier"
@@ -2706,7 +2798,7 @@ msgstr "Ungültiges aktuelles Passwort"
#: taiga/users/api.py:203
msgid "Incomplete arguments"
-msgstr ""
+msgstr "Unvollständige Argumente"
#: taiga/users/api.py:208
msgid "Invalid image format"
From a3b5fd8feb0fd829af8e7a36f58712474ce58982 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jes=C3=BAs=20Espino?=
Date: Wed, 24 Jun 2015 19:47:13 +0200
Subject: [PATCH 020/190] Issue#2803: Reduce the amount of data on search
results
---
taiga/searches/api.py | 14 +++++-----
taiga/searches/serializers.py | 49 +++++++++++++++++++++++++++++++++++
2 files changed, 55 insertions(+), 8 deletions(-)
create mode 100644 taiga/searches/serializers.py
diff --git a/taiga/searches/api.py b/taiga/searches/api.py
index 6aa2060d..e4934194 100644
--- a/taiga/searches/api.py
+++ b/taiga/searches/api.py
@@ -20,13 +20,11 @@ from taiga.base.api import viewsets
from taiga.base import response
from taiga.base.api.utils import get_object_or_404
-from taiga.projects.userstories.serializers import UserStorySerializer
-from taiga.projects.tasks.serializers import TaskSerializer
-from taiga.projects.issues.serializers import IssueSerializer
-from taiga.projects.wiki.serializers import WikiPageSerializer
from taiga.permissions.service import user_has_perm
from . import services
+from . import serializers
+
class SearchViewSet(viewsets.ViewSet):
@@ -55,20 +53,20 @@ class SearchViewSet(viewsets.ViewSet):
def _search_user_stories(self, project, text):
queryset = services.search_user_stories(project, text)
- serializer = UserStorySerializer(queryset, many=True)
+ serializer = serializers.UserStorySearchResultsSerializer(queryset, many=True)
return serializer.data
def _search_tasks(self, project, text):
queryset = services.search_tasks(project, text)
- serializer = TaskSerializer(queryset, many=True)
+ serializer = serializers.TaskSearchResultsSerializer(queryset, many=True)
return serializer.data
def _search_issues(self, project, text):
queryset = services.search_issues(project, text)
- serializer = IssueSerializer(queryset, many=True)
+ serializer = serializers.IssueSearchResultsSerializer(queryset, many=True)
return serializer.data
def _search_wiki_pages(self, project, text):
queryset = services.search_wiki_pages(project, text)
- serializer = WikiPageSerializer(queryset, many=True)
+ serializer = serializers.WikiPageSearchResultsSerializer(queryset, many=True)
return serializer.data
diff --git a/taiga/searches/serializers.py b/taiga/searches/serializers.py
new file mode 100644
index 00000000..ee713fff
--- /dev/null
+++ b/taiga/searches/serializers.py
@@ -0,0 +1,49 @@
+# Copyright (C) 2014 Andrey Antukh
+# Copyright (C) 2014 Jesús Espino
+# Copyright (C) 2014 David Barragán
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+from taiga.projects.issues.serializers import IssueSerializer
+from taiga.projects.userstories.serializers import UserStorySerializer
+from taiga.projects.tasks.serializers import TaskSerializer
+from taiga.projects.wiki.serializers import WikiPageSerializer
+
+from taiga.projects.issues.models import Issue
+from taiga.projects.userstories.models import UserStory
+from taiga.projects.tasks.models import Task
+from taiga.projects.wiki.models import WikiPage
+
+
+class IssueSearchResultsSerializer(IssueSerializer):
+ class Meta:
+ model = Issue
+ fields = ('id', 'ref', 'subject', 'status', 'assigned_to')
+
+
+class TaskSearchResultsSerializer(TaskSerializer):
+ class Meta:
+ model = Task
+ fields = ('id', 'ref', 'subject', 'status', 'assigned_to')
+
+
+class UserStorySearchResultsSerializer(UserStorySerializer):
+ class Meta:
+ model = UserStory
+ fields = ('id', 'ref', 'subject', 'status', 'total_points')
+
+
+class WikiPageSearchResultsSerializer(WikiPageSerializer):
+ class Meta:
+ model = WikiPage
+ fields = ('id', 'slug')
From aaf89ebb048a91091a8f52523c72bf02cd59f6e2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jes=C3=BAs=20Espino?=
Date: Tue, 23 Jun 2015 17:24:17 +0200
Subject: [PATCH 021/190] Issue#2943: Regenerate refs for tasks, issues and
user stories on project change
---
taiga/projects/issues/api.py | 58 ++++++++-
taiga/projects/references/models.py | 30 +++--
taiga/projects/tasks/api.py | 42 ++++++-
taiga/projects/userstories/api.py | 38 ++++++
tests/factories.py | 1 +
.../test_issues_resources.py | 113 +++++++++++++++++
.../test_tasks_resources.py | 114 +++++++++++++++++-
.../test_userstories_resources.py | 107 +++++++++++++++-
tests/integration/test_history.py | 4 +-
tests/integration/test_notifications.py | 27 +++--
.../integration/test_references_sequences.py | 67 ++++++++++
tests/integration/test_stats.py | 12 +-
tests/integration/test_tasks.py | 5 +-
tests/integration/test_userstories.py | 4 +-
14 files changed, 575 insertions(+), 47 deletions(-)
diff --git a/taiga/projects/issues/api.py b/taiga/projects/issues/api.py
index d2582223..6f59a93a 100644
--- a/taiga/projects/issues/api.py
+++ b/taiga/projects/issues/api.py
@@ -31,7 +31,8 @@ from taiga.projects.notifications.mixins import WatchedResourceMixin
from taiga.projects.occ import OCCResourceMixin
from taiga.projects.history.mixins import HistoryResourceMixin
-from taiga.projects.models import Project
+from taiga.projects.models import Project, IssueStatus, Severity, Priority, IssueType
+from taiga.projects.milestones.models import Milestone
from taiga.projects.votes.utils import attach_votescount_to_queryset
from taiga.projects.votes import services as votes_service
from taiga.projects.votes import serializers as votes_serializers
@@ -121,6 +122,60 @@ class IssueViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMixin,
"assigned_to",
"subject")
+ def update(self, request, *args, **kwargs):
+ self.object = self.get_object_or_none()
+ project_id = request.DATA.get('project', None)
+ if project_id and self.object and self.object.project.id != project_id:
+ try:
+ new_project = Project.objects.get(pk=project_id)
+ self.check_permissions(request, "destroy", self.object)
+ self.check_permissions(request, "create", new_project)
+
+ sprint_id = request.DATA.get('milestone', None)
+ if sprint_id is not None and new_project.milestones.filter(pk=sprint_id).count() == 0:
+ request.DATA['milestone'] = None
+
+ status_id = request.DATA.get('status', None)
+ if status_id is not None:
+ try:
+ old_status = self.object.project.issue_statuses.get(pk=status_id)
+ new_status = new_project.issue_statuses.get(slug=old_status.slug)
+ request.DATA['status'] = new_status.id
+ except IssueStatus.DoesNotExist:
+ request.DATA['status'] = new_project.default_issue_status.id
+
+ priority_id = request.DATA.get('priority', None)
+ if priority_id is not None:
+ try:
+ old_priority = self.object.project.priorities.get(pk=priority_id)
+ new_priority = new_project.priorities.get(name=old_priority.name)
+ request.DATA['priority'] = new_priority.id
+ except Priority.DoesNotExist:
+ request.DATA['priority'] = new_project.default_priority.id
+
+ severity_id = request.DATA.get('severity', None)
+ if severity_id is not None:
+ try:
+ old_severity = self.object.project.severities.get(pk=severity_id)
+ new_severity = new_project.severities.get(name=old_severity.name)
+ request.DATA['severity'] = new_severity.id
+ except Severity.DoesNotExist:
+ request.DATA['severity'] = new_project.default_severity.id
+
+ type_id = request.DATA.get('type', None)
+ if type_id is not None:
+ try:
+ old_type = self.object.project.issue_types.get(pk=type_id)
+ new_type = new_project.issue_types.get(name=old_type.name)
+ request.DATA['type'] = new_type.id
+ except IssueType.DoesNotExist:
+ request.DATA['type'] = new_project.default_issue_type.id
+
+ except Project.DoesNotExist:
+ return response.BadRequest(_("The project doesn't exist"))
+
+ return super().update(request, *args, **kwargs)
+
def get_queryset(self):
qs = models.Issue.objects.all()
qs = qs.prefetch_related("attachments")
@@ -130,6 +185,7 @@ class IssueViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMixin,
def pre_save(self, obj):
if not obj.id:
obj.owner = self.request.user
+
super().pre_save(obj)
def pre_conditions_on_save(self, obj):
diff --git a/taiga/projects/references/models.py b/taiga/projects/references/models.py
index 8832034c..583c69c8 100644
--- a/taiga/projects/references/models.py
+++ b/taiga/projects/references/models.py
@@ -80,19 +80,31 @@ def delete_sequence(sender, instance, **kwargs):
seq.delete(seqname)
-def attach_sequence(sender, instance, created, **kwargs):
- if created and not instance._importing:
- # Create a reference object. This operation should be
- # used in transaction context, otherwise it can
- # create a lot of phantom reference objects.
- refval, _ = make_reference(instance, instance.project)
+def store_previous_project(sender, instance, **kwargs):
+ try:
+ prev_instance = sender.objects.get(pk=instance.pk)
+ instance.prev_project = prev_instance.project
+ except sender.DoesNotExist:
+ instance.prev_project = None
- # Additionally, attach sequence number to instance as ref
- instance.ref = refval
- instance.save(update_fields=['ref'])
+
+def attach_sequence(sender, instance, created, **kwargs):
+ if not instance._importing:
+ if created or instance.prev_project != instance.project:
+ # Create a reference object. This operation should be
+ # used in transaction context, otherwise it can
+ # create a lot of phantom reference objects.
+ refval, _ = make_reference(instance, instance.project)
+
+ # Additionally, attach sequence number to instance as ref
+ instance.ref = refval
+ instance.save(update_fields=['ref'])
models.signals.post_save.connect(create_sequence, sender=Project, dispatch_uid="refproj")
+models.signals.pre_save.connect(store_previous_project, sender=UserStory, dispatch_uid="refus")
+models.signals.pre_save.connect(store_previous_project, sender=Issue, dispatch_uid="refissue")
+models.signals.pre_save.connect(store_previous_project, sender=Task, dispatch_uid="reftask")
models.signals.post_save.connect(attach_sequence, sender=UserStory, dispatch_uid="refus")
models.signals.post_save.connect(attach_sequence, sender=Issue, dispatch_uid="refissue")
models.signals.post_save.connect(attach_sequence, sender=Task, dispatch_uid="reftask")
diff --git a/taiga/projects/tasks/api.py b/taiga/projects/tasks/api.py
index 2f1c008b..5d5581cd 100644
--- a/taiga/projects/tasks/api.py
+++ b/taiga/projects/tasks/api.py
@@ -21,7 +21,7 @@ from taiga.base import filters, response
from taiga.base import exceptions as exc
from taiga.base.decorators import list_route
from taiga.base.api import ModelCrudViewSet
-from taiga.projects.models import Project
+from taiga.projects.models import Project, TaskStatus
from django.http import HttpResponse
from taiga.projects.notifications.mixins import WatchedResourceMixin
@@ -44,6 +44,38 @@ class TaskViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMixin,
filter_fields = ["user_story", "milestone", "project", "assigned_to",
"status__is_closed", "watchers"]
+ def update(self, request, *args, **kwargs):
+ self.object = self.get_object_or_none()
+ project_id = request.DATA.get('project', None)
+ if project_id and self.object and self.object.project.id != project_id:
+ try:
+ new_project = Project.objects.get(pk=project_id)
+ self.check_permissions(request, "destroy", self.object)
+ self.check_permissions(request, "create", new_project)
+
+ sprint_id = request.DATA.get('milestone', None)
+ if sprint_id is not None and new_project.milestones.filter(pk=sprint_id).count() == 0:
+ request.DATA['milestone'] = None
+
+ us_id = request.DATA.get('user_story', None)
+ if us_id is not None and new_project.user_stories.filter(pk=us_id).count() == 0:
+ request.DATA['user_story'] = None
+
+ status_id = request.DATA.get('status', None)
+ if status_id is not None:
+ try:
+ old_status = self.object.project.task_statuses.get(pk=status_id)
+ new_status = new_project.task_statuses.get(slug=old_status.slug)
+ request.DATA['status'] = new_status.id
+ except TaskStatus.DoesNotExist:
+ request.DATA['status'] = new_project.default_task_status.id
+
+ except Project.DoesNotExist:
+ return response.BadRequest(_("The project doesn't exist"))
+
+ return super().update(request, *args, **kwargs)
+
+
def pre_save(self, obj):
if obj.user_story:
obj.milestone = obj.user_story.milestone
@@ -55,16 +87,16 @@ class TaskViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMixin,
super().pre_conditions_on_save(obj)
if obj.milestone and obj.milestone.project != obj.project:
- raise exc.WrongArguments(_("You don't have permissions for add/modify this task."))
+ raise exc.WrongArguments(_("You don't have permissions to set this sprint to this task."))
if obj.user_story and obj.user_story.project != obj.project:
- raise exc.WrongArguments(_("You don't have permissions for add/modify this task."))
+ raise exc.WrongArguments(_("You don't have permissions to set this user story to this task."))
if obj.status and obj.status.project != obj.project:
- raise exc.WrongArguments(_("You don't have permissions for add/modify this task."))
+ raise exc.WrongArguments(_("You don't have permissions to set this status to this task."))
if obj.milestone and obj.user_story and obj.milestone != obj.user_story.milestone:
- raise exc.WrongArguments(_("You don't have permissions for add/modify this task."))
+ raise exc.WrongArguments(_("You don't have permissions to set this sprint to this task."))
@list_route(methods=["GET"])
def by_ref(self, request):
diff --git a/taiga/projects/userstories/api.py b/taiga/projects/userstories/api.py
index b3e6ed05..9820f589 100644
--- a/taiga/projects/userstories/api.py
+++ b/taiga/projects/userstories/api.py
@@ -62,6 +62,33 @@ class UserStoryViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMi
# Specific filter used for filtering neighbor user stories
_neighbor_tags_filter = filters.TagsFilter('neighbor_tags')
+ def update(self, request, *args, **kwargs):
+ self.object = self.get_object_or_none()
+ project_id = request.DATA.get('project', None)
+ if project_id and self.object and self.object.project.id != project_id:
+ try:
+ new_project = Project.objects.get(pk=project_id)
+ self.check_permissions(request, "destroy", self.object)
+ self.check_permissions(request, "create", new_project)
+
+ sprint_id = request.DATA.get('milestone', None)
+ if sprint_id is not None and new_project.milestones.filter(pk=sprint_id).count() == 0:
+ request.DATA['milestone'] = None
+
+ status_id = request.DATA.get('status', None)
+ if status_id is not None:
+ try:
+ old_status = self.object.project.us_statuses.get(pk=status_id)
+ new_status = new_project.us_statuses.get(slug=old_status.slug)
+ request.DATA['status'] = new_status.id
+ except UserStoryStatus.DoesNotExist:
+ request.DATA['status'] = new_project.default_us_status.id
+ except Project.DoesNotExist:
+ return response.BadRequest(_("The project doesn't exist"))
+
+ return super().update(request, *args, **kwargs)
+
+
def get_queryset(self):
qs = self.model.objects.all()
qs = qs.prefetch_related("role_points",
@@ -100,6 +127,17 @@ class UserStoryViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMi
super().post_save(obj, created)
+ def pre_conditions_on_save(self, obj):
+ super().pre_conditions_on_save(obj)
+
+ if obj.milestone and obj.milestone.project != obj.project:
+ raise exc.PermissionDenied(_("You don't have permissions to set this sprint "
+ "to this user story."))
+
+ if obj.status and obj.status.project != obj.project:
+ raise exc.PermissionDenied(_("You don't have permissions to set this status "
+ "to this user story."))
+
@list_route(methods=["GET"])
def by_ref(self, request):
ref = request.QUERY_PARAMS.get("ref", None)
diff --git a/tests/factories.py b/tests/factories.py
index 4e9b9d0c..8a351d49 100644
--- a/tests/factories.py
+++ b/tests/factories.py
@@ -231,6 +231,7 @@ class UserStoryFactory(Factory):
subject = factory.Sequence(lambda n: "User Story {}".format(n))
description = factory.Sequence(lambda n: "User Story {} description".format(n))
status = factory.SubFactory("tests.factories.UserStoryStatusFactory")
+ milestone = factory.SubFactory("tests.factories.MilestoneFactory")
class UserStoryStatusFactory(Factory):
diff --git a/tests/integration/resources_permissions/test_issues_resources.py b/tests/integration/resources_permissions/test_issues_resources.py
index c6c99f2d..43abdeac 100644
--- a/tests/integration/resources_permissions/test_issues_resources.py
+++ b/tests/integration/resources_permissions/test_issues_resources.py
@@ -160,6 +160,119 @@ def test_issue_update(client, data):
assert results == [401, 403, 403, 200, 200]
+def test_issue_update_with_project_change(client):
+ user1 = f.UserFactory.create()
+ user2 = f.UserFactory.create()
+ user3 = f.UserFactory.create()
+ user4 = f.UserFactory.create()
+ project1 = f.ProjectFactory()
+ project2 = f.ProjectFactory()
+
+ issue_status1 = f.IssueStatusFactory.create(project=project1)
+ issue_status2 = f.IssueStatusFactory.create(project=project2)
+
+ priority1 = f.PriorityFactory.create(project=project1)
+ priority2 = f.PriorityFactory.create(project=project2)
+
+ severity1 = f.SeverityFactory.create(project=project1)
+ severity2 = f.SeverityFactory.create(project=project2)
+
+ issue_type1 = f.IssueTypeFactory.create(project=project1)
+ issue_type2 = f.IssueTypeFactory.create(project=project2)
+
+ project1.default_issue_status = issue_status1
+ project2.default_issue_status = issue_status2
+
+ project1.default_priority = priority1
+ project2.default_priority = priority2
+
+ project1.default_severity = severity1
+ project2.default_severity = severity2
+
+ project1.default_issue_type = issue_type1
+ project2.default_issue_type = issue_type2
+
+ project1.save()
+ project2.save()
+
+ membership1 = f.MembershipFactory(project=project1,
+ user=user1,
+ role__project=project1,
+ role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS)))
+ membership2 = f.MembershipFactory(project=project2,
+ user=user1,
+ role__project=project2,
+ role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS)))
+ membership3 = f.MembershipFactory(project=project1,
+ user=user2,
+ role__project=project1,
+ role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS)))
+ membership4 = f.MembershipFactory(project=project2,
+ user=user3,
+ role__project=project2,
+ role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS)))
+
+ issue = f.IssueFactory.create(project=project1)
+
+ url = reverse('issues-detail', kwargs={"pk": issue.pk})
+
+ # Test user with permissions in both projects
+ client.login(user1)
+
+ issue_data = IssueSerializer(issue).data
+ issue_data["project"] = project2.id
+ issue_data = json.dumps(issue_data)
+
+ response = client.put(url, data=issue_data, content_type="application/json")
+
+ assert response.status_code == 200
+
+ issue.project = project1
+ issue.save()
+
+ # Test user with permissions in only origin project
+ client.login(user2)
+
+ issue_data = IssueSerializer(issue).data
+ issue_data["project"] = project2.id
+ issue_data = json.dumps(issue_data)
+
+ response = client.put(url, data=issue_data, content_type="application/json")
+
+ assert response.status_code == 403
+
+ issue.project = project1
+ issue.save()
+
+ # Test user with permissions in only destionation project
+ client.login(user3)
+
+ issue_data = IssueSerializer(issue).data
+ issue_data["project"] = project2.id
+ issue_data = json.dumps(issue_data)
+
+ response = client.put(url, data=issue_data, content_type="application/json")
+
+ assert response.status_code == 403
+
+ issue.project = project1
+ issue.save()
+
+ # Test user without permissions in the projects
+ client.login(user4)
+
+ issue_data = IssueSerializer(issue).data
+ issue_data["project"] = project2.id
+ issue_data = json.dumps(issue_data)
+
+ response = client.put(url, data=issue_data, content_type="application/json")
+
+ assert response.status_code == 403
+
+ issue.project = project1
+ issue.save()
+
+
def test_issue_delete(client, data):
public_url = reverse('issues-detail', kwargs={"pk": data.public_issue.pk})
private_url1 = reverse('issues-detail', kwargs={"pk": data.private_issue1.pk})
diff --git a/tests/integration/resources_permissions/test_tasks_resources.py b/tests/integration/resources_permissions/test_tasks_resources.py
index 22bb719f..2259cfa4 100644
--- a/tests/integration/resources_permissions/test_tasks_resources.py
+++ b/tests/integration/resources_permissions/test_tasks_resources.py
@@ -83,18 +83,25 @@ def data():
user=m.project_owner,
is_owner=True)
+ milestone_public_task = f.MilestoneFactory(project=m.public_project)
+ milestone_private_task1 = f.MilestoneFactory(project=m.private_project1)
+ milestone_private_task2 = f.MilestoneFactory(project=m.private_project2)
+
m.public_task = f.TaskFactory(project=m.public_project,
status__project=m.public_project,
- milestone__project=m.public_project,
- user_story__project=m.public_project)
+ milestone=milestone_public_task,
+ user_story__project=m.public_project,
+ user_story__milestone=milestone_public_task)
m.private_task1 = f.TaskFactory(project=m.private_project1,
status__project=m.private_project1,
- milestone__project=m.private_project1,
- user_story__project=m.private_project1)
+ milestone=milestone_private_task1,
+ user_story__project=m.private_project1,
+ user_story__milestone=milestone_private_task1)
m.private_task2 = f.TaskFactory(project=m.private_project2,
status__project=m.private_project2,
- milestone__project=m.private_project2,
- user_story__project=m.private_project2)
+ milestone=milestone_private_task2,
+ user_story__project=m.private_project2,
+ user_story__milestone=milestone_private_task2)
m.public_project.default_task_status = m.public_task.status
m.public_project.save()
@@ -160,6 +167,101 @@ def test_task_update(client, data):
assert results == [401, 403, 403, 200, 200]
+def test_task_update_with_project_change(client):
+ user1 = f.UserFactory.create()
+ user2 = f.UserFactory.create()
+ user3 = f.UserFactory.create()
+ user4 = f.UserFactory.create()
+ project1 = f.ProjectFactory()
+ project2 = f.ProjectFactory()
+
+ task_status1 = f.TaskStatusFactory.create(project=project1)
+ task_status2 = f.TaskStatusFactory.create(project=project2)
+
+ project1.default_task_status = task_status1
+ project2.default_task_status = task_status2
+
+ project1.save()
+ project2.save()
+
+ membership1 = f.MembershipFactory(project=project1,
+ user=user1,
+ role__project=project1,
+ role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS)))
+ membership2 = f.MembershipFactory(project=project2,
+ user=user1,
+ role__project=project2,
+ role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS)))
+ membership3 = f.MembershipFactory(project=project1,
+ user=user2,
+ role__project=project1,
+ role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS)))
+ membership4 = f.MembershipFactory(project=project2,
+ user=user3,
+ role__project=project2,
+ role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS)))
+
+ task = f.TaskFactory.create(project=project1)
+
+ url = reverse('tasks-detail', kwargs={"pk": task.pk})
+
+ # Test user with permissions in both projects
+ client.login(user1)
+
+ task_data = TaskSerializer(task).data
+ task_data["project"] = project2.id
+ task_data = json.dumps(task_data)
+
+ response = client.put(url, data=task_data, content_type="application/json")
+
+ assert response.status_code == 200
+
+ task.project = project1
+ task.save()
+
+ # Test user with permissions in only origin project
+ client.login(user2)
+
+ task_data = TaskSerializer(task).data
+ task_data["project"] = project2.id
+ task_data = json.dumps(task_data)
+
+ response = client.put(url, data=task_data, content_type="application/json")
+
+ assert response.status_code == 403
+
+ task.project = project1
+ task.save()
+
+ # Test user with permissions in only destionation project
+ client.login(user3)
+
+ task_data = TaskSerializer(task).data
+ task_data["project"] = project2.id
+ task_data = json.dumps(task_data)
+
+ response = client.put(url, data=task_data, content_type="application/json")
+
+ assert response.status_code == 403
+
+ task.project = project1
+ task.save()
+
+ # Test user without permissions in the projects
+ client.login(user4)
+
+ task_data = TaskSerializer(task).data
+ task_data["project"] = project2.id
+ task_data = json.dumps(task_data)
+
+ response = client.put(url, data=task_data, content_type="application/json")
+
+ assert response.status_code == 403
+
+ task.project = project1
+ task.save()
+
+
def test_task_delete(client, data):
public_url = reverse('tasks-detail', kwargs={"pk": data.public_task.pk})
private_url1 = reverse('tasks-detail', kwargs={"pk": data.private_task1.pk})
diff --git a/tests/integration/resources_permissions/test_userstories_resources.py b/tests/integration/resources_permissions/test_userstories_resources.py
index 3a718cc7..a3e09808 100644
--- a/tests/integration/resources_permissions/test_userstories_resources.py
+++ b/tests/integration/resources_permissions/test_userstories_resources.py
@@ -89,13 +89,19 @@ def data():
m.public_role_points = f.RolePointsFactory(role=m.public_project.roles.all()[0],
points=m.public_points,
- user_story__project=m.public_project)
+ user_story__project=m.public_project,
+ user_story__milestone__project=m.public_project,
+ user_story__status__project=m.public_project)
m.private_role_points1 = f.RolePointsFactory(role=m.private_project1.roles.all()[0],
points=m.private_points1,
- user_story__project=m.private_project1)
+ user_story__project=m.private_project1,
+ user_story__milestone__project=m.private_project1,
+ user_story__status__project=m.private_project1)
m.private_role_points2 = f.RolePointsFactory(role=m.private_project2.roles.all()[0],
points=m.private_points2,
- user_story__project=m.private_project2)
+ user_story__project=m.private_project2,
+ user_story__milestone__project=m.private_project2,
+ user_story__status__project=m.private_project2)
m.public_user_story = m.public_role_points.user_story
m.private_user_story1 = m.private_role_points1.user_story
@@ -158,6 +164,101 @@ def test_user_story_update(client, data):
assert results == [401, 403, 403, 200, 200]
+def test_user_story_update_with_project_change(client):
+ user1 = f.UserFactory.create()
+ user2 = f.UserFactory.create()
+ user3 = f.UserFactory.create()
+ user4 = f.UserFactory.create()
+ project1 = f.ProjectFactory()
+ project2 = f.ProjectFactory()
+
+ us_status1 = f.UserStoryStatusFactory.create(project=project1)
+ us_status2 = f.UserStoryStatusFactory.create(project=project2)
+
+ project1.default_us_status = us_status1
+ project2.default_us_status = us_status2
+
+ project1.save()
+ project2.save()
+
+ membership1 = f.MembershipFactory(project=project1,
+ user=user1,
+ role__project=project1,
+ role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS)))
+ membership2 = f.MembershipFactory(project=project2,
+ user=user1,
+ role__project=project2,
+ role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS)))
+ membership3 = f.MembershipFactory(project=project1,
+ user=user2,
+ role__project=project1,
+ role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS)))
+ membership4 = f.MembershipFactory(project=project2,
+ user=user3,
+ role__project=project2,
+ role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS)))
+
+ us = f.UserStoryFactory.create(project=project1)
+
+ url = reverse('userstories-detail', kwargs={"pk": us.pk})
+
+ # Test user with permissions in both projects
+ client.login(user1)
+
+ us_data = UserStorySerializer(us).data
+ us_data["project"] = project2.id
+ us_data = json.dumps(us_data)
+
+ response = client.put(url, data=us_data, content_type="application/json")
+
+ assert response.status_code == 200
+
+ us.project = project1
+ us.save()
+
+ # Test user with permissions in only origin project
+ client.login(user2)
+
+ us_data = UserStorySerializer(us).data
+ us_data["project"] = project2.id
+ us_data = json.dumps(us_data)
+
+ response = client.put(url, data=us_data, content_type="application/json")
+
+ assert response.status_code == 403
+
+ us.project = project1
+ us.save()
+
+ # Test user with permissions in only destionation project
+ client.login(user3)
+
+ us_data = UserStorySerializer(us).data
+ us_data["project"] = project2.id
+ us_data = json.dumps(us_data)
+
+ response = client.put(url, data=us_data, content_type="application/json")
+
+ assert response.status_code == 403
+
+ us.project = project1
+ us.save()
+
+ # Test user without permissions in the projects
+ client.login(user4)
+
+ us_data = UserStorySerializer(us).data
+ us_data["project"] = project2.id
+ us_data = json.dumps(us_data)
+
+ response = client.put(url, data=us_data, content_type="application/json")
+
+ assert response.status_code == 403
+
+ us.project = project1
+ us.save()
+
+
def test_user_story_delete(client, data):
public_url = reverse('userstories-detail', kwargs={"pk": data.public_user_story.pk})
private_url1 = reverse('userstories-detail', kwargs={"pk": data.private_user_story1.pk})
diff --git a/tests/integration/test_history.py b/tests/integration/test_history.py
index 020eadcd..4ea8efbd 100644
--- a/tests/integration/test_history.py
+++ b/tests/integration/test_history.py
@@ -199,7 +199,7 @@ def test_take_hidden_snapshot():
def test_history_with_only_comment_shouldnot_be_hidden(client):
project = f.create_project()
- us = f.create_userstory(project=project)
+ us = f.create_userstory(project=project, status__project=project)
f.MembershipFactory.create(project=project, user=project.owner, is_owner=True)
qs_all = HistoryEntry.objects.all()
@@ -213,7 +213,7 @@ def test_history_with_only_comment_shouldnot_be_hidden(client):
client.login(project.owner)
response = client.patch(url, data, content_type="application/json")
- assert response.status_code == 200, response.content
+ assert response.status_code == 200, str(response.content)
assert qs_all.count() == 1
assert qs_hidden.count() == 0
diff --git a/tests/integration/test_notifications.py b/tests/integration/test_notifications.py
index d3d64ddb..13087254 100644
--- a/tests/integration/test_notifications.py
+++ b/tests/integration/test_notifications.py
@@ -32,6 +32,7 @@ from taiga.projects.history.services import take_snapshot
from taiga.projects.issues.serializers import IssueSerializer
from taiga.projects.userstories.serializers import UserStorySerializer
from taiga.projects.tasks.serializers import TaskSerializer
+from taiga.permissions.permissions import MEMBERS_PERMISSIONS
pytestmark = pytest.mark.django_db
@@ -317,7 +318,7 @@ def test_watchers_assignation_for_issue(client):
url = reverse("issues-detail", args=[issue.pk])
response = client.json.patch(url, json.dumps(data))
- assert response.status_code == 200, response.content
+ assert response.status_code == 200, str(response.content)
issue = f.create_issue(project=project1, owner=user1)
data = {"version": issue.version,
@@ -356,22 +357,22 @@ def test_watchers_assignation_for_task(client):
user2 = f.UserFactory.create()
project1 = f.ProjectFactory.create(owner=user1)
project2 = f.ProjectFactory.create(owner=user2)
- role1 = f.RoleFactory.create(project=project1)
+ role1 = f.RoleFactory.create(project=project1, permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS)))
role2 = f.RoleFactory.create(project=project2)
f.MembershipFactory.create(project=project1, user=user1, role=role1, is_owner=True)
f.MembershipFactory.create(project=project2, user=user2, role=role2)
client.login(user1)
- task = f.create_task(project=project1, owner=user1)
+ task = f.create_task(project=project1, owner=user1, status__project=project1, milestone__project=project1, user_story=None)
data = {"version": task.version,
"watchers": [user1.pk]}
url = reverse("tasks-detail", args=[task.pk])
response = client.json.patch(url, json.dumps(data))
- assert response.status_code == 200, response.content
+ assert response.status_code == 200, str(response.content)
- task = f.create_task(project=project1, owner=user1)
+ task = f.create_task(project=project1, owner=user1, status__project=project1, milestone__project=project1)
data = {"version": task.version,
"watchers": [user1.pk, user2.pk]}
@@ -379,7 +380,7 @@ def test_watchers_assignation_for_task(client):
response = client.json.patch(url, json.dumps(data))
assert response.status_code == 400
- task = f.create_task(project=project1, owner=user1)
+ task = f.create_task(project=project1, owner=user1, status__project=project1, milestone__project=project1)
data = dict(TaskSerializer(task).data)
data["id"] = None
data["version"] = None
@@ -391,7 +392,7 @@ def test_watchers_assignation_for_task(client):
# Test the impossible case when project is not
# exists in create request, and validator works as expected
- task = f.create_task(project=project1, owner=user1)
+ task = f.create_task(project=project1, owner=user1, status__project=project1, milestone__project=project1)
data = dict(TaskSerializer(task).data)
data["id"] = None
@@ -415,15 +416,15 @@ def test_watchers_assignation_for_us(client):
client.login(user1)
- us = f.create_userstory(project=project1, owner=user1)
+ us = f.create_userstory(project=project1, owner=user1, status__project=project1)
data = {"version": us.version,
"watchers": [user1.pk]}
url = reverse("userstories-detail", args=[us.pk])
response = client.json.patch(url, json.dumps(data))
- assert response.status_code == 200
+ assert response.status_code == 200, str(response.content)
- us = f.create_userstory(project=project1, owner=user1)
+ us = f.create_userstory(project=project1, owner=user1, status__project=project1)
data = {"version": us.version,
"watchers": [user1.pk, user2.pk]}
@@ -431,7 +432,7 @@ def test_watchers_assignation_for_us(client):
response = client.json.patch(url, json.dumps(data))
assert response.status_code == 400
- us = f.create_userstory(project=project1, owner=user1)
+ us = f.create_userstory(project=project1, owner=user1, status__project=project1)
data = dict(UserStorySerializer(us).data)
data["id"] = None
data["version"] = None
@@ -443,7 +444,7 @@ def test_watchers_assignation_for_us(client):
# Test the impossible case when project is not
# exists in create request, and validator works as expected
- us = f.create_userstory(project=project1, owner=user1)
+ us = f.create_userstory(project=project1, owner=user1, status__project=project1)
data = dict(UserStorySerializer(us).data)
data["id"] = None
@@ -463,4 +464,4 @@ def test_retrieve_notify_policies_by_anonymous_user(client):
url = reverse("notifications-detail", args=[policy.pk])
response = client.get(url, content_type="application/json")
assert response.status_code == 404, response.status_code
- assert response.data["_error_message"] == "No NotifyPolicy matches the given query.", response.content
+ assert response.data["_error_message"] == "No NotifyPolicy matches the given query.", str(response.content)
diff --git a/tests/integration/test_references_sequences.py b/tests/integration/test_references_sequences.py
index 10653cea..f384791e 100644
--- a/tests/integration/test_references_sequences.py
+++ b/tests/integration/test_references_sequences.py
@@ -74,3 +74,70 @@ def test_unique_reference_per_project(seq, refmodels):
project.delete()
assert not seq.exists(seqname)
+
+
+@pytest.mark.django_db
+def test_regenerate_us_reference_on_project_change(seq, refmodels):
+ project1 = factories.ProjectFactory.create()
+ seqname1 = refmodels.make_sequence_name(project1)
+ project2 = factories.ProjectFactory.create()
+ seqname2 = refmodels.make_sequence_name(project2)
+
+ seq.alter(seqname1, 100)
+ seq.alter(seqname2, 200)
+
+ user_story = factories.UserStoryFactory.create(project=project1)
+ assert user_story.ref == 101
+
+ user_story.subject = "other"
+ user_story.save()
+ assert user_story.ref == 101
+
+ user_story.project = project2
+ user_story.save()
+
+ assert user_story.ref == 201
+
+@pytest.mark.django_db
+def test_regenerate_task_reference_on_project_change(seq, refmodels):
+ project1 = factories.ProjectFactory.create()
+ seqname1 = refmodels.make_sequence_name(project1)
+ project2 = factories.ProjectFactory.create()
+ seqname2 = refmodels.make_sequence_name(project2)
+
+ seq.alter(seqname1, 100)
+ seq.alter(seqname2, 200)
+
+ task = factories.TaskFactory.create(project=project1)
+ assert task.ref == 101
+
+ task.subject = "other"
+ task.save()
+ assert task.ref == 101
+
+ task.project = project2
+ task.save()
+
+ assert task.ref == 201
+
+@pytest.mark.django_db
+def test_regenerate_issue_reference_on_project_change(seq, refmodels):
+ project1 = factories.ProjectFactory.create()
+ seqname1 = refmodels.make_sequence_name(project1)
+ project2 = factories.ProjectFactory.create()
+ seqname2 = refmodels.make_sequence_name(project2)
+
+ seq.alter(seqname1, 100)
+ seq.alter(seqname2, 200)
+
+ issue = factories.IssueFactory.create(project=project1)
+ assert issue.ref == 101
+
+ issue.subject = "other"
+ issue.save()
+ assert issue.ref == 101
+
+ issue.project = project2
+ issue.save()
+
+ assert issue.ref == 201
diff --git a/tests/integration/test_stats.py b/tests/integration/test_stats.py
index 97bce3ff..4dfab9e4 100644
--- a/tests/integration/test_stats.py
+++ b/tests/integration/test_stats.py
@@ -37,19 +37,23 @@ def data():
m.role_points1 = f.RolePointsFactory(role=m.role1,
points=m.points1,
user_story__project=m.project,
- user_story__status=m.open_status)
+ user_story__status=m.open_status,
+ user_story__milestone=None)
m.role_points2 = f.RolePointsFactory(role=m.role1,
points=m.points2,
user_story__project=m.project,
- user_story__status=m.open_status)
+ user_story__status=m.open_status,
+ user_story__milestone=None)
m.role_points3 = f.RolePointsFactory(role=m.role1,
points=m.points3,
user_story__project=m.project,
- user_story__status=m.open_status)
+ user_story__status=m.open_status,
+ user_story__milestone=None)
m.role_points4 = f.RolePointsFactory(role=m.project.roles.all()[0],
points=m.points4,
user_story__project=m.project,
- user_story__status=m.open_status)
+ user_story__status=m.open_status,
+ user_story__milestone=None)
m.user_story1 = m.role_points1.user_story
m.user_story2 = m.role_points2.user_story
diff --git a/tests/integration/test_tasks.py b/tests/integration/test_tasks.py
index 61d1954a..382bfc6f 100644
--- a/tests/integration/test_tasks.py
+++ b/tests/integration/test_tasks.py
@@ -54,8 +54,9 @@ def test_create_task_without_status(client):
def test_api_update_task_tags(client):
- task = f.create_task()
- f.MembershipFactory.create(project=task.project, user=task.owner, is_owner=True)
+ 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_owner=True)
url = reverse("tasks-detail", kwargs={"pk": task.pk})
data = {"tags": ["back", "front"], "version": task.version}
diff --git a/tests/integration/test_userstories.py b/tests/integration/test_userstories.py
index e86f1dad..90b85444 100644
--- a/tests/integration/test_userstories.py
+++ b/tests/integration/test_userstories.py
@@ -152,7 +152,7 @@ def test_update_userstory_points(client):
f.PointsFactory.create(project=project, value=1)
points3 = f.PointsFactory.create(project=project, value=2)
- us = f.UserStoryFactory.create(project=project, owner=user1)
+ us = f.UserStoryFactory.create(project=project, owner=user1, status__project=project, milestone__project=project)
usdata = UserStorySerializer(us).data
url = reverse("userstories-detail", args=[us.pk])
@@ -166,7 +166,7 @@ def test_update_userstory_points(client):
data["points"].update({'2000': points3.pk})
response = client.json.patch(url, json.dumps(data))
- assert response.status_code == 200
+ assert response.status_code == 200, str(response.content)
assert response.data["points"] == usdata['points']
# Api should save successful
From 4480cb474ef240fd6f8ee153f97d4e46398c0da7 Mon Sep 17 00:00:00 2001
From: Alejandro Alonso
Date: Wed, 24 Jun 2015 14:10:50 +0200
Subject: [PATCH 022/190] Issue 2818 - When I comment a story, I should be
"Involved" by the story
---
taiga/projects/notifications/services.py | 3 +++
tests/integration/test_notifications.py | 30 ++++++++++++++++++++++++
2 files changed, 33 insertions(+)
diff --git a/taiga/projects/notifications/services.py b/taiga/projects/notifications/services.py
index 67fb894a..75f7e48b 100644
--- a/taiga/projects/notifications/services.py
+++ b/taiga/projects/notifications/services.py
@@ -123,6 +123,9 @@ def analize_object_for_watchers(obj:object, history:object):
for user in data["mentions"]:
obj.watchers.add(user)
+ # Adding the person who edited the object to the watchers
+ if history.comment and not history.owner.is_system:
+ obj.watchers.add(history.owner)
def _filter_by_permissions(obj, user):
UserStory = apps.get_model("userstories", "UserStory")
diff --git a/tests/integration/test_notifications.py b/tests/integration/test_notifications.py
index 13087254..a5a9847c 100644
--- a/tests/integration/test_notifications.py
+++ b/tests/integration/test_notifications.py
@@ -100,6 +100,36 @@ def test_analize_object_for_watchers():
assert issue.watchers.add.call_count == 2
+def test_analize_object_for_watchers_adding_owner_non_empty_comment():
+ user1 = f.UserFactory.create()
+
+ issue = MagicMock()
+ issue.description = "Foo"
+ issue.content = ""
+
+ history = MagicMock()
+ history.comment = "Comment"
+ history.owner = user1
+
+ services.analize_object_for_watchers(issue, history)
+ assert issue.watchers.add.call_count == 1
+
+
+def test_analize_object_for_watchers_no_adding_owner_empty_comment():
+ user1 = f.UserFactory.create()
+
+ issue = MagicMock()
+ issue.description = "Foo"
+ issue.content = ""
+
+ history = MagicMock()
+ history.comment = ""
+ history.owner = user1
+
+ services.analize_object_for_watchers(issue, history)
+ assert issue.watchers.add.call_count == 0
+
+
def test_users_to_notify():
project = f.ProjectFactory.create()
role1 = f.RoleFactory.create(project=project, permissions=['view_issues'])
From 3f369cf799d25b487c932ab6c63309e3f7a2abe4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?=
Date: Thu, 25 Jun 2015 11:47:53 +0200
Subject: [PATCH 023/190] Update CHANGELOG
---
CHANGELOG.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2b5fdcda..99d9e63e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,7 @@
### Features
- Add a "field type" property for custom fields: 'text' and 'multiline text' right now (thanks to [@artlepool](https://github.com/artlepool))
- Allow multiple actions in the commit messages.
+- Now every user that coments USs, Issues or Tasks will be involved in it (add author to the watchers list).
### Misc
- API: Mixin fields 'users', 'members' and 'memberships' in ProjectDetailSerializer
From b4a30f64afbedb2ff9cab3b60d800c5509457627 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jes=C3=BAs=20Espino?=
Date: Thu, 25 Jun 2015 13:50:36 +0200
Subject: [PATCH 024/190] Add full-text index to the search app
---
taiga/searches/migrations/0001_initial.py | 41 +++++++++++++++++++++++
taiga/searches/migrations/__init__.py | 0
taiga/searches/services.py | 10 +++---
3 files changed, 46 insertions(+), 5 deletions(-)
create mode 100644 taiga/searches/migrations/0001_initial.py
create mode 100644 taiga/searches/migrations/__init__.py
diff --git a/taiga/searches/migrations/0001_initial.py b/taiga/searches/migrations/0001_initial.py
new file mode 100644
index 00000000..b30cfa4d
--- /dev/null
+++ b/taiga/searches/migrations/0001_initial.py
@@ -0,0 +1,41 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('wiki', '0001_initial'),
+ ('userstories', '0009_remove_userstory_is_archived'),
+ ('issues', '0005_auto_20150623_1923'),
+ ('tasks', '0006_auto_20150623_1923'),
+ ]
+
+ operations = [
+ migrations.RunSQL(
+ """
+ CREATE INDEX "userstories_full_text_idx" ON userstories_userstory USING gin(to_tsvector('simple', coalesce(subject, '') || ' ' || coalesce(ref) || ' ' || coalesce(description, '')));
+ """,
+ reverse_sql="""DROP INDEX IF EXISTS "userstories_full_text_idx";"""
+ ),
+ migrations.RunSQL(
+ """
+ CREATE INDEX "tasks_full_text_idx" ON tasks_task USING gin(to_tsvector('simple', coalesce(subject, '') || ' ' || coalesce(ref) || ' ' || coalesce(description, '')));
+ """,
+ reverse_sql="""DROP INDEX IF EXISTS "tasks_full_text_idx";"""
+ ),
+ migrations.RunSQL(
+ """
+ CREATE INDEX "issues_full_text_idx" ON issues_issue USING gin(to_tsvector('simple', coalesce(subject, '') || ' ' || coalesce(ref) || ' ' || coalesce(description, '')));
+ """,
+ reverse_sql="""DROP INDEX IF EXISTS "issues_full_text_idx";"""
+ ),
+ migrations.RunSQL(
+ """
+ CREATE INDEX "wikipages_full_text_idx" ON wiki_wikipage USING gin(to_tsvector('simple', coalesce(slug, '') || ' ' || coalesce(content, '')));
+ """,
+ reverse_sql="""DROP INDEX IF EXISTS "wikipages_full_text_idx";"""
+ ),
+ ]
diff --git a/taiga/searches/migrations/__init__.py b/taiga/searches/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/taiga/searches/services.py b/taiga/searches/services.py
index 1cc2374e..ccea5c19 100644
--- a/taiga/searches/services.py
+++ b/taiga/searches/services.py
@@ -23,9 +23,9 @@ MAX_RESULTS = getattr(settings, "SEARCHES_MAX_RESULTS", 150)
def search_user_stories(project, text):
model_cls = apps.get_model("userstories", "UserStory")
- where_clause = ("to_tsvector(coalesce(userstories_userstory.subject) || ' ' || "
+ where_clause = ("to_tsvector('simple', coalesce(userstories_userstory.subject, '') || ' ' || "
"coalesce(userstories_userstory.ref) || ' ' || "
- "coalesce(userstories_userstory.description)) @@ to_tsquery(%s)")
+ "coalesce(userstories_userstory.description, '')) @@ to_tsquery(%s)")
if text:
text += ":*"
@@ -37,7 +37,7 @@ def search_user_stories(project, text):
def search_tasks(project, text):
model_cls = apps.get_model("tasks", "Task")
- where_clause = ("to_tsvector(coalesce(tasks_task.subject, '') || ' ' || "
+ where_clause = ("to_tsvector('simple', coalesce(tasks_task.subject, '') || ' ' || "
"coalesce(tasks_task.ref) || ' ' || "
"coalesce(tasks_task.description, '')) @@ to_tsquery(%s)")
@@ -51,7 +51,7 @@ def search_tasks(project, text):
def search_issues(project, text):
model_cls = apps.get_model("issues", "Issue")
- where_clause = ("to_tsvector(coalesce(issues_issue.subject) || ' ' || "
+ where_clause = ("to_tsvector('simple', coalesce(issues_issue.subject) || ' ' || "
"coalesce(issues_issue.ref) || ' ' || "
"coalesce(issues_issue.description)) @@ to_tsquery(%s)")
@@ -65,7 +65,7 @@ def search_issues(project, text):
def search_wiki_pages(project, text):
model_cls = apps.get_model("wiki", "WikiPage")
- where_clause = ("to_tsvector(coalesce(wiki_wikipage.slug) || ' ' || coalesce(wiki_wikipage.content)) "
+ where_clause = ("to_tsvector('simple', coalesce(wiki_wikipage.slug) || ' ' || coalesce(wiki_wikipage.content)) "
"@@ to_tsquery(%s)")
if text:
From 30e08c61efe8152c3068de829e9d0ec88b411baf Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?=
Date: Thu, 25 Jun 2015 16:13:20 +0200
Subject: [PATCH 025/190] Excluded some unnecessary fields in
ProjectMemberSerializer
---
taiga/projects/serializers.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/taiga/projects/serializers.py b/taiga/projects/serializers.py
index d34d5939..bc1de975 100644
--- a/taiga/projects/serializers.py
+++ b/taiga/projects/serializers.py
@@ -295,6 +295,7 @@ class ProjectMemberSerializer(serializers.ModelSerializer):
class Meta:
model = models.Membership
+ exclude = ("project", "email", "created_at", "token", "invited_by", "invitation_extra_text", "user_order")
def get_photo(self, membership):
return get_photo_or_gravatar_url(membership.user)
From 8b05f206b210e773853933ccf053e16eefbc9391 Mon Sep 17 00:00:00 2001
From: Alejandro Alonso
Date: Thu, 25 Jun 2015 09:37:51 +0200
Subject: [PATCH 026/190] Concurrent queries for global search results
---
taiga/searches/api.py | 35 +++++++++++++++++++++++-------
tests/integration/test_searches.py | 2 +-
2 files changed, 28 insertions(+), 9 deletions(-)
diff --git a/taiga/searches/api.py b/taiga/searches/api.py
index e4934194..befe539b 100644
--- a/taiga/searches/api.py
+++ b/taiga/searches/api.py
@@ -26,6 +26,7 @@ from . import services
from . import serializers
+from concurrent import futures
class SearchViewSet(viewsets.ViewSet):
def list(self, request, **kwargs):
@@ -35,14 +36,32 @@ class SearchViewSet(viewsets.ViewSet):
project = self._get_project(project_id)
result = {}
- if user_has_perm(request.user, "view_us", project):
- result["userstories"] = self._search_user_stories(project, text)
- if user_has_perm(request.user, "view_tasks", project):
- result["tasks"] = self._search_tasks(project, text)
- if user_has_perm(request.user, "view_issues", project):
- result["issues"] = self._search_issues(project, text)
- if user_has_perm(request.user, "view_wiki_pages", project):
- result["wikipages"] = self._search_wiki_pages(project, text)
+ with futures.ThreadPoolExecutor(max_workers=4) as executor:
+ futures_list = []
+ if user_has_perm(request.user, "view_us", project):
+ uss_future = executor.submit(self._search_user_stories, project, text)
+ uss_future.result_key = "userstories"
+ futures_list.append(uss_future)
+ if user_has_perm(request.user, "view_tasks", project):
+ tasks_future = executor.submit(self._search_tasks, project, text)
+ tasks_future.result_key = "tasks"
+ futures_list.append(tasks_future)
+ if user_has_perm(request.user, "view_issues", project):
+ issues_future = executor.submit(self._search_issues, project, text)
+ issues_future.result_key = "issues"
+ futures_list.append(issues_future)
+ if user_has_perm(request.user, "view_wiki_pages", project):
+ wiki_pages_future = executor.submit(self._search_wiki_pages, project, text)
+ wiki_pages_future.result_key = "wikipages"
+ futures_list.append(wiki_pages_future)
+
+ for future in futures.as_completed(futures_list):
+ try:
+ data = future.result()
+ except Exception as exc:
+ print('%s generated an exception: %s' % (future.result_key, exc))
+ else:
+ result[future.result_key] = data
result["count"] = sum(map(lambda x: len(x), result.values()))
return response.Ok(result)
diff --git a/tests/integration/test_searches.py b/tests/integration/test_searches.py
index 0837c4c5..f6a26cc1 100644
--- a/tests/integration/test_searches.py
+++ b/tests/integration/test_searches.py
@@ -25,7 +25,7 @@ from taiga.permissions.permissions import MEMBERS_PERMISSIONS
from tests.utils import disconnect_signals, reconnect_signals
-pytestmark = pytest.mark.django_db
+pytestmark = pytest.mark.django_db(transaction=True)
def setup_module(module):
From 6d33c7821d80cbea2cd004b510f59090d93bc8c1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?=
Date: Thu, 25 Jun 2015 18:05:11 +0200
Subject: [PATCH 027/190] Make minor improvements over front sitemap
---
taiga/front/sitemaps/generics.py | 8 ++++----
taiga/front/sitemaps/projects.py | 4 ++--
2 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/taiga/front/sitemaps/generics.py b/taiga/front/sitemaps/generics.py
index 180c6eb0..27fbc075 100644
--- a/taiga/front/sitemaps/generics.py
+++ b/taiga/front/sitemaps/generics.py
@@ -25,10 +25,10 @@ from .base import Sitemap
class GenericSitemap(Sitemap):
def items(self):
return [
- {"url_key": "home", "changefreq": "monthly", "priority": 0.6},
- {"url_key": "login", "changefreq": "monthly", "priority": 0.6},
- {"url_key": "register", "changefreq": "monthly", "priority": 0.6},
- {"url_key": "forgot-password", "changefreq": "monthly", "priority": 0.6}
+ {"url_key": "home", "changefreq": "monthly", "priority": 1},
+ {"url_key": "login", "changefreq": "monthly", "priority": 1},
+ {"url_key": "register", "changefreq": "monthly", "priority": 1},
+ {"url_key": "forgot-password", "changefreq": "monthly", "priority": 1}
]
def location(self, obj):
diff --git a/taiga/front/sitemaps/projects.py b/taiga/front/sitemaps/projects.py
index bbbbfbb8..f9ad82f8 100644
--- a/taiga/front/sitemaps/projects.py
+++ b/taiga/front/sitemaps/projects.py
@@ -40,10 +40,10 @@ class ProjectsSitemap(Sitemap):
return obj.modified_date
def changefreq(self, obj):
- return "daily"
+ return "hourly"
def priority(self, obj):
- return 0.6
+ return 0.9
class ProjectBacklogsSitemap(Sitemap):
From fad37091e8c1737bd45f4849fe2ea17bd02f50f8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jes=C3=BAs=20Espino?=
Date: Thu, 25 Jun 2015 17:06:29 +0200
Subject: [PATCH 028/190] Issue#2650: Allow local network ips in gitlab and
bitbucket ip filters
---
taiga/hooks/bitbucket/api.py | 4 ++--
taiga/hooks/gitlab/api.py | 4 ++--
tests/integration/test_hooks_bitbucket.py | 19 +++++++++++++++++++
tests/integration/test_hooks_gitlab.py | 20 ++++++++++++++++++++
4 files changed, 43 insertions(+), 4 deletions(-)
diff --git a/taiga/hooks/bitbucket/api.py b/taiga/hooks/bitbucket/api.py
index 562b5763..82b71ea0 100644
--- a/taiga/hooks/bitbucket/api.py
+++ b/taiga/hooks/bitbucket/api.py
@@ -24,7 +24,7 @@ from taiga.hooks.api import BaseWebhookApiViewSet
from . import event_hooks
from urllib.parse import parse_qs
-from ipware.ip import get_real_ip
+from ipware.ip import get_ip
class BitBucketViewSet(BaseWebhookApiViewSet):
@@ -60,7 +60,7 @@ class BitBucketViewSet(BaseWebhookApiViewSet):
bitbucket_config = project.modules_config.config.get("bitbucket", {})
valid_origin_ips = bitbucket_config.get("valid_origin_ips",
settings.BITBUCKET_VALID_ORIGIN_IPS)
- origin_ip = get_real_ip(request)
+ origin_ip = get_ip(request)
if valid_origin_ips and (not origin_ip or origin_ip not in valid_origin_ips):
return False
diff --git a/taiga/hooks/gitlab/api.py b/taiga/hooks/gitlab/api.py
index 01e455c5..48d70fe7 100644
--- a/taiga/hooks/gitlab/api.py
+++ b/taiga/hooks/gitlab/api.py
@@ -16,7 +16,7 @@
from django.conf import settings
-from ipware.ip import get_real_ip
+from ipware.ip import get_ip
from taiga.base.utils import json
@@ -50,7 +50,7 @@ class GitLabViewSet(BaseWebhookApiViewSet):
gitlab_config = project.modules_config.config.get("gitlab", {})
valid_origin_ips = gitlab_config.get("valid_origin_ips", settings.GITLAB_VALID_ORIGIN_IPS)
- origin_ip = get_real_ip(request)
+ origin_ip = get_ip(request)
if valid_origin_ips and (not origin_ip or origin_ip not in valid_origin_ips):
return False
diff --git a/tests/integration/test_hooks_bitbucket.py b/tests/integration/test_hooks_bitbucket.py
index 9f19014d..ecb4058d 100644
--- a/tests/integration/test_hooks_bitbucket.py
+++ b/tests/integration/test_hooks_bitbucket.py
@@ -73,6 +73,25 @@ def test_invalid_ip(client):
assert response.status_code == 400
+def test_valid_local_network_ip(client):
+ project = f.ProjectFactory()
+ f.ProjectModulesConfigFactory(project=project, config={
+ "bitbucket": {
+ "secret": "tpnIwJDz4e",
+ "valid_origin_ips": ["192.168.1.1"]
+ }
+ })
+
+ url = reverse("bitbucket-hook-list")
+ url = "{}?project={}&key={}".format(url, project.id, "tpnIwJDz4e")
+ data = {'payload': ['{"commits": []}']}
+ response = client.post(url,
+ urllib.parse.urlencode(data, True),
+ content_type="application/x-www-form-urlencoded",
+ REMOTE_ADDR="192.168.1.1")
+ assert response.status_code == 204
+
+
def test_not_ip_filter(client):
project = f.ProjectFactory()
f.ProjectModulesConfigFactory(project=project, config={
diff --git a/tests/integration/test_hooks_gitlab.py b/tests/integration/test_hooks_gitlab.py
index c7b79c24..39aa5485 100644
--- a/tests/integration/test_hooks_gitlab.py
+++ b/tests/integration/test_hooks_gitlab.py
@@ -78,6 +78,26 @@ def test_invalid_ip(client):
assert response.status_code == 400
+def test_valid_local_network_ip(client):
+ project = f.ProjectFactory()
+ f.ProjectModulesConfigFactory(project=project, config={
+ "gitlab": {
+ "secret": "tpnIwJDz4e",
+ "valid_origin_ips": ["192.168.1.1"],
+ }
+ })
+
+ url = reverse("gitlab-hook-list")
+ url = "{}?project={}&key={}".format(url, project.id, "tpnIwJDz4e")
+ data = {"test:": "data"}
+ response = client.post(url,
+ json.dumps(data),
+ content_type="application/json",
+ REMOTE_ADDR="192.168.1.1")
+
+ assert response.status_code == 204
+
+
def test_not_ip_filter(client):
project = f.ProjectFactory()
f.ProjectModulesConfigFactory(project=project, config={
From 3e8c1814d5a4e557a13c13bb7d6f787627fb7473 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jes=C3=BAs=20Espino?=
Date: Thu, 25 Jun 2015 15:46:48 +0200
Subject: [PATCH 029/190] Issue#2572: On status open/closed change recalc the
is_closed for user stories
---
taiga/projects/apps.py | 20 +++++++++++++++
taiga/projects/signals.py | 22 ++++++++++++++++
tests/integration/test_projects.py | 40 ++++++++++++++++++++++++++++++
3 files changed, 82 insertions(+)
diff --git a/taiga/projects/apps.py b/taiga/projects/apps.py
index a57f2f5a..c3ef4df3 100644
--- a/taiga/projects/apps.py
+++ b/taiga/projects/apps.py
@@ -53,6 +53,18 @@ def connect_projects_signals():
dispatch_uid="update_project_tags_when_create_or_edit_taggable_item_projects")
+def connect_us_status_signals():
+ signals.post_save.connect(handlers.try_to_close_or_open_user_stories_when_edit_us_status,
+ sender=apps.get_model("projects", "UserStoryStatus"),
+ dispatch_uid="try_to_close_or_open_user_stories_when_edit_us_status")
+
+
+def connect_task_status_signals():
+ signals.post_save.connect(handlers.try_to_close_or_open_user_stories_when_edit_task_status,
+ sender=apps.get_model("projects", "TaskStatus"),
+ dispatch_uid="try_to_close_or_open_user_stories_when_edit_task_status")
+
+
def disconnect_memberships_signals():
signals.pre_delete.disconnect(dispatch_uid='membership_pre_delete')
signals.post_delete.disconnect(dispatch_uid='update_watchers_on_membership_post_delete')
@@ -64,6 +76,12 @@ def disconnect_projects_signals():
signals.pre_save.disconnect(dispatch_uid="tags_normalization_projects")
signals.pre_save.disconnect(dispatch_uid="update_project_tags_when_create_or_edit_taggable_item_projects")
+def disconnect_us_status_signals():
+ signals.post_save.disconnect(dispatch_uid="try_to_close_or_open_user_stories_when_edit_us_status")
+
+def disconnect_task_status_signals():
+ signals.post_save.disconnect(dispatch_uid="try_to_close_or_open_user_stories_when_edit_task_status")
+
class ProjectsAppConfig(AppConfig):
name = "taiga.projects"
@@ -72,3 +90,5 @@ class ProjectsAppConfig(AppConfig):
def ready(self):
connect_memberships_signals()
connect_projects_signals()
+ connect_us_status_signals()
+ connect_task_status_signals()
diff --git a/taiga/projects/signals.py b/taiga/projects/signals.py
index 61dd0709..f6cb0b1a 100644
--- a/taiga/projects/signals.py
+++ b/taiga/projects/signals.py
@@ -97,3 +97,25 @@ def project_post_save(sender, instance, created, **kwargs):
Membership = apps.get_model("projects", "Membership")
Membership.objects.create(user=instance.owner, project=instance, role=owner_role,
is_owner=True, email=instance.owner.email)
+
+
+def try_to_close_or_open_user_stories_when_edit_us_status(sender, instance, created, **kwargs):
+ from taiga.projects.userstories import services
+
+ for user_story in instance.user_stories.all():
+ if services.calculate_userstory_is_closed(user_story):
+ services.close_userstory(user_story)
+ else:
+ services.open_userstory(user_story)
+
+
+def try_to_close_or_open_user_stories_when_edit_task_status(sender, instance, created, **kwargs):
+ from taiga.projects.userstories import services
+
+ UserStory = apps.get_model("userstories", "UserStory")
+
+ for user_story in UserStory.objects.filter(tasks__status=instance).distinct():
+ if services.calculate_userstory_is_closed(user_story):
+ services.close_userstory(user_story)
+ else:
+ services.open_userstory(user_story)
diff --git a/tests/integration/test_projects.py b/tests/integration/test_projects.py
index 527b0d0e..45b7c96e 100644
--- a/tests/integration/test_projects.py
+++ b/tests/integration/test_projects.py
@@ -45,6 +45,46 @@ def test_partially_update_project(client):
assert response.status_code == 400
+def test_us_status_is_closed_changed_recalc_us_is_closed(client):
+ us_status = f.UserStoryStatusFactory(is_closed=False)
+ user_story = f.UserStoryFactory.create(project=us_status.project, status=us_status)
+
+ assert user_story.is_closed is False
+
+ us_status.is_closed = True
+ us_status.save()
+
+ user_story = user_story.__class__.objects.get(pk=user_story.pk)
+ assert user_story.is_closed is True
+
+ us_status.is_closed = False
+ us_status.save()
+
+ user_story = user_story.__class__.objects.get(pk=user_story.pk)
+ assert user_story.is_closed is False
+
+
+def test_task_status_is_closed_changed_recalc_us_is_closed(client):
+ us_status = f.UserStoryStatusFactory()
+ user_story = f.UserStoryFactory.create(project=us_status.project, status=us_status)
+ task_status = f.TaskStatusFactory.create(project=us_status.project, is_closed=False)
+ task = f.TaskFactory.create(project=us_status.project, status=task_status, user_story=user_story)
+
+ assert user_story.is_closed is False
+
+ task_status.is_closed = True
+ task_status.save()
+
+ user_story = user_story.__class__.objects.get(pk=user_story.pk)
+ assert user_story.is_closed is True
+
+ task_status.is_closed = False
+ task_status.save()
+
+ user_story = user_story.__class__.objects.get(pk=user_story.pk)
+ assert user_story.is_closed is False
+
+
def test_us_status_slug_generation(client):
us_status = f.UserStoryStatusFactory(name="NEW")
f.MembershipFactory(user=us_status.project.owner, project=us_status.project, is_owner=True)
From a7b9873e8dd63adc5e4fe3d964e038219e5fba05 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?=
Date: Thu, 25 Jun 2015 18:08:12 +0200
Subject: [PATCH 030/190] [i18n] Update locales de
---
taiga/locale/de/LC_MESSAGES/django.po | 229 +++++++++++++++++++++++++-
1 file changed, 228 insertions(+), 1 deletion(-)
diff --git a/taiga/locale/de/LC_MESSAGES/django.po b/taiga/locale/de/LC_MESSAGES/django.po
index 9147444a..e8e499f3 100644
--- a/taiga/locale/de/LC_MESSAGES/django.po
+++ b/taiga/locale/de/LC_MESSAGES/django.po
@@ -14,7 +14,7 @@ msgstr ""
"Project-Id-Version: taiga-back\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2015-06-15 12:34+0200\n"
-"PO-Revision-Date: 2015-06-24 10:12+0000\n"
+"PO-Revision-Date: 2015-06-25 11:22+0000\n"
"Last-Translator: Regina \n"
"Language-Team: German (http://www.transifex.com/projects/p/taiga-back/"
"language/de/)\n"
@@ -434,6 +434,22 @@ msgid ""
" \n"
" "
msgstr ""
+"\n"
+" Taiga Support:\n"
+" "
+"%(support_url)s\n"
+"
\n"
+" Kontaktieren Sie uns:\n"
+" \n"
+" %(support_email)s\n"
+" \n"
+"
\n"
+" Mailing list:\n"
+" \n"
+" %(mailing_list_url)s\n"
+" "
#: taiga/base/templates/emails/hero-body-html.jinja:6
msgid "You have been Taigatized"
@@ -914,6 +930,12 @@ msgid ""
"\n"
"{message}"
msgstr ""
+"Kommentar von [@{github_user_name}]({github_user_url} \"See "
+"@{github_user_name}'s GitHub profile\") von GitHub.\n"
+"Origin GitHub issue: [gh#{number} - {subject}]({github_url} \"Go to "
+"'gh#{number} - {subject}'\")\n"
+"\n"
+"{message}"
#: taiga/hooks/github/event_hooks.py:212
#, python-brace-format
@@ -1693,6 +1715,14 @@ msgid ""
"%(subject)s in Taiga\">See issue\n"
" "
msgstr ""
+"\n"
+"Ticket aktualisiert
\n"
+"Hallo %(user)s,
%(changer)s hat ein Ticket aktualisiert in "
+"%(project)s
\n"
+"Issue #%(ref)s %(subject)s
\n"
+"Ticket ansehen\n"
+" "
#: taiga/projects/notifications/templates/emails/issues/issue-change-body-text.jinja:3
#, python-format
@@ -1731,6 +1761,15 @@ msgid ""
" The Taiga Team
\n"
" "
msgstr ""
+"\n"
+"Neues Ticket wurde erstellt
\n"
+"Hallo %(user)s,
%(changer)s hat ein neues Ticket erstellt in "
+"%(project)s
\n"
+"Ticket #%(ref)s %(subject)s
\n"
+"Ticket ansehen\n"
+"Das Taiga Team
\n"
+" "
#: taiga/projects/notifications/templates/emails/issues/issue-create-body-text.jinja:1
#, python-format
@@ -1772,6 +1811,13 @@ msgid ""
" The Taiga Team
\n"
" "
msgstr ""
+"\n"
+"Ticket gelöscht
\n"
+"Hallo %(user)s,
%(changer)s hat ein Ticket gelöscht in %(project)s"
+"p>\n"
+"
Ticket #%(ref)s %(subject)s
\n"
+"Das Taiga Team
\n"
+" "
#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-text.jinja:1
#, python-format
@@ -1816,6 +1862,14 @@ msgid ""
"Taiga\">See sprint\n"
" "
msgstr ""
+"\n"
+"Sprint wurde aktualisiert
\n"
+"Hallo %(user)s,
%(changer)s hat einen Sprint aktualisiert in "
+"%(project)s
\n"
+"Sprint %(name)s
\n"
+"Sprint ansehen\n"
+" "
#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-text.jinja:3
#, python-format
@@ -1825,6 +1879,11 @@ msgid ""
"Hello %(user)s, %(changer)s has updated a sprint on %(project)s\n"
"See sprint %(name)s at %(url)s\n"
msgstr ""
+"\n"
+"Sprint aktualisiert\n"
+"Hallo %(user)s, %(changer)s hat einen Sprint aktualisiert in %(project)s\n"
+"Sprint ansehen %(name)s auf %(url)s \n"
+"\n"
#: taiga/projects/notifications/templates/emails/milestones/milestone-change-subject.jinja:1
#, python-format
@@ -1849,6 +1908,15 @@ msgid ""
" The Taiga Team
\n"
" "
msgstr ""
+"\n"
+"Neuer Sprint wurde erstellt
\n"
+"Hallo %(user)s,
%(changer)s hat einen neuen Sprint erstellt in "
+"%(project)s
\n"
+"Sprint %(name)s
\n"
+"See "
+"sprint\n"
+"Das Taiga Team
\n"
+" "
#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-text.jinja:1
#, python-format
@@ -1861,6 +1929,14 @@ msgid ""
"---\n"
"The Taiga Team\n"
msgstr ""
+"\n"
+"Neuer Sprint wurde erstellt\n"
+"Hallo %(user)s, %(changer)s hat einen neuen Sprint erstellt in %(project)s\n"
+"Sprint ansehen %(name)s at %(url)s\n"
+"\n"
+"---\n"
+"Das Taiga Team\n"
+" \n"
#: taiga/projects/notifications/templates/emails/milestones/milestone-create-subject.jinja:1
#, python-format
@@ -1883,6 +1959,13 @@ msgid ""
" The Taiga Team
\n"
" "
msgstr ""
+"\n"
+"Sprint wurde gelöscht
\n"
+"Hallo %(user)s,
%(changer)s hat einen Sprint gelöscht in "
+"%(project)s
\n"
+"Sprint %(name)s
\n"
+"Das Taiga Team
\n"
+" "
#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-text.jinja:1
#, python-format
@@ -1895,6 +1978,14 @@ msgid ""
"---\n"
"The Taiga Team\n"
msgstr ""
+"\n"
+"Sprint wurde gelöscht\n"
+"Hallo %(user)s, %(changer)s hat einen Sprint gelöscht in %(project)s\n"
+"Sprint %(name)s\n"
+"\n"
+"---\n"
+"Das Taiga Team\n"
+"\n"
#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-subject.jinja:1
#, python-format
@@ -1964,6 +2055,15 @@ msgid ""
" The Taiga Team
\n"
" "
msgstr ""
+"\n"
+"Neue Aufgabe wurde erstellt
\n"
+"Hallo %(user)s,
%(changer)s hat eine neue Aufgabe erstellt in "
+"%(project)s
\n"
+"Aufgabe #%(ref)s %(subject)s
\n"
+"Aufgabe ansehen\n"
+"Das Taiga Team
\n"
+" "
#: taiga/projects/notifications/templates/emails/tasks/task-create-body-text.jinja:1
#, python-format
@@ -2007,6 +2107,13 @@ msgid ""
" The Taiga Team
\n"
" "
msgstr ""
+"\n"
+"Aufgabe wurde gelöscht
\n"
+"Hallo %(user)s,
%(changer)s hat eine Aufgabe gelöscht in "
+"%(project)s
\n"
+"Aufgabe #%(ref)s %(subject)s
\n"
+"Das Taiga Team
\n"
+" "
#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-text.jinja:1
#, python-format
@@ -2019,6 +2126,14 @@ msgid ""
"---\n"
"The Taiga Team\n"
msgstr ""
+"\n"
+"Aufgabe wurde gelöscht\n"
+"Hallo %(user)s, %(changer)s hat eine Aufgabe gelöscht in %(project)s\n"
+"Aufgabe #%(ref)s %(subject)s\n"
+"\n"
+"---\n"
+"Das Taiga Team\n"
+" \n"
#: taiga/projects/notifications/templates/emails/tasks/task-delete-subject.jinja:1
#, python-format
@@ -2026,6 +2141,9 @@ msgid ""
"\n"
"[%(project)s] Deleted the task #%(ref)s \"%(subject)s\"\n"
msgstr ""
+"\n"
+"[%(project)s] Hat die Aufgabe gelöscht #%(ref)s \"%(subject)s\"\n"
+"\n"
#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-html.jinja:4
#, python-format
@@ -2039,6 +2157,15 @@ msgid ""
"%(subject)s in Taiga\">See user story\n"
" "
msgstr ""
+"\n"
+"User-Story wurde aktualisiert
\n"
+"Hallo %(user)s,
%(changer)s hat eine User-Story aktualisiert in "
+"%(project)s
\n"
+"User-Story #%(ref)s %(subject)s
\n"
+"User-Story ansehen\n"
+"\n"
+" "
#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-text.jinja:3
#, python-format
@@ -2048,6 +2175,11 @@ msgid ""
"Hello %(user)s, %(changer)s has updated a user story on %(project)s\n"
"See user story #%(ref)s %(subject)s at %(url)s\n"
msgstr ""
+"\n"
+"User-Story wurde aktualisiert\n"
+"Hallo %(user)s, %(changer)s hat eine User-Story aktualisiert in %(project)s\n"
+"User-Story ansehen #%(ref)s %(subject)s auf %(url)s\n"
+"\n"
#: taiga/projects/notifications/templates/emails/userstories/userstory-change-subject.jinja:1
#, python-format
@@ -2055,6 +2187,8 @@ msgid ""
"\n"
"[%(project)s] Updated the US #%(ref)s \"%(subject)s\"\n"
msgstr ""
+"\n"
+"[%(project)s] Aktualisierte die User-Story #%(ref)s \"%(subject)s\"\n"
#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-html.jinja:4
#, python-format
@@ -2069,6 +2203,16 @@ msgid ""
" The Taiga Team
\n"
" "
msgstr ""
+"\n"
+"Neue User-Story wurde erstellt
\n"
+"Hallo %(user)s,
%(changer)s hat eine neue User-Story erstellt in "
+"%(project)s
\n"
+"User-Story #%(ref)s %(subject)s
\n"
+"User-Story ansehen\n"
+" Das Taiga Team
\n"
+"\n"
+" "
#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-text.jinja:1
#, python-format
@@ -2081,6 +2225,15 @@ msgid ""
"---\n"
"The Taiga Team\n"
msgstr ""
+"\n"
+"Neue User-Story wurde erstellt\n"
+"Hallo %(user)s, %(changer)s hat eine neue User-Story erstellt in "
+"%(project)s\n"
+"User-Story ansehen #%(ref)s %(subject)s auf %(url)s\n"
+"\n"
+"---\n"
+"Das Taiga Team\n"
+"\n"
#: taiga/projects/notifications/templates/emails/userstories/userstory-create-subject.jinja:1
#, python-format
@@ -2100,6 +2253,14 @@ msgid ""
" The Taiga Team
\n"
" "
msgstr ""
+"\n"
+"User-Story wurde gelöscht
\n"
+"Hallo %(user)s,
%(changer)s hat eine User-Story gelöscht in "
+"%(project)s
\n"
+"User-Story #%(ref)s %(subject)s
\n"
+"Das Taiga Team
\n"
+"\n"
+" "
#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-text.jinja:1
#, python-format
@@ -2112,6 +2273,14 @@ msgid ""
"---\n"
"The Taiga Team\n"
msgstr ""
+"\n"
+"User-Story wurde gelöscht\n"
+"Hallo %(user)s, %(changer)s hat eine User-Story gelöscht in %(project)s\n"
+"User-Story #%(ref)s %(subject)s\n"
+"\n"
+"---\n"
+"Das Taiga Team\n"
+"\n"
#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-subject.jinja:1
#, python-format
@@ -2132,6 +2301,15 @@ msgid ""
"\">See Wiki Page\n"
" "
msgstr ""
+"\n"
+"Wiki Seite wurde aktualisiert
\n"
+"Hallo %(user)s,
%(changer)s hat eine Wiki Seite aktualisiert in "
+"%(project)s
\n"
+"Wiki Seite %(page)s
\n"
+"Wiki "
+"Seite ansehen\n"
+"\n"
+" "
#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-text.jinja:3
#, python-format
@@ -2143,6 +2321,13 @@ msgid ""
"\n"
"See wiki page %(page)s at %(url)s\n"
msgstr ""
+"\n"
+"Wiki Seite wurde aktualisiert\n"
+"\n"
+"Hallo %(user)s, %(changer)s hat eine Wiki Seite aktualisiert in %(project)s\n"
+"\n"
+"Wiki Seite ansehen %(page)s auf %(url)s\n"
+"\n"
#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-subject.jinja:1
#, python-format
@@ -2164,6 +2349,16 @@ msgid ""
" The Taiga Team
\n"
" "
msgstr ""
+"\n"
+"Neue Wiki Seite wurde erstellt
\n"
+"Hallo %(user)s,
%(changer)s hat eine neue Wiki Seite erstellt in "
+"%(project)s
\n"
+"Wiki Seite %(page)s
\n"
+"Wiki Seite "
+"ansehen\n"
+"Das Taiga Team
\n"
+"\n"
+" "
#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-text.jinja:1
#, python-format
@@ -2178,6 +2373,17 @@ msgid ""
"---\n"
"The Taiga Team\n"
msgstr ""
+"\n"
+"Neue Wiki Seite wurde erstellt\n"
+"\n"
+"Hallo %(user)s, %(changer)s hat eine neue Wiki Seite erstellt in "
+"%(project)s\n"
+"\n"
+"Wiki Seite ansehen %(page)s auf %(url)s\n"
+"\n"
+"---\n"
+"Das Taiga Team\n"
+"\n"
#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-subject.jinja:1
#, python-format
@@ -2197,6 +2403,14 @@ msgid ""
" The Taiga Team
\n"
" "
msgstr ""
+"\n"
+"Wiki Seite wurde gelöscht
\n"
+"Hallo %(user)s,
%(changer)s hat eine Wiki Seite gelöscht in "
+"%(project)s
\n"
+"Wiki Seite %(page)s
\n"
+"Das Taiga Team
\n"
+"\n"
+" "
#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-text.jinja:1
#, python-format
@@ -2211,6 +2425,16 @@ msgid ""
"---\n"
"The Taiga Team\n"
msgstr ""
+"\n"
+"Wiki Seite wurde gelöscht\n"
+"\n"
+"Hallo %(user)s, %(changer)s hat eine Wiki Seite gelöscht in %(project)s\n"
+"\n"
+"Wiki Seite %(page)s\n"
+"\n"
+"---\n"
+"Das Taiga Team\n"
+"\n"
#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-subject.jinja:1
#, python-format
@@ -2407,6 +2631,9 @@ msgid ""
"\n"
"[Taiga] Invitation to join to the project '%(project)s'\n"
msgstr ""
+"\n"
+"[Taiga] Einladung zur Teilnahme am Projekt '%(project)s'\n"
+"\n"
#: taiga/projects/templates/emails/membership_notification-body-html.jinja:4
#, python-format
From 07a48372e30291f8a37aeb237a0f659cb8d4f0c9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?=
Date: Wed, 24 Jun 2015 16:00:31 +0200
Subject: [PATCH 031/190] Add user info to the HistoryEntrySerializer
---
taiga/projects/history/serializers.py | 19 ++++++++++++++++++-
1 file changed, 18 insertions(+), 1 deletion(-)
diff --git a/taiga/projects/history/serializers.py b/taiga/projects/history/serializers.py
index 4834e504..2b5f4e00 100644
--- a/taiga/projects/history/serializers.py
+++ b/taiga/projects/history/serializers.py
@@ -17,17 +17,34 @@
from taiga.base.api import serializers
from taiga.base.fields import JsonField, I18NJsonField
+from taiga.users.services import get_photo_or_gravatar_url
+
from . import models
+
HISTORY_ENTRY_I18N_FIELDS=("points", "status", "severity", "priority", "type")
+
class HistoryEntrySerializer(serializers.ModelSerializer):
diff = JsonField()
snapshot = JsonField()
values = I18NJsonField(i18n_fields=HISTORY_ENTRY_I18N_FIELDS)
values_diff = I18NJsonField(i18n_fields=HISTORY_ENTRY_I18N_FIELDS)
- user = JsonField()
+ user = serializers.SerializerMethodField("get_user")
delete_comment_user = JsonField()
class Meta:
model = models.HistoryEntry
+
+ def get_user(self, entry):
+ user = {"pk": None, "username": None, "name": None, "photo": None, "is_active": False}
+ user.update(entry.user)
+
+ user["photo"] = get_photo_or_gravatar_url(entry.owner)
+ user["is_active"] = entry.owner.is_active
+
+ if entry.owner.is_active or entry.owner.is_system:
+ user["name"] = entry.owner.get_full_name()
+ user["username"] = entry.owner.username
+
+ return user
From 45186558a7379df8f1df12b83ad89b051a035d24 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?=
Date: Fri, 26 Jun 2015 12:40:16 +0200
Subject: [PATCH 032/190] Added owner extra info to serializers
---
taiga/projects/issues/serializers.py | 1 +
taiga/projects/tasks/serializers.py | 1 +
taiga/projects/userstories/serializers.py | 1 +
3 files changed, 3 insertions(+)
diff --git a/taiga/projects/issues/serializers.py b/taiga/projects/issues/serializers.py
index dd0d4ef5..77b22bc5 100644
--- a/taiga/projects/issues/serializers.py
+++ b/taiga/projects/issues/serializers.py
@@ -40,6 +40,7 @@ class IssueSerializer(WatchersValidator, serializers.ModelSerializer):
votes = serializers.SerializerMethodField("get_votes_number")
status_extra_info = BasicIssueStatusSerializer(source="status", required=False, read_only=True)
assigned_to_extra_info = UserBasicInfoSerializer(source="assigned_to", required=False, read_only=True)
+ owner_extra_info = UserBasicInfoSerializer(source="owner", required=False, read_only=True)
class Meta:
model = models.Issue
diff --git a/taiga/projects/tasks/serializers.py b/taiga/projects/tasks/serializers.py
index 30a63d1b..131bb75b 100644
--- a/taiga/projects/tasks/serializers.py
+++ b/taiga/projects/tasks/serializers.py
@@ -42,6 +42,7 @@ class TaskSerializer(WatchersValidator, serializers.ModelSerializer):
is_closed = serializers.SerializerMethodField("get_is_closed")
status_extra_info = BasicTaskStatusSerializerSerializer(source="status", required=False, read_only=True)
assigned_to_extra_info = UserBasicInfoSerializer(source="assigned_to", required=False, read_only=True)
+ owner_extra_info = UserBasicInfoSerializer(source="owner", required=False, read_only=True)
class Meta:
model = models.Task
diff --git a/taiga/projects/userstories/serializers.py b/taiga/projects/userstories/serializers.py
index 30129e7b..1773776f 100644
--- a/taiga/projects/userstories/serializers.py
+++ b/taiga/projects/userstories/serializers.py
@@ -55,6 +55,7 @@ class UserStorySerializer(WatchersValidator, serializers.ModelSerializer):
description_html = serializers.SerializerMethodField("get_description_html")
status_extra_info = BasicUserStoryStatusSerializer(source="status", required=False, read_only=True)
assigned_to_extra_info = UserBasicInfoSerializer(source="assigned_to", required=False, read_only=True)
+ owner_extra_info = UserBasicInfoSerializer(source="owner", required=False, read_only=True)
class Meta:
model = models.UserStory
From 2d2528608496bc319aa1bc9b472a64d30af25c0c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?=
Date: Fri, 26 Jun 2015 12:45:48 +0200
Subject: [PATCH 033/190] Add is_active to UserBasicInfoSerializer
---
taiga/users/serializers.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/taiga/users/serializers.py b/taiga/users/serializers.py
index 1a22ff70..115462be 100644
--- a/taiga/users/serializers.py
+++ b/taiga/users/serializers.py
@@ -111,7 +111,7 @@ class UserAdminSerializer(UserSerializer):
class UserBasicInfoSerializer(UserSerializer):
class Meta:
model = User
- fields = ("username", "full_name_display","photo", "big_photo")
+ fields = ("username", "full_name_display","photo", "big_photo", "is_active")
class RecoverySerializer(serializers.Serializer):
From 6aea158762caecf0d6d6adae9327a73c0703a973 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jes=C3=BAs=20Espino?=
Date: Mon, 29 Jun 2015 12:49:19 +0200
Subject: [PATCH 034/190] Issue#2966: Change text "milestone" by "sprint" in
change emails
---
taiga/projects/history/templatetags/functions.py | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/taiga/projects/history/templatetags/functions.py b/taiga/projects/history/templatetags/functions.py
index eef5133b..8d616b73 100644
--- a/taiga/projects/history/templatetags/functions.py
+++ b/taiga/projects/history/templatetags/functions.py
@@ -24,7 +24,8 @@ register = library.Library()
EXTRA_FIELD_VERBOSE_NAMES = {
"description_diff": _("description"),
"content_diff": _("content"),
- "blocked_note_diff": _("blocked note")
+ "blocked_note_diff": _("blocked note"),
+ "milestone": _("sprint"),
}
From 00901a3c936f41f4adf771bc06c37f0ca6221b8d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?=
Date: Mon, 29 Jun 2015 14:46:03 +0200
Subject: [PATCH 035/190] [i18n] Update locales
---
taiga/locale/de/LC_MESSAGES/django.po | 187 ++++++++++++++++++---
taiga/locale/zh-Hant/LC_MESSAGES/django.po | 13 +-
2 files changed, 175 insertions(+), 25 deletions(-)
diff --git a/taiga/locale/de/LC_MESSAGES/django.po b/taiga/locale/de/LC_MESSAGES/django.po
index e8e499f3..12fedc19 100644
--- a/taiga/locale/de/LC_MESSAGES/django.po
+++ b/taiga/locale/de/LC_MESSAGES/django.po
@@ -14,7 +14,7 @@ msgstr ""
"Project-Id-Version: taiga-back\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2015-06-15 12:34+0200\n"
-"PO-Revision-Date: 2015-06-25 11:22+0000\n"
+"PO-Revision-Date: 2015-06-29 10:23+0000\n"
"Last-Translator: Regina \n"
"Language-Team: German (http://www.transifex.com/projects/p/taiga-back/"
"language/de/)\n"
@@ -349,7 +349,7 @@ msgstr "Fehler bei Datenüberprüfung "
#: taiga/base/exceptions.py:162
msgid "Integrity Error for wrong or invalid arguments"
-msgstr ""
+msgstr "Integritätsfehler wegen falscher oder ungültiger Argumente"
#: taiga/base/exceptions.py:169
msgid "Precondition error"
@@ -614,6 +614,16 @@ msgid ""
" The Taiga Team
\n"
" "
msgstr ""
+"\n"
+"Projekt Export Datei wurde erstellt
\n"
+"Hallo %(user)s,
\n"
+"Ihre Export Datei von Projekt %(project)s wurde korrekt erstellt.
\n"
+"Sie können sie hier herunterladen:
\n"
+"Export "
+"Datei herunterladen\n"
+"Diese Datei wird gelöscht am %(deletion_date)s.
\n"
+"Das Taiga Team
\n"
+" "
#: taiga/export_import/templates/emails/dump_project-body-text.jinja:1
#, python-format
@@ -631,6 +641,19 @@ msgid ""
"---\n"
"The Taiga Team\n"
msgstr ""
+"\n"
+"Hallo %(user)s,\n"
+"\n"
+"Ihre Export Datei von Projekt %(project)s wurde korrekt erstellt. Sie können "
+"sie hier herunterladen:\n"
+"\n"
+"%(url)s\n"
+"\n"
+"Diese Datei wird gelöscht am %(deletion_date)s.\n"
+"\n"
+"---\n"
+"Das Taiga Team\n"
+"\n"
#: taiga/export_import/templates/emails/dump_project-subject.jinja:1
#, python-format
@@ -651,6 +674,16 @@ msgid ""
" The Taiga Team
\n"
" "
msgstr ""
+"\n"
+"%(error_message)s
\n"
+"Hallo %(user)s,
\n"
+"Ihr Projekt %(project)s wurde nicht korrekt importiert.
\n"
+"Die Taiga System Administratoren wurden informiert.
Bitte versuchen "
+"Sie es erneut oder kontaktieren Sie das Support Team unter\n"
+"%(support_email)s
\n"
+"Das Taiga Team
\n"
+" "
#: taiga/export_import/templates/emails/export_error-body-text.jinja:1
#, python-format
@@ -668,6 +701,20 @@ msgid ""
"---\n"
"The Taiga Team\n"
msgstr ""
+"\n"
+"Hallo %(user)s,\n"
+"\n"
+"%(error_message)s\n"
+"Ihr Projekt %(project)s wurde nicht korrekt importiert.\n"
+"\n"
+"Die Taiga System Administratoren wurden informiert.\n"
+"\n"
+"Bitte versuchen Sie es erneut oder kontaktieren Sie das Support Team unter "
+"%(support_email)s\n"
+"\n"
+"---\n"
+"Das Taiga Team\n"
+"\n"
#: taiga/export_import/templates/emails/export_error-subject.jinja:1
#, python-format
@@ -688,6 +735,17 @@ msgid ""
" The Taiga Team
\n"
" "
msgstr ""
+"\n"
+"%(error_message)s
\n"
+"Hallo %(user)s,
\n"
+"Ihr Projekt wurde nicht korrekt importiert.
\n"
+"Die Taiga System Administratoren wurden informiert.
Bitte versuchen "
+"Sie es erneut oder kontaktieren Sie das Support Team unter\n"
+"%(support_email)s
\n"
+"Das Taiga Team
\n"
+"\n"
+" "
#: taiga/export_import/templates/emails/import_error-body-text.jinja:1
#, python-format
@@ -738,6 +796,15 @@ msgid ""
" The Taiga Team
\n"
" "
msgstr ""
+"\n"
+"Projekt Export-Datei wurde importiert
\n"
+"Hallo %(user)s,
\n"
+"Ihre Projekt Export-Datei wurde korrekt importiert.
\n"
+"Gehe zu %(project)s\n"
+"Das Taiga Team
\n"
+"\n"
+" "
#: taiga/export_import/templates/emails/load_dump-body-text.jinja:1
#, python-format
@@ -754,6 +821,18 @@ msgid ""
"---\n"
"The Taiga Team\n"
msgstr ""
+"\n"
+"Hallo %(user)s,\n"
+"\n"
+"Ihre Projekt Export-Datei wurde korrekt importiert.\n"
+"\n"
+"Sie können das Projekt %(project)s hier sehen:\n"
+"\n"
+"%(url)s\n"
+"\n"
+"---\n"
+"Das Taiga Team\n"
+"\n"
#: taiga/export_import/templates/emails/load_dump-subject.jinja:1
#, python-format
@@ -844,7 +923,7 @@ msgstr ""
#: taiga/hooks/api.py:52
msgid "The payload is not a valid json"
-msgstr ""
+msgstr "Die Nutzlast ist kein gültiges json"
#: taiga/hooks/api.py:61
msgid "The project doesn't exist"
@@ -852,15 +931,15 @@ msgstr "Das Projekt existiert nicht"
#: taiga/hooks/api.py:64
msgid "Bad signature"
-msgstr ""
+msgstr "Falsche Signatur"
#: taiga/hooks/bitbucket/api.py:40
msgid "The payload is not a valid application/x-www-form-urlencoded"
-msgstr ""
+msgstr "Die Nutzlast ist eine ungültige Anwendung/x-www-form-urlencoded"
#: taiga/hooks/bitbucket/event_hooks.py:45
msgid "The payload is not valid"
-msgstr ""
+msgstr "Die Nutzlast ist ungültig"
#: taiga/hooks/bitbucket/event_hooks.py:81
#: taiga/hooks/github/event_hooks.py:76 taiga/hooks/gitlab/event_hooks.py:74
@@ -1604,7 +1683,7 @@ msgstr "Tag Farben"
#: taiga/projects/models.py:339
msgid "modules config"
-msgstr ""
+msgstr "Module konfigurieren"
#: taiga/projects/models.py:358
msgid "is archived"
@@ -1745,7 +1824,7 @@ msgid ""
"[%(project)s] Updated the issue #%(ref)s \"%(subject)s\"\n"
msgstr ""
"\n"
-"[%(project)s] Aktualisierte das Ticket #%(ref)s \"%(subject)s\"\n"
+"[%(project)s] aktualisierte das Ticket #%(ref)s \"%(subject)s\"\n"
" \n"
#: taiga/projects/notifications/templates/emails/issues/issue-create-body-html.jinja:4
@@ -1797,7 +1876,7 @@ msgid ""
"[%(project)s] Created the issue #%(ref)s \"%(subject)s\"\n"
msgstr ""
"\n"
-"[%(project)s] Erstellte das Ticket #%(ref)s \"%(subject)s\"\n"
+"[%(project)s] erstellte das Ticket #%(ref)s \"%(subject)s\"\n"
"\n"
#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-html.jinja:4
@@ -1846,7 +1925,7 @@ msgid ""
"[%(project)s] Deleted the issue #%(ref)s \"%(subject)s\"\n"
msgstr ""
"\n"
-"[%(project)s] Löschte das Ticket #%(ref)s \"%(subject)s\"\n"
+"[%(project)s] löschte das Ticket #%(ref)s \"%(subject)s\"\n"
" \n"
"\n"
@@ -1892,7 +1971,7 @@ msgid ""
"[%(project)s] Updated the sprint \"%(milestone)s\"\n"
msgstr ""
"\n"
-"[%(project)s] Aktualisierte den Sprint \"%(milestone)s\"\n"
+"[%(project)s] aktualisierte den Sprint \"%(milestone)s\"\n"
" \n"
#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-html.jinja:4
@@ -1945,7 +2024,7 @@ msgid ""
"[%(project)s] Created the sprint \"%(milestone)s\"\n"
msgstr ""
"\n"
-"[%(project)s] Erstellte den Sprint \"%(milestone)s\"\n"
+"[%(project)s] erstellte den Sprint \"%(milestone)s\"\n"
"\n"
#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-html.jinja:4
@@ -1994,7 +2073,7 @@ msgid ""
"[%(project)s] Deleted the Sprint \"%(milestone)s\"\n"
msgstr ""
"\n"
-"[%(project)s] Löschte den Sprint \"%(milestone)s\"\n"
+"[%(project)s] löschte den Sprint \"%(milestone)s\"\n"
" \n"
#: taiga/projects/notifications/templates/emails/tasks/task-change-body-html.jinja:4
@@ -2040,7 +2119,7 @@ msgid ""
"[%(project)s] Updated the task #%(ref)s \"%(subject)s\"\n"
msgstr ""
"\n"
-"[%(project)s] Aktualisierte die Aufgabe #%(ref)s \"%(subject)s\"\n"
+"[%(project)s] aktualisierte die Aufgabe #%(ref)s \"%(subject)s\"\n"
#: taiga/projects/notifications/templates/emails/tasks/task-create-body-html.jinja:4
#, python-format
@@ -2093,7 +2172,7 @@ msgid ""
"[%(project)s] Created the task #%(ref)s \"%(subject)s\"\n"
msgstr ""
"\n"
-"[%(project)s] Erstellte die Aufgabe #%(ref)s \"%(subject)s\"\n"
+"[%(project)s] erstellte die Aufgabe #%(ref)s \"%(subject)s\"\n"
"\n"
#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-html.jinja:4
@@ -2142,7 +2221,7 @@ msgid ""
"[%(project)s] Deleted the task #%(ref)s \"%(subject)s\"\n"
msgstr ""
"\n"
-"[%(project)s] Hat die Aufgabe gelöscht #%(ref)s \"%(subject)s\"\n"
+"[%(project)s] hat die Aufgabe gelöscht #%(ref)s \"%(subject)s\"\n"
"\n"
#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-html.jinja:4
@@ -2188,7 +2267,7 @@ msgid ""
"[%(project)s] Updated the US #%(ref)s \"%(subject)s\"\n"
msgstr ""
"\n"
-"[%(project)s] Aktualisierte die User-Story #%(ref)s \"%(subject)s\"\n"
+"[%(project)s] aktualisierte die User-Story #%(ref)s \"%(subject)s\"\n"
#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-html.jinja:4
#, python-format
@@ -2241,6 +2320,9 @@ msgid ""
"\n"
"[%(project)s] Created the US #%(ref)s \"%(subject)s\"\n"
msgstr ""
+"\n"
+"[%(project)s] erstellte die User-Story #%(ref)s \"%(subject)s\"\n"
+"\n"
#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-html.jinja:4
#, python-format
@@ -2288,6 +2370,9 @@ msgid ""
"\n"
"[%(project)s] Deleted the US #%(ref)s \"%(subject)s\"\n"
msgstr ""
+"\n"
+"[%(project)s] löschte die User-Story #%(ref)s \"%(subject)s\"\n"
+"\n"
#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-html.jinja:4
#, python-format
@@ -2335,6 +2420,9 @@ msgid ""
"\n"
"[%(project)s] Updated the Wiki Page \"%(page)s\"\n"
msgstr ""
+"\n"
+"[%(project)s] aktualisierte die Wiki Seite \"%(page)s\"\n"
+" \n"
#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-html.jinja:4
#, python-format
@@ -2391,6 +2479,9 @@ msgid ""
"\n"
"[%(project)s] Created the Wiki Page \"%(page)s\"\n"
msgstr ""
+"\n"
+"[%(project)s] erstetllte die Wiki Seite \"%(page)s\"\n"
+"\n"
#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-html.jinja:4
#, python-format
@@ -2442,6 +2533,9 @@ msgid ""
"\n"
"[%(project)s] Deleted the Wiki Page \"%(page)s\"\n"
msgstr ""
+"\n"
+"[%(project)s] löschte die Wiki Seite \"%(page)s\"\n"
+"\n"
#: taiga/projects/notifications/validators.py:44
msgid "Watchers contains invalid users"
@@ -2562,6 +2656,14 @@ msgid ""
"Management Tool.\n"
" "
msgstr ""
+"\n"
+"Sie wurden zu Taiga eingeladen!
\n"
+"Hi! %(full_name)s hat Sie eingeladen, an folgendem Projekt in Taiga "
+"teilzunehmen %(project)s \n"
+" Taiga ist ein kostenloses, quelloffenes, agiles Projekt Management "
+"Tool.
\n"
+"\n"
+" "
#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:17
#, python-format
@@ -2647,6 +2749,15 @@ msgid ""
" The Taiga Team
\n"
" "
msgstr ""
+"\n"
+"Sie wurden einem Projekt hinzugefügt
\n"
+"Hallo %(full_name)s,
Sie wurden einem Projekt hinzugefügt "
+"%(project)s
\n"
+" Zum Projekt "
+"gehen\n"
+"Das Taiga Team
\n"
+"\n"
+" "
#: taiga/projects/templates/emails/membership_notification-body-text.jinja:1
#, python-format
@@ -2657,6 +2768,11 @@ msgid ""
"\n"
"See project at %(url)s\n"
msgstr ""
+"\n"
+"Sie wurden einem Projekt hinzugefügt\n"
+"Hallo %(full_name)s, Sie wurden folgendem Projekt hinzugefügt %(project)s\n"
+"\n"
+"Projekt ansehen auf %(url)s\n"
#: taiga/projects/templates/emails/membership_notification-subject.jinja:1
#, python-format
@@ -2883,12 +2999,12 @@ msgstr "Design"
#. Translators: User role
#: taiga/projects/translations.py:174
msgid "Front"
-msgstr ""
+msgstr "Front"
#. Translators: User role
#: taiga/projects/translations.py:176
msgid "Back"
-msgstr ""
+msgstr "Back"
#. Translators: User role
#: taiga/projects/translations.py:178
@@ -3106,7 +3222,7 @@ msgstr "Vorgegebene Sprache"
#: taiga/users/models.py:122
msgid "default theme"
-msgstr ""
+msgstr "Standard-Theme"
#: taiga/users/models.py:124
msgid "default timezone"
@@ -3152,6 +3268,16 @@ msgid ""
" The Taiga Team
\n"
" "
msgstr ""
+"\n"
+"Änderung Ihrer E-Mail Adresse
\n"
+"Hallo %(full_name)s,
Bitte bestätigen Sie Ihre E-Mail Adresse
\n"
+"E-Mail Adresse "
+"bestätigen\n"
+"Sie können diese Nachricht ignorieren, wenn Sie keine Anfrage gestellt "
+"haben
\n"
+"Das Taiga Team
\n"
+"\n"
+" "
#: taiga/users/templates/emails/change_email-body-text.jinja:1
#, python-format
@@ -3199,6 +3325,16 @@ msgid ""
" The Taiga Team
\n"
" "
msgstr ""
+"\n"
+"Passwort wiederherstellen
\n"
+"Hallo %(full_name)s,
Sie möchten Ihr Passwort wiederherstellen
\n"
+"Passwort wiederherstellen\n"
+"Sie können diese Nachricht ignorieren, wenn Sie keine Anfrage gestellt "
+"haben.
\n"
+"Das Taiga Team
\n"
+"\n"
+" "
#: taiga/users/templates/emails/password_recovery-body-text.jinja:1
#, python-format
@@ -3213,6 +3349,17 @@ msgid ""
"---\n"
"The Taiga Team\n"
msgstr ""
+"\n"
+"Hallo %(full_name)s, Sie möchten Ihr Passwort wiederherstellen\n"
+"\n"
+"%(url)s\n"
+"\n"
+"Sie können diese Nachricht ignorieren, wenn Sie keine Anfrage gestellt "
+"haben.\n"
+"\n"
+"---\n"
+"Das Taiga Team\n"
+"\n"
#: taiga/users/templates/emails/password_recovery-subject.jinja:1
msgid "[Taiga] Password recovery"
diff --git a/taiga/locale/zh-Hant/LC_MESSAGES/django.po b/taiga/locale/zh-Hant/LC_MESSAGES/django.po
index 0969028b..7eae5e43 100644
--- a/taiga/locale/zh-Hant/LC_MESSAGES/django.po
+++ b/taiga/locale/zh-Hant/LC_MESSAGES/django.po
@@ -12,8 +12,8 @@ msgstr ""
"Project-Id-Version: taiga-back\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2015-06-15 12:34+0200\n"
-"PO-Revision-Date: 2015-06-09 07:47+0000\n"
-"Last-Translator: Taiga Dev Team \n"
+"PO-Revision-Date: 2015-06-27 02:13+0000\n"
+"Last-Translator: Chi-Hsun Tsai \n"
"Language-Team: Chinese Traditional (http://www.transifex.com/projects/p/"
"taiga-back/language/zh-Hant/)\n"
"MIME-Version: 1.0\n"
@@ -469,6 +469,9 @@ msgid ""
"%(comment)s\n"
" "
msgstr ""
+"\n"
+"評論:
\n"
+"%(comment)s
"
#: taiga/base/templates/emails/updates-body-text.jinja:6
#, python-format
@@ -482,7 +485,7 @@ msgstr ""
#: taiga/export_import/api.py:103
msgid "We needed at least one role"
-msgstr ""
+msgstr "我們至少需要一個角色"
#: taiga/export_import/api.py:197
msgid "Needed dump file"
@@ -2473,7 +2476,7 @@ msgstr "版本須為整數值 "
#: taiga/projects/occ/mixins.py:58
msgid "The version parameter is not valid"
-msgstr ""
+msgstr "本版本參數無效"
#: taiga/projects/occ/mixins.py:74
msgid "The version doesn't match with the current one"
@@ -3144,7 +3147,7 @@ msgstr "預設語言 "
#: taiga/users/models.py:122
msgid "default theme"
-msgstr ""
+msgstr "預設主題"
#: taiga/users/models.py:124
msgid "default timezone"
From 83b8243d395ff73c0ddfd9491d7e25e59479fcb5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jes=C3=BAs=20Espino?=
Date: Mon, 29 Jun 2015 16:05:42 +0200
Subject: [PATCH 036/190] Issue#2884: Crop images for the timeline
---
settings/common.py | 2 ++
taiga/projects/history/freeze_impl.py | 14 ++++++++++++--
taiga/projects/history/models.py | 3 ++-
3 files changed, 16 insertions(+), 3 deletions(-)
diff --git a/settings/common.py b/settings/common.py
index a8c88c41..1a441e92 100644
--- a/settings/common.py
+++ b/settings/common.py
@@ -409,11 +409,13 @@ SOUTH_MIGRATION_MODULES = {
DEFAULT_AVATAR_SIZE = 80 # 80x80 pixels
DEFAULT_BIG_AVATAR_SIZE = 300 # 300x300 pixels
+DEFAULT_TIMELINE_IMAGE_SIZE = 640 # 640x??? pixels
THUMBNAIL_ALIASES = {
'': {
'avatar': {'size': (DEFAULT_AVATAR_SIZE, DEFAULT_AVATAR_SIZE), 'crop': True},
'big-avatar': {'size': (DEFAULT_BIG_AVATAR_SIZE, DEFAULT_BIG_AVATAR_SIZE), 'crop': True},
+ 'timeline-image': {'size': (DEFAULT_TIMELINE_IMAGE_SIZE, 0), 'crop': True},
},
}
diff --git a/taiga/projects/history/freeze_impl.py b/taiga/projects/history/freeze_impl.py
index 1e1038cb..a591c666 100644
--- a/taiga/projects/history/freeze_impl.py
+++ b/taiga/projects/history/freeze_impl.py
@@ -21,6 +21,10 @@ from django.apps import apps
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
+from easy_thumbnails.files import get_thumbnailer
+from easy_thumbnails.exceptions import InvalidImageFormatError
+
+from taiga.base.utils.urls import get_absolute_url
from taiga.base.utils.iterators import as_tuple
from taiga.base.utils.iterators import as_dict
from taiga.mdrender.service import render as mdrender
@@ -100,7 +104,7 @@ def project_values(diff):
values = _common_users_values(diff)
return values
-
+
def milestone_values(diff):
values = _common_users_values(diff)
return values
@@ -179,10 +183,16 @@ def _generic_extract(obj:object, fields:list, default=None) -> dict:
@as_tuple
def extract_attachments(obj) -> list:
for attach in obj.attachments.all():
+ try:
+ thumb_url = get_thumbnailer(attach.attached_file)['timeline-image'].url
+ thumb_url = get_absolute_url(thumb_url)
+ except InvalidImageFormatError as e:
+ thumb_url = None
+
yield {"id": attach.id,
"filename": os.path.basename(attach.attached_file.name),
"url": attach.attached_file.url,
- "description": attach.description,
+ "thumb_url": thumb_url,
"is_deprecated": attach.is_deprecated,
"description": attach.description,
"order": attach.order}
diff --git a/taiga/projects/history/models.py b/taiga/projects/history/models.py
index 48b890fb..82d1667d 100644
--- a/taiga/projects/history/models.py
+++ b/taiga/projects/history/models.py
@@ -182,12 +182,13 @@ class HistoryEntry(models.Model):
for aid in set(tuple(oldattachs.keys()) + tuple(newattachs.keys())):
if aid in oldattachs and aid in newattachs:
changes = make_diff_from_dicts(oldattachs[aid], newattachs[aid],
- excluded_keys=("filename", "url"))
+ excluded_keys=("filename", "url", "thumb_url"))
if changes:
change = {
"filename": newattachs.get(aid, {}).get("filename", ""),
"url": newattachs.get(aid, {}).get("url", ""),
+ "thumb_url": newattachs.get(aid, {}).get("thumb_url", ""),
"changes": changes
}
attachments["changed"].append(change)
From 7ff2126b4914506c7aa93ce8e5bdb2cd4e5fc6b0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jes=C3=BAs=20Espino?=
Date: Mon, 29 Jun 2015 17:58:31 +0200
Subject: [PATCH 037/190] Issue#2967: Don't delete tasks on milestone delete
---
.../migrations/0007_auto_20150629_1556.py | 21 +++++++++++++++++++
taiga/projects/tasks/models.py | 2 +-
2 files changed, 22 insertions(+), 1 deletion(-)
create mode 100644 taiga/projects/tasks/migrations/0007_auto_20150629_1556.py
diff --git a/taiga/projects/tasks/migrations/0007_auto_20150629_1556.py b/taiga/projects/tasks/migrations/0007_auto_20150629_1556.py
new file mode 100644
index 00000000..e6596d7f
--- /dev/null
+++ b/taiga/projects/tasks/migrations/0007_auto_20150629_1556.py
@@ -0,0 +1,21 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('tasks', '0006_auto_20150623_1923'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='task',
+ name='milestone',
+ field=models.ForeignKey(to='milestones.Milestone', related_name='tasks', default=None, verbose_name='milestone', on_delete=django.db.models.deletion.SET_NULL, null=True, blank=True),
+ preserve_default=True,
+ ),
+ ]
diff --git a/taiga/projects/tasks/models.py b/taiga/projects/tasks/models.py
index 699321c0..37176fab 100644
--- a/taiga/projects/tasks/models.py
+++ b/taiga/projects/tasks/models.py
@@ -39,7 +39,7 @@ class Task(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, models.M
related_name="tasks", verbose_name=_("status"))
project = models.ForeignKey("projects.Project", null=False, blank=False,
related_name="tasks", verbose_name=_("project"))
- milestone = models.ForeignKey("milestones.Milestone", null=True, blank=True,
+ milestone = models.ForeignKey("milestones.Milestone", null=True, blank=True, on_delete=models.SET_NULL,
default=None, related_name="tasks",
verbose_name=_("milestone"))
created_date = models.DateTimeField(null=False, blank=False,
From d4446cd4269269dd720761233344bceefab92bd3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?=
Date: Mon, 29 Jun 2015 18:13:33 +0200
Subject: [PATCH 038/190] Fix migration
---
taiga/projects/tasks/migrations/0007_auto_20150629_1556.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/taiga/projects/tasks/migrations/0007_auto_20150629_1556.py b/taiga/projects/tasks/migrations/0007_auto_20150629_1556.py
index e6596d7f..4bc08697 100644
--- a/taiga/projects/tasks/migrations/0007_auto_20150629_1556.py
+++ b/taiga/projects/tasks/migrations/0007_auto_20150629_1556.py
@@ -8,7 +8,7 @@ import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
- ('tasks', '0006_auto_20150623_1923'),
+ ('tasks', '0005_auto_20150114_0954'),
]
operations = [
From 9d94de9b219c2783673a18d517b36b33a222174c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?=
Date: Mon, 29 Jun 2015 18:57:08 +0200
Subject: [PATCH 039/190] Revert "Fix migration"
This reverts commit d4446cd4269269dd720761233344bceefab92bd3.
---
taiga/projects/tasks/migrations/0007_auto_20150629_1556.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/taiga/projects/tasks/migrations/0007_auto_20150629_1556.py b/taiga/projects/tasks/migrations/0007_auto_20150629_1556.py
index 4bc08697..e6596d7f 100644
--- a/taiga/projects/tasks/migrations/0007_auto_20150629_1556.py
+++ b/taiga/projects/tasks/migrations/0007_auto_20150629_1556.py
@@ -8,7 +8,7 @@ import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
- ('tasks', '0005_auto_20150114_0954'),
+ ('tasks', '0006_auto_20150623_1923'),
]
operations = [
From c80152599a6008bd9a0bcf15b151e4e8c7d609ca Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jes=C3=BAs=20Espino?=
Date: Tue, 30 Jun 2015 15:07:46 +0200
Subject: [PATCH 040/190] Fix us auto closing test
---
tests/integration/test_us_autoclosing.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tests/integration/test_us_autoclosing.py b/tests/integration/test_us_autoclosing.py
index 35620026..e2205bb0 100644
--- a/tests/integration/test_us_autoclosing.py
+++ b/tests/integration/test_us_autoclosing.py
@@ -221,7 +221,7 @@ def test_auto_close_userstory_with_milestone_when_task_and_milestone_are_removed
data.task3.delete()
data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk)
- assert data.user_story1.is_closed is False
+ assert data.user_story1.is_closed is True
def test_auto_close_us_when_all_tasks_are_changed_to_close_status(data):
From 9769386b8ee501eeef526f4431bd54d50e4ba9fc Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jes=C3=BAs=20Espino?=
Date: Tue, 30 Jun 2015 12:29:38 +0200
Subject: [PATCH 041/190] Issue#2981: Adapt taiga to the new bitbucket webhooks
---
taiga/hooks/bitbucket/api.py | 15 +-
taiga/hooks/bitbucket/event_hooks.py | 110 ++++++++-
taiga/hooks/bitbucket/services.py | 15 +-
tests/integration/test_hooks_bitbucket.py | 287 ++++++++++++++++++----
4 files changed, 351 insertions(+), 76 deletions(-)
diff --git a/taiga/hooks/bitbucket/api.py b/taiga/hooks/bitbucket/api.py
index 82b71ea0..0b304ac8 100644
--- a/taiga/hooks/bitbucket/api.py
+++ b/taiga/hooks/bitbucket/api.py
@@ -29,18 +29,11 @@ from ipware.ip import get_ip
class BitBucketViewSet(BaseWebhookApiViewSet):
event_hook_classes = {
- "push": event_hooks.PushEventHook,
+ "repo:push": event_hooks.PushEventHook,
+ "issue:created": event_hooks.IssuesEventHook,
+ "issue:comment_created": event_hooks.IssueCommentEventHook,
}
- def _get_payload(self, request):
- try:
- body = parse_qs(request.body.decode("utf-8"), strict_parsing=True)
- payload = body["payload"]
- except (ValueError, KeyError):
- raise exc.BadRequest(_("The payload is not a valid application/x-www-form-urlencoded"))
-
- return payload
-
def _validate_signature(self, project, request):
secret_key = request.GET.get("key", None)
@@ -75,4 +68,4 @@ class BitBucketViewSet(BaseWebhookApiViewSet):
return None
def _get_event_name(self, request):
- return "push"
+ return request.META.get('HTTP_X_EVENT_KEY', None)
diff --git a/taiga/hooks/bitbucket/event_hooks.py b/taiga/hooks/bitbucket/event_hooks.py
index 969dae64..5aa86fde 100644
--- a/taiga/hooks/bitbucket/event_hooks.py
+++ b/taiga/hooks/bitbucket/event_hooks.py
@@ -37,17 +37,10 @@ class PushEventHook(BaseEventHook):
if self.payload is None:
return
- # In bitbucket the payload is a list! :(
- for payload_element_text in self.payload:
- try:
- payload_element = json.loads(payload_element_text)
- except ValueError:
- raise exc.BadRequest(_("The payload is not valid"))
-
- commits = payload_element.get("commits", [])
- for commit in commits:
- message = commit.get("message", None)
- self._process_message(message, None)
+ changes = self.payload.get("push", {}).get('changes', [])
+ for change in changes:
+ message = change.get("new", {}).get("target", {}).get("message", None)
+ self._process_message(message, None)
def _process_message(self, message, bitbucket_user):
"""
@@ -98,3 +91,98 @@ class PushEventHook(BaseEventHook):
def replace_bitbucket_references(project_url, wiki_text):
template = "\g<1>[BitBucket#\g<2>]({}/issues/\g<2>)\g<3>".format(project_url)
return re.sub(r"(\s|^)#(\d+)(\s|$)", template, wiki_text, 0, re.M)
+
+
+class IssuesEventHook(BaseEventHook):
+ def process_event(self):
+ number = self.payload.get('issue', {}).get('id', None)
+ subject = self.payload.get('issue', {}).get('title', None)
+
+ bitbucket_url = self.payload.get('issue', {}).get('links', {}).get('html', {}).get('href', None)
+
+ bitbucket_user_id = self.payload.get('actor', {}).get('user', {}).get('uuid', None)
+ bitbucket_user_name = self.payload.get('actor', {}).get('user', {}).get('username', None)
+ bitbucket_user_url = self.payload.get('actor', {}).get('user', {}).get('links', {}).get('html', {}).get('href')
+
+ project_url = self.payload.get('repository', {}).get('links', {}).get('html', {}).get('href', None)
+
+ description = self.payload.get('issue', {}).get('content', {}).get('raw', '')
+ description = replace_bitbucket_references(project_url, description)
+
+ user = get_bitbucket_user(bitbucket_user_id)
+
+ if not all([subject, bitbucket_url, project_url]):
+ raise ActionSyntaxException(_("Invalid issue information"))
+
+ issue = Issue.objects.create(
+ project=self.project,
+ subject=subject,
+ description=description,
+ status=self.project.default_issue_status,
+ type=self.project.default_issue_type,
+ severity=self.project.default_severity,
+ priority=self.project.default_priority,
+ external_reference=['bitbucket', bitbucket_url],
+ owner=user
+ )
+ take_snapshot(issue, user=user)
+
+ if number and subject and bitbucket_user_name and bitbucket_user_url:
+ comment = _("Issue created by [@{bitbucket_user_name}]({bitbucket_user_url} "
+ "\"See @{bitbucket_user_name}'s BitBucket profile\") "
+ "from BitBucket.\nOrigin BitBucket issue: [gh#{number} - {subject}]({bitbucket_url} "
+ "\"Go to 'gh#{number} - {subject}'\"):\n\n"
+ "{description}").format(bitbucket_user_name=bitbucket_user_name,
+ bitbucket_user_url=bitbucket_user_url,
+ number=number,
+ subject=subject,
+ bitbucket_url=bitbucket_url,
+ description=description)
+ else:
+ comment = _("Issue created from BitBucket.")
+
+ snapshot = take_snapshot(issue, comment=comment, user=user)
+ send_notifications(issue, history=snapshot)
+
+
+class IssueCommentEventHook(BaseEventHook):
+ def process_event(self):
+ number = self.payload.get('issue', {}).get('id', None)
+ subject = self.payload.get('issue', {}).get('title', None)
+
+ bitbucket_url = self.payload.get('issue', {}).get('links', {}).get('html', {}).get('href', None)
+ bitbucket_user_id = self.payload.get('actor', {}).get('user', {}).get('uuid', None)
+ bitbucket_user_name = self.payload.get('actor', {}).get('user', {}).get('username', None)
+ bitbucket_user_url = self.payload.get('actor', {}).get('user', {}).get('links', {}).get('html', {}).get('href')
+
+ project_url = self.payload.get('repository', {}).get('links', {}).get('html', {}).get('href', None)
+
+ comment_message = self.payload.get('comment', {}).get('content', {}).get('raw', '')
+ comment_message = replace_bitbucket_references(project_url, comment_message)
+
+ user = get_bitbucket_user(bitbucket_user_id)
+
+ if not all([comment_message, bitbucket_url, project_url]):
+ raise ActionSyntaxException(_("Invalid issue comment information"))
+
+ issues = Issue.objects.filter(external_reference=["bitbucket", bitbucket_url])
+ tasks = Task.objects.filter(external_reference=["bitbucket", bitbucket_url])
+ uss = UserStory.objects.filter(external_reference=["bitbucket", bitbucket_url])
+
+ for item in list(issues) + list(tasks) + list(uss):
+ if number and subject and bitbucket_user_name and bitbucket_user_url:
+ comment = _("Comment by [@{bitbucket_user_name}]({bitbucket_user_url} "
+ "\"See @{bitbucket_user_name}'s BitBucket profile\") "
+ "from BitBucket.\nOrigin BitBucket issue: [gh#{number} - {subject}]({bitbucket_url} "
+ "\"Go to 'gh#{number} - {subject}'\")\n\n"
+ "{message}").format(bitbucket_user_name=bitbucket_user_name,
+ bitbucket_user_url=bitbucket_user_url,
+ number=number,
+ subject=subject,
+ bitbucket_url=bitbucket_url,
+ message=comment_message)
+ else:
+ comment = _("Comment From BitBucket:\n\n{message}").format(message=comment_message)
+
+ snapshot = take_snapshot(item, comment=comment, user=user)
+ send_notifications(item, history=snapshot)
diff --git a/taiga/hooks/bitbucket/services.py b/taiga/hooks/bitbucket/services.py
index 625c91a8..ddd4af79 100644
--- a/taiga/hooks/bitbucket/services.py
+++ b/taiga/hooks/bitbucket/services.py
@@ -40,16 +40,5 @@ def get_or_generate_config(project):
return g_config
-def get_bitbucket_user(user_email):
- user = None
-
- if user_email:
- try:
- user = User.objects.get(email=user_email)
- except User.DoesNotExist:
- pass
-
- if user is None:
- user = User.objects.get(is_system=True, username__startswith="bitbucket")
-
- return user
+def get_bitbucket_user(user_id):
+ return User.objects.get(is_system=True, username__startswith="bitbucket")
diff --git a/tests/integration/test_hooks_bitbucket.py b/tests/integration/test_hooks_bitbucket.py
index ecb4058d..27cf34db 100644
--- a/tests/integration/test_hooks_bitbucket.py
+++ b/tests/integration/test_hooks_bitbucket.py
@@ -14,6 +14,10 @@ from taiga.hooks.exceptions import ActionSyntaxException
from taiga.projects.issues.models import Issue
from taiga.projects.tasks.models import Task
from taiga.projects.userstories.models import UserStory
+from taiga.projects.models import Membership
+from taiga.projects.history.services import get_history_queryset_by_model_instance, take_snapshot
+from taiga.projects.notifications.choices import NotifyLevel
+from taiga.projects.notifications.models import NotifyPolicy
from taiga.projects import services
from .. import factories as f
@@ -30,8 +34,9 @@ def test_bad_signature(client):
url = reverse("bitbucket-hook-list")
url = "{}?project={}&key={}".format(url, project.id, "badbadbad")
- data = {}
- response = client.post(url, urllib.parse.urlencode(data, True), content_type="application/x-www-form-urlencoded")
+ data = "{}"
+ response = client.post(url, data, content_type="application/json", HTTP_X_EVENT_KEY="repo:push")
+
response_content = response.data
assert response.status_code == 400
assert "Bad signature" in response_content["_error_message"]
@@ -47,10 +52,11 @@ def test_ok_signature(client):
url = reverse("bitbucket-hook-list")
url = "{}?project={}&key={}".format(url, project.id, "tpnIwJDz4e")
- data = {'payload': ['{"commits": []}']}
+ data = json.dumps({"push": {"changes": [{"new": {"target": { "message": "test message"}}}]}})
response = client.post(url,
- urllib.parse.urlencode(data, True),
- content_type="application/x-www-form-urlencoded",
+ data,
+ content_type="application/json",
+ HTTP_X_EVENT_KEY="repo:push",
REMOTE_ADDR=settings.BITBUCKET_VALID_ORIGIN_IPS[0])
assert response.status_code == 204
@@ -65,10 +71,11 @@ def test_invalid_ip(client):
url = reverse("bitbucket-hook-list")
url = "{}?project={}&key={}".format(url, project.id, "tpnIwJDz4e")
- data = {'payload': ['{"commits": []}']}
+ data = json.dumps({"push": {"changes": [{"new": {"target": { "message": "test message"}}}]}})
response = client.post(url,
- urllib.parse.urlencode(data, True),
- content_type="application/x-www-form-urlencoded",
+ data,
+ content_type="application/json",
+ HTTP_X_EVENT_KEY="repo:push",
REMOTE_ADDR="111.111.111.112")
assert response.status_code == 400
@@ -84,10 +91,11 @@ def test_valid_local_network_ip(client):
url = reverse("bitbucket-hook-list")
url = "{}?project={}&key={}".format(url, project.id, "tpnIwJDz4e")
- data = {'payload': ['{"commits": []}']}
+ data = json.dumps({"push": {"changes": [{"new": {"target": { "message": "test message"}}}]}})
response = client.post(url,
- urllib.parse.urlencode(data, True),
- content_type="application/x-www-form-urlencoded",
+ data,
+ content_type="application/json",
+ HTTP_X_EVENT_KEY="repo:push",
REMOTE_ADDR="192.168.1.1")
assert response.status_code == 204
@@ -103,10 +111,11 @@ def test_not_ip_filter(client):
url = reverse("bitbucket-hook-list")
url = "{}?project={}&key={}".format(url, project.id, "tpnIwJDz4e")
- data = {'payload': ['{"commits": []}']}
+ data = json.dumps({"push": {"changes": [{"new": {"target": { "message": "test message"}}}]}})
response = client.post(url,
- urllib.parse.urlencode(data, True),
- content_type="application/x-www-form-urlencoded",
+ data,
+ content_type="application/json",
+ HTTP_X_EVENT_KEY="repo:push",
REMOTE_ADDR="111.111.111.112")
assert response.status_code == 204
@@ -115,13 +124,14 @@ def test_push_event_detected(client):
project = f.ProjectFactory()
url = reverse("bitbucket-hook-list")
url = "%s?project=%s" % (url, project.id)
- data = {'payload': ['{"commits": [{"message": "test message"}]}']}
+ data = json.dumps({"push": {"changes": [{"new": {"target": { "message": "test message"}}}]}})
BitBucketViewSet._validate_signature = mock.Mock(return_value=True)
with mock.patch.object(event_hooks.PushEventHook, "process_event") as process_event_mock:
- response = client.post(url, urllib.parse.urlencode(data, True),
- content_type="application/x-www-form-urlencoded")
+ response = client.post(url, data,
+ HTTP_X_EVENT_KEY="repo:push",
+ content_type="application/json")
assert process_event_mock.call_count == 1
@@ -134,9 +144,7 @@ def test_push_event_issue_processing(client):
f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner)
new_status = f.IssueStatusFactory(project=creation_status.project)
issue = f.IssueFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner)
- payload = [
- '{"commits": [{"message": "test message test TG-%s #%s ok bye!"}]}' % (issue.ref, new_status.slug)
- ]
+ payload = {"push": {"changes": [{"new": {"target": { "message": "test message test TG-%s #%s ok bye!" % (issue.ref, new_status.slug)}}}]}}
mail.outbox = []
ev_hook = event_hooks.PushEventHook(issue.project, payload)
ev_hook.process_event()
@@ -151,9 +159,7 @@ def test_push_event_task_processing(client):
f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner)
new_status = f.TaskStatusFactory(project=creation_status.project)
task = f.TaskFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner)
- payload = [
- '{"commits": [{"message": "test message test TG-%s #%s ok bye!"}]}' % (task.ref, new_status.slug)
- ]
+ payload = {"push": {"changes": [{"new": {"target": { "message": "test message test TG-%s #%s ok bye!" % (task.ref, new_status.slug)}}}]}}
mail.outbox = []
ev_hook = event_hooks.PushEventHook(task.project, payload)
ev_hook.process_event()
@@ -168,9 +174,7 @@ def test_push_event_user_story_processing(client):
f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner)
new_status = f.UserStoryStatusFactory(project=creation_status.project)
user_story = f.UserStoryFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner)
- payload = [
- '{"commits": [{"message": "test message test TG-%s #%s ok bye!"}]}' % (user_story.ref, new_status.slug)
- ]
+ payload = {"push": {"changes": [{"new": {"target": { "message": "test message test TG-%s #%s ok bye!" % (user_story.ref, new_status.slug)}}}]}}
mail.outbox = []
ev_hook = event_hooks.PushEventHook(user_story.project, payload)
ev_hook.process_event()
@@ -186,9 +190,7 @@ def test_push_event_multiple_actions(client):
new_status = f.IssueStatusFactory(project=creation_status.project)
issue1 = f.IssueFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner)
issue2 = f.IssueFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner)
- payload = [
- '{"commits": [{"message": "test message test TG-%s #%s ok test TG-%s #%s ok bye!"}]}' % (issue1.ref, new_status.slug, issue2.ref, new_status.slug)
- ]
+ payload = {"push": {"changes": [{"new": {"target": { "message": "test message test TG-%s #%s ok test TG-%s #%s ok bye!" % (issue1.ref, new_status.slug, issue2.ref, new_status.slug)}}}]}}
mail.outbox = []
ev_hook1 = event_hooks.PushEventHook(issue1.project, payload)
ev_hook1.process_event()
@@ -205,9 +207,7 @@ def test_push_event_processing_case_insensitive(client):
f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner)
new_status = f.TaskStatusFactory(project=creation_status.project)
task = f.TaskFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner)
- payload = [
- '{"commits": [{"message": "test message test tg-%s #%s ok bye!"}]}' % (task.ref, new_status.slug.upper())
- ]
+ payload = {"push": {"changes": [{"new": {"target": { "message": "test message test TG-%s #%s ok bye!" % (task.ref, new_status.slug)}}}]}}
mail.outbox = []
ev_hook = event_hooks.PushEventHook(task.project, payload)
ev_hook.process_event()
@@ -218,9 +218,7 @@ def test_push_event_processing_case_insensitive(client):
def test_push_event_task_bad_processing_non_existing_ref(client):
issue_status = f.IssueStatusFactory()
- payload = [
- '{"commits": [{"message": "test message test TG-6666666 #%s ok bye!"}]}' % (issue_status.slug)
- ]
+ payload = {"push": {"changes": [{"new": {"target": { "message": "test message test TG-6666666 #%s ok bye!" % (issue_status.slug)}}}]}}
mail.outbox = []
ev_hook = event_hooks.PushEventHook(issue_status.project, payload)
@@ -233,9 +231,7 @@ def test_push_event_task_bad_processing_non_existing_ref(client):
def test_push_event_us_bad_processing_non_existing_status(client):
user_story = f.UserStoryFactory.create()
- payload = [
- '{"commits": [{"message": "test message test TG-%s #non-existing-slug ok bye!"}]}' % (user_story.ref)
- ]
+ payload = {"push": {"changes": [{"new": {"target": { "message": "test message test TG-%s #non-existing-slug ok bye!" % (user_story.ref)}}}]}}
mail.outbox = []
@@ -249,9 +245,7 @@ def test_push_event_us_bad_processing_non_existing_status(client):
def test_push_event_bad_processing_non_existing_status(client):
issue = f.IssueFactory.create()
- payload = [
- '{"commits": [{"message": "test message test TG-%s #non-existing-slug ok bye!"}]}' % (issue.ref)
- ]
+ payload = {"push": {"changes": [{"new": {"target": { "message": "test message test TG-%s #non-existing-slug ok bye!" % (issue.ref)}}}]}}
mail.outbox = []
ev_hook = event_hooks.PushEventHook(issue.project, payload)
@@ -262,6 +256,217 @@ def test_push_event_bad_processing_non_existing_status(client):
assert len(mail.outbox) == 0
+def test_issues_event_opened_issue(client):
+ issue = f.IssueFactory.create()
+ issue.project.default_issue_status = issue.status
+ issue.project.default_issue_type = issue.type
+ issue.project.default_severity = issue.severity
+ issue.project.default_priority = issue.priority
+ issue.project.save()
+ Membership.objects.create(user=issue.owner, project=issue.project, role=f.RoleFactory.create(project=issue.project), is_owner=True)
+ notify_policy = NotifyPolicy.objects.get(user=issue.owner, project=issue.project)
+ notify_policy.notify_level = NotifyLevel.watch
+ notify_policy.save()
+
+ payload = {
+ "actor": {
+ "user": {
+ "uuid": "{ce1054cd-3f43-49dc-8aea-d3085ee7ec9b}",
+ "username": "test-user",
+ "links": {"html": {"href": "http://bitbucket.com/test-user"}}
+ }
+ },
+ "issue": {
+ "id": "10",
+ "title": "test-title",
+ "links": {"html": {"href": "http://bitbucket.com/site/master/issue/10"}},
+ "content": {"raw": "test-content"}
+ },
+ "repository": {
+ "links": {"html": {"href": "http://bitbucket.com/test-user/test-project"}}
+ }
+ }
+
+ mail.outbox = []
+
+ ev_hook = event_hooks.IssuesEventHook(issue.project, payload)
+ ev_hook.process_event()
+
+ assert Issue.objects.count() == 2
+ assert len(mail.outbox) == 1
+
+
+def test_issues_event_bad_issue(client):
+ issue = f.IssueFactory.create()
+ issue.project.default_issue_status = issue.status
+ issue.project.default_issue_type = issue.type
+ issue.project.default_severity = issue.severity
+ issue.project.default_priority = issue.priority
+ issue.project.save()
+
+ payload = {
+ "actor": {
+ },
+ "issue": {
+ },
+ "repository": {
+ }
+ }
+ mail.outbox = []
+
+ ev_hook = event_hooks.IssuesEventHook(issue.project, payload)
+
+ with pytest.raises(ActionSyntaxException) as excinfo:
+ ev_hook.process_event()
+
+ assert str(excinfo.value) == "Invalid issue information"
+
+ assert Issue.objects.count() == 1
+ assert len(mail.outbox) == 0
+
+
+def test_issue_comment_event_on_existing_issue_task_and_us(client):
+ project = f.ProjectFactory()
+ role = f.RoleFactory(project=project, permissions=["view_tasks", "view_issues", "view_us"])
+ f.MembershipFactory(project=project, role=role, user=project.owner)
+ user = f.UserFactory()
+
+ issue = f.IssueFactory.create(external_reference=["bitbucket", "http://bitbucket.com/site/master/issue/11"], owner=project.owner, project=project)
+ take_snapshot(issue, user=user)
+ task = f.TaskFactory.create(external_reference=["bitbucket", "http://bitbucket.com/site/master/issue/11"], owner=project.owner, project=project)
+ take_snapshot(task, user=user)
+ us = f.UserStoryFactory.create(external_reference=["bitbucket", "http://bitbucket.com/site/master/issue/11"], owner=project.owner, project=project)
+ take_snapshot(us, user=user)
+
+ payload = {
+ "actor": {
+ "user": {
+ "uuid": "{ce1054cd-3f43-49dc-8aea-d3085ee7ec9b}",
+ "username": "test-user",
+ "links": {"html": {"href": "http://bitbucket.com/test-user"}}
+ }
+ },
+ "issue": {
+ "id": "11",
+ "title": "test-title",
+ "links": {"html": {"href": "http://bitbucket.com/site/master/issue/11"}},
+ "content": {"raw": "test-content"}
+ },
+ "comment": {
+ "content": {"raw": "Test body"},
+ },
+ "repository": {
+ "links": {"html": {"href": "http://bitbucket.com/test-user/test-project"}}
+ }
+ }
+
+ mail.outbox = []
+
+ assert get_history_queryset_by_model_instance(issue).count() == 0
+ assert get_history_queryset_by_model_instance(task).count() == 0
+ assert get_history_queryset_by_model_instance(us).count() == 0
+
+ ev_hook = event_hooks.IssueCommentEventHook(issue.project, payload)
+ ev_hook.process_event()
+
+ issue_history = get_history_queryset_by_model_instance(issue)
+ assert issue_history.count() == 1
+ assert "Test body" in issue_history[0].comment
+
+ task_history = get_history_queryset_by_model_instance(task)
+ assert task_history.count() == 1
+ assert "Test body" in issue_history[0].comment
+
+ us_history = get_history_queryset_by_model_instance(us)
+ assert us_history.count() == 1
+ assert "Test body" in issue_history[0].comment
+
+ assert len(mail.outbox) == 3
+
+
+def test_issue_comment_event_on_not_existing_issue_task_and_us(client):
+ issue = f.IssueFactory.create(external_reference=["bitbucket", "10"])
+ take_snapshot(issue, user=issue.owner)
+ task = f.TaskFactory.create(project=issue.project, external_reference=["bitbucket", "10"])
+ take_snapshot(task, user=task.owner)
+ us = f.UserStoryFactory.create(project=issue.project, external_reference=["bitbucket", "10"])
+ take_snapshot(us, user=us.owner)
+
+ payload = {
+ "actor": {
+ "user": {
+ "uuid": "{ce1054cd-3f43-49dc-8aea-d3085ee7ec9b}",
+ "username": "test-user",
+ "links": {"html": {"href": "http://bitbucket.com/test-user"}}
+ }
+ },
+ "issue": {
+ "id": "10",
+ "title": "test-title",
+ "links": {"html": {"href": "http://bitbucket.com/site/master/issue/10"}},
+ "content": {"raw": "test-content"}
+ },
+ "comment": {
+ "content": {"raw": "Test body"},
+ },
+ "repository": {
+ "links": {"html": {"href": "http://bitbucket.com/test-user/test-project"}}
+ }
+ }
+
+ mail.outbox = []
+
+ assert get_history_queryset_by_model_instance(issue).count() == 0
+ assert get_history_queryset_by_model_instance(task).count() == 0
+ assert get_history_queryset_by_model_instance(us).count() == 0
+
+ ev_hook = event_hooks.IssueCommentEventHook(issue.project, payload)
+ ev_hook.process_event()
+
+ assert get_history_queryset_by_model_instance(issue).count() == 0
+ assert get_history_queryset_by_model_instance(task).count() == 0
+ assert get_history_queryset_by_model_instance(us).count() == 0
+
+ assert len(mail.outbox) == 0
+
+
+def test_issues_event_bad_comment(client):
+ issue = f.IssueFactory.create(external_reference=["bitbucket", "10"])
+ take_snapshot(issue, user=issue.owner)
+
+ payload = {
+ "actor": {
+ "user": {
+ "uuid": "{ce1054cd-3f43-49dc-8aea-d3085ee7ec9b}",
+ "username": "test-user",
+ "links": {"html": {"href": "http://bitbucket.com/test-user"}}
+ }
+ },
+ "issue": {
+ "id": "10",
+ "title": "test-title",
+ "links": {"html": {"href": "http://bitbucket.com/site/master/issue/10"}},
+ "content": {"raw": "test-content"}
+ },
+ "comment": {
+ },
+ "repository": {
+ "links": {"html": {"href": "http://bitbucket.com/test-user/test-project"}}
+ }
+ }
+ ev_hook = event_hooks.IssueCommentEventHook(issue.project, payload)
+
+ mail.outbox = []
+
+ with pytest.raises(ActionSyntaxException) as excinfo:
+ ev_hook.process_event()
+
+ assert str(excinfo.value) == "Invalid issue comment information"
+
+ assert Issue.objects.count() == 1
+ assert len(mail.outbox) == 0
+
+
def test_api_get_project_modules(client):
project = f.create_project()
f.MembershipFactory(project=project, user=project.owner, is_owner=True)
From 8b6fa19a5661745855ed452508b979e1cb1af94d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jes=C3=BAs=20Espino?=
Date: Tue, 30 Jun 2015 13:29:14 +0200
Subject: [PATCH 042/190] Issue#2981: Adapt taiga to the new bitbucket webhooks
(Changelog)
---
CHANGELOG.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 99d9e63e..9798b058 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,7 @@
- Add a "field type" property for custom fields: 'text' and 'multiline text' right now (thanks to [@artlepool](https://github.com/artlepool))
- Allow multiple actions in the commit messages.
- Now every user that coments USs, Issues or Tasks will be involved in it (add author to the watchers list).
+- Fix the compatibility with BitBucket webhooks and add issues and issues comments integration.
### Misc
- API: Mixin fields 'users', 'members' and 'memberships' in ProjectDetailSerializer
From 8742f0cabb39ef6dce69681596a0ae5e740e89e8 Mon Sep 17 00:00:00 2001
From: Andrey Alekseenko
Date: Tue, 30 Jun 2015 14:43:02 +0300
Subject: [PATCH 043/190] Using get_valid_filename to sanitize attachment names
---
taiga/projects/attachments/models.py | 6 ++----
1 file changed, 2 insertions(+), 4 deletions(-)
diff --git a/taiga/projects/attachments/models.py b/taiga/projects/attachments/models.py
index c95c0f6b..5619bf68 100644
--- a/taiga/projects/attachments/models.py
+++ b/taiga/projects/attachments/models.py
@@ -27,16 +27,14 @@ from django.contrib.contenttypes import generic
from django.utils import timezone
from django.utils.encoding import force_bytes
from django.utils.translation import ugettext_lazy as _
-from django.template.defaultfilters import slugify
+from django.utils.text import get_valid_filename
from taiga.base.utils.iterators import split_by_n
def get_attachment_file_path(instance, filename):
basename = path.basename(filename).lower()
- base, ext = path.splitext(basename)
- base = slugify(unidecode(base))
- basename = "".join([base, ext])
+ basename = get_valid_filename(basename)
hs = hashlib.sha256()
hs.update(force_bytes(timezone.now().isoformat()))
From eeaac88463f424b8cb8ef6feab87c2a18c5d3f32 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jes=C3=BAs=20Espino?=
Date: Tue, 30 Jun 2015 20:08:57 +0200
Subject: [PATCH 044/190] Remove unnecesary lowecase of attachments filename
---
taiga/projects/attachments/models.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/taiga/projects/attachments/models.py b/taiga/projects/attachments/models.py
index 5619bf68..61c590e4 100644
--- a/taiga/projects/attachments/models.py
+++ b/taiga/projects/attachments/models.py
@@ -33,7 +33,7 @@ from taiga.base.utils.iterators import split_by_n
def get_attachment_file_path(instance, filename):
- basename = path.basename(filename).lower()
+ basename = path.basename(filename)
basename = get_valid_filename(basename)
hs = hashlib.sha256()
From 12b93b78a199542a7d2d1a4815b13c72ac31b47e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jes=C3=BAs=20Espino?=
Date: Tue, 30 Jun 2015 20:34:56 +0200
Subject: [PATCH 045/190] Remove more unnecesary lowercase on attachments
---
taiga/export_import/service.py | 2 +-
taiga/projects/attachments/api.py | 2 +-
taiga/projects/management/commands/sample_data.py | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/taiga/export_import/service.py b/taiga/export_import/service.py
index bd3049b9..1ca76eb0 100644
--- a/taiga/export_import/service.py
+++ b/taiga/export_import/service.py
@@ -276,7 +276,7 @@ def store_attachment(project, obj, attachment):
serialized.object.owner = serialized.object.project.owner
serialized.object._importing = True
serialized.object.size = serialized.object.attached_file.size
- serialized.object.name = path.basename(serialized.object.attached_file.name).lower()
+ serialized.object.name = path.basename(serialized.object.attached_file.name)
serialized.save()
return serialized
add_errors("attachments", serialized.errors)
diff --git a/taiga/projects/attachments/api.py b/taiga/projects/attachments/api.py
index 0d26a0a8..6018433a 100644
--- a/taiga/projects/attachments/api.py
+++ b/taiga/projects/attachments/api.py
@@ -56,7 +56,7 @@ class BaseAttachmentViewSet(HistoryResourceMixin, WatchedResourceMixin, ModelCru
obj.content_type = self.get_content_type()
obj.owner = self.request.user
obj.size = obj.attached_file.size
- obj.name = path.basename(obj.attached_file.name).lower()
+ obj.name = path.basename(obj.attached_file.name)
if obj.project_id != obj.content_object.project_id:
raise exc.WrongArguments(_("Project ID not matches between object and project"))
diff --git a/taiga/projects/management/commands/sample_data.py b/taiga/projects/management/commands/sample_data.py
index 96b3c47d..3fca91db 100644
--- a/taiga/projects/management/commands/sample_data.py
+++ b/taiga/projects/management/commands/sample_data.py
@@ -221,7 +221,7 @@ class Command(BaseCommand):
membership = self.sd.db_object_from_queryset(obj.project.memberships
.filter(user__isnull=False))
attachment = Attachment.objects.create(project=obj.project,
- name=path.basename(attached_file.name).lower(),
+ name=path.basename(attached_file.name),
size=attached_file.size,
content_object=obj,
order=order,
From 16f39c8b7306731edad674cc0e1ce43ac1933e5d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?=
Date: Tue, 30 Jun 2015 20:39:50 +0200
Subject: [PATCH 046/190] Add @al42and to AUTHORS :dancer:
---
AUTHORS.rst | 18 +++++++++---------
1 file changed, 9 insertions(+), 9 deletions(-)
diff --git a/AUTHORS.rst b/AUTHORS.rst
index 896b7876..95154a8a 100644
--- a/AUTHORS.rst
+++ b/AUTHORS.rst
@@ -7,21 +7,21 @@ The PRIMARY AUTHORS are:
- Xavi Julian
- Anler Hernández
-Special thanks to Kaleidos Open Source S.L. for provice time for taiga
+Special thanks to Kaleidos Open Source S.L. for provice time for Taiga
development.
And here is an inevitably incomplete list of MUCH-APPRECIATED CONTRIBUTORS --
people who have submitted patches, reported bugs, added translations, helped
answer newbie questions, and generally made taiga that much better:
-- Andrés Moya
-- Yamila Moreno
-- Ricky Posner
-- Alonso Torres
- Alejandro Gómez
+- Alonso Torres
- Andrea Stagi
-- Hector Colina
-- Julien Palard
-- Joe Letts
+- Andrés Moya
+- Andrey Alekseenko
- Chris Wilson
-
+- Hector Colina
+- Joe Letts
+- Julien Palard
+- Ricky Posner
+- Yamila Moreno
From bc2d8ece7f7c0c5eb1ad87284fda424de894e16a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?=
Date: Thu, 2 Jul 2015 00:40:51 +0200
Subject: [PATCH 047/190] Update regenerate.sh - Remove unnecessary commands
---
regenerate.sh | 10 +++-------
1 file changed, 3 insertions(+), 7 deletions(-)
diff --git a/regenerate.sh b/regenerate.sh
index 0efc51a4..47f9c962 100755
--- a/regenerate.sh
+++ b/regenerate.sh
@@ -6,16 +6,12 @@ dropdb taiga
echo "-> Create taiga DB"
createdb taiga
-echo "-> Run syncdb"
+echo "-> Load migrations"
python manage.py migrate
-# echo "-> Load initial Site"
-# python manage.py loaddata initial_site --traceback
-echo "-> Load initial user"
+echo "-> Load initial user (admin/123123)"
python manage.py loaddata initial_user --traceback
-echo "-> Load initial project_templates"
+echo "-> Load initial project_templates (scrum/kanban)"
python manage.py loaddata initial_project_templates --traceback
-echo "-> Load initial roles"
-python manage.py loaddata initial_role --traceback
echo "-> Generate sample data"
python manage.py sample_data --traceback
echo "-> Rebuilding timeline"
From 03ff40ddd400b3da48790348842ad1f52dd64817 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?=
Date: Thu, 2 Jul 2015 11:33:26 +0200
Subject: [PATCH 048/190] Add system-stats endpoint
---
settings/common.py | 4 ++++
settings/local.py.example | 7 ++++++
taiga/routers.py | 7 +++++-
taiga/stats/__init__.py | 15 +++++++++++++
taiga/stats/api.py | 33 ++++++++++++++++++++++++++++
taiga/stats/apps.py | 30 ++++++++++++++++++++++++++
taiga/stats/permissions.py | 20 +++++++++++++++++
taiga/stats/routers.py | 20 +++++++++++++++++
taiga/stats/services.py | 44 ++++++++++++++++++++++++++++++++++++++
9 files changed, 179 insertions(+), 1 deletion(-)
create mode 100644 taiga/stats/__init__.py
create mode 100644 taiga/stats/api.py
create mode 100644 taiga/stats/apps.py
create mode 100644 taiga/stats/permissions.py
create mode 100644 taiga/stats/routers.py
create mode 100644 taiga/stats/services.py
diff --git a/settings/common.py b/settings/common.py
index 1a441e92..716760aa 100644
--- a/settings/common.py
+++ b/settings/common.py
@@ -283,6 +283,7 @@ INSTALLED_APPS = [
"taiga.mdrender",
"taiga.export_import",
"taiga.feedback",
+ "taiga.stats",
"taiga.hooks.github",
"taiga.hooks.gitlab",
"taiga.hooks.bitbucket",
@@ -434,6 +435,9 @@ TAGS_PREDEFINED_COLORS = ["#fce94f", "#edd400", "#c4a000", "#8ae234",
FEEDBACK_ENABLED = True
FEEDBACK_EMAIL = "support@taiga.io"
+# Stats module settings
+STATS_ENABLED = False
+
# 0 notifications will work in a synchronous way
# >0 an external process will check the pending notifications and will send them
# collapsed during that interval
diff --git a/settings/local.py.example b/settings/local.py.example
index a74baa27..dd4ce8c9 100644
--- a/settings/local.py.example
+++ b/settings/local.py.example
@@ -63,6 +63,13 @@ DATABASES = {
#GITHUB_API_CLIENT_ID = "yourgithubclientid"
#GITHUB_API_CLIENT_SECRET = "yourgithubclientsecret"
+# FEEDBACK MODULE (See config in taiga-front too)
+#FEEDBACK_ENABLED = True
+#FEEDBACK_EMAIL = "support@taiga.io"
+
+# STATS MODULE
+#STATS_ENABLED = False
+
# SITEMAP
# If is True /front/sitemap.xml show a valid sitemap of taiga-front client
#FRONT_SITEMAP_ENABLED = False
diff --git a/taiga/routers.py b/taiga/routers.py
index 968c6354..0f8bc675 100644
--- a/taiga/routers.py
+++ b/taiga/routers.py
@@ -1,3 +1,4 @@
+
# Copyright (C) 2014 Andrey Antukh
# Copyright (C) 2014 Jesús Espino
# Copyright (C) 2014 David Barragán
@@ -192,5 +193,9 @@ router.register(r"importer", ProjectImporterViewSet, base_name="importer")
router.register(r"exporter", ProjectExporterViewSet, base_name="exporter")
-# feedback
+# Stats
+# - see taiga.stats.routers and taiga.stats.apps
+
+
+# Feedback
# - see taiga.feedback.routers and taiga.feedback.apps
diff --git a/taiga/stats/__init__.py b/taiga/stats/__init__.py
new file mode 100644
index 00000000..92ed9d3d
--- /dev/null
+++ b/taiga/stats/__init__.py
@@ -0,0 +1,15 @@
+# Copyright (C) 2015 Taiga Agile LLC
+# 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 .
+
+default_app_config = "taiga.stats.apps.StatsAppConfig"
diff --git a/taiga/stats/api.py b/taiga/stats/api.py
new file mode 100644
index 00000000..dae5cfdd
--- /dev/null
+++ b/taiga/stats/api.py
@@ -0,0 +1,33 @@
+# Copyright (C) 2015 Taiga Agile LLC
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+
+from taiga.base.api import viewsets
+from taiga.base import response
+
+from . import permissions
+from . import services
+
+
+class SystemStatsViewSet(viewsets.ViewSet):
+ permission_classes = (permissions.SystemStatsPermission,)
+
+ def list(self, request, **kwargs):
+ stats = {
+ "total_users": services.get_total_users(),
+ "total_projects": services.get_total_projects(),
+ "total_userstories": services.grt_total_user_stories(),
+ "total_issues": services.get_total_issues(),
+ }
+ return response.Ok(stats)
diff --git a/taiga/stats/apps.py b/taiga/stats/apps.py
new file mode 100644
index 00000000..e8b8e63a
--- /dev/null
+++ b/taiga/stats/apps.py
@@ -0,0 +1,30 @@
+# Copyright (C) 2015 Taiga Agile LLC
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+from django.apps import AppConfig
+from django.apps import apps
+from django.conf import settings
+from django.conf.urls import include, url
+
+from .routers import router
+
+
+class StatsAppConfig(AppConfig):
+ name = "taiga.stats"
+ verbose_name = "Stats"
+
+ def ready(self):
+ if settings.STATS_ENABLED:
+ from taiga.urls import urlpatterns
+ urlpatterns.append(url(r'^api/v1/', include(router.urls)))
diff --git a/taiga/stats/permissions.py b/taiga/stats/permissions.py
new file mode 100644
index 00000000..0eb1a362
--- /dev/null
+++ b/taiga/stats/permissions.py
@@ -0,0 +1,20 @@
+# Copyright (C) 2015 Taiga Agile LLC
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+
+from taiga.base.api import permissions
+
+
+class SystemStatsPermission(permissions.TaigaResourcePermission):
+ global_perms = permissions.AllowAny()
diff --git a/taiga/stats/routers.py b/taiga/stats/routers.py
new file mode 100644
index 00000000..7257b473
--- /dev/null
+++ b/taiga/stats/routers.py
@@ -0,0 +1,20 @@
+# Copyright (C) 2015 Taiga Agile LLC
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+from taiga.base import routers
+from . import api
+
+
+router = routers.DefaultRouter(trailing_slash=False)
+router.register(r"stats/system", api.SystemStatsViewSet, base_name="system-stats")
diff --git a/taiga/stats/services.py b/taiga/stats/services.py
new file mode 100644
index 00000000..11f41d37
--- /dev/null
+++ b/taiga/stats/services.py
@@ -0,0 +1,44 @@
+# Copyright (C) 2015 Taiga Agile LLC
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+
+from django.apps import apps
+
+
+def get_total_projects():
+ model = apps.get_model("projects", "Project")
+ queryset = model.objects.all()
+ return queryset.count()
+
+
+def grt_total_user_stories():
+ model = apps.get_model("userstories", "UserStory")
+ queryset = model.objects.all()
+ return queryset.count()
+
+
+def get_total_issues():
+ model = apps.get_model("issues", "Issue")
+ queryset = model.objects.all()
+ return queryset.count()
+
+
+def get_total_users(only_active=True, no_system=True):
+ model = apps.get_model("users", "User")
+ queryset = model.objects.all()
+ if only_active:
+ queryset = queryset.filter(is_active=True)
+ if no_system:
+ queryset = queryset.filter(is_system=False)
+ return queryset.count()
From 69d1ed91a3b7838e3e90cc245b9e3783f620b120 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?=
Date: Thu, 2 Jul 2015 11:33:42 +0200
Subject: [PATCH 049/190] Update CHANGELOG
---
CHANGELOG.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9798b058..8798a560 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,7 @@
### Misc
- API: Mixin fields 'users', 'members' and 'memberships' in ProjectDetailSerializer
+- API: Add stats/system resource with global server stats (total project, total users....)
- Lots of small and not so small bugfixes.
From ba828b4aa2ebd006d64a6af42df740cdd6ae2fbb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jes=C3=BAs=20Espino?=
Date: Wed, 1 Jul 2015 11:13:03 +0200
Subject: [PATCH 050/190] Issue#2995: Add custom video conference system
---
CHANGELOG.md | 1 +
taiga/projects/choices.py | 1 +
.../fixtures/initial_project_templates.json | 4 +--
.../migrations/0022_auto_20150701_0924.py | 36 +++++++++++++++++++
taiga/projects/models.py | 14 ++++----
5 files changed, 47 insertions(+), 9 deletions(-)
create mode 100644 taiga/projects/migrations/0022_auto_20150701_0924.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8798a560..924f2ebb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,7 @@
- Allow multiple actions in the commit messages.
- Now every user that coments USs, Issues or Tasks will be involved in it (add author to the watchers list).
- Fix the compatibility with BitBucket webhooks and add issues and issues comments integration.
+- Add custom videoconference system.
### Misc
- API: Mixin fields 'users', 'members' and 'memberships' in ProjectDetailSerializer
diff --git a/taiga/projects/choices.py b/taiga/projects/choices.py
index 9447898d..4cf2c843 100644
--- a/taiga/projects/choices.py
+++ b/taiga/projects/choices.py
@@ -20,5 +20,6 @@ from django.utils.translation import ugettext_lazy as _
VIDEOCONFERENCES_CHOICES = (
("appear-in", _("AppearIn")),
("jitsi", _("Jitsi")),
+ ("custom", _("Custom")),
("talky", _("Talky")),
)
diff --git a/taiga/projects/fixtures/initial_project_templates.json b/taiga/projects/fixtures/initial_project_templates.json
index f0c2ebbb..46d369a5 100644
--- a/taiga/projects/fixtures/initial_project_templates.json
+++ b/taiga/projects/fixtures/initial_project_templates.json
@@ -16,7 +16,7 @@
"created_date": "2014-04-22T14:48:43.596Z",
"default_options": "{\"us_status\": \"New\", \"task_status\": \"New\", \"priority\": \"Normal\", \"issue_type\": \"Bug\", \"severity\": \"Normal\", \"points\": \"?\", \"issue_status\": \"New\"}",
"slug": "scrum",
- "videoconferences_salt": "",
+ "videoconferences_extra_data": "",
"issue_statuses": "[{\"color\": \"#8C2318\", \"order\": 1, \"is_closed\": false, \"name\": \"New\", \"slug\": \"new\"}, {\"color\": \"#5E8C6A\", \"order\": 2, \"is_closed\": false, \"name\": \"In progress\", \"slug\": \"in-progress\"}, {\"color\": \"#88A65E\", \"order\": 3, \"is_closed\": true, \"name\": \"Ready for test\", \"slug\": \"ready-for-test\"}, {\"color\": \"#BFB35A\", \"order\": 4, \"is_closed\": true, \"name\": \"Closed\", \"slug\": \"closed\"}, {\"color\": \"#89BAB4\", \"order\": 5, \"is_closed\": false, \"name\": \"Needs Info\", \"slug\": \"needs-info\"}, {\"color\": \"#CC0000\", \"order\": 6, \"is_closed\": true, \"name\": \"Rejected\", \"slug\": \"rejected\"}, {\"color\": \"#666666\", \"order\": 7, \"is_closed\": false, \"name\": \"Postponed\", \"slug\": \"posponed\"}]",
"default_owner_role": "product-owner",
"issue_types": "[{\"color\": \"#89BAB4\", \"order\": 1, \"name\": \"Bug\"}, {\"color\": \"#ba89a8\", \"order\": 2, \"name\": \"Question\"}, {\"color\": \"#89a8ba\", \"order\": 3, \"name\": \"Enhancement\"}]",
@@ -43,7 +43,7 @@
"created_date": "2014-04-22T14:50:19.738Z",
"default_options": "{\"us_status\": \"New\", \"task_status\": \"New\", \"priority\": \"Normal\", \"issue_type\": \"Bug\", \"severity\": \"Normal\", \"points\": \"?\", \"issue_status\": \"New\"}",
"slug": "kanban",
- "videoconferences_salt": "",
+ "videoconferences_extra_data": "",
"issue_statuses": "[{\"color\": \"#999999\", \"order\": 1, \"is_closed\": false, \"name\": \"New\", \"slug\": \"new\"}, {\"color\": \"#729fcf\", \"order\": 2, \"is_closed\": false, \"name\": \"In progress\", \"slug\": \"in-progress\"}, {\"color\": \"#f57900\", \"order\": 3, \"is_closed\": true, \"name\": \"Ready for test\", \"slug\": \"ready-for-test\"}, {\"color\": \"#4e9a06\", \"order\": 4, \"is_closed\": true, \"name\": \"Closed\", \"slug\": \"closed\"}, {\"color\": \"#cc0000\", \"order\": 5, \"is_closed\": false, \"name\": \"Needs Info\", \"slug\": \"needs-info\"}, {\"color\": \"#d3d7cf\", \"order\": 6, \"is_closed\": true, \"name\": \"Rejected\", \"slug\": \"rejected\"}, {\"color\": \"#75507b\", \"order\": 7, \"is_closed\": false, \"name\": \"Postponed\", \"slug\": \"posponed\"}]",
"default_owner_role": "product-owner",
"issue_types": "[{\"color\": \"#cc0000\", \"order\": 1, \"name\": \"Bug\"}, {\"color\": \"#729fcf\", \"order\": 2, \"name\": \"Question\"}, {\"color\": \"#4e9a06\", \"order\": 3, \"name\": \"Enhancement\"}]",
diff --git a/taiga/projects/migrations/0022_auto_20150701_0924.py b/taiga/projects/migrations/0022_auto_20150701_0924.py
new file mode 100644
index 00000000..83d7a337
--- /dev/null
+++ b/taiga/projects/migrations/0022_auto_20150701_0924.py
@@ -0,0 +1,36 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('projects', '0021_auto_20150504_1524'),
+ ]
+
+ operations = [
+ migrations.RenameField(
+ model_name='projecttemplate',
+ old_name='videoconferences_salt',
+ new_name='videoconferences_extra_data',
+ ),
+ migrations.RenameField(
+ model_name='project',
+ old_name='videoconferences_salt',
+ new_name='videoconferences_extra_data',
+ ),
+ migrations.AlterField(
+ model_name='project',
+ name='videoconferences',
+ field=models.CharField(blank=True, verbose_name='videoconference system', choices=[('appear-in', 'AppearIn'), ('jitsi', 'Jitsi'), ('custom', 'Custom'), ('talky', 'Talky')], null=True, max_length=250),
+ preserve_default=True,
+ ),
+ migrations.AlterField(
+ model_name='projecttemplate',
+ name='videoconferences',
+ field=models.CharField(blank=True, verbose_name='videoconference system', choices=[('appear-in', 'AppearIn'), ('jitsi', 'Jitsi'), ('custom', 'Custom'), ('talky', 'Talky')], null=True, max_length=250),
+ preserve_default=True,
+ ),
+ ]
diff --git a/taiga/projects/models.py b/taiga/projects/models.py
index 64fff2a9..145edfb0 100644
--- a/taiga/projects/models.py
+++ b/taiga/projects/models.py
@@ -150,8 +150,8 @@ class Project(ProjectDefaults, TaggedMixin, models.Model):
videoconferences = models.CharField(max_length=250, null=True, blank=True,
choices=choices.VIDEOCONFERENCES_CHOICES,
verbose_name=_("videoconference system"))
- videoconferences_salt = models.CharField(max_length=250, null=True, blank=True,
- verbose_name=_("videoconference room salt"))
+ videoconferences_extra_data = models.CharField(max_length=250, null=True, blank=True,
+ verbose_name=_("videoconference extra data"))
creation_template = models.ForeignKey("projects.ProjectTemplate",
related_name="projects", null=True,
@@ -209,7 +209,7 @@ class Project(ProjectDefaults, TaggedMixin, models.Model):
self.slug = slug
if not self.videoconferences:
- self.videoconferences_salt = None
+ self.videoconferences_extra_data = None
super().save(*args, **kwargs)
@@ -577,8 +577,8 @@ class ProjectTemplate(models.Model):
videoconferences = models.CharField(max_length=250, null=True, blank=True,
choices=choices.VIDEOCONFERENCES_CHOICES,
verbose_name=_("videoconference system"))
- videoconferences_salt = models.CharField(max_length=250, null=True, blank=True,
- verbose_name=_("videoconference room salt"))
+ videoconferences_extra_data = models.CharField(max_length=250, null=True, blank=True,
+ verbose_name=_("videoconference extra data"))
default_options = JsonField(null=True, blank=True, verbose_name=_("default options"))
us_statuses = JsonField(null=True, blank=True, verbose_name=_("us statuses"))
@@ -613,7 +613,7 @@ class ProjectTemplate(models.Model):
self.is_wiki_activated = project.is_wiki_activated
self.is_issues_activated = project.is_issues_activated
self.videoconferences = project.videoconferences
- self.videoconferences_salt = project.videoconferences_salt
+ self.videoconferences_extra_data = project.videoconferences_extra_data
self.default_options = {
"points": getattr(project.default_points, "name", None),
@@ -717,7 +717,7 @@ class ProjectTemplate(models.Model):
project.is_wiki_activated = self.is_wiki_activated
project.is_issues_activated = self.is_issues_activated
project.videoconferences = self.videoconferences
- project.videoconferences_salt = self.videoconferences_salt
+ project.videoconferences_extra_data = self.videoconferences_extra_data
for us_status in self.us_statuses:
UserStoryStatus.objects.create(
From 3bae896199d260188e087ea65d3d29157e7798c9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jes=C3=BAs=20Espino?=
Date: Tue, 30 Jun 2015 17:30:06 +0200
Subject: [PATCH 051/190] Add gitlab integration with commets webhook
---
CHANGELOG.md | 1 +
taiga/hooks/gitlab/api.py | 1 +
taiga/hooks/gitlab/event_hooks.py | 47 ++++++++-
tests/integration/test_hooks_gitlab.py | 129 +++++++++++++++++++++++++
4 files changed, 177 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 924f2ebb..d22da965 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@
- Now every user that coments USs, Issues or Tasks will be involved in it (add author to the watchers list).
- Fix the compatibility with BitBucket webhooks and add issues and issues comments integration.
- Add custom videoconference system.
+- Add support for comments in the Gitlab webhooks integration.
### Misc
- API: Mixin fields 'users', 'members' and 'memberships' in ProjectDetailSerializer
diff --git a/taiga/hooks/gitlab/api.py b/taiga/hooks/gitlab/api.py
index 48d70fe7..03ae3a06 100644
--- a/taiga/hooks/gitlab/api.py
+++ b/taiga/hooks/gitlab/api.py
@@ -30,6 +30,7 @@ class GitLabViewSet(BaseWebhookApiViewSet):
event_hook_classes = {
"push": event_hooks.PushEventHook,
"issue": event_hooks.IssuesEventHook,
+ "note": event_hooks.IssueCommentEventHook,
}
def _validate_signature(self, project, request):
diff --git a/taiga/hooks/gitlab/event_hooks.py b/taiga/hooks/gitlab/event_hooks.py
index 84079121..fdc83066 100644
--- a/taiga/hooks/gitlab/event_hooks.py
+++ b/taiga/hooks/gitlab/event_hooks.py
@@ -89,7 +89,7 @@ class PushEventHook(BaseEventHook):
def replace_gitlab_references(project_url, wiki_text):
- if wiki_text == None:
+ if wiki_text is None:
wiki_text = ""
template = "\g<1>[GitLab#\g<2>]({}/issues/\g<2>)\g<3>".format(project_url)
@@ -127,3 +127,48 @@ class IssuesEventHook(BaseEventHook):
snapshot = take_snapshot(issue, comment=_("Created from GitLab"), user=get_gitlab_user(None))
send_notifications(issue, history=snapshot)
+
+
+class IssueCommentEventHook(BaseEventHook):
+ def process_event(self):
+ if self.payload.get('object_attributes', {}).get("noteable_type", None) != "Issue":
+ return
+
+ number = self.payload.get('issue', {}).get('iid', None)
+ subject = self.payload.get('issue', {}).get('title', None)
+
+ project_url = self.payload.get('repository', {}).get('homepage', None)
+
+ gitlab_url = os.path.join(project_url, "issues", str(number))
+ gitlab_user_name = self.payload.get('user', {}).get('username', None)
+ gitlab_user_url = os.path.join(os.path.dirname(os.path.dirname(project_url)), "u", gitlab_user_name)
+
+ comment_message = self.payload.get('object_attributes', {}).get('note', None)
+ comment_message = replace_gitlab_references(project_url, comment_message)
+
+ user = get_gitlab_user(None)
+
+ if not all([comment_message, gitlab_url, project_url]):
+ raise ActionSyntaxException(_("Invalid issue comment information"))
+
+ issues = Issue.objects.filter(external_reference=["gitlab", gitlab_url])
+ tasks = Task.objects.filter(external_reference=["gitlab", gitlab_url])
+ uss = UserStory.objects.filter(external_reference=["gitlab", gitlab_url])
+
+ for item in list(issues) + list(tasks) + list(uss):
+ if number and subject and gitlab_user_name and gitlab_user_url:
+ comment = _("Comment by [@{gitlab_user_name}]({gitlab_user_url} "
+ "\"See @{gitlab_user_name}'s GitLab profile\") "
+ "from GitLab.\nOrigin GitLab issue: [gh#{number} - {subject}]({gitlab_url} "
+ "\"Go to 'gh#{number} - {subject}'\")\n\n"
+ "{message}").format(gitlab_user_name=gitlab_user_name,
+ gitlab_user_url=gitlab_user_url,
+ number=number,
+ subject=subject,
+ gitlab_url=gitlab_url,
+ message=comment_message)
+ else:
+ comment = _("Comment From GitLab:\n\n{message}").format(message=comment_message)
+
+ snapshot = take_snapshot(item, comment=comment, user=user)
+ send_notifications(item, history=snapshot)
diff --git a/tests/integration/test_hooks_gitlab.py b/tests/integration/test_hooks_gitlab.py
index 39aa5485..eac03668 100644
--- a/tests/integration/test_hooks_gitlab.py
+++ b/tests/integration/test_hooks_gitlab.py
@@ -13,6 +13,7 @@ from taiga.projects.issues.models import Issue
from taiga.projects.tasks.models import Task
from taiga.projects.userstories.models import UserStory
from taiga.projects.models import Membership
+from taiga.projects.history.services import get_history_queryset_by_model_instance, take_snapshot
from taiga.projects.notifications.choices import NotifyLevel
from taiga.projects.notifications.models import NotifyPolicy
from taiga.projects import services
@@ -384,6 +385,134 @@ def test_issues_event_bad_issue(client):
assert len(mail.outbox) == 0
+def test_issue_comment_event_on_existing_issue_task_and_us(client):
+ project = f.ProjectFactory()
+ role = f.RoleFactory(project=project, permissions=["view_tasks", "view_issues", "view_us"])
+ f.MembershipFactory(project=project, role=role, user=project.owner)
+ user = f.UserFactory()
+
+ issue = f.IssueFactory.create(external_reference=["gitlab", "http://gitlab.com/test/project/issues/11"], owner=project.owner, project=project)
+ take_snapshot(issue, user=user)
+ task = f.TaskFactory.create(external_reference=["gitlab", "http://gitlab.com/test/project/issues/11"], owner=project.owner, project=project)
+ take_snapshot(task, user=user)
+ us = f.UserStoryFactory.create(external_reference=["gitlab", "http://gitlab.com/test/project/issues/11"], owner=project.owner, project=project)
+ take_snapshot(us, user=user)
+
+ payload = {
+ "user": {
+ "username": "test"
+ },
+ "issue": {
+ "iid": "11",
+ "title": "test-title",
+ },
+ "object_attributes": {
+ "noteable_type": "Issue",
+ "note": "Test body",
+ },
+ "repository": {
+ "homepage": "http://gitlab.com/test/project",
+ },
+ }
+
+
+ mail.outbox = []
+
+ assert get_history_queryset_by_model_instance(issue).count() == 0
+ assert get_history_queryset_by_model_instance(task).count() == 0
+ assert get_history_queryset_by_model_instance(us).count() == 0
+
+ ev_hook = event_hooks.IssueCommentEventHook(issue.project, payload)
+ ev_hook.process_event()
+
+ issue_history = get_history_queryset_by_model_instance(issue)
+ assert issue_history.count() == 1
+ assert "Test body" in issue_history[0].comment
+
+ task_history = get_history_queryset_by_model_instance(task)
+ assert task_history.count() == 1
+ assert "Test body" in issue_history[0].comment
+
+ us_history = get_history_queryset_by_model_instance(us)
+ assert us_history.count() == 1
+ assert "Test body" in issue_history[0].comment
+
+ assert len(mail.outbox) == 3
+
+
+def test_issue_comment_event_on_not_existing_issue_task_and_us(client):
+ issue = f.IssueFactory.create(external_reference=["gitlab", "10"])
+ take_snapshot(issue, user=issue.owner)
+ task = f.TaskFactory.create(project=issue.project, external_reference=["gitlab", "10"])
+ take_snapshot(task, user=task.owner)
+ us = f.UserStoryFactory.create(project=issue.project, external_reference=["gitlab", "10"])
+ take_snapshot(us, user=us.owner)
+
+ payload = {
+ "user": {
+ "username": "test"
+ },
+ "issue": {
+ "iid": "99999",
+ "title": "test-title",
+ },
+ "object_attributes": {
+ "noteable_type": "Issue",
+ "note": "test comment",
+ },
+ "repository": {
+ "homepage": "test",
+ },
+ }
+
+ mail.outbox = []
+
+ assert get_history_queryset_by_model_instance(issue).count() == 0
+ assert get_history_queryset_by_model_instance(task).count() == 0
+ assert get_history_queryset_by_model_instance(us).count() == 0
+
+ ev_hook = event_hooks.IssueCommentEventHook(issue.project, payload)
+ ev_hook.process_event()
+
+ assert get_history_queryset_by_model_instance(issue).count() == 0
+ assert get_history_queryset_by_model_instance(task).count() == 0
+ assert get_history_queryset_by_model_instance(us).count() == 0
+
+ assert len(mail.outbox) == 0
+
+
+def test_issues_event_bad_comment(client):
+ issue = f.IssueFactory.create(external_reference=["gitlab", "10"])
+ take_snapshot(issue, user=issue.owner)
+
+ payload = {
+ "user": {
+ "username": "test"
+ },
+ "issue": {
+ "iid": "10",
+ "title": "test-title",
+ },
+ "object_attributes": {
+ "noteable_type": "Issue",
+ },
+ "repository": {
+ "homepage": "test",
+ },
+ }
+ ev_hook = event_hooks.IssueCommentEventHook(issue.project, payload)
+
+ mail.outbox = []
+
+ with pytest.raises(ActionSyntaxException) as excinfo:
+ ev_hook.process_event()
+
+ assert str(excinfo.value) == "Invalid issue comment information"
+
+ assert Issue.objects.count() == 1
+ assert len(mail.outbox) == 0
+
+
def test_api_get_project_modules(client):
project = f.create_project()
f.MembershipFactory(project=project, user=project.owner, is_owner=True)
From 33a00657137fdc2f455a5467b7a4de3e962132bf Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?=
Date: Fri, 3 Jul 2015 14:55:34 +0200
Subject: [PATCH 052/190] Update gitlab user logo
---
.../migrations/0002_auto_20150703_1102.py | 50 ++++++++++++++++++
taiga/hooks/gitlab/migrations/logo-v2.png | Bin 0 -> 37963 bytes
2 files changed, 50 insertions(+)
create mode 100644 taiga/hooks/gitlab/migrations/0002_auto_20150703_1102.py
create mode 100644 taiga/hooks/gitlab/migrations/logo-v2.png
diff --git a/taiga/hooks/gitlab/migrations/0002_auto_20150703_1102.py b/taiga/hooks/gitlab/migrations/0002_auto_20150703_1102.py
new file mode 100644
index 00000000..4613c4ac
--- /dev/null
+++ b/taiga/hooks/gitlab/migrations/0002_auto_20150703_1102.py
@@ -0,0 +1,50 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+from django.core.files import File
+
+def update_gitlab_system_user_photo_to_v2(apps, schema_editor):
+ # We get the model from the versioned app registry;
+ # if we directly import it, it'll be the wrong version
+ User = apps.get_model("users", "User")
+ db_alias = schema_editor.connection.alias
+
+ try:
+ user = User.objects.using(db_alias).get(username__startswith="gitlab-",
+ is_active=False,
+ is_system=True)
+ f = open("taiga/hooks/gitlab/migrations/logo-v2.png", "rb")
+ user.photo.save("logo.png", File(f))
+ user.save()
+ except User.DoesNotExist:
+ pass
+
+def update_gitlab_system_user_photo_to_v1(apps, schema_editor):
+ # We get the model from the versioned app registry;
+ # if we directly import it, it'll be the wrong version
+ User = apps.get_model("users", "User")
+ db_alias = schema_editor.connection.alias
+
+ try:
+ user = User.objects.using(db_alias).get(username__startswith="gitlab-",
+ is_active=False,
+ is_system=True)
+ f = open("taiga/hooks/gitlab/migrations/logo.png", "rb")
+ user.photo.save("logo.png", File(f))
+ user.save()
+ except User.DoesNotExist:
+ pass
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('gitlab', '0001_initial'),
+ ('users', '0011_user_theme'),
+ ]
+
+ operations = [
+ migrations.RunPython(update_gitlab_system_user_photo_to_v2,
+ update_gitlab_system_user_photo_to_v1),
+ ]
diff --git a/taiga/hooks/gitlab/migrations/logo-v2.png b/taiga/hooks/gitlab/migrations/logo-v2.png
new file mode 100644
index 0000000000000000000000000000000000000000..01063fc389f3ff1904c8f9614753e34f27d344b9
GIT binary patch
literal 37963
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