diff --git a/CHANGELOG.md b/CHANGELOG.md index d84dd8ba..074e016a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog # -## 1.3.0 Dryas hookeriana (Unreleased) +## 1.3.0 Dryas hookeriana (2014-11-18) ### Features - GitHub integration (Phase I): diff --git a/taiga/__init__.py b/taiga/__init__.py index a10bec52..b62e0bd0 100644 --- a/taiga/__init__.py +++ b/taiga/__init__.py @@ -1 +1,17 @@ +# 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 . import celery diff --git a/taiga/auth/services.py b/taiga/auth/services.py index 26f3d0db..dee8c606 100644 --- a/taiga/auth/services.py +++ b/taiga/auth/services.py @@ -34,6 +34,7 @@ from djmail.template_mail import MagicMailBuilder from taiga.base import exceptions as exc from taiga.users.serializers import UserSerializer from taiga.users.services import get_and_validate_user +from taiga.base.utils.slug import slugify_uniquely from .tokens import get_token_for_user from .signals import user_registered as user_registered_signal @@ -50,7 +51,7 @@ def send_register_email(user) -> bool: return bool(email.send()) -def is_user_already_registered(*, username:str, email:str, github_id:int=None) -> (bool, str): +def is_user_already_registered(*, username:str, email:str) -> (bool, str): """ Checks if a specified user is already registred. @@ -65,9 +66,6 @@ def is_user_already_registered(*, username:str, email:str, github_id:int=None) - if user_model.objects.filter(email=email): return (True, _("Email is already in use.")) - if github_id and user_model.objects.filter(github_id=github_id): - return (True, _("GitHub id is already in use")) - return (False, None) @@ -182,20 +180,33 @@ def github_register(username:str, email:str, full_name:str, github_id:int, bio:s :returns: User """ user_model = apps.get_model("users", "User") - user, created = user_model.objects.get_or_create(github_id=github_id, - defaults={"username": username, - "email": email, - "full_name": full_name, - "bio": bio}) + + try: + # Github user association exist? + user = user_model.objects.get(github_id=github_id) + except user_model.DoesNotExist: + try: + # Is a user with the same email as the github user? + user = user_model.objects.get(email=email) + user.github_id = github_id + user.save(update_fields=["github_id"]) + except user_model.DoesNotExist: + # Create a new user + username_unique = slugify_uniquely(username, user_model, slugfield="username") + user = user_model.objects.create(email=email, + username=username_unique, + github_id=github_id, + full_name=full_name, + bio=bio) + + send_register_email(user) + user_registered_signal.send(sender=user.__class__, user=user) + if token: membership = get_membership_by_token(token) membership.user = user membership.save(update_fields=["user"]) - if created: - send_register_email(user) - user_registered_signal.send(sender=user.__class__, user=user) - return user diff --git a/taiga/celery.py b/taiga/celery.py index 01a93494..ef9b7d06 100644 --- a/taiga/celery.py +++ b/taiga/celery.py @@ -1,3 +1,19 @@ +# 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 . + import os from celery import Celery diff --git a/taiga/deferred.py b/taiga/deferred.py index eec20909..62080a77 100644 --- a/taiga/deferred.py +++ b/taiga/deferred.py @@ -1,3 +1,19 @@ +# 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 django.conf import settings from .celery import app diff --git a/taiga/github_hook/event_hooks.py b/taiga/github_hook/event_hooks.py index 7f60d7c2..bf7745f0 100644 --- a/taiga/github_hook/event_hooks.py +++ b/taiga/github_hook/event_hooks.py @@ -110,11 +110,11 @@ class IssuesEventHook(BaseEventHook): subject = self.payload.get('issue', {}).get('title', None) description = self.payload.get('issue', {}).get('body', None) - github_reference = self.payload.get('issue', {}).get('number', None) + github_url = self.payload.get('issue', {}).get('html_url', None) github_user = self.payload.get('issue', {}).get('user', {}).get('id', None) project_url = self.payload.get('repository', {}).get('html_url', None) - if not all([subject, github_reference, project_url]): + if not all([subject, github_url, project_url]): raise ActionSyntaxException(_("Invalid issue information")) issue = Issue.objects.create( @@ -125,7 +125,7 @@ class IssuesEventHook(BaseEventHook): type=self.project.default_issue_type, severity=self.project.default_severity, priority=self.project.default_priority, - external_reference=['github', github_reference], + external_reference=['github', github_url], owner=get_github_user(github_user) ) take_snapshot(issue, user=get_github_user(github_user)) @@ -139,18 +139,18 @@ class IssueCommentEventHook(BaseEventHook): if self.payload.get('action', None) != "created": raise ActionSyntaxException(_("Invalid issue comment information")) - github_reference = self.payload.get('issue', {}).get('number', None) + github_url = self.payload.get('issue', {}).get('html_url', None) comment_message = self.payload.get('comment', {}).get('body', None) github_user = self.payload.get('sender', {}).get('id', None) project_url = self.payload.get('repository', {}).get('html_url', None) comment_message = replace_github_references(project_url, comment_message) - if not all([comment_message, github_reference, project_url]): + if not all([comment_message, github_url, project_url]): raise ActionSyntaxException(_("Invalid issue comment information")) - issues = Issue.objects.filter(external_reference=["github", github_reference]) - tasks = Task.objects.filter(external_reference=["github", github_reference]) - uss = UserStory.objects.filter(external_reference=["github", github_reference]) + issues = Issue.objects.filter(external_reference=["github", github_url]) + tasks = Task.objects.filter(external_reference=["github", github_url]) + uss = UserStory.objects.filter(external_reference=["github", github_url]) for item in list(issues) + list(tasks) + list(uss): snapshot = take_snapshot(item, diff --git a/taiga/mdrender/service.py b/taiga/mdrender/service.py index baba2349..82824e10 100644 --- a/taiga/mdrender/service.py +++ b/taiga/mdrender/service.py @@ -52,7 +52,7 @@ from .extensions.references import TaigaReferencesExtension # Bleach configuration -bleach.ALLOWED_TAGS += ["p", "table", "th", "tr", "td", "h1", "h2", "h3", +bleach.ALLOWED_TAGS += ["p", "table", "thead", "tbody", "th", "tr", "td", "h1", "h2", "h3", "div", "pre", "span", "hr", "dl", "dt", "dd", "sup", "img", "del", "br", "ins"] @@ -74,7 +74,8 @@ def _make_extensions_list(wikilinks_config=None, project=None): MentionsExtension(), TaigaReferencesExtension(project), "extra", - "codehilite"] + "codehilite", + "nl2br"] import diff_match_patch diff --git a/taiga/projects/issues/serializers.py b/taiga/projects/issues/serializers.py index d3f5d740..cc025185 100644 --- a/taiga/projects/issues/serializers.py +++ b/taiga/projects/issues/serializers.py @@ -16,7 +16,7 @@ from rest_framework import serializers -from taiga.base.serializers import Serializer, PickleField, NeighborsSerializerMixin +from taiga.base.serializers import Serializer, PickleField, NeighborsSerializerMixin, PgArrayField from taiga.mdrender.service import render as mdrender from taiga.projects.validators import ProjectExistsValidator from taiga.projects.notifications.validators import WatchersValidator @@ -26,6 +26,7 @@ from . import models class IssueSerializer(WatchersValidator, serializers.ModelSerializer): tags = PickleField(required=False) + external_reference = PgArrayField(required=False) is_closed = serializers.Field(source="is_closed") comment = serializers.SerializerMethodField("get_comment") generated_user_stories = serializers.SerializerMethodField("get_generated_user_stories") diff --git a/taiga/projects/tasks/serializers.py b/taiga/projects/tasks/serializers.py index 20bd17dc..2dcf6097 100644 --- a/taiga/projects/tasks/serializers.py +++ b/taiga/projects/tasks/serializers.py @@ -16,7 +16,7 @@ from rest_framework import serializers -from taiga.base.serializers import Serializer, PickleField, NeighborsSerializerMixin +from taiga.base.serializers import Serializer, PickleField, NeighborsSerializerMixin, PgArrayField from taiga.mdrender.service import render as mdrender from taiga.projects.validators import ProjectExistsValidator, TaskStatusExistsValidator from taiga.projects.milestones.validators import SprintExistsValidator @@ -28,6 +28,7 @@ from . import models class TaskSerializer(WatchersValidator, serializers.ModelSerializer): tags = PickleField(required=False, default=[]) + external_reference = PgArrayField(required=False) comment = serializers.SerializerMethodField("get_comment") milestone_slug = serializers.SerializerMethodField("get_milestone_slug") blocked_note_html = serializers.SerializerMethodField("get_blocked_note_html") diff --git a/taiga/projects/userstories/models.py b/taiga/projects/userstories/models.py index f9688380..54458b3c 100644 --- a/taiga/projects/userstories/models.py +++ b/taiga/projects/userstories/models.py @@ -126,9 +126,15 @@ class UserStory(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, mod return self.role_points def get_total_points(self): + not_null_role_points = self.role_points.select_related("points").\ + exclude(points__value__isnull=True) + + #If we only have None values the sum should be None + if not not_null_role_points: + return None + total = 0.0 - for rp in self.role_points.select_related("points"): - if rp.points.value: - total += rp.points.value + for rp in not_null_role_points: + total += rp.points.value return total diff --git a/taiga/projects/userstories/serializers.py b/taiga/projects/userstories/serializers.py index 06aaed22..c768f909 100644 --- a/taiga/projects/userstories/serializers.py +++ b/taiga/projects/userstories/serializers.py @@ -18,7 +18,7 @@ import json from django.apps import apps from rest_framework import serializers -from taiga.base.serializers import Serializer, PickleField, NeighborsSerializerMixin +from taiga.base.serializers import Serializer, PickleField, NeighborsSerializerMixin, PgArrayField from taiga.mdrender.service import render as mdrender from taiga.projects.validators import ProjectExistsValidator, UserStoryStatusExistsValidator from taiga.projects.userstories.validators import UserStoryExistsValidator @@ -39,6 +39,7 @@ class RolePointsField(serializers.WritableField): class UserStorySerializer(WatchersValidator, serializers.ModelSerializer): tags = PickleField(default=[], required=False) + external_reference = PgArrayField(required=False) points = RolePointsField(source="role_points", required=False) total_points = serializers.SerializerMethodField("get_total_points") comment = serializers.SerializerMethodField("get_comment") diff --git a/tests/integration/test_auth_api.py b/tests/integration/test_auth_api.py index 60dced10..d322fa32 100644 --- a/tests/integration/test_auth_api.py +++ b/tests/integration/test_auth_api.py @@ -115,6 +115,78 @@ def test_response_200_in_registration_with_github_account(client, settings): assert response.data["bio"] == "time traveler" assert response.data["github_id"] == 1955 +def test_response_200_in_registration_with_github_account_and_existed_user_by_email(client, settings): + settings.PUBLIC_REGISTER_ENABLED = False + form = {"type": "github", + "code": "xxxxxx"} + user = factories.UserFactory() + user.email = "mmcfly@bttf.com" + user.github_id = None + user.save() + + with patch("taiga.base.connectors.github.me") as m_me: + m_me.return_value = ("mmcfly@bttf.com", + github.User(id=1955, + username="mmcfly", + full_name="martin seamus mcfly", + bio="time traveler")) + + response = client.post(reverse("auth-list"), form) + assert response.status_code == 200 + assert response.data["username"] == user.username + assert response.data["auth_token"] != "" and response.data["auth_token"] != None + assert response.data["email"] == user.email + assert response.data["full_name"] == user.full_name + assert response.data["bio"] == user.bio + assert response.data["github_id"] == 1955 + +def test_response_200_in_registration_with_github_account_and_existed_user_by_github_id(client, settings): + settings.PUBLIC_REGISTER_ENABLED = False + form = {"type": "github", + "code": "xxxxxx"} + user = factories.UserFactory() + user.github_id = 1955 + user.save() + + with patch("taiga.base.connectors.github.me") as m_me: + m_me.return_value = ("mmcfly@bttf.com", + github.User(id=1955, + username="mmcfly", + full_name="martin seamus mcfly", + bio="time traveler")) + + response = client.post(reverse("auth-list"), form) + assert response.status_code == 200 + assert response.data["username"] != "mmcfly" + assert response.data["auth_token"] != "" and response.data["auth_token"] != None + assert response.data["email"] != "mmcfly@bttf.com" + assert response.data["full_name"] != "martin seamus mcfly" + assert response.data["bio"] != "time traveler" + assert response.data["github_id"] == user.github_id + +def test_response_200_in_registration_with_github_account_and_change_github_username(client, settings): + settings.PUBLIC_REGISTER_ENABLED = False + form = {"type": "github", + "code": "xxxxxx"} + user = factories.UserFactory() + user.username = "mmcfly" + user.save() + + with patch("taiga.base.connectors.github.me") as m_me: + m_me.return_value = ("mmcfly@bttf.com", + github.User(id=1955, + username="mmcfly", + full_name="martin seamus mcfly", + bio="time traveler")) + + response = client.post(reverse("auth-list"), form) + assert response.status_code == 200 + assert response.data["username"] == "mmcfly-1" + assert response.data["auth_token"] != "" and response.data["auth_token"] != None + assert response.data["email"] == "mmcfly@bttf.com" + assert response.data["full_name"] == "martin seamus mcfly" + assert response.data["bio"] == "time traveler" + assert response.data["github_id"] == 1955 def test_response_200_in_registration_with_github_account_in_a_project(client, settings): settings.PUBLIC_REGISTER_ENABLED = False @@ -171,7 +243,6 @@ def test_respond_400_if_username_or_email_is_duplicate(client, settings, registe response = client.post(reverse("auth-register"), register_form) assert response.status_code == 201 - register_form["username"] = "username" register_form["email"] = "ff@dd.com" response = client.post(reverse("auth-register"), register_form) diff --git a/tests/integration/test_github_hook.py b/tests/integration/test_github_hook.py index 0032f0b5..168e44ef 100644 --- a/tests/integration/test_github_hook.py +++ b/tests/integration/test_github_hook.py @@ -219,7 +219,7 @@ def test_issues_event_opened_issue(client): "issue": { "title": "test-title", "body": "test-body", - "number": 10, + "html_url": "http://github.com/test/project/issues/11", }, "assignee": {}, "label": {}, @@ -249,7 +249,7 @@ def test_issues_event_other_than_opened_issue(client): "issue": { "title": "test-title", "body": "test-body", - "number": 10, + "html_url": "http://github.com/test/project/issues/11", }, "assignee": {}, "label": {}, @@ -291,17 +291,17 @@ def test_issues_event_bad_issue(client): def test_issue_comment_event_on_existing_issue_task_and_us(client): - issue = f.IssueFactory.create(external_reference=["github", "10"]) + issue = f.IssueFactory.create(external_reference=["github", "http://github.com/test/project/issues/11"]) take_snapshot(issue, user=issue.owner) - task = f.TaskFactory.create(project=issue.project, external_reference=["github", "10"]) + task = f.TaskFactory.create(project=issue.project, external_reference=["github", "http://github.com/test/project/issues/11"]) take_snapshot(task, user=task.owner) - us = f.UserStoryFactory.create(project=issue.project, external_reference=["github", "10"]) + us = f.UserStoryFactory.create(project=issue.project, external_reference=["github", "http://github.com/test/project/issues/11"]) take_snapshot(us, user=us.owner) payload = { "action": "created", "issue": { - "number": 10, + "html_url": "http://github.com/test/project/issues/11", }, "comment": { "body": "Test body", @@ -346,7 +346,7 @@ def test_issue_comment_event_on_not_existing_issue_task_and_us(client): payload = { "action": "created", "issue": { - "number": 11, + "html_url": "http://github.com/test/project/issues/11", }, "comment": { "body": "Test body", diff --git a/tests/integration/test_userstories.py b/tests/integration/test_userstories.py index 8fbb33ab..83c35c65 100644 --- a/tests/integration/test_userstories.py +++ b/tests/integration/test_userstories.py @@ -200,3 +200,34 @@ def test_archived_filter(client): data = {"is_archived": 1} response = client.get(url, data) assert len(json.loads(response.content)) == 1 + +def test_get_total_points(client): + project = f.ProjectFactory.create() + + role1 = f.RoleFactory.create(project=project) + role2 = f.RoleFactory.create(project=project) + + points1 = f.PointsFactory.create(project=project, value=None) + points2 = f.PointsFactory.create(project=project, value=1) + points3 = f.PointsFactory.create(project=project, value=2) + + us_with_points = f.UserStoryFactory.create(project=project) + us_with_points.role_points.all().delete() + f.RolePointsFactory.create(user_story=us_with_points, role=role1, points=points2) + f.RolePointsFactory.create(user_story=us_with_points, role=role2, points=points3) + + assert us_with_points.get_total_points() == 3.0 + + us_without_points = f.UserStoryFactory.create(project=project) + us_without_points.role_points.all().delete() + f.RolePointsFactory.create(user_story=us_without_points, role=role1, points=points1) + f.RolePointsFactory.create(user_story=us_without_points, role=role2, points=points1) + + assert us_without_points.get_total_points() is None + + us_mixed = f.UserStoryFactory.create(project=project) + us_mixed.role_points.all().delete() + f.RolePointsFactory.create(user_story=us_mixed, role=role1, points=points1) + f.RolePointsFactory.create(user_story=us_mixed, role=role2, points=points2) + + assert us_mixed.get_total_points() == 1.0