Add trello importer
parent
23056f4e56
commit
f7595b65cc
|
@ -17,6 +17,8 @@ Markdown==2.6.7
|
||||||
fn==0.4.3
|
fn==0.4.3
|
||||||
diff-match-patch==20121119
|
diff-match-patch==20121119
|
||||||
requests==2.12.4
|
requests==2.12.4
|
||||||
|
requests-oauthlib==0.6.2
|
||||||
|
webcolors==1.5
|
||||||
django-sr==0.0.4
|
django-sr==0.0.4
|
||||||
easy-thumbnails==2.3
|
easy-thumbnails==2.3
|
||||||
celery==3.1.24
|
celery==3.1.24
|
||||||
|
@ -35,3 +37,5 @@ netaddr==0.7.18
|
||||||
serpy==0.1.1
|
serpy==0.1.1
|
||||||
psd-tools==1.4
|
psd-tools==1.4
|
||||||
CairoSVG==2.0.1
|
CairoSVG==2.0.1
|
||||||
|
cryptography==1.7.1
|
||||||
|
PyJWT==1.4.2
|
||||||
|
|
|
@ -318,6 +318,7 @@ INSTALLED_APPS = [
|
||||||
"taiga.hooks.bitbucket",
|
"taiga.hooks.bitbucket",
|
||||||
"taiga.hooks.gogs",
|
"taiga.hooks.gogs",
|
||||||
"taiga.webhooks",
|
"taiga.webhooks",
|
||||||
|
"taiga.importers",
|
||||||
|
|
||||||
"djmail",
|
"djmail",
|
||||||
"django_jinja",
|
"django_jinja",
|
||||||
|
@ -561,6 +562,9 @@ MAX_PENDING_MEMBERSHIPS = 30 # Max number of unconfirmed memberships in a projec
|
||||||
from .sr import *
|
from .sr import *
|
||||||
|
|
||||||
|
|
||||||
|
TRELLO_API_KEY = ""
|
||||||
|
TRELLO_SECRET_KEY = ""
|
||||||
|
|
||||||
# NOTE: DON'T INSERT MORE SETTINGS AFTER THIS LINE
|
# NOTE: DON'T INSERT MORE SETTINGS AFTER THIS LINE
|
||||||
TEST_RUNNER="django.test.runner.DiscoverRunner"
|
TEST_RUNNER="django.test.runner.DiscoverRunner"
|
||||||
|
|
||||||
|
|
|
@ -23,6 +23,8 @@ urls = {
|
||||||
"login": "/login",
|
"login": "/login",
|
||||||
"register": "/register",
|
"register": "/register",
|
||||||
"forgot-password": "/forgot-password",
|
"forgot-password": "/forgot-password",
|
||||||
|
"new-project": "/project/new",
|
||||||
|
"new-project-import": "/project/new/import/{0}",
|
||||||
|
|
||||||
"change-password": "/change-password/{0}", # user.token
|
"change-password": "/change-password/{0}", # user.token
|
||||||
"change-email": "/change-email/{0}", # user.email_token
|
"change-email": "/change-email/{0}", # user.email_token
|
||||||
|
|
|
@ -0,0 +1,39 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright (C) 2014-2016 Taiga Agile LLC <support@taiga.io>
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as
|
||||||
|
# published by the Free Software Foundation, either version 3 of the
|
||||||
|
# License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from taiga.base.api import viewsets
|
||||||
|
from taiga.base.decorators import list_route
|
||||||
|
|
||||||
|
|
||||||
|
class BaseImporterViewSet(viewsets.ViewSet):
|
||||||
|
@list_route(methods=["GET"])
|
||||||
|
def list_users(self, request, *args, **kwargs):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@list_route(methods=["GET"])
|
||||||
|
def list_projects(self, request, *args, **kwargs):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@list_route(methods=["POST"])
|
||||||
|
def import_project(self, request, *args, **kwargs):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@list_route(methods=["GET"])
|
||||||
|
def auth_url(self, request, *args, **kwargs):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@list_route(methods=["POST"])
|
||||||
|
def authorize(self, request, *args, **kwargs):
|
||||||
|
raise NotImplementedError
|
|
@ -0,0 +1,89 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright (C) 2014-2016 Andrey Antukh <niwi@niwi.nz>
|
||||||
|
# Copyright (C) 2014-2016 Jesús Espino <jespinog@gmail.com>
|
||||||
|
# Copyright (C) 2014-2016 David Barragán <bameda@dbarragan.com>
|
||||||
|
# Copyright (C) 2014-2016 Alejandro Alonso <alejandro.alonso@kaleidos.net>
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as
|
||||||
|
# published by the Free Software Foundation, either version 3 of the
|
||||||
|
# License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.db.models import Q
|
||||||
|
|
||||||
|
from taiga.importers.trello.importer import TrelloImporter
|
||||||
|
from taiga.users.models import User
|
||||||
|
from taiga.projects.services import projects as service
|
||||||
|
|
||||||
|
import unittest.mock
|
||||||
|
import timeit
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument('--token', dest="token", type=str,
|
||||||
|
help='Auth token')
|
||||||
|
parser.add_argument('--project-id', dest="project_id", type=str,
|
||||||
|
help='Project ID or full name (ex: taigaio/taiga-back)')
|
||||||
|
parser.add_argument('--template', dest='template', default="kanban",
|
||||||
|
help='template to use: scrum or kanban (default kanban)')
|
||||||
|
parser.add_argument('--ask-for-users', dest='ask_for_users', const=True,
|
||||||
|
action="store_const", default=False,
|
||||||
|
help='Import closed data')
|
||||||
|
parser.add_argument('--closed-data', dest='closed_data', const=True,
|
||||||
|
action="store_const", default=False,
|
||||||
|
help='Import closed data')
|
||||||
|
parser.add_argument('--keep-external-reference', dest='keep_external_reference', const=True,
|
||||||
|
action="store_const", default=False,
|
||||||
|
help='Store external reference of imported data')
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
admin = User.objects.get(username="admin")
|
||||||
|
if options.get('token', None):
|
||||||
|
token = options.get('token')
|
||||||
|
else:
|
||||||
|
(oauth_token, oauth_token_secret, url) = TrelloImporter.get_auth_url()
|
||||||
|
print("Go to here and come with your token: {}".format(url))
|
||||||
|
oauth_verifier = input("Code: ")
|
||||||
|
access_data = TrelloImporter.get_access_token(oauth_token, oauth_token_secret, oauth_verifier)
|
||||||
|
token = access_data['oauth_token']
|
||||||
|
print("Access token: {}".format(token))
|
||||||
|
importer = TrelloImporter(admin, token)
|
||||||
|
|
||||||
|
if options.get('project_id', None):
|
||||||
|
project_id = options.get('project_id')
|
||||||
|
else:
|
||||||
|
print("Select the project to import:")
|
||||||
|
for project in importer.list_projects():
|
||||||
|
print("- {}: {}".format(project['id'], project['name']))
|
||||||
|
project_id = input("Project id: ")
|
||||||
|
|
||||||
|
users_bindings = {}
|
||||||
|
if options.get('ask_for_users', None):
|
||||||
|
print("Add the username or email for next trello users:")
|
||||||
|
for user in importer.list_users(project_id):
|
||||||
|
while True:
|
||||||
|
username_or_email = input("{}: ".format(user['fullName']))
|
||||||
|
if username_or_email == "":
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
users_bindings[user['id']] = User.objects.get(Q(username=username_or_email) | Q(email=username_or_email))
|
||||||
|
break
|
||||||
|
except User.DoesNotExist:
|
||||||
|
print("ERROR: Invalid username or email")
|
||||||
|
|
||||||
|
options = {
|
||||||
|
"template": options.get('template'),
|
||||||
|
"import_closed_data": options.get("closed_data", False),
|
||||||
|
"users_bindings": users_bindings,
|
||||||
|
"keep_external_reference": options.get('keep_external_reference')
|
||||||
|
}
|
||||||
|
importer.import_project(project_id, options)
|
|
@ -0,0 +1,32 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright (C) 2014-2016 Andrey Antukh <niwi@niwi.nz>
|
||||||
|
# Copyright (C) 2014-2016 Jesús Espino <jespinog@gmail.com>
|
||||||
|
# Copyright (C) 2014-2016 David Barragán <bameda@dbarragan.com>
|
||||||
|
# Copyright (C) 2014-2016 Alejandro Alonso <alejandro.alonso@kaleidos.net>
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as
|
||||||
|
# published by the Free Software Foundation, either version 3 of the
|
||||||
|
# License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from taiga.base.api.permissions import TaigaResourcePermission, AllowAny, IsAuthenticated
|
||||||
|
from taiga.base.api.permissions import IsSuperUser, HasProjectPerm, IsProjectAdmin
|
||||||
|
|
||||||
|
from taiga.permissions.permissions import CommentAndOrUpdatePerm
|
||||||
|
|
||||||
|
|
||||||
|
class ImporterPermission(TaigaResourcePermission):
|
||||||
|
enought_perms = IsAuthenticated()
|
||||||
|
global_perms = None
|
||||||
|
auth_url_perms = IsAuthenticated()
|
||||||
|
authorize_perms = IsAuthenticated()
|
||||||
|
list_users_perms = IsAuthenticated()
|
||||||
|
list_projects_perms = IsAuthenticated()
|
||||||
|
import_project_perms = IsAuthenticated()
|
|
@ -0,0 +1,147 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright (C) 2014-2016 Taiga Agile LLC <support@taiga.io>
|
||||||
|
# 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.utils.translation import ugettext as _
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from taiga.base.api import viewsets
|
||||||
|
from taiga.base import response
|
||||||
|
from taiga.base import exceptions as exc
|
||||||
|
from taiga.base.decorators import list_route
|
||||||
|
from taiga.users.models import AuthData, User
|
||||||
|
from taiga.users.services import get_user_photo_url
|
||||||
|
from taiga.users.gravatar import get_user_gravatar_id
|
||||||
|
from taiga.projects.serializers import ProjectSerializer
|
||||||
|
|
||||||
|
from .importer import TrelloImporter
|
||||||
|
from taiga.importers import permissions
|
||||||
|
from . import tasks
|
||||||
|
|
||||||
|
|
||||||
|
class TrelloImporterViewSet(viewsets.ViewSet):
|
||||||
|
permission_classes = (permissions.ImporterPermission,)
|
||||||
|
|
||||||
|
@list_route(methods=["POST"])
|
||||||
|
def list_users(self, request, *args, **kwargs):
|
||||||
|
self.check_permissions(request, "list_users", None)
|
||||||
|
|
||||||
|
token = request.DATA.get('token', None)
|
||||||
|
project_id = request.DATA.get('project', None)
|
||||||
|
|
||||||
|
if not project_id:
|
||||||
|
raise exc.WrongArguments(_("The project param is needed"))
|
||||||
|
|
||||||
|
importer = TrelloImporter(request.user, token)
|
||||||
|
users = importer.list_users(project_id)
|
||||||
|
for user in users:
|
||||||
|
user['user'] = None
|
||||||
|
if not user['email']:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
taiga_user = User.objects.get(email=user['email'])
|
||||||
|
except User.DoesNotExist:
|
||||||
|
continue
|
||||||
|
|
||||||
|
user['user'] = {
|
||||||
|
'id': taiga_user.id,
|
||||||
|
'full_name': taiga_user.get_full_name(),
|
||||||
|
'gravatar_id': get_user_gravatar_id(taiga_user),
|
||||||
|
'photo': get_user_photo_url(taiga_user),
|
||||||
|
}
|
||||||
|
return response.Ok(users)
|
||||||
|
|
||||||
|
@list_route(methods=["POST"])
|
||||||
|
def list_projects(self, request, *args, **kwargs):
|
||||||
|
self.check_permissions(request, "list_projects", None)
|
||||||
|
token = request.DATA.get('token', None)
|
||||||
|
importer = TrelloImporter(request.user, token)
|
||||||
|
projects = importer.list_projects()
|
||||||
|
return response.Ok(projects)
|
||||||
|
|
||||||
|
@list_route(methods=["POST"])
|
||||||
|
def import_project(self, request, *args, **kwargs):
|
||||||
|
self.check_permissions(request, "import_project", None)
|
||||||
|
|
||||||
|
token = request.DATA.get('token', None)
|
||||||
|
project_id = request.DATA.get('project', None)
|
||||||
|
if not project_id:
|
||||||
|
raise exc.WrongArguments(_("The project param is needed"))
|
||||||
|
|
||||||
|
options = {
|
||||||
|
"name": request.DATA.get('name', None),
|
||||||
|
"description": request.DATA.get('description', None),
|
||||||
|
"template": request.DATA.get('template', "kanban"),
|
||||||
|
"users_bindings": request.DATA.get("users_bindings", {}),
|
||||||
|
"keep_external_reference": request.DATA.get("keep_external_reference", False),
|
||||||
|
"is_private": request.DATA.get("is_private", False),
|
||||||
|
}
|
||||||
|
|
||||||
|
if settings.CELERY_ENABLED:
|
||||||
|
task = tasks.import_project.delay(request.user.id, token, project_id, options)
|
||||||
|
return response.Accepted({"task_id": task.id})
|
||||||
|
|
||||||
|
importer = TrelloImporter(request.user, token)
|
||||||
|
project = importer.import_project(project_id, options)
|
||||||
|
project_data = {
|
||||||
|
"slug": project.slug,
|
||||||
|
"my_permissions": ["view_us"],
|
||||||
|
"is_backlog_activated": project.is_backlog_activated,
|
||||||
|
"is_kanban_activated": project.is_kanban_activated,
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Ok(project_data)
|
||||||
|
|
||||||
|
@list_route(methods=["GET"])
|
||||||
|
def auth_url(self, request, *args, **kwargs):
|
||||||
|
self.check_permissions(request, "auth_url", None)
|
||||||
|
|
||||||
|
(oauth_token, oauth_secret, url) = TrelloImporter.get_auth_url()
|
||||||
|
|
||||||
|
(auth_data, created) = AuthData.objects.get_or_create(
|
||||||
|
user=request.user,
|
||||||
|
key="trello-oauth",
|
||||||
|
defaults={
|
||||||
|
"value": uuid.uuid4().hex,
|
||||||
|
"extra": {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
auth_data.extra = {
|
||||||
|
"oauth_token": oauth_token,
|
||||||
|
"oauth_secret": oauth_secret,
|
||||||
|
}
|
||||||
|
auth_data.save()
|
||||||
|
|
||||||
|
return response.Ok({"url": url})
|
||||||
|
|
||||||
|
@list_route(methods=["POST"])
|
||||||
|
def authorize(self, request, *args, **kwargs):
|
||||||
|
self.check_permissions(request, "authorize", None)
|
||||||
|
|
||||||
|
try:
|
||||||
|
oauth_data = request.user.auth_data.get(key="trello-oauth")
|
||||||
|
oauth_token = oauth_data.extra['oauth_token']
|
||||||
|
oauth_secret = oauth_data.extra['oauth_secret']
|
||||||
|
oauth_verifier = request.DATA.get('code')
|
||||||
|
oauth_data.delete()
|
||||||
|
trello_token = TrelloImporter.get_access_token(oauth_token, oauth_secret, oauth_verifier)['oauth_token']
|
||||||
|
except Exception as e:
|
||||||
|
raise exc.WrongArguments(_("Invalid or expired auth token"))
|
||||||
|
|
||||||
|
return response.Ok({
|
||||||
|
"token": trello_token
|
||||||
|
})
|
|
@ -0,0 +1,537 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright (C) 2014-2016 Andrey Antukh <niwi@niwi.nz>
|
||||||
|
# Copyright (C) 2014-2016 Jesús Espino <jespinog@gmail.com>
|
||||||
|
# Copyright (C) 2014-2016 David Barragán <bameda@dbarragan.com>
|
||||||
|
# Copyright (C) 2014-2016 Alejandro Alonso <alejandro.alonso@kaleidos.net>
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as
|
||||||
|
# published by the Free Software Foundation, either version 3 of the
|
||||||
|
# License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from django.utils.translation import ugettext as _
|
||||||
|
|
||||||
|
from requests_oauthlib import OAuth1Session, OAuth1
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
import requests
|
||||||
|
import webcolors
|
||||||
|
|
||||||
|
from django.template.defaultfilters import slugify
|
||||||
|
from taiga.base import exceptions as exc
|
||||||
|
from taiga.projects.services import projects as projects_service
|
||||||
|
from taiga.projects.models import Project, ProjectTemplate, Membership
|
||||||
|
from taiga.projects.userstories.models import UserStory
|
||||||
|
from taiga.projects.tasks.models import Task
|
||||||
|
from taiga.projects.attachments.models import Attachment
|
||||||
|
from taiga.projects.history.services import (make_diff_from_dicts,
|
||||||
|
make_diff_values,
|
||||||
|
make_key_from_model_object,
|
||||||
|
get_typename_for_model_class,
|
||||||
|
FrozenDiff)
|
||||||
|
from taiga.projects.history.models import HistoryEntry
|
||||||
|
from taiga.projects.history.choices import HistoryType
|
||||||
|
from taiga.projects.custom_attributes.models import UserStoryCustomAttribute
|
||||||
|
from taiga.mdrender.service import render as mdrender
|
||||||
|
from taiga.timeline.rebuilder import rebuild_timeline
|
||||||
|
from taiga.timeline.models import Timeline
|
||||||
|
from taiga.front.templatetags.functions import resolve as resolve_front_url
|
||||||
|
|
||||||
|
from taiga.base import exceptions
|
||||||
|
|
||||||
|
|
||||||
|
class TrelloClient:
|
||||||
|
def __init__(self, api_key, api_secret, token):
|
||||||
|
self.api_key = api_key
|
||||||
|
self.api_secret = api_secret
|
||||||
|
self.token = token
|
||||||
|
if self.token:
|
||||||
|
self.oauth = OAuth1(
|
||||||
|
client_key=self.api_key,
|
||||||
|
client_secret=self.api_secret,
|
||||||
|
resource_owner_key=self.token
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.oauth = None
|
||||||
|
|
||||||
|
def get(self, uri_path, query_params=None):
|
||||||
|
headers = {'Accept': 'application/json'}
|
||||||
|
if query_params is None:
|
||||||
|
query_params = {}
|
||||||
|
|
||||||
|
if uri_path[0] == '/':
|
||||||
|
uri_path = uri_path[1:]
|
||||||
|
url = 'https://api.trello.com/1/%s' % uri_path
|
||||||
|
|
||||||
|
response = requests.get(url, params=query_params, headers=headers, auth=self.oauth)
|
||||||
|
|
||||||
|
if response.status_code == 400:
|
||||||
|
raise exc.WrongArguments(_("Invalid Request: %s at %s") % (response.text, url))
|
||||||
|
if response.status_code == 401:
|
||||||
|
raise exc.AuthenticationFailed(_("Unauthorized: %s at %s") % (response.text, url))
|
||||||
|
if response.status_code == 403:
|
||||||
|
raise exc.PermissionDenied(_("Unauthorized: %s at %s") % (response.text, url))
|
||||||
|
if response.status_code == 404:
|
||||||
|
raise exc.NotFound(_("Resource Unavailable: %s at %s") % (response.text, url))
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise exc.WrongArguments(_("Resource Unavailable: %s at %s") % (response.text, url))
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
class TrelloImporter:
|
||||||
|
def __init__(self, user, token):
|
||||||
|
self._user = user
|
||||||
|
self._cached_orgs = {}
|
||||||
|
self._client = TrelloClient(
|
||||||
|
api_key=settings.TRELLO_API_KEY,
|
||||||
|
api_secret=settings.TRELLO_SECRET_KEY,
|
||||||
|
token=token,
|
||||||
|
)
|
||||||
|
|
||||||
|
def list_projects(self):
|
||||||
|
projects_data = self._client.get("/members/me/boards", {
|
||||||
|
"fields": "id,name,desc,prefs,idOrganization",
|
||||||
|
"organization": "true",
|
||||||
|
"organization_fields": "prefs",
|
||||||
|
})
|
||||||
|
projects = []
|
||||||
|
for project in projects_data:
|
||||||
|
is_private = False
|
||||||
|
if project['prefs']['permissionLevel'] == "private":
|
||||||
|
is_private = True
|
||||||
|
|
||||||
|
if project['prefs']['permissionLevel'] == "org":
|
||||||
|
if 'organization' not in project:
|
||||||
|
is_private = True
|
||||||
|
elif project['organization']['prefs']['permissionLevel'] == "private":
|
||||||
|
is_private = True
|
||||||
|
|
||||||
|
projects.append({
|
||||||
|
"id": project['id'],
|
||||||
|
"name": project['name'],
|
||||||
|
"description": project['desc'],
|
||||||
|
"is_private": is_private,
|
||||||
|
})
|
||||||
|
return projects
|
||||||
|
|
||||||
|
def list_users(self, project_id):
|
||||||
|
members = []
|
||||||
|
for member in self._client.get("/board/{}/members/all".format(project_id), {"fields": "id"}):
|
||||||
|
user = self._client.get("/member/{}".format(member['id']), {"fields": "id,fullName,email"})
|
||||||
|
members.append({
|
||||||
|
"id": user['id'],
|
||||||
|
"full_name": user['fullName'],
|
||||||
|
"email": user['email'],
|
||||||
|
})
|
||||||
|
return members
|
||||||
|
|
||||||
|
def import_project(self, project_id, options):
|
||||||
|
data = self._client.get(
|
||||||
|
"/board/{}".format(project_id),
|
||||||
|
{
|
||||||
|
"fields": "name,desc",
|
||||||
|
"cards": "all",
|
||||||
|
"card_fields": "closed,labels,idList,desc,due,name,pos,dateLastActivity,idChecklists,idMembers,url",
|
||||||
|
"card_attachments": "true",
|
||||||
|
"labels": "all",
|
||||||
|
"labels_limit": "1000",
|
||||||
|
"lists": "all",
|
||||||
|
"list_fields": "closed,name,pos",
|
||||||
|
"members": "none",
|
||||||
|
"checklists": "all",
|
||||||
|
"checklist_fields": "name",
|
||||||
|
"organization": "true",
|
||||||
|
"organization_fields": "logoHash",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
project = self._import_project_data(data, options)
|
||||||
|
self._import_user_stories_data(data, project, options)
|
||||||
|
self._cleanup(project, options)
|
||||||
|
Timeline.objects.filter(project=project).delete()
|
||||||
|
rebuild_timeline(None, None, project.id)
|
||||||
|
return project
|
||||||
|
|
||||||
|
def _import_project_data(self, data, options):
|
||||||
|
board = data
|
||||||
|
labels = board['labels']
|
||||||
|
statuses = board['lists']
|
||||||
|
project_template = ProjectTemplate.objects.get(slug=options.get('template', "kanban"))
|
||||||
|
project_template.us_statuses = []
|
||||||
|
counter = 0
|
||||||
|
for us_status in statuses:
|
||||||
|
if counter == 0:
|
||||||
|
project_template.default_options["us_status"] = us_status['name']
|
||||||
|
|
||||||
|
counter += 1
|
||||||
|
if us_status['name'] not in [s['name'] for s in project_template.us_statuses]:
|
||||||
|
project_template.us_statuses.append({
|
||||||
|
"name": us_status['name'],
|
||||||
|
"slug": slugify(us_status['name']),
|
||||||
|
"is_closed": False,
|
||||||
|
"is_archived": True if us_status['closed'] else False,
|
||||||
|
"color": "#999999",
|
||||||
|
"wip_limit": None,
|
||||||
|
"order": us_status['pos'],
|
||||||
|
})
|
||||||
|
|
||||||
|
project_template.task_statuses = []
|
||||||
|
project_template.task_statuses.append({
|
||||||
|
"name": "Incomplete",
|
||||||
|
"slug": "incomplete",
|
||||||
|
"is_closed": False,
|
||||||
|
"color": "#ff8a84",
|
||||||
|
"order": 1,
|
||||||
|
})
|
||||||
|
project_template.task_statuses.append({
|
||||||
|
"name": "Complete",
|
||||||
|
"slug": "complete",
|
||||||
|
"is_closed": True,
|
||||||
|
"color": "#669900",
|
||||||
|
"order": 2,
|
||||||
|
})
|
||||||
|
project_template.default_options["task_status"] = "Incomplete"
|
||||||
|
project_template.roles.append({
|
||||||
|
"name": "Trello",
|
||||||
|
"slug": "trello",
|
||||||
|
"computable": False,
|
||||||
|
"permissions": project_template.roles[0]['permissions'],
|
||||||
|
"order": 70,
|
||||||
|
})
|
||||||
|
|
||||||
|
tags_colors = []
|
||||||
|
for label in labels:
|
||||||
|
name = label['name']
|
||||||
|
if not name:
|
||||||
|
name = label['color']
|
||||||
|
name = name.lower()
|
||||||
|
color = self._ensure_hex_color(label['color'])
|
||||||
|
tags_colors.append([name, color])
|
||||||
|
|
||||||
|
project = Project(
|
||||||
|
name=options.get('name', None) or board['name'],
|
||||||
|
description=options.get('description', None) or board['desc'],
|
||||||
|
owner=self._user,
|
||||||
|
tags_colors=tags_colors,
|
||||||
|
creation_template=project_template,
|
||||||
|
is_private=options.get('is_private', False),
|
||||||
|
)
|
||||||
|
(can_create, error_message) = projects_service.check_if_project_can_be_created_or_updated(project)
|
||||||
|
if not can_create:
|
||||||
|
raise exceptions.NotEnoughSlotsForProject(project.is_private, 1, error_message)
|
||||||
|
project.save()
|
||||||
|
|
||||||
|
if board.get('organization', None):
|
||||||
|
trello_avatar_template = "https://trello-logos.s3.amazonaws.com/{}/170.png"
|
||||||
|
project_logo_url = trello_avatar_template.format(board['organization']['logoHash'])
|
||||||
|
data = requests.get(project_logo_url)
|
||||||
|
project.logo.save("logo.png", ContentFile(data.content), save=True)
|
||||||
|
|
||||||
|
UserStoryCustomAttribute.objects.create(
|
||||||
|
name="Due",
|
||||||
|
description="Due date",
|
||||||
|
type="date",
|
||||||
|
order=1,
|
||||||
|
project=project
|
||||||
|
)
|
||||||
|
for user in options.get('users_bindings', {}).values():
|
||||||
|
Membership.objects.create(
|
||||||
|
user=user,
|
||||||
|
project=project,
|
||||||
|
role=project.get_roles().get(slug="trello"),
|
||||||
|
is_admin=False,
|
||||||
|
invited_by=self._user,
|
||||||
|
)
|
||||||
|
return project
|
||||||
|
|
||||||
|
def _import_user_stories_data(self, data, project, options):
|
||||||
|
users_bindings = options.get('users_bindings', {})
|
||||||
|
statuses = {s['id']: s for s in data['lists']}
|
||||||
|
cards = data['cards']
|
||||||
|
due_date_field = project.userstorycustomattributes.first()
|
||||||
|
|
||||||
|
for card in cards:
|
||||||
|
if card['closed'] and not options.get("import_closed_data", False):
|
||||||
|
continue
|
||||||
|
if statuses[card['idList']]['closed'] and not options.get("import_closed_data", False):
|
||||||
|
continue
|
||||||
|
|
||||||
|
tags = []
|
||||||
|
for tag in card['labels']:
|
||||||
|
name = tag['name']
|
||||||
|
if not name:
|
||||||
|
name = tag['color']
|
||||||
|
name = name.lower()
|
||||||
|
tags.append(name)
|
||||||
|
|
||||||
|
assigned_to = None
|
||||||
|
if len(card['idMembers']) > 0:
|
||||||
|
assigned_to = users_bindings.get(card['idMembers'][0], None)
|
||||||
|
|
||||||
|
external_reference = None
|
||||||
|
if options.get('keep_external_reference', False):
|
||||||
|
external_reference = ["trello", card['url']]
|
||||||
|
|
||||||
|
us = UserStory.objects.create(
|
||||||
|
project=project,
|
||||||
|
owner=self._user,
|
||||||
|
assigned_to=assigned_to,
|
||||||
|
status=project.us_statuses.get(name=statuses[card['idList']]['name']),
|
||||||
|
kanban_order=card['pos'],
|
||||||
|
sprint_order=card['pos'],
|
||||||
|
backlog_order=card['pos'],
|
||||||
|
subject=card['name'],
|
||||||
|
description=card['desc'],
|
||||||
|
tags=tags,
|
||||||
|
external_reference=external_reference
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(card['idMembers']) > 1:
|
||||||
|
for watcher in card['idMembers'][1:]:
|
||||||
|
watcher_user = users_bindings.get(watcher, None)
|
||||||
|
if watcher_user:
|
||||||
|
us.add_watcher(watcher_user)
|
||||||
|
|
||||||
|
if card['due']:
|
||||||
|
us.custom_attributes_values.attributes_values = {due_date_field.id: card['due']}
|
||||||
|
us.custom_attributes_values.save()
|
||||||
|
|
||||||
|
UserStory.objects.filter(id=us.id).update(
|
||||||
|
modified_date=card['dateLastActivity'],
|
||||||
|
created_date=card['dateLastActivity']
|
||||||
|
)
|
||||||
|
self._import_attachments(us, card, options)
|
||||||
|
self._import_tasks(data, us, card)
|
||||||
|
self._import_actions(us, card, statuses, options)
|
||||||
|
|
||||||
|
def _import_tasks(self, data, us, card):
|
||||||
|
checklists_by_id = {c['id']: c for c in data['checklists']}
|
||||||
|
for checklist_id in card['idChecklists']:
|
||||||
|
for item in checklists_by_id.get(checklist_id, {}).get('checkItems', []):
|
||||||
|
Task.objects.create(
|
||||||
|
subject=item['name'],
|
||||||
|
status=us.project.task_statuses.get(slug=item['state']),
|
||||||
|
project=us.project,
|
||||||
|
user_story=us
|
||||||
|
)
|
||||||
|
|
||||||
|
def _import_attachments(self, us, card, options):
|
||||||
|
users_bindings = options.get('users_bindings', {})
|
||||||
|
for attachment in card['attachments']:
|
||||||
|
if attachment['bytes'] is None:
|
||||||
|
continue
|
||||||
|
data = requests.get(attachment['url'])
|
||||||
|
att = Attachment(
|
||||||
|
owner=users_bindings.get(attachment['idMember'], self._user),
|
||||||
|
project=us.project,
|
||||||
|
content_type=ContentType.objects.get_for_model(UserStory),
|
||||||
|
object_id=us.id,
|
||||||
|
name=attachment['name'],
|
||||||
|
size=attachment['bytes'],
|
||||||
|
created_date=attachment['date'],
|
||||||
|
is_deprecated=False,
|
||||||
|
)
|
||||||
|
att.attached_file.save(attachment['name'], ContentFile(data.content), save=True)
|
||||||
|
|
||||||
|
UserStory.objects.filter(id=us.id, created_date__gt=attachment['date']).update(
|
||||||
|
created_date=attachment['date']
|
||||||
|
)
|
||||||
|
|
||||||
|
def _import_actions(self, us, card, statuses, options):
|
||||||
|
included_actions = [
|
||||||
|
"addAttachmentToCard", "addMemberToCard", "commentCard",
|
||||||
|
"convertToCardFromCheckItem", "copyCommentCard", "createCard",
|
||||||
|
"deleteAttachmentFromCard", "deleteCard", "removeMemberFromCard",
|
||||||
|
"updateCard",
|
||||||
|
]
|
||||||
|
|
||||||
|
actions = self._client.get(
|
||||||
|
"/card/{}/actions".format(card['id']),
|
||||||
|
{
|
||||||
|
"filter": ",".join(included_actions),
|
||||||
|
"limit": "1000",
|
||||||
|
"memberCreator": "true",
|
||||||
|
"memberCreator_fields": "fullName",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
while actions:
|
||||||
|
for action in actions:
|
||||||
|
self._import_action(us, action, statuses, options)
|
||||||
|
actions = self._client.get(
|
||||||
|
"/card/{}/actions".format(card['id']),
|
||||||
|
{
|
||||||
|
"filter": ",".join(included_actions),
|
||||||
|
"limit": "1000",
|
||||||
|
"since": "lastView",
|
||||||
|
"before": action['date'],
|
||||||
|
"memberCreator": "true",
|
||||||
|
"memberCreator_fields": "fullName",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def _import_action(self, us, action, statuses, options):
|
||||||
|
key = make_key_from_model_object(us)
|
||||||
|
typename = get_typename_for_model_class(UserStory)
|
||||||
|
action_data = self._transform_action_data(us, action, statuses, options)
|
||||||
|
if action_data is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
change_old = action_data['change_old']
|
||||||
|
change_new = action_data['change_new']
|
||||||
|
hist_type = action_data['hist_type']
|
||||||
|
comment = action_data['comment']
|
||||||
|
user = action_data['user']
|
||||||
|
|
||||||
|
diff = make_diff_from_dicts(change_old, change_new)
|
||||||
|
fdiff = FrozenDiff(key, diff, {})
|
||||||
|
|
||||||
|
entry = HistoryEntry.objects.create(
|
||||||
|
user=user,
|
||||||
|
project_id=us.project.id,
|
||||||
|
key=key,
|
||||||
|
type=hist_type,
|
||||||
|
snapshot=None,
|
||||||
|
diff=fdiff.diff,
|
||||||
|
values=make_diff_values(typename, fdiff),
|
||||||
|
comment=comment,
|
||||||
|
comment_html=mdrender(us.project, comment),
|
||||||
|
is_hidden=False,
|
||||||
|
is_snapshot=False,
|
||||||
|
)
|
||||||
|
HistoryEntry.objects.filter(id=entry.id).update(created_at=action['date'])
|
||||||
|
return HistoryEntry.objects.get(id=entry.id)
|
||||||
|
|
||||||
|
def _transform_action_data(self, us, action, statuses, options):
|
||||||
|
users_bindings = options.get('users_bindings', {})
|
||||||
|
due_date_field = us.project.userstorycustomattributes.first()
|
||||||
|
|
||||||
|
ignored_actions = ["addAttachmentToCard", "addMemberToCard",
|
||||||
|
"deleteAttachmentFromCard", "deleteCard",
|
||||||
|
"removeMemberFromCard"]
|
||||||
|
|
||||||
|
if action['type'] in ignored_actions:
|
||||||
|
return None
|
||||||
|
|
||||||
|
user = {"pk": None, "name": action.get('memberCreator', {}).get('fullName', None)}
|
||||||
|
taiga_user = users_bindings.get(action.get('memberCreator', {}).get('id', None), None)
|
||||||
|
if taiga_user:
|
||||||
|
user = {"pk": taiga_user.id, "name": taiga_user.get_full_name()}
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"change_old": {},
|
||||||
|
"change_new": {},
|
||||||
|
"hist_type": HistoryType.change,
|
||||||
|
"comment": "",
|
||||||
|
"user": user
|
||||||
|
}
|
||||||
|
|
||||||
|
if action['type'] == "commentCard":
|
||||||
|
result['comment'] = str(action['data']['text'])
|
||||||
|
elif action['type'] == "convertToCardFromCheckItem":
|
||||||
|
UserStory.objects.filter(id=us.id, created_date__gt=action['date']).update(
|
||||||
|
created_date=action['date'],
|
||||||
|
owner=users_bindings.get(action["idMemberCreator"], self._user)
|
||||||
|
)
|
||||||
|
result['hist_type'] = HistoryType.create
|
||||||
|
elif action['type'] == "copyCommentCard":
|
||||||
|
UserStory.objects.filter(id=us.id, created_date__gt=action['date']).update(
|
||||||
|
created_date=action['date'],
|
||||||
|
owner=users_bindings.get(action["idMemberCreator"], self._user)
|
||||||
|
)
|
||||||
|
result['hist_type'] = HistoryType.create
|
||||||
|
elif action['type'] == "createCard":
|
||||||
|
UserStory.objects.filter(id=us.id, created_date__gt=action['date']).update(
|
||||||
|
created_date=action['date'],
|
||||||
|
owner=users_bindings.get(action["idMemberCreator"], self._user)
|
||||||
|
)
|
||||||
|
result['hist_type'] = HistoryType.create
|
||||||
|
elif action['type'] == "updateCard":
|
||||||
|
if 'desc' in action['data']['old']:
|
||||||
|
result['change_old']["description"] = str(action['data']['old'].get('desc', ''))
|
||||||
|
result['change_new']["description"] = str(action['data']['card'].get('desc', ''))
|
||||||
|
result['change_old']["description_html"] = mdrender(us.project, str(action['data']['old'].get('desc', '')))
|
||||||
|
result['change_new']["description_html"] = mdrender(us.project, str(action['data']['card'].get('desc', '')))
|
||||||
|
if 'idList' in action['data']['old']:
|
||||||
|
old_status_name = statuses[action['data']['old']['idList']]['name']
|
||||||
|
result['change_old']["status"] = us.project.us_statuses.get(name=old_status_name).id
|
||||||
|
new_status_name = statuses[action['data']['card']['idList']]['name']
|
||||||
|
result['change_new']["status"] = us.project.us_statuses.get(name=new_status_name).id
|
||||||
|
if 'name' in action['data']['old']:
|
||||||
|
result['change_old']["subject"] = action['data']['old']['name']
|
||||||
|
result['change_new']["subject"] = action['data']['card']['name']
|
||||||
|
if 'due' in action['data']['old']:
|
||||||
|
result['change_old']["custom_attributes"] = [{
|
||||||
|
"name": "Due",
|
||||||
|
"value": action['data']['old']['due'],
|
||||||
|
"id": due_date_field.id
|
||||||
|
}]
|
||||||
|
result['change_new']["custom_attributes"] = [{
|
||||||
|
"name": "Due",
|
||||||
|
"value": action['data']['card']['due'],
|
||||||
|
"id": due_date_field.id
|
||||||
|
}]
|
||||||
|
|
||||||
|
if result['change_old'] == {}:
|
||||||
|
return None
|
||||||
|
return result
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_auth_url(cls):
|
||||||
|
request_token_url = 'https://trello.com/1/OAuthGetRequestToken'
|
||||||
|
authorize_url = 'https://trello.com/1/OAuthAuthorizeToken'
|
||||||
|
return_url = resolve_front_url("new-project-import", "trello")
|
||||||
|
expiration = "1day"
|
||||||
|
scope = "read,write,account"
|
||||||
|
trello_key = settings.TRELLO_API_KEY
|
||||||
|
trello_secret = settings.TRELLO_SECRET_KEY
|
||||||
|
name = "Taiga"
|
||||||
|
|
||||||
|
session = OAuth1Session(client_key=trello_key, client_secret=trello_secret)
|
||||||
|
response = session.fetch_request_token(request_token_url)
|
||||||
|
oauth_token, oauth_token_secret = response.get('oauth_token'), response.get('oauth_token_secret')
|
||||||
|
|
||||||
|
return (
|
||||||
|
oauth_token,
|
||||||
|
oauth_token_secret,
|
||||||
|
"{authorize_url}?oauth_token={oauth_token}&scope={scope}&expiration={expiration}&name={name}&return_url={return_url}".format(
|
||||||
|
authorize_url=authorize_url,
|
||||||
|
oauth_token=oauth_token,
|
||||||
|
expiration=expiration,
|
||||||
|
scope=scope,
|
||||||
|
name=name,
|
||||||
|
return_url=return_url,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_access_token(cls, oauth_token, oauth_token_secret, oauth_verifier):
|
||||||
|
api_key = settings.TRELLO_API_KEY
|
||||||
|
api_secret = settings.TRELLO_SECRET_KEY
|
||||||
|
access_token_url = 'https://trello.com/1/OAuthGetAccessToken'
|
||||||
|
session = OAuth1Session(client_key=api_key, client_secret=api_secret,
|
||||||
|
resource_owner_key=oauth_token, resource_owner_secret=oauth_token_secret,
|
||||||
|
verifier=oauth_verifier)
|
||||||
|
access_token = session.fetch_access_token(access_token_url)
|
||||||
|
return access_token
|
||||||
|
|
||||||
|
def _ensure_hex_color(self, color):
|
||||||
|
if color is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return webcolors.name_to_hex(color)
|
||||||
|
except ValueError:
|
||||||
|
return color
|
||||||
|
|
||||||
|
def _cleanup(self, project, options):
|
||||||
|
if not options.get("import_closed_data", False):
|
||||||
|
project.us_statuses.filter(is_archived=True).delete()
|
|
@ -0,0 +1,56 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright (C) 2014-2016 Andrey Antukh <niwi@niwi.nz>
|
||||||
|
# Copyright (C) 2014-2016 Jesús Espino <jespinog@gmail.com>
|
||||||
|
# Copyright (C) 2014-2016 David Barragán <bameda@dbarragan.com>
|
||||||
|
# Copyright (C) 2014-2016 Alejandro Alonso <alejandro.alonso@kaleidos.net>
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as
|
||||||
|
# published by the Free Software Foundation, either version 3 of the
|
||||||
|
# License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from django.utils.translation import ugettext as _
|
||||||
|
|
||||||
|
from taiga.base.mails import mail_builder
|
||||||
|
from taiga.users.models import User
|
||||||
|
from taiga.celery import app
|
||||||
|
from .importer import TrelloImporter
|
||||||
|
|
||||||
|
logger = logging.getLogger('taiga.importers.trello')
|
||||||
|
|
||||||
|
|
||||||
|
@app.task(bind=True)
|
||||||
|
def import_project(self, user_id, token, project_id, options):
|
||||||
|
user = User.object.get(id=user_id)
|
||||||
|
importer = TrelloImporter(user, token)
|
||||||
|
try:
|
||||||
|
project = importer.import_project(project_id, options)
|
||||||
|
except Exception as e:
|
||||||
|
# Error
|
||||||
|
ctx = {
|
||||||
|
"user": user,
|
||||||
|
"error_subject": _("Error importing trello project"),
|
||||||
|
"error_message": _("Error importing trello project"),
|
||||||
|
"project": project_id,
|
||||||
|
"exception": e
|
||||||
|
}
|
||||||
|
email = mail_builder.trello_import_error(admin, ctx)
|
||||||
|
email.send()
|
||||||
|
logger.error('Error importing trello project %s (by %s)', project_id, user, exc_info=sys.exc_info())
|
||||||
|
else:
|
||||||
|
ctx = {
|
||||||
|
"project": project,
|
||||||
|
"user": user,
|
||||||
|
}
|
||||||
|
email = mail_builder.trello_import_success(user, ctx)
|
||||||
|
email.send()
|
|
@ -0,0 +1,21 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by Django 1.9.2 on 2016-11-08 11:19
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
import django.contrib.postgres.fields
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('epics', '0004_auto_20160928_0540'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='epic',
|
||||||
|
name='external_reference',
|
||||||
|
field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), blank=True, default=None, null=True, size=None, verbose_name='external reference'),
|
||||||
|
),
|
||||||
|
]
|
|
@ -18,6 +18,7 @@
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.contrib.contenttypes.fields import GenericRelation
|
from django.contrib.contenttypes.fields import GenericRelation
|
||||||
|
from django.contrib.postgres.fields import ArrayField
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
@ -67,6 +68,8 @@ class Epic(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, models.M
|
||||||
user_stories = models.ManyToManyField("userstories.UserStory", related_name="epics",
|
user_stories = models.ManyToManyField("userstories.UserStory", related_name="epics",
|
||||||
through='RelatedUserStory',
|
through='RelatedUserStory',
|
||||||
verbose_name=_("user stories"))
|
verbose_name=_("user stories"))
|
||||||
|
external_reference = ArrayField(models.TextField(null=False, blank=False),
|
||||||
|
null=True, blank=True, default=None, verbose_name=_("external reference"))
|
||||||
|
|
||||||
attachments = GenericRelation("attachments.Attachment")
|
attachments = GenericRelation("attachments.Attachment")
|
||||||
|
|
||||||
|
|
|
@ -68,6 +68,19 @@ def make_reference(instance, project, create=False):
|
||||||
return refval, refinstance
|
return refval, refinstance
|
||||||
|
|
||||||
|
|
||||||
|
def recalc_reference_counter(project):
|
||||||
|
seqname = make_sequence_name(project)
|
||||||
|
max_ref_us = project.user_stories.all().aggregate(max=models.Max('ref'))
|
||||||
|
max_ref_task = project.tasks.all().aggregate(max=models.Max('ref'))
|
||||||
|
max_ref_issue = project.issues.all().aggregate(max=models.Max('ref'))
|
||||||
|
max_references = list(filter(lambda x: x is not None, [max_ref_us['max'], max_ref_task['max'], max_ref_issue['max']]))
|
||||||
|
|
||||||
|
max_value = 0
|
||||||
|
if len(max_references) > 0:
|
||||||
|
max_value = max(max_references)
|
||||||
|
seq.set_max(seqname, max_value)
|
||||||
|
|
||||||
|
|
||||||
def create_sequence(sender, instance, created, **kwargs):
|
def create_sequence(sender, instance, created, **kwargs):
|
||||||
if not created:
|
if not created:
|
||||||
return
|
return
|
||||||
|
|
|
@ -106,6 +106,7 @@ class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixi
|
||||||
|
|
||||||
include_attachments = "include_attachments" in self.request.QUERY_PARAMS
|
include_attachments = "include_attachments" in self.request.QUERY_PARAMS
|
||||||
include_tasks = "include_tasks" in self.request.QUERY_PARAMS
|
include_tasks = "include_tasks" in self.request.QUERY_PARAMS
|
||||||
|
|
||||||
epic_id = self.request.QUERY_PARAMS.get("epic", None)
|
epic_id = self.request.QUERY_PARAMS.get("epic", None)
|
||||||
# We can be filtering by more than one epic so epic_id can consist
|
# We can be filtering by more than one epic so epic_id can consist
|
||||||
# of different ids separete by comma. In that situation we will use
|
# of different ids separete by comma. In that situation we will use
|
||||||
|
|
|
@ -283,6 +283,11 @@ from taiga.external_apps.api import Application, ApplicationToken
|
||||||
router.register(r"applications", Application, base_name="applications")
|
router.register(r"applications", Application, base_name="applications")
|
||||||
router.register(r"application-tokens", ApplicationToken, base_name="application-tokens")
|
router.register(r"application-tokens", ApplicationToken, base_name="application-tokens")
|
||||||
|
|
||||||
|
# Third party importers
|
||||||
|
from taiga.importers.trello.api import TrelloImporterViewSet
|
||||||
|
|
||||||
|
router.register(r"importers/trello", TrelloImporterViewSet, base_name="importers-trello")
|
||||||
|
|
||||||
|
|
||||||
# Stats
|
# Stats
|
||||||
# - see taiga.stats.routers and taiga.stats.apps
|
# - see taiga.stats.routers and taiga.stats.apps
|
||||||
|
|
|
@ -0,0 +1,244 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright (C) 2014-2016 Andrey Antukh <niwi@niwi.nz>
|
||||||
|
# Copyright (C) 2014-2016 Jesús Espino <jespinog@gmail.com>
|
||||||
|
# Copyright (C) 2014-2016 David Barragán <bameda@dbarragan.com>
|
||||||
|
# Copyright (C) 2014-2016 Alejandro Alonso <alejandro.alonso@kaleidos.net>
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as
|
||||||
|
# published by the Free Software Foundation, either version 3 of the
|
||||||
|
# License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import json
|
||||||
|
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.core.urlresolvers import reverse
|
||||||
|
|
||||||
|
from .. import factories as f
|
||||||
|
from taiga.base.utils import json
|
||||||
|
from taiga.base import exceptions as exc
|
||||||
|
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_url(client):
|
||||||
|
user = f.UserFactory.create()
|
||||||
|
client.login(user)
|
||||||
|
|
||||||
|
url = reverse("importers-trello-auth-url")
|
||||||
|
|
||||||
|
with mock.patch('taiga.importers.trello.importer.OAuth1Session') as OAuth1SessionMock:
|
||||||
|
session = mock.Mock()
|
||||||
|
session.fetch_request_token.return_value = {"oauth_token": "token", "oauth_token_secret": "token"}
|
||||||
|
OAuth1SessionMock.return_value = session
|
||||||
|
|
||||||
|
response = client.get(url, content_type="application/json")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert 'url' in response.data
|
||||||
|
assert response.data['url'] == "https://trello.com/1/OAuthAuthorizeToken?oauth_token=token&scope=read,write,account&expiration=1day&name=Taiga&return_url=http://localhost:9001/project/new/import/trello"
|
||||||
|
|
||||||
|
|
||||||
|
def test_authorize(client):
|
||||||
|
user = f.UserFactory.create()
|
||||||
|
client.login(user)
|
||||||
|
|
||||||
|
url = reverse("importers-trello-auth-url")
|
||||||
|
authorize_url = reverse("importers-trello-authorize")
|
||||||
|
|
||||||
|
with mock.patch('taiga.importers.trello.importer.OAuth1Session') as OAuth1SessionMock:
|
||||||
|
session = mock.Mock()
|
||||||
|
session.fetch_request_token.return_value = {"oauth_token": "token", "oauth_token_secret": "token"}
|
||||||
|
session.fetch_access_token.return_value = {"oauth_token": "token", "oauth_token_secret": "token"}
|
||||||
|
OAuth1SessionMock.return_value = session
|
||||||
|
|
||||||
|
client.get(url, content_type="application/json")
|
||||||
|
response = client.post(authorize_url, content_type="application/json", data=json.dumps({"oauth_verifier": "token"}))
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert 'token' in response.data
|
||||||
|
assert response.data['token'] == "token"
|
||||||
|
|
||||||
|
def test_authorize_without_token_and_secret(client):
|
||||||
|
user = f.UserFactory.create()
|
||||||
|
client.login(user)
|
||||||
|
|
||||||
|
authorize_url = reverse("importers-trello-authorize")
|
||||||
|
|
||||||
|
with mock.patch('taiga.importers.trello.importer.OAuth1Session') as OAuth1SessionMock:
|
||||||
|
session = mock.Mock()
|
||||||
|
session.fetch_access_token.return_value = {"oauth_token": "token", "oauth_token_secret": "token"}
|
||||||
|
OAuth1SessionMock.return_value = session
|
||||||
|
|
||||||
|
response = client.post(authorize_url, content_type="application/json", data=json.dumps({"oauth_verifier": "token"}))
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert 'token' not in response.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_authorize_with_bad_verify(client):
|
||||||
|
user = f.UserFactory.create()
|
||||||
|
client.login(user)
|
||||||
|
|
||||||
|
url = reverse("importers-trello-auth-url")
|
||||||
|
authorize_url = reverse("importers-trello-authorize")
|
||||||
|
|
||||||
|
with mock.patch('taiga.importers.trello.importer.OAuth1Session') as OAuth1SessionMock:
|
||||||
|
session = mock.Mock()
|
||||||
|
session.fetch_request_token.return_value = {"oauth_token": "token", "oauth_token_secret": "token"}
|
||||||
|
session.fetch_access_token.side_effect = Exception("Bad Token")
|
||||||
|
OAuth1SessionMock.return_value = session
|
||||||
|
|
||||||
|
client.get(url, content_type="application/json")
|
||||||
|
response = client.post(authorize_url, content_type="application/json", data=json.dumps({"oauth_verifier": "token"}))
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert 'token' not in response.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_trello_list_users(client, settings):
|
||||||
|
user = f.UserFactory.create()
|
||||||
|
client.login(user)
|
||||||
|
|
||||||
|
url = reverse("importers-trello-list-users")
|
||||||
|
|
||||||
|
with mock.patch('taiga.importers.trello.api.TrelloImporter') as TrelloImporterMock:
|
||||||
|
instance = mock.Mock()
|
||||||
|
instance.list_users.return_value = [
|
||||||
|
{"id": 1, "fullName": "user1", "email": None},
|
||||||
|
{"id": 2, "fullName": "user2", "email": None}
|
||||||
|
]
|
||||||
|
TrelloImporterMock.return_value = instance
|
||||||
|
response = client.post(url, content_type="application/json", data=json.dumps({"token": "token", "project": 1}))
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.data[0]["id"] == 1
|
||||||
|
assert response.data[1]["id"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_trello_list_users_without_project(client, settings):
|
||||||
|
user = f.UserFactory.create()
|
||||||
|
client.login(user)
|
||||||
|
|
||||||
|
url = reverse("importers-trello-list-users")
|
||||||
|
|
||||||
|
with mock.patch('taiga.importers.trello.api.TrelloImporter') as TrelloImporterMock:
|
||||||
|
instance = mock.Mock()
|
||||||
|
instance.list_users.return_value = [
|
||||||
|
{"id": 1, "fullName": "user1", "email": None},
|
||||||
|
{"id": 2, "fullName": "user2", "email": None}
|
||||||
|
]
|
||||||
|
TrelloImporterMock.return_value = instance
|
||||||
|
response = client.post(url, content_type="application/json", data=json.dumps({"token": "token"}))
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_trello_list_users_with_problem_on_request(client, settings):
|
||||||
|
user = f.UserFactory.create()
|
||||||
|
client.login(user)
|
||||||
|
|
||||||
|
url = reverse("importers-trello-list-users")
|
||||||
|
|
||||||
|
with mock.patch('taiga.importers.trello.importer.TrelloClient') as TrelloClientMock:
|
||||||
|
instance = mock.Mock()
|
||||||
|
instance.get.side_effect = exc.WrongArguments("Invalid Request")
|
||||||
|
TrelloClientMock.return_value = instance
|
||||||
|
response = client.post(url, content_type="application/json", data=json.dumps({"token": "token", "project": 1}))
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_trello_list_projects(client, settings):
|
||||||
|
user = f.UserFactory.create()
|
||||||
|
client.login(user)
|
||||||
|
|
||||||
|
url = reverse("importers-trello-list-projects")
|
||||||
|
|
||||||
|
with mock.patch('taiga.importers.trello.api.TrelloImporter') as TrelloImporterMock:
|
||||||
|
instance = mock.Mock()
|
||||||
|
instance.list_projects.return_value = ["project1", "project2"]
|
||||||
|
TrelloImporterMock.return_value = instance
|
||||||
|
response = client.post(url, content_type="application/json", data=json.dumps({"token": "token"}))
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.data[0] == "project1"
|
||||||
|
assert response.data[1] == "project2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_trello_list_projects_with_problem_on_request(client, settings):
|
||||||
|
user = f.UserFactory.create()
|
||||||
|
client.login(user)
|
||||||
|
|
||||||
|
url = reverse("importers-trello-list-projects")
|
||||||
|
|
||||||
|
with mock.patch('taiga.importers.trello.importer.TrelloClient') as TrelloClientMock:
|
||||||
|
instance = mock.Mock()
|
||||||
|
instance.get.side_effect = exc.WrongArguments("Invalid Request")
|
||||||
|
TrelloClientMock.return_value = instance
|
||||||
|
response = client.post(url, content_type="application/json", data=json.dumps({"token": "token"}))
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_trello_project_without_project_id(client, settings):
|
||||||
|
settings.CELERY_ENABLED = True
|
||||||
|
|
||||||
|
user = f.UserFactory.create()
|
||||||
|
client.login(user)
|
||||||
|
|
||||||
|
url = reverse("importers-trello-import-project")
|
||||||
|
|
||||||
|
with mock.patch('taiga.importers.trello.tasks.TrelloImporter') as TrelloImporterMock:
|
||||||
|
response = client.post(url, content_type="application/json", data=json.dumps({"token": "token"}))
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_trello_project_with_celery_enabled(client, settings):
|
||||||
|
settings.CELERY_ENABLED = True
|
||||||
|
|
||||||
|
user = f.UserFactory.create()
|
||||||
|
project = f.ProjectFactory.create(slug="async-imported-project")
|
||||||
|
client.login(user)
|
||||||
|
|
||||||
|
url = reverse("importers-trello-import-project")
|
||||||
|
|
||||||
|
with mock.patch('taiga.importers.trello.tasks.TrelloImporter') as TrelloImporterMock:
|
||||||
|
instance = mock.Mock()
|
||||||
|
instance.import_project.return_value = project
|
||||||
|
TrelloImporterMock.return_value = instance
|
||||||
|
response = client.post(url, content_type="application/json", data=json.dumps({"token": "token", "project": 1}))
|
||||||
|
|
||||||
|
assert response.status_code == 202
|
||||||
|
assert "task_id" in response.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_trello_project_with_celery_disabled(client, settings):
|
||||||
|
settings.CELERY_ENABLED = False
|
||||||
|
|
||||||
|
user = f.UserFactory.create()
|
||||||
|
project = f.ProjectFactory.create(slug="imported-project")
|
||||||
|
client.login(user)
|
||||||
|
|
||||||
|
url = reverse("importers-trello-import-project")
|
||||||
|
|
||||||
|
with mock.patch('taiga.importers.trello.api.TrelloImporter') as TrelloImporterMock:
|
||||||
|
instance = mock.Mock()
|
||||||
|
instance.import_project.return_value = project
|
||||||
|
TrelloImporterMock.return_value = instance
|
||||||
|
response = client.post(url, content_type="application/json", data=json.dumps({"token": "token", "project": 1}))
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "slug" in response.data
|
||||||
|
assert response.data['slug'] == "imported-project"
|
Loading…
Reference in New Issue