Merge branch 'master' into stable
commit
4db9155769
15
CHANGELOG.md
15
CHANGELOG.md
|
@ -1,5 +1,18 @@
|
|||
# Changelog #
|
||||
|
||||
## 1.3.0 Dryas hookeriana (Unreleased)
|
||||
|
||||
### Features
|
||||
- GitHub integration (Phase I):
|
||||
+ Login/singin connector.
|
||||
+ Change status of user stories, tasks and issues with the commit messages.
|
||||
+ Sync issues creation in Taiga from GitHub.
|
||||
+ Sync comments in Taiga from GitHub issues.
|
||||
|
||||
### Misc
|
||||
- Lots of small and not so small bugfixes.
|
||||
|
||||
|
||||
## 1.2.0 Picea obovata (2014-11-04)
|
||||
|
||||
### Features
|
||||
|
@ -10,6 +23,7 @@
|
|||
### Misc
|
||||
- Lots of small and not so small bugfixes.
|
||||
|
||||
|
||||
## 1.1.0 Alnus maximowiczii (2014-10-13)
|
||||
|
||||
### Misc
|
||||
|
@ -17,6 +31,7 @@
|
|||
- Fix wrong static url resolve usage on emails.
|
||||
- Fix some bugs on import/export api related with attachments.
|
||||
|
||||
|
||||
## 1.0.0 (2014-10-07)
|
||||
|
||||
### Misc
|
||||
|
|
|
@ -194,6 +194,7 @@ INSTALLED_APPS = [
|
|||
"taiga.mdrender",
|
||||
"taiga.export_import",
|
||||
"taiga.feedback",
|
||||
"taiga.github_hook",
|
||||
|
||||
"rest_framework",
|
||||
"djmail",
|
||||
|
@ -337,6 +338,14 @@ FEEDBACK_EMAIL = "support@taiga.io"
|
|||
# collapsed during that interval
|
||||
CHANGE_NOTIFICATIONS_MIN_INTERVAL = 0 #seconds
|
||||
|
||||
|
||||
# List of functions called for filling correctly the ProjectModulesConfig associated to a project
|
||||
# This functions should receive a Project parameter and return a dict with the desired configuration
|
||||
PROJECT_MODULES_CONFIGURATORS = {
|
||||
"github": "taiga.github_hook.services.get_or_generate_config",
|
||||
}
|
||||
|
||||
|
||||
# NOTE: DON'T INSERT MORE SETTINGS AFTER THIS LINE
|
||||
TEST_RUNNER="django.test.runner.DiscoverRunner"
|
||||
|
||||
|
|
|
@ -66,7 +66,7 @@ def is_user_already_registered(*, username:str, email:str, github_id:int=None) -
|
|||
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 (True, _("GitHub id is already in use"))
|
||||
|
||||
return (False, None)
|
||||
|
||||
|
|
|
@ -21,6 +21,7 @@ from collections import namedtuple
|
|||
from urllib.parse import urljoin
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from . import exceptions as exc
|
||||
|
||||
|
@ -107,6 +108,11 @@ def login(access_code:str, client_id:str=CLIENT_ID, client_secret:str=CLIENT_SEC
|
|||
Get access_token fron an user authorized code, the client id and the client secret key.
|
||||
(See https://developer.github.com/v3/oauth/#web-application-flow).
|
||||
"""
|
||||
if not CLIENT_ID or not CLIENT_SECRET:
|
||||
raise exc.GitHubApiError({"error_message": _("Login with github account is disabled. Contact "
|
||||
"with the sysadmins. Maybe they're snoozing in a "
|
||||
"secret hideout of the data center.")})
|
||||
|
||||
url = urljoin(URL, "login/oauth/access_token")
|
||||
params={"code": access_code,
|
||||
"client_id": client_id,
|
||||
|
|
|
@ -39,6 +39,23 @@ def slugify_uniquely(value, model, slugfield="slug"):
|
|||
suffix += 1
|
||||
|
||||
|
||||
def slugify_uniquely_for_queryset(value, queryset, slugfield="slug"):
|
||||
"""
|
||||
Returns a slug on a name which doesn't exist in a queryset
|
||||
"""
|
||||
|
||||
suffix = 0
|
||||
potential = base = slugify(unidecode(value))
|
||||
if len(potential) == 0:
|
||||
potential = 'null'
|
||||
while True:
|
||||
if suffix:
|
||||
potential = "-".join([base, str(suffix)])
|
||||
if not queryset.filter(**{slugfield: potential}).exists():
|
||||
return potential
|
||||
suffix += 1
|
||||
|
||||
|
||||
def ref_uniquely(p, seq_field, model, field='ref'):
|
||||
project = p.__class__.objects.select_for_update().get(pk=p.pk)
|
||||
ref = getattr(project, seq_field) + 1
|
||||
|
|
|
@ -1,3 +1,18 @@
|
|||
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
|
||||
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
|
||||
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from django.db.models import signals
|
||||
from django.dispatch import receiver
|
||||
|
|
|
@ -0,0 +1,95 @@
|
|||
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
|
||||
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
|
||||
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from rest_framework.response import Response
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from taiga.base.api.viewsets import GenericViewSet
|
||||
from taiga.base import exceptions as exc
|
||||
from taiga.base.utils import json
|
||||
from taiga.projects.models import Project
|
||||
|
||||
from . import event_hooks
|
||||
from .exceptions import ActionSyntaxException
|
||||
|
||||
import hmac
|
||||
import hashlib
|
||||
|
||||
|
||||
class GitHubViewSet(GenericViewSet):
|
||||
# We don't want rest framework to parse the request body and transform it in
|
||||
# a dict in request.DATA, we need it raw
|
||||
parser_classes = ()
|
||||
|
||||
# This dict associates the event names we are listening for
|
||||
# with their reponsible classes (extending event_hooks.BaseEventHook)
|
||||
event_hook_classes = {
|
||||
"push": event_hooks.PushEventHook,
|
||||
"issues": event_hooks.IssuesEventHook,
|
||||
"issue_comment": event_hooks.IssueCommentEventHook,
|
||||
}
|
||||
|
||||
def _validate_signature(self, project, request):
|
||||
x_hub_signature = request.META.get("HTTP_X_HUB_SIGNATURE", None)
|
||||
if not x_hub_signature:
|
||||
return False
|
||||
|
||||
sha_name, signature = x_hub_signature.split('=')
|
||||
if sha_name != 'sha1':
|
||||
return False
|
||||
|
||||
if not hasattr(project, "modules_config"):
|
||||
return False
|
||||
|
||||
if project.modules_config.config is None:
|
||||
return False
|
||||
|
||||
secret = bytes(project.modules_config.config.get("github", {}).get("secret", "").encode("utf-8"))
|
||||
mac = hmac.new(secret, msg=request.body,digestmod=hashlib.sha1)
|
||||
return hmac.compare_digest(mac.hexdigest(), signature)
|
||||
|
||||
def _get_project(self, request):
|
||||
project_id = request.GET.get("project", None)
|
||||
try:
|
||||
project = Project.objects.get(id=project_id)
|
||||
return project
|
||||
except Project.DoesNotExist:
|
||||
return None
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
project = self._get_project(request)
|
||||
if not project:
|
||||
raise exc.BadRequest(_("The project doesn't exist"))
|
||||
|
||||
if not self._validate_signature(project, request):
|
||||
raise exc.BadRequest(_("Bad signature"))
|
||||
|
||||
event_name = request.META.get("HTTP_X_GITHUB_EVENT", None)
|
||||
|
||||
try:
|
||||
payload = json.loads(request.body.decode("utf-8"))
|
||||
except ValueError:
|
||||
raise exc.BadRequest(_("The payload is not a valid json"))
|
||||
|
||||
event_hook_class = self.event_hook_classes.get(event_name, None)
|
||||
if event_hook_class is not None:
|
||||
event_hook = event_hook_class(project, payload)
|
||||
try:
|
||||
event_hook.process_event()
|
||||
except ActionSyntaxException as e:
|
||||
raise exc.BadRequest(e)
|
||||
|
||||
return Response({})
|
|
@ -0,0 +1,159 @@
|
|||
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
|
||||
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
|
||||
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from taiga.projects.models import Project, IssueStatus, TaskStatus, UserStoryStatus
|
||||
|
||||
from taiga.projects.issues.models import Issue
|
||||
from taiga.projects.tasks.models import Task
|
||||
from taiga.projects.userstories.models import UserStory
|
||||
from taiga.projects.history.services import take_snapshot
|
||||
from taiga.projects.notifications.services import send_notifications
|
||||
|
||||
from .exceptions import ActionSyntaxException
|
||||
from .services import get_github_user
|
||||
|
||||
import re
|
||||
|
||||
|
||||
class BaseEventHook:
|
||||
def __init__(self, project, payload):
|
||||
self.project = project
|
||||
self.payload = payload
|
||||
|
||||
def process_event(self):
|
||||
raise NotImplementedError("process_event must be overwritten")
|
||||
|
||||
|
||||
class PushEventHook(BaseEventHook):
|
||||
def process_event(self):
|
||||
if self.payload is None:
|
||||
return
|
||||
|
||||
github_user = self.payload.get('sender', {}).get('id', None)
|
||||
|
||||
commits = self.payload.get("commits", [])
|
||||
for commit in commits:
|
||||
message = commit.get("message", None)
|
||||
self._process_message(message, github_user)
|
||||
|
||||
def _process_message(self, message, github_user):
|
||||
"""
|
||||
The message we will be looking for seems like
|
||||
TG-XX #yyyyyy
|
||||
Where:
|
||||
XX: is the ref for us, issue or task
|
||||
yyyyyy: is the status slug we are setting
|
||||
"""
|
||||
if message is None:
|
||||
return
|
||||
|
||||
p = re.compile("tg-(\d+) +#([-\w]+)")
|
||||
m = p.search(message.lower())
|
||||
if m:
|
||||
ref = m.group(1)
|
||||
status_slug = m.group(2)
|
||||
self._change_status(ref, status_slug, github_user)
|
||||
|
||||
def _change_status(self, ref, status_slug, github_user):
|
||||
if Issue.objects.filter(project=self.project, ref=ref).exists():
|
||||
modelClass = Issue
|
||||
statusClass = IssueStatus
|
||||
elif Task.objects.filter(project=self.project, ref=ref).exists():
|
||||
modelClass = Task
|
||||
statusClass = TaskStatus
|
||||
elif UserStory.objects.filter(project=self.project, ref=ref).exists():
|
||||
modelClass = UserStory
|
||||
statusClass = UserStoryStatus
|
||||
else:
|
||||
raise ActionSyntaxException(_("The referenced element doesn't exist"))
|
||||
|
||||
element = modelClass.objects.get(project=self.project, ref=ref)
|
||||
|
||||
try:
|
||||
status = statusClass.objects.get(project=self.project, slug=status_slug)
|
||||
except statusClass.DoesNotExist:
|
||||
raise ActionSyntaxException(_("The status doesn't exist"))
|
||||
|
||||
element.status = status
|
||||
element.save()
|
||||
|
||||
snapshot = take_snapshot(element,
|
||||
comment="Status changed from GitHub commit",
|
||||
user=get_github_user(github_user))
|
||||
send_notifications(element, history=snapshot)
|
||||
|
||||
|
||||
def replace_github_references(project_url, wiki_text):
|
||||
template = "\g<1>[GitHub#\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):
|
||||
if self.payload.get('action', None) != "opened":
|
||||
return
|
||||
|
||||
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_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]):
|
||||
raise ActionSyntaxException(_("Invalid issue information"))
|
||||
|
||||
issue = Issue.objects.create(
|
||||
project=self.project,
|
||||
subject=subject,
|
||||
description=replace_github_references(project_url, 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=['github', github_reference],
|
||||
owner=get_github_user(github_user)
|
||||
)
|
||||
take_snapshot(issue, user=get_github_user(github_user))
|
||||
|
||||
snapshot = take_snapshot(issue, comment="Created from GitHub", user=get_github_user(github_user))
|
||||
send_notifications(issue, history=snapshot)
|
||||
|
||||
|
||||
class IssueCommentEventHook(BaseEventHook):
|
||||
def process_event(self):
|
||||
if self.payload.get('action', None) != "created":
|
||||
raise ActionSyntaxException(_("Invalid issue comment information"))
|
||||
|
||||
github_reference = self.payload.get('issue', {}).get('number', 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]):
|
||||
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])
|
||||
|
||||
for item in list(issues) + list(tasks) + list(uss):
|
||||
snapshot = take_snapshot(item,
|
||||
comment="From GitHub:\n\n{}".format(comment_message),
|
||||
user=get_github_user(github_user))
|
||||
send_notifications(item, history=snapshot)
|
|
@ -0,0 +1,19 @@
|
|||
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
|
||||
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
|
||||
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
class ActionSyntaxException(Exception):
|
||||
pass
|
|
@ -0,0 +1,36 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import models, migrations
|
||||
from django.core.files import File
|
||||
|
||||
import uuid
|
||||
|
||||
def create_github_system_user(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
|
||||
random_hash = uuid.uuid4().hex
|
||||
user = User.objects.using(db_alias).create(
|
||||
username="github-{}".format(random_hash),
|
||||
email="github-{}@taiga.io".format(random_hash),
|
||||
full_name="GitHub",
|
||||
is_active=False,
|
||||
is_system=True,
|
||||
bio="",
|
||||
)
|
||||
f = open("taiga/github_hook/migrations/logo.png", "rb")
|
||||
user.photo.save("logo.png", File(f))
|
||||
user.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0006_auto_20141030_1132')
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(create_github_system_user),
|
||||
]
|
Binary file not shown.
After Width: | Height: | Size: 35 KiB |
|
@ -0,0 +1 @@
|
|||
# This file is needed to load migrations
|
|
@ -0,0 +1,51 @@
|
|||
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
|
||||
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
|
||||
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import uuid
|
||||
|
||||
from django.core.urlresolvers import reverse
|
||||
|
||||
from taiga.users.models import User
|
||||
from taiga.base.utils.urls import get_absolute_url
|
||||
|
||||
|
||||
def get_or_generate_config(project):
|
||||
config = project.modules_config.config
|
||||
if config and "github" in config:
|
||||
g_config = project.modules_config.config["github"]
|
||||
else:
|
||||
g_config = {"secret": uuid.uuid4().hex }
|
||||
|
||||
url = reverse("github-hook-list")
|
||||
url = get_absolute_url(url)
|
||||
url = "%s?project=%s"%(url, project.id)
|
||||
g_config["webhooks_url"] = url
|
||||
return g_config
|
||||
|
||||
|
||||
def get_github_user(user_id):
|
||||
user = None
|
||||
|
||||
if user_id:
|
||||
try:
|
||||
user = User.objects.get(github_id=user_id)
|
||||
except User.DoesNotExist:
|
||||
pass
|
||||
|
||||
if user is None:
|
||||
user = User.objects.get(is_system=True, username__startswith="github")
|
||||
|
||||
return user
|
|
@ -21,7 +21,7 @@ class SemiSaneListExtension(markdown.Extension):
|
|||
the sane_lists extension, GitHub will mix list types if they're not
|
||||
separated by multiple newlines.
|
||||
|
||||
Github also recognizes lists that start in the middle of a paragraph. This
|
||||
GitHub also recognizes lists that start in the middle of a paragraph. This
|
||||
is currently not supported by this extension, since the Python parser has a
|
||||
deeply-ingrained belief that blocks are always separated by multiple
|
||||
newlines.
|
||||
|
|
|
@ -60,6 +60,20 @@ class ProjectViewSet(ModelCrudViewSet):
|
|||
qs = models.Project.objects.all()
|
||||
return attach_votescount_to_queryset(qs, as_field="stars_count")
|
||||
|
||||
@detail_route(methods=["GET", "PATCH"])
|
||||
def modules(self, request, pk=None):
|
||||
project = self.get_object()
|
||||
self.check_permissions(request, 'modules', project)
|
||||
modules_config = services.get_modules_config(project)
|
||||
|
||||
if request.method == "GET":
|
||||
return Response(modules_config.config)
|
||||
|
||||
else:
|
||||
modules_config.config.update(request.DATA)
|
||||
modules_config.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@detail_route(methods=['get'])
|
||||
def stats(self, request, pk=None):
|
||||
project = self.get_object()
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import models, migrations
|
||||
import djorm_pgarray.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('issues', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='issue',
|
||||
name='external_reference',
|
||||
field=djorm_pgarray.fields.TextArrayField(dbtype='text', verbose_name='external reference'),
|
||||
preserve_default=True,
|
||||
),
|
||||
]
|
|
@ -21,6 +21,8 @@ from django.utils import timezone
|
|||
from django.dispatch import receiver
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from djorm_pgarray.fields import TextArrayField
|
||||
|
||||
from taiga.projects.occ import OCCModelMixin
|
||||
from taiga.projects.notifications.mixins import WatchedModelMixin
|
||||
from taiga.projects.mixins.blocked import BlockedMixin
|
||||
|
@ -61,6 +63,7 @@ class Issue(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, models.
|
|||
default=None, related_name="issues_assigned_to_me",
|
||||
verbose_name=_("assigned to"))
|
||||
attachments = generic.GenericRelation("attachments.Attachment")
|
||||
external_reference = TextArrayField(default=None, verbose_name=_("external reference"))
|
||||
_importing = None
|
||||
|
||||
class Meta:
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import models, migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('projects', '0006_auto_20141029_1040'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='issuestatus',
|
||||
name='slug',
|
||||
field=models.SlugField(verbose_name='slug', blank=True, max_length=255, null=True),
|
||||
preserve_default=True,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='taskstatus',
|
||||
name='slug',
|
||||
field=models.SlugField(verbose_name='slug', blank=True, max_length=255, null=True),
|
||||
preserve_default=True,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='userstorystatus',
|
||||
name='slug',
|
||||
field=models.SlugField(verbose_name='slug', blank=True, max_length=255, null=True),
|
||||
preserve_default=True,
|
||||
),
|
||||
]
|
|
@ -0,0 +1,70 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from unidecode import unidecode
|
||||
|
||||
from django.db import models, migrations
|
||||
from django.template.defaultfilters import slugify
|
||||
|
||||
from taiga.projects.models import UserStoryStatus, TaskStatus, IssueStatus
|
||||
|
||||
def update_many(objects, fields=[], using="default"):
|
||||
"""Update list of Django objects in one SQL query, optionally only
|
||||
overwrite the given fields (as names, e.g. fields=["foo"]).
|
||||
Objects must be of the same Django model. Note that save is not
|
||||
called and signals on the model are not raised."""
|
||||
if not objects:
|
||||
return
|
||||
|
||||
import django.db.models
|
||||
from django.db import connections
|
||||
con = connections[using]
|
||||
|
||||
names = fields
|
||||
meta = objects[0]._meta
|
||||
fields = [f for f in meta.fields if not isinstance(f, django.db.models.AutoField) and (not names or f.name in names)]
|
||||
|
||||
if not fields:
|
||||
raise ValueError("No fields to update, field names are %s." % names)
|
||||
|
||||
fields_with_pk = fields + [meta.pk]
|
||||
parameters = []
|
||||
for o in objects:
|
||||
parameters.append(tuple(f.get_db_prep_save(f.pre_save(o, True), connection=con) for f in fields_with_pk))
|
||||
|
||||
table = meta.db_table
|
||||
assignments = ",".join(("%s=%%s"% con.ops.quote_name(f.column)) for f in fields)
|
||||
con.cursor().executemany(
|
||||
"update %s set %s where %s=%%s" % (table, assignments, con.ops.quote_name(meta.pk.column)),
|
||||
parameters)
|
||||
|
||||
|
||||
def update_slug(apps, schema_editor):
|
||||
update_qs = UserStoryStatus.objects.all()
|
||||
for us_status in update_qs:
|
||||
us_status.slug = slugify(unidecode(us_status.name))
|
||||
|
||||
update_many(update_qs, fields=["slug"])
|
||||
|
||||
update_qs = TaskStatus.objects.all()
|
||||
for task_status in update_qs:
|
||||
task_status.slug = slugify(unidecode(task_status.name))
|
||||
|
||||
update_many(update_qs, fields=["slug"])
|
||||
|
||||
update_qs = IssueStatus.objects.all()
|
||||
for issue_status in update_qs:
|
||||
issue_status.slug = slugify(unidecode(issue_status.name))
|
||||
|
||||
update_many(update_qs, fields=["slug"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('projects', '0007_auto_20141024_1011'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(update_slug)
|
||||
]
|
|
@ -0,0 +1,41 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import models, migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('projects', '0008_auto_20141024_1012'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='issuestatus',
|
||||
name='slug',
|
||||
field=models.SlugField(verbose_name='slug', blank=True, max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='taskstatus',
|
||||
name='slug',
|
||||
field=models.SlugField(verbose_name='slug', blank=True, max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='userstorystatus',
|
||||
name='slug',
|
||||
field=models.SlugField(verbose_name='slug', blank=True, max_length=255),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='issuestatus',
|
||||
unique_together=set([('project', 'name'), ('project', 'slug')]),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='taskstatus',
|
||||
unique_together=set([('project', 'name'), ('project', 'slug')]),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='userstorystatus',
|
||||
unique_together=set([('project', 'name'), ('project', 'slug')]),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,21 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import models, migrations
|
||||
import django_pgjson.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('projects', '0009_auto_20141024_1037'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='project',
|
||||
name='modules_config',
|
||||
field=django_pgjson.fields.JsonField(blank=True, null=True, verbose_name='modules config'),
|
||||
preserve_default=True,
|
||||
),
|
||||
]
|
|
@ -0,0 +1,33 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import models, migrations
|
||||
import django_pgjson.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('projects', '0010_project_modules_config'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ProjectModulesConfig',
|
||||
fields=[
|
||||
('id', models.AutoField(serialize=False, auto_created=True, verbose_name='ID', primary_key=True)),
|
||||
('config', django_pgjson.fields.JsonField(null=True, verbose_name='modules config', blank=True)),
|
||||
('project', models.OneToOneField(to='projects.Project', verbose_name='project', related_name='modules_config')),
|
||||
],
|
||||
options={
|
||||
'verbose_name_plural': 'project modules configs',
|
||||
'verbose_name': 'project modules config',
|
||||
'ordering': ['project'],
|
||||
},
|
||||
bases=(models.Model,),
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='project',
|
||||
name='modules_config',
|
||||
),
|
||||
]
|
|
@ -35,6 +35,7 @@ from taiga.users.models import Role
|
|||
from taiga.base.utils.slug import slugify_uniquely
|
||||
from taiga.base.utils.dicts import dict_sum
|
||||
from taiga.base.utils.sequence import arithmetic_progression
|
||||
from taiga.base.utils.slug import slugify_uniquely_for_queryset
|
||||
from taiga.projects.notifications.services import create_notify_policy_if_not_exists
|
||||
|
||||
from . import choices
|
||||
|
@ -302,10 +303,23 @@ class Project(ProjectDefaults, TaggedMixin, models.Model):
|
|||
return self._get_user_stories_points(self.user_stories.filter(milestone__isnull=False).prefetch_related('role_points', 'role_points__points'))
|
||||
|
||||
|
||||
class ProjectModulesConfig(models.Model):
|
||||
project = models.OneToOneField("Project", null=False, blank=False,
|
||||
related_name="modules_config", verbose_name=_("project"))
|
||||
config = JsonField(null=True, blank=True, verbose_name=_("modules config"))
|
||||
|
||||
class Meta:
|
||||
verbose_name = "project modules config"
|
||||
verbose_name_plural = "project modules configs"
|
||||
ordering = ["project"]
|
||||
|
||||
|
||||
# User Stories common Models
|
||||
class UserStoryStatus(models.Model):
|
||||
name = models.CharField(max_length=255, null=False, blank=False,
|
||||
verbose_name=_("name"))
|
||||
slug = models.SlugField(max_length=255, null=False, blank=True,
|
||||
verbose_name=_("slug"))
|
||||
order = models.IntegerField(default=10, null=False, blank=False,
|
||||
verbose_name=_("order"))
|
||||
is_closed = models.BooleanField(default=False, null=False, blank=True,
|
||||
|
@ -321,7 +335,7 @@ class UserStoryStatus(models.Model):
|
|||
verbose_name = "user story status"
|
||||
verbose_name_plural = "user story statuses"
|
||||
ordering = ["project", "order", "name"]
|
||||
unique_together = ("project", "name")
|
||||
unique_together = (("project", "name"), ("project", "slug"))
|
||||
permissions = (
|
||||
("view_userstorystatus", "Can view user story status"),
|
||||
)
|
||||
|
@ -329,6 +343,14 @@ class UserStoryStatus(models.Model):
|
|||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
qs = self.project.us_statuses
|
||||
if self.id:
|
||||
qs = qs.exclude(id=self.id)
|
||||
|
||||
self.slug = slugify_uniquely_for_queryset(self.name, qs)
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class Points(models.Model):
|
||||
name = models.CharField(max_length=255, null=False, blank=False,
|
||||
|
@ -358,6 +380,8 @@ class Points(models.Model):
|
|||
class TaskStatus(models.Model):
|
||||
name = models.CharField(max_length=255, null=False, blank=False,
|
||||
verbose_name=_("name"))
|
||||
slug = models.SlugField(max_length=255, null=False, blank=True,
|
||||
verbose_name=_("slug"))
|
||||
order = models.IntegerField(default=10, null=False, blank=False,
|
||||
verbose_name=_("order"))
|
||||
is_closed = models.BooleanField(default=False, null=False, blank=True,
|
||||
|
@ -371,7 +395,7 @@ class TaskStatus(models.Model):
|
|||
verbose_name = "task status"
|
||||
verbose_name_plural = "task statuses"
|
||||
ordering = ["project", "order", "name"]
|
||||
unique_together = ("project", "name")
|
||||
unique_together = (("project", "name"), ("project", "slug"))
|
||||
permissions = (
|
||||
("view_taskstatus", "Can view task status"),
|
||||
)
|
||||
|
@ -379,6 +403,14 @@ class TaskStatus(models.Model):
|
|||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
qs = self.project.task_statuses
|
||||
if self.id:
|
||||
qs = qs.exclude(id=self.id)
|
||||
|
||||
self.slug = slugify_uniquely_for_queryset(self.name, qs)
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
|
||||
# Issue common Models
|
||||
|
||||
|
@ -431,6 +463,8 @@ class Severity(models.Model):
|
|||
class IssueStatus(models.Model):
|
||||
name = models.CharField(max_length=255, null=False, blank=False,
|
||||
verbose_name=_("name"))
|
||||
slug = models.SlugField(max_length=255, null=False, blank=True,
|
||||
verbose_name=_("slug"))
|
||||
order = models.IntegerField(default=10, null=False, blank=False,
|
||||
verbose_name=_("order"))
|
||||
is_closed = models.BooleanField(default=False, null=False, blank=True,
|
||||
|
@ -444,7 +478,7 @@ class IssueStatus(models.Model):
|
|||
verbose_name = "issue status"
|
||||
verbose_name_plural = "issue statuses"
|
||||
ordering = ["project", "order", "name"]
|
||||
unique_together = ("project", "name")
|
||||
unique_together = (("project", "name"), ("project", "slug"))
|
||||
permissions = (
|
||||
("view_issuestatus", "Can view issue status"),
|
||||
)
|
||||
|
@ -452,6 +486,14 @@ class IssueStatus(models.Model):
|
|||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
qs = self.project.issue_statuses
|
||||
if self.id:
|
||||
qs = qs.exclude(id=self.id)
|
||||
|
||||
self.slug = slugify_uniquely_for_queryset(self.name, qs)
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class IssueType(models.Model):
|
||||
name = models.CharField(max_length=255, null=False, blank=False,
|
||||
|
|
|
@ -24,6 +24,7 @@ class ProjectPermission(TaigaResourcePermission):
|
|||
create_perms = IsAuthenticated()
|
||||
update_perms = IsProjectOwner()
|
||||
destroy_perms = IsProjectOwner()
|
||||
modules_perms = IsProjectOwner()
|
||||
list_perms = AllowAny()
|
||||
stats_perms = AllowAny()
|
||||
star_perms = IsAuthenticated()
|
||||
|
|
|
@ -68,6 +68,22 @@ class TaskStatusSerializer(ModelSerializer):
|
|||
class Meta:
|
||||
model = models.TaskStatus
|
||||
|
||||
def validate_name(self, attrs, source):
|
||||
"""
|
||||
Check the task name is not duplicated in the project on creation
|
||||
"""
|
||||
qs = None
|
||||
# If the user story status exists:
|
||||
if self.object and attrs.get("name", None):
|
||||
qs = models.TaskStatus.objects.filter(project=self.object.project, name=attrs[source])
|
||||
|
||||
if not self.object and attrs.get("project", None) and attrs.get("name", None):
|
||||
qs = models.TaskStatus.objects.filter(project=attrs["project"], name=attrs[source])
|
||||
|
||||
if qs and qs.exists():
|
||||
raise serializers.ValidationError("Name duplicated for the project")
|
||||
|
||||
return attrs
|
||||
|
||||
# Issues common serializers
|
||||
|
||||
|
@ -85,6 +101,23 @@ class IssueStatusSerializer(ModelSerializer):
|
|||
class Meta:
|
||||
model = models.IssueStatus
|
||||
|
||||
def validate_name(self, attrs, source):
|
||||
"""
|
||||
Check the issue name is not duplicated in the project on creation
|
||||
"""
|
||||
qs = None
|
||||
# If the user story status exists:
|
||||
if self.object and attrs.get("name", None):
|
||||
qs = models.IssueStatus.objects.filter(project=self.object.project, name=attrs[source])
|
||||
|
||||
if not self.object and attrs.get("project", None) and attrs.get("name", None):
|
||||
qs = models.IssueStatus.objects.filter(project=attrs["project"], name=attrs[source])
|
||||
|
||||
if qs and qs.exists():
|
||||
raise serializers.ValidationError("Name duplicated for the project")
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
class IssueTypeSerializer(ModelSerializer):
|
||||
class Meta:
|
||||
|
@ -189,6 +222,7 @@ class ProjectSerializer(ModelSerializer):
|
|||
raise serializers.ValidationError("Total milestones must be major or equal to zero")
|
||||
return attrs
|
||||
|
||||
|
||||
class ProjectDetailSerializer(ProjectSerializer):
|
||||
roles = serializers.SerializerMethodField("get_roles")
|
||||
memberships = serializers.SerializerMethodField("get_memberships")
|
||||
|
|
|
@ -38,3 +38,5 @@ from .invitations import send_invitation
|
|||
from .invitations import find_invited_user
|
||||
|
||||
from .tags_colors import update_project_tags_colors_handler
|
||||
|
||||
from .modules_config import get_modules_config
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
|
||||
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
|
||||
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import importlib
|
||||
|
||||
from .. import models
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
def get_modules_config(project):
|
||||
modules_config, created = models.ProjectModulesConfig.objects.get_or_create(project=project)
|
||||
|
||||
if created:
|
||||
modules_config.config = {}
|
||||
|
||||
for key, configurator_function_name in settings.PROJECT_MODULES_CONFIGURATORS.items():
|
||||
mod_name, func_name = configurator_function_name.rsplit('.',1)
|
||||
mod = importlib.import_module(mod_name)
|
||||
configurator = getattr(mod, func_name)
|
||||
modules_config.config[key] = configurator(project)
|
||||
|
||||
modules_config.save()
|
||||
return modules_config
|
|
@ -0,0 +1,21 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import models, migrations
|
||||
import djorm_pgarray.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tasks', '0002_tasks_order_fields'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='task',
|
||||
name='external_reference',
|
||||
field=djorm_pgarray.fields.TextArrayField(dbtype='text', verbose_name='external reference'),
|
||||
preserve_default=True,
|
||||
),
|
||||
]
|
|
@ -20,6 +20,8 @@ from django.conf import settings
|
|||
from django.utils import timezone
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from djorm_pgarray.fields import TextArrayField
|
||||
|
||||
from taiga.projects.occ import OCCModelMixin
|
||||
from taiga.projects.notifications.mixins import WatchedModelMixin
|
||||
from taiga.projects.mixins.blocked import BlockedMixin
|
||||
|
@ -62,6 +64,7 @@ class Task(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, models.M
|
|||
attachments = generic.GenericRelation("attachments.Attachment")
|
||||
is_iocaine = models.BooleanField(default=False, null=False, blank=True,
|
||||
verbose_name=_("is iocaine"))
|
||||
external_reference = TextArrayField(default=None, verbose_name=_("external reference"))
|
||||
_importing = None
|
||||
|
||||
class Meta:
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import models, migrations
|
||||
import djorm_pgarray.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('userstories', '0006_auto_20141014_1524'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='userstory',
|
||||
name='external_reference',
|
||||
field=djorm_pgarray.fields.TextArrayField(dbtype='text', verbose_name='external reference'),
|
||||
preserve_default=True,
|
||||
),
|
||||
]
|
|
@ -20,6 +20,8 @@ from django.conf import settings
|
|||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils import timezone
|
||||
|
||||
from djorm_pgarray.fields import TextArrayField
|
||||
|
||||
from taiga.projects.occ import OCCModelMixin
|
||||
from taiga.projects.notifications.mixins import WatchedModelMixin
|
||||
from taiga.projects.mixins.blocked import BlockedMixin
|
||||
|
@ -97,6 +99,7 @@ class UserStory(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, mod
|
|||
on_delete=models.SET_NULL,
|
||||
related_name="generated_user_stories",
|
||||
verbose_name=_("generated from issue"))
|
||||
external_reference = TextArrayField(default=None, verbose_name=_("external reference"))
|
||||
_importing = None
|
||||
|
||||
class Meta:
|
||||
|
|
|
@ -131,8 +131,9 @@ from taiga.projects.notifications.api import NotifyPolicyViewSet
|
|||
|
||||
router.register(r"notify-policies", NotifyPolicyViewSet, base_name="notifications")
|
||||
|
||||
# GitHub webhooks
|
||||
from taiga.github_hook.api import GitHubViewSet
|
||||
router.register(r"github-hook", GitHubViewSet, base_name="github-hook")
|
||||
|
||||
# feedback
|
||||
# - see taiga.feedback.routers and taiga.feedback.apps
|
||||
|
||||
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
[
|
||||
{
|
||||
"pk": 1,
|
||||
"model": "users.user",
|
||||
"fields": {
|
||||
"username": "admin",
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import models, migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0005_alter_user_photo'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='is_system',
|
||||
field=models.BooleanField(default=False),
|
||||
preserve_default=True,
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='github_id',
|
||||
field=models.IntegerField(blank=True, null=True, db_index=True, verbose_name='github ID'),
|
||||
),
|
||||
]
|
|
@ -129,7 +129,8 @@ class User(AbstractBaseUser, PermissionsMixin):
|
|||
|
||||
new_email = models.EmailField(_('new email address'), null=True, blank=True)
|
||||
|
||||
github_id = models.IntegerField(null=True, blank=True, verbose_name=_("github ID"))
|
||||
github_id = models.IntegerField(null=True, blank=True, verbose_name=_("github ID"), db_index=True)
|
||||
is_system = models.BooleanField(null=False, blank=False, default=False)
|
||||
|
||||
USERNAME_FIELD = 'username'
|
||||
REQUIRED_FIELDS = ['email']
|
||||
|
|
|
@ -73,6 +73,14 @@ class ProjectFactory(Factory):
|
|||
creation_template = factory.SubFactory("tests.factories.ProjectTemplateFactory")
|
||||
|
||||
|
||||
class ProjectModulesConfigFactory(Factory):
|
||||
class Meta:
|
||||
model = "projects.ProjectModulesConfig"
|
||||
strategy = factory.CREATE_STRATEGY
|
||||
|
||||
project = factory.SubFactory("tests.factories.ProjectFactory")
|
||||
|
||||
|
||||
class RoleFactory(Factory):
|
||||
class Meta:
|
||||
model = "users.Role"
|
||||
|
|
|
@ -103,7 +103,7 @@ def test_user_list(client, data):
|
|||
|
||||
response = client.get(url)
|
||||
users_data = json.loads(response.content.decode('utf-8'))
|
||||
assert len(users_data) == 3
|
||||
assert len(users_data) == 4
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
|
|
|
@ -0,0 +1,439 @@
|
|||
import pytest
|
||||
import json
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.core import mail
|
||||
|
||||
from taiga.github_hook.api import GitHubViewSet
|
||||
from taiga.github_hook import event_hooks
|
||||
from taiga.github_hook.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
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_bad_signature(client):
|
||||
project=f.ProjectFactory()
|
||||
url = reverse("github-hook-list")
|
||||
url = "%s?project=%s"%(url, project.id)
|
||||
data = {}
|
||||
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"))
|
||||
assert response.status_code == 400
|
||||
assert "Bad signature" in response_content["_error_message"]
|
||||
|
||||
|
||||
def test_ok_signature(client):
|
||||
project=f.ProjectFactory()
|
||||
f.ProjectModulesConfigFactory(project=project, config={
|
||||
"github": {
|
||||
"secret": "tpnIwJDz4e"
|
||||
}
|
||||
})
|
||||
|
||||
url = reverse("github-hook-list")
|
||||
url = "%s?project=%s"%(url, project.id)
|
||||
data = {"test:": "data"}
|
||||
response = client.post(url, json.dumps(data),
|
||||
HTTP_X_HUB_SIGNATURE="sha1=3c8e83fdaa266f81c036ea0b71e98eb5e054581a",
|
||||
content_type="application/json")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_push_event_detected(client):
|
||||
project=f.ProjectFactory()
|
||||
url = reverse("github-hook-list")
|
||||
url = "%s?project=%s"%(url, project.id)
|
||||
data = {"commits": [
|
||||
{"message": "test message"},
|
||||
]}
|
||||
|
||||
GitHubViewSet._validate_signature = mock.Mock(return_value=True)
|
||||
|
||||
with mock.patch.object(event_hooks.PushEventHook, "process_event") as process_event_mock:
|
||||
response = client.post(url, json.dumps(data),
|
||||
HTTP_X_GITHUB_EVENT="push",
|
||||
content_type="application/json")
|
||||
|
||||
assert process_event_mock.call_count == 1
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_push_event_issue_processing(client):
|
||||
creation_status = f.IssueStatusFactory()
|
||||
new_status = f.IssueStatusFactory(project=creation_status.project)
|
||||
issue = f.IssueFactory.create(status=creation_status, project=creation_status.project)
|
||||
payload = {"commits": [
|
||||
{"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()
|
||||
issue = Issue.objects.get(id=issue.id)
|
||||
assert issue.status.id == new_status.id
|
||||
assert len(mail.outbox) == 1
|
||||
|
||||
|
||||
def test_push_event_task_processing(client):
|
||||
creation_status = f.TaskStatusFactory()
|
||||
new_status = f.TaskStatusFactory(project=creation_status.project)
|
||||
task = f.TaskFactory.create(status=creation_status, project=creation_status.project)
|
||||
payload = {"commits": [
|
||||
{"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()
|
||||
task = Task.objects.get(id=task.id)
|
||||
assert task.status.id == new_status.id
|
||||
assert len(mail.outbox) == 1
|
||||
|
||||
|
||||
def test_push_event_user_story_processing(client):
|
||||
creation_status = f.UserStoryStatusFactory()
|
||||
new_status = f.UserStoryStatusFactory(project=creation_status.project)
|
||||
user_story = f.UserStoryFactory.create(status=creation_status, project=creation_status.project)
|
||||
payload = {"commits": [
|
||||
{"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()
|
||||
user_story = UserStory.objects.get(id=user_story.id)
|
||||
assert user_story.status.id == new_status.id
|
||||
assert len(mail.outbox) == 1
|
||||
|
||||
|
||||
def test_push_event_processing_case_insensitive(client):
|
||||
creation_status = f.TaskStatusFactory()
|
||||
new_status = f.TaskStatusFactory(project=creation_status.project)
|
||||
task = f.TaskFactory.create(status=creation_status, project=creation_status.project)
|
||||
payload = {"commits": [
|
||||
{"message": """test message
|
||||
test tg-%s #%s ok
|
||||
bye!
|
||||
"""%(task.ref, new_status.slug.upper())},
|
||||
]}
|
||||
mail.outbox = []
|
||||
ev_hook = event_hooks.PushEventHook(task.project, payload)
|
||||
ev_hook.process_event()
|
||||
task = Task.objects.get(id=task.id)
|
||||
assert task.status.id == new_status.id
|
||||
assert len(mail.outbox) == 1
|
||||
|
||||
|
||||
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)},
|
||||
]}
|
||||
mail.outbox = []
|
||||
|
||||
ev_hook = event_hooks.PushEventHook(issue_status.project, payload)
|
||||
with pytest.raises(ActionSyntaxException) as excinfo:
|
||||
ev_hook.process_event()
|
||||
|
||||
assert str(excinfo.value) == "The referenced element doesn't exist"
|
||||
assert len(mail.outbox) == 0
|
||||
|
||||
|
||||
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)},
|
||||
]}
|
||||
|
||||
mail.outbox = []
|
||||
|
||||
ev_hook = event_hooks.PushEventHook(user_story.project, payload)
|
||||
with pytest.raises(ActionSyntaxException) as excinfo:
|
||||
ev_hook.process_event()
|
||||
|
||||
assert str(excinfo.value) == "The status doesn't exist"
|
||||
assert len(mail.outbox) == 0
|
||||
|
||||
|
||||
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)},
|
||||
]}
|
||||
|
||||
mail.outbox = []
|
||||
|
||||
ev_hook = event_hooks.PushEventHook(issue.project, payload)
|
||||
with pytest.raises(ActionSyntaxException) as excinfo:
|
||||
ev_hook.process_event()
|
||||
|
||||
assert str(excinfo.value) == "The status doesn't exist"
|
||||
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 = {
|
||||
"action": "opened",
|
||||
"issue": {
|
||||
"title": "test-title",
|
||||
"body": "test-body",
|
||||
"number": 10,
|
||||
},
|
||||
"assignee": {},
|
||||
"label": {},
|
||||
"repository": {
|
||||
"html_url": "test",
|
||||
},
|
||||
}
|
||||
|
||||
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_other_than_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()
|
||||
|
||||
payload = {
|
||||
"action": "closed",
|
||||
"issue": {
|
||||
"title": "test-title",
|
||||
"body": "test-body",
|
||||
"number": 10,
|
||||
},
|
||||
"assignee": {},
|
||||
"label": {},
|
||||
}
|
||||
|
||||
mail.outbox = []
|
||||
|
||||
ev_hook = event_hooks.IssuesEventHook(issue.project, payload)
|
||||
ev_hook.process_event()
|
||||
|
||||
assert Issue.objects.count() == 1
|
||||
assert len(mail.outbox) == 0
|
||||
|
||||
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 = {
|
||||
"action": "opened",
|
||||
"issue": {},
|
||||
"assignee": {},
|
||||
"label": {},
|
||||
}
|
||||
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):
|
||||
issue = f.IssueFactory.create(external_reference=["github", "10"])
|
||||
take_snapshot(issue, user=issue.owner)
|
||||
task = f.TaskFactory.create(project=issue.project, external_reference=["github", "10"])
|
||||
take_snapshot(task, user=task.owner)
|
||||
us = f.UserStoryFactory.create(project=issue.project, external_reference=["github", "10"])
|
||||
take_snapshot(us, user=us.owner)
|
||||
|
||||
payload = {
|
||||
"action": "created",
|
||||
"issue": {
|
||||
"number": 10,
|
||||
},
|
||||
"comment": {
|
||||
"body": "Test body",
|
||||
},
|
||||
"repository": {
|
||||
"html_url": "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()
|
||||
|
||||
issue_history = get_history_queryset_by_model_instance(issue)
|
||||
assert issue_history.count() == 1
|
||||
assert issue_history[0].comment == "From GitHub:\n\nTest body"
|
||||
|
||||
task_history = get_history_queryset_by_model_instance(task)
|
||||
assert task_history.count() == 1
|
||||
assert task_history[0].comment == "From GitHub:\n\nTest body"
|
||||
|
||||
us_history = get_history_queryset_by_model_instance(us)
|
||||
assert us_history.count() == 1
|
||||
assert us_history[0].comment == "From GitHub:\n\nTest body"
|
||||
|
||||
assert len(mail.outbox) == 3
|
||||
|
||||
|
||||
def test_issue_comment_event_on_not_existing_issue_task_and_us(client):
|
||||
issue = f.IssueFactory.create(external_reference=["github", "10"])
|
||||
take_snapshot(issue, user=issue.owner)
|
||||
task = f.TaskFactory.create(project=issue.project, external_reference=["github", "10"])
|
||||
take_snapshot(task, user=task.owner)
|
||||
us = f.UserStoryFactory.create(project=issue.project, external_reference=["github", "10"])
|
||||
take_snapshot(us, user=us.owner)
|
||||
|
||||
payload = {
|
||||
"action": "created",
|
||||
"issue": {
|
||||
"number": 11,
|
||||
},
|
||||
"comment": {
|
||||
"body": "Test body",
|
||||
},
|
||||
"repository": {
|
||||
"html_url": "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=["github", "10"])
|
||||
take_snapshot(issue, user=issue.owner)
|
||||
|
||||
payload = {
|
||||
"action": "other",
|
||||
"issue": {},
|
||||
"comment": {},
|
||||
"repository": {
|
||||
"html_url": "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()
|
||||
|
||||
url = reverse("projects-modules", args=(project.id,))
|
||||
|
||||
client.login(project.owner)
|
||||
response = client.get(url)
|
||||
assert response.status_code == 200
|
||||
content = json.loads(response.content.decode("utf-8"))
|
||||
assert "github" in content
|
||||
assert content["github"]["secret"] != ""
|
||||
assert content["github"]["webhooks_url"] != ""
|
||||
|
||||
|
||||
def test_api_patch_project_modules(client):
|
||||
project = f.create_project()
|
||||
|
||||
url = reverse("projects-modules", args=(project.id,))
|
||||
|
||||
client.login(project.owner)
|
||||
data = {
|
||||
"github": {
|
||||
"secret": "test_secret",
|
||||
"url": "test_url",
|
||||
}
|
||||
}
|
||||
response = client.patch(url, json.dumps(data), content_type="application/json")
|
||||
assert response.status_code == 204
|
||||
|
||||
config = services.get_modules_config(project).config
|
||||
assert "github" in config
|
||||
assert config["github"]["secret"] == "test_secret"
|
||||
assert config["github"]["webhooks_url"] != "test_url"
|
||||
|
||||
def test_replace_github_references():
|
||||
assert event_hooks.replace_github_references("project-url", "#2") == "[GitHub#2](project-url/issues/2)"
|
||||
assert event_hooks.replace_github_references("project-url", "#2 ") == "[GitHub#2](project-url/issues/2) "
|
||||
assert event_hooks.replace_github_references("project-url", " #2 ") == " [GitHub#2](project-url/issues/2) "
|
||||
assert event_hooks.replace_github_references("project-url", " #2") == " [GitHub#2](project-url/issues/2)"
|
||||
assert event_hooks.replace_github_references("project-url", "#test") == "#test"
|
|
@ -26,3 +26,60 @@ def test_partially_update_project(client):
|
|||
client.login(project.owner)
|
||||
response = client.json.patch(url, json.dumps(data))
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_us_status_slug_generation(client):
|
||||
us_status = f.UserStoryStatusFactory(name="NEW")
|
||||
assert us_status.slug == "new"
|
||||
|
||||
client.login(us_status.project.owner)
|
||||
|
||||
url = reverse("userstory-statuses-detail", kwargs={"pk": us_status.pk})
|
||||
|
||||
data = {"name": "new"}
|
||||
response = client.json.patch(url, json.dumps(data))
|
||||
assert response.status_code == 200
|
||||
assert response.data["slug"] == "new"
|
||||
|
||||
data = {"name": "new status"}
|
||||
response = client.json.patch(url, json.dumps(data))
|
||||
assert response.status_code == 200
|
||||
assert response.data["slug"] == "new-status"
|
||||
|
||||
|
||||
def test_task_status_slug_generation(client):
|
||||
task_status = f.TaskStatusFactory(name="NEW")
|
||||
assert task_status.slug == "new"
|
||||
|
||||
client.login(task_status.project.owner)
|
||||
|
||||
url = reverse("task-statuses-detail", kwargs={"pk": task_status.pk})
|
||||
|
||||
data = {"name": "new"}
|
||||
response = client.json.patch(url, json.dumps(data))
|
||||
assert response.status_code == 200
|
||||
assert response.data["slug"] == "new"
|
||||
|
||||
data = {"name": "new status"}
|
||||
response = client.json.patch(url, json.dumps(data))
|
||||
assert response.status_code == 200
|
||||
assert response.data["slug"] == "new-status"
|
||||
|
||||
|
||||
def test_issue_status_slug_generation(client):
|
||||
issue_status = f.IssueStatusFactory(name="NEW")
|
||||
assert issue_status.slug == "new"
|
||||
|
||||
client.login(issue_status.project.owner)
|
||||
|
||||
url = reverse("issue-statuses-detail", kwargs={"pk": issue_status.pk})
|
||||
|
||||
data = {"name": "new"}
|
||||
response = client.json.patch(url, json.dumps(data))
|
||||
assert response.status_code == 200
|
||||
assert response.data["slug"] == "new"
|
||||
|
||||
data = {"name": "new status"}
|
||||
response = client.json.patch(url, json.dumps(data))
|
||||
assert response.status_code == 200
|
||||
assert response.data["slug"] == "new-status"
|
||||
|
|
|
@ -33,8 +33,24 @@ def test_url_builder():
|
|||
"https://api.github.com/user/emails")
|
||||
|
||||
|
||||
def test_login_without_settings_params():
|
||||
with pytest.raises(exc.GitHubApiError) as e, \
|
||||
patch("taiga.base.connectors.github.requests") as m_requests:
|
||||
m_requests.post.return_value = m_response = Mock()
|
||||
m_response.status_code = 200
|
||||
m_response.json.return_value = {"access_token": "xxxxxxxx"}
|
||||
|
||||
auth_info = github.login("*access-code*", "**client-id**", "*ient-secret*", github.HEADERS)
|
||||
assert e.value.status_code == 400
|
||||
assert "error_message" in e.value.detail
|
||||
|
||||
|
||||
def test_login_success():
|
||||
with patch("taiga.base.connectors.github.requests") as m_requests:
|
||||
with patch("taiga.base.connectors.github.requests") as m_requests, \
|
||||
patch("taiga.base.connectors.github.CLIENT_ID") as CLIENT_ID, \
|
||||
patch("taiga.base.connectors.github.CLIENT_SECRET") as CLIENT_SECRET:
|
||||
CLIENT_ID = "*CLIENT_ID*"
|
||||
CLIENT_SECRET = "*CLIENT_SECRET*"
|
||||
m_requests.post.return_value = m_response = Mock()
|
||||
m_response.status_code = 200
|
||||
m_response.json.return_value = {"access_token": "xxxxxxxx"}
|
||||
|
@ -52,7 +68,11 @@ def test_login_success():
|
|||
|
||||
def test_login_whit_errors():
|
||||
with pytest.raises(exc.GitHubApiError) as e, \
|
||||
patch("taiga.base.connectors.github.requests") as m_requests:
|
||||
patch("taiga.base.connectors.github.requests") as m_requests, \
|
||||
patch("taiga.base.connectors.github.CLIENT_ID") as CLIENT_ID, \
|
||||
patch("taiga.base.connectors.github.CLIENT_SECRET") as CLIENT_SECRET:
|
||||
CLIENT_ID = "*CLIENT_ID*"
|
||||
CLIENT_SECRET = "*CLIENT_SECRET*"
|
||||
m_requests.post.return_value = m_response = Mock()
|
||||
m_response.status_code = 200
|
||||
m_response.json.return_value = {"error": "Invalid credentials"}
|
||||
|
|
Loading…
Reference in New Issue