From 723034d37343df87f0b16861e43f8ba2834de2d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Espino?= Date: Wed, 18 Jan 2017 09:39:10 +0100 Subject: [PATCH] Add jira importer --- settings/common.py | 4 + taiga/importers/jira/agile.py | 357 +++++++++ taiga/importers/jira/api.py | 229 ++++++ taiga/importers/jira/common.py | 748 ++++++++++++++++++ taiga/importers/jira/normal.py | 439 ++++++++++ taiga/importers/jira/tasks.py | 61 ++ .../management/commands/import_from_jira.py | 146 ++++ taiga/routers.py | 2 + tests/integration/test_importers_jira_api.py | 259 ++++++ 9 files changed, 2245 insertions(+) create mode 100644 taiga/importers/jira/agile.py create mode 100644 taiga/importers/jira/api.py create mode 100644 taiga/importers/jira/common.py create mode 100644 taiga/importers/jira/normal.py create mode 100644 taiga/importers/jira/tasks.py create mode 100644 taiga/importers/management/commands/import_from_jira.py create mode 100644 tests/integration/test_importers_jira_api.py diff --git a/settings/common.py b/settings/common.py index 2e0cd3ad..7a894969 100644 --- a/settings/common.py +++ b/settings/common.py @@ -565,6 +565,10 @@ from .sr import * TRELLO_API_KEY = "" TRELLO_SECRET_KEY = "" +JIRA_CONSUMER_KEY = "" +JIRA_CERT = "" +JIRA_PUB_CERT = "" + # NOTE: DON'T INSERT MORE SETTINGS AFTER THIS LINE TEST_RUNNER="django.test.runner.DiscoverRunner" diff --git a/taiga/importers/jira/agile.py b/taiga/importers/jira/agile.py new file mode 100644 index 00000000..0f4d70cf --- /dev/null +++ b/taiga/importers/jira/agile.py @@ -0,0 +1,357 @@ +import datetime + +from django.template.defaultfilters import slugify +from taiga.projects.references.models import recalc_reference_counter +from taiga.projects.models import Project, ProjectTemplate, Membership, Points +from taiga.projects.userstories.models import UserStory, RolePoints +from taiga.projects.tasks.models import Task +from taiga.projects.milestones.models import Milestone +from taiga.projects.epics.models import Epic, RelatedUserStory +from taiga.projects.history.services import take_snapshot +from taiga.timeline.rebuilder import rebuild_timeline +from taiga.timeline.models import Timeline +from .common import JiraImporterCommon + + +class JiraAgileImporter(JiraImporterCommon): + def list_projects(self): + return [{"id": board['id'], + "name": board['name'], + "description": "", + "is_private": True, + "importer_type": "agile"} for board in self._client.get_agile('/board')['values']] + + def list_issue_types(self, project_id): + board_project = self._client.get_agile("/board/{}/project".format(project_id))['values'][0] + statuses = self._client.get("/project/{}/statuses".format(board_project['id'])) + return statuses + + def import_project(self, project_id, options=None): + self.resolve_user_bindings(options) + project = self._import_project_data(project_id, options) + self._import_epics_data(project_id, project, options) + self._import_user_stories_data(project_id, project, options) + self._cleanup(project, options) + Timeline.objects.filter(project=project).delete() + rebuild_timeline(None, None, project.id) + recalc_reference_counter(project) + return project + + def _import_project_data(self, project_id, options): + project = self._client.get_agile("/board/{}".format(project_id)) + project_config = self._client.get_agile("/board/{}/configuration".format(project_id)) + if project['type'] == "scrum": + project_template = ProjectTemplate.objects.get(slug="scrum") + options['type'] = "scrum" + elif project['type'] == "kanban": + project_template = ProjectTemplate.objects.get(slug="kanban") + options['type'] = "kanban" + + project_template.is_epics_activated = True + project_template.epic_statuses = [] + project_template.us_statuses = [] + project_template.task_statuses = [] + project_template.issue_statuses = [] + + counter = 0 + for column in project_config['columnConfig']['columns']: + project_template.epic_statuses.append({ + "name": column['name'], + "slug": slugify(column['name']), + "is_closed": False, + "is_archived": False, + "color": "#999999", + "wip_limit": None, + "order": counter, + }) + project_template.us_statuses.append({ + "name": column['name'], + "slug": slugify(column['name']), + "is_closed": False, + "is_archived": False, + "color": "#999999", + "wip_limit": None, + "order": counter, + }) + project_template.task_statuses.append({ + "name": column['name'], + "slug": slugify(column['name']), + "is_closed": False, + "is_archived": False, + "color": "#999999", + "wip_limit": None, + "order": counter, + }) + project_template.issue_statuses.append({ + "name": column['name'], + "slug": slugify(column['name']), + "is_closed": False, + "is_archived": False, + "color": "#999999", + "wip_limit": None, + "order": counter, + }) + counter += 1 + + project_template.default_options["epic_status"] = project_template.epic_statuses[0]['name'] + project_template.default_options["us_status"] = project_template.us_statuses[0]['name'] + project_template.default_options["task_status"] = project_template.task_statuses[0]['name'] + project_template.default_options["issue_status"] = project_template.issue_statuses[0]['name'] + + project_template.points = [{ + "value": None, + "name": "?", + "order": 0, + }] + + main_permissions = project_template.roles[0]['permissions'] + project_template.roles = [{ + "name": "Main", + "slug": "main", + "computable": True, + "permissions": main_permissions, + "order": 70, + }] + + project = Project.objects.create( + name=options.get('name', None) or project['name'], + description=options.get('description', None) or project.get('description', ''), + owner=self._user, + creation_template=project_template, + is_private=options.get('is_private', False), + ) + + self._create_custom_fields(project) + + for user in options.get('users_bindings', {}).values(): + if user != self._user: + Membership.objects.get_or_create( + user=user, + project=project, + role=project.get_roles().get(slug="main"), + is_admin=False, + ) + + if project_template.slug == "scrum": + for sprint in self._client.get_agile("/board/{}/sprint".format(project_id))['values']: + start_datetime = sprint.get('startDate', None) + end_datetime = sprint.get('startDate', None) + start_date = datetime.date.today() + if start_datetime: + start_date = start_datetime[:10] + end_date = datetime.date.today() + if end_datetime: + end_date = end_datetime[:10] + + milestone = Milestone.objects.create( + name=sprint['name'], + slug=slugify(sprint['name']), + owner=self._user, + project=project, + estimated_start=start_date, + estimated_finish=end_date, + ) + Milestone.objects.filter(id=milestone.id).update( + created_date=start_datetime or datetime.datetime.now(), + modified_date=start_datetime or datetime.datetime.now(), + ) + return project + + def _import_user_stories_data(self, project_id, project, options): + users_bindings = options.get('users_bindings', {}) + project_conf = self._client.get_agile("/board/{}/configuration".format(project_id)) + if options['type'] == "scrum": + estimation_field = project_conf['estimation']['field']['fieldId'] + + counter = 0 + offset = 0 + while True: + issues = self._client.get_agile("/board/{}/issue".format(project_id), { + "startAt": offset, + "expand": "changelog", + }) + offset += issues['maxResults'] + + for issue in issues['issues']: + issue['fields']['issuelinks'] += self._client.get("/issue/{}/remotelink".format(issue['key'])) + assigned_to = users_bindings.get(issue['fields']['assignee']['key'] if issue['fields']['assignee'] else None, None) + owner = users_bindings.get(issue['fields']['creator']['key'] if issue['fields']['creator'] else None, self._user) + + external_reference = None + if options.get('keep_external_reference', False): + external_reference = ["jira", self._client.get_issue_url(issue['key'])] + + try: + milestone = project.milestones.get(name=issue['fields'].get('sprint', {}).get('name', '')) + except Milestone.DoesNotExist: + milestone = None + + us = UserStory.objects.create( + project=project, + owner=owner, + assigned_to=assigned_to, + status=project.us_statuses.get(name=issue['fields']['status']['name']), + kanban_order=counter, + sprint_order=counter, + backlog_order=counter, + subject=issue['fields']['summary'], + description=issue['fields']['description'] or '', + tags=issue['fields']['labels'], + external_reference=external_reference, + milestone=milestone, + ) + + try: + epic = project.epics.get(ref=int(issue['fields'].get("epic", {}).get("key", "FAKE-0").split("-")[1])) + RelatedUserStory.objects.create( + user_story=us, + epic=epic, + order=1 + ) + except Epic.DoesNotExist: + pass + + if options['type'] == "scrum": + estimation = None + if issue['fields'].get(estimation_field, None): + estimation = float(issue['fields'].get(estimation_field)) + + (points, _) = Points.objects.get_or_create( + project=project, + value=estimation, + defaults={ + "name": str(estimation), + "order": estimation, + } + ) + RolePoints.objects.filter(user_story=us, role__slug="main").update(points_id=points.id) + + self._import_to_custom_fields(us, issue, options) + + us.ref = issue['key'].split("-")[1] + UserStory.objects.filter(id=us.id).update( + ref=us.ref, + modified_date=issue['fields']['updated'], + created_date=issue['fields']['created'] + ) + take_snapshot(us, comment="", user=None, delete=False) + self._import_subtasks(project_id, project, us, issue, options) + self._import_comments(us, issue, options) + self._import_attachments(us, issue, options) + self._import_changelog(project, us, issue, options) + counter += 1 + + if len(issues['issues']) < issues['maxResults']: + break + + def _import_subtasks(self, project_id, project, us, issue, options): + users_bindings = options.get('users_bindings', {}) + + if len(issue['fields']['subtasks']) == 0: + return + + counter = 0 + offset = 0 + while True: + issues = self._client.get_agile("/board/{}/issue".format(project_id), { + "jql": "parent={}".format(issue['key']), + "startAt": offset, + "expand": "changelog", + }) + offset += issues['maxResults'] + + for issue in issues['issues']: + issue['fields']['issuelinks'] += self._client.get("/issue/{}/remotelink".format(issue['key'])) + assigned_to = users_bindings.get(issue['fields']['assignee']['key'] if issue['fields']['assignee'] else None, None) + owner = users_bindings.get(issue['fields']['creator']['key'] if issue['fields']['creator'] else None, self._user) + + external_reference = None + if options.get('keep_external_reference', False): + external_reference = ["jira", self._client.get_issue_url(issue['key'])] + + task = Task.objects.create( + user_story=us, + project=project, + owner=owner, + assigned_to=assigned_to, + status=project.task_statuses.get(name=issue['fields']['status']['name']), + subject=issue['fields']['summary'], + description=issue['fields']['description'] or '', + tags=issue['fields']['labels'], + external_reference=external_reference, + milestone=us.milestone, + ) + + self._import_to_custom_fields(task, issue, options) + + task.ref = issue['key'].split("-")[1] + Task.objects.filter(id=task.id).update( + ref=task.ref, + modified_date=issue['fields']['updated'], + created_date=issue['fields']['created'] + ) + take_snapshot(task, comment="", user=None, delete=False) + for subtask in issue['fields']['subtasks']: + print("WARNING: Ignoring subtask {} because parent isn't a User Story".format(subtask['key'])) + self._import_comments(task, issue, options) + self._import_attachments(task, issue, options) + self._import_changelog(project, task, issue, options) + counter += 1 + if len(issues['issues']) < issues['maxResults']: + break + + def _import_epics_data(self, project_id, project, options): + users_bindings = options.get('users_bindings', {}) + + counter = 0 + offset = 0 + while True: + issues = self._client.get_agile("/board/{}/epic".format(project_id), { + "startAt": offset, + }) + offset += issues['maxResults'] + + for epic in issues['values']: + issue = self._client.get_agile("/issue/{}".format(epic['key'])) + issue['fields']['issuelinks'] += self._client.get("/issue/{}/remotelink".format(issue['key'])) + assigned_to = users_bindings.get(issue['fields']['assignee']['key'] if issue['fields']['assignee'] else None, None) + owner = users_bindings.get(issue['fields']['creator']['key'] if issue['fields']['creator'] else None, self._user) + + external_reference = None + if options.get('keep_external_reference', False): + external_reference = ["jira", self._client.get_issue_url(issue['key'])] + + epic = Epic.objects.create( + project=project, + owner=owner, + assigned_to=assigned_to, + status=project.epic_statuses.get(name=issue['fields']['status']['name']), + subject=issue['fields']['summary'], + description=issue['fields']['description'] or '', + epics_order=counter, + tags=issue['fields']['labels'], + external_reference=external_reference, + ) + + self._import_to_custom_fields(epic, issue, options) + + epic.ref = issue['key'].split("-")[1] + Epic.objects.filter(id=epic.id).update( + ref=epic.ref, + modified_date=issue['fields']['updated'], + created_date=issue['fields']['created'] + ) + + take_snapshot(epic, comment="", user=None, delete=False) + for subtask in issue['fields']['subtasks']: + print("WARNING: Ignoring subtask {} because parent isn't a User Story".format(subtask['key'])) + self._import_comments(epic, issue, options) + self._import_attachments(epic, issue, options) + issue_with_changelog = self._client.get("/issue/{}".format(issue['key']), { + "expand": "changelog" + }) + self._import_changelog(project, epic, issue_with_changelog, options) + counter += 1 + + if len(issues['values']) < issues['maxResults']: + break diff --git a/taiga/importers/jira/api.py b/taiga/importers/jira/api.py new file mode 100644 index 00000000..47bb6cda --- /dev/null +++ b/taiga/importers/jira/api.py @@ -0,0 +1,229 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2014-2016 Taiga Agile LLC +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from django.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.importers import permissions +from .normal import JiraNormalImporter +from .agile import JiraAgileImporter +from . import tasks + + +class JiraImporterViewSet(viewsets.ViewSet): + permission_classes = (permissions.ImporterPermission,) + + def _get_token(self, request): + token_data = request.DATA.get('token', "").split(".") + + token = { + "access_token": token_data[0], + "access_token_secret": token_data[1], + "key_cert": settings.JIRA_CERT, + "consumer_key": settings.JIRA_CONSUMER_KEY + } + return token + + @list_route(methods=["POST"]) + def list_users(self, request, *args, **kwargs): + self.check_permissions(request, "list_users", None) + + url = request.DATA.get('url', None) + token = self._get_token(request) + project_id = request.DATA.get('project', None) + + if not project_id: + raise exc.WrongArguments(_("The project param is needed")) + if not url: + raise exc.WrongArguments(_("The url param is needed")) + + importer = JiraNormalImporter(request.user, url, token) + users = importer.list_users() + 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) + url = request.DATA.get('url', None) + if not url: + raise exc.WrongArguments(_("The url param is needed")) + + token = self._get_token(request) + importer = JiraNormalImporter(request.user, url, token) + agile_importer = JiraAgileImporter(request.user, url, token) + projects = importer.list_projects() + boards = agile_importer.list_projects() + return response.Ok(sorted(projects + boards, key=lambda x: x['name'])) + + @list_route(methods=["POST"]) + def import_project(self, request, *args, **kwargs): + self.check_permissions(request, "import_project", None) + + url = request.DATA.get('url', None) + token = self._get_token(request) + project_id = request.DATA.get('project', None) + if not project_id: + raise exc.WrongArguments(_("The project param is needed")) + + if not url: + raise exc.WrongArguments(_("The url param is needed")) + + options = { + "name": request.DATA.get('name', None), + "description": request.DATA.get('description', None), + "users_bindings": request.DATA.get("user_bindings", {}), + "keep_external_reference": request.DATA.get("keep_external_reference", False), + "is_private": request.DATA.get("is_private", False), + } + + importer_type = request.DATA.get('importer_type', "normal") + if importer_type == "agile": + importer = JiraAgileImporter(request.user, url, token) + else: + project_type = request.DATA.get("project_type", "scrum") + if project_type == "kanban": + options['template'] = "kanban" + else: + options['template'] = "scrum" + + importer = JiraNormalImporter(request.user, url, token) + + types_bindings = { + "epic": [], + "us": [], + "task": [], + "issue": [], + } + for issue_type in importer.list_issue_types(project_id): + if project_type in ['scrum', 'kanban']: + # Set the type bindings + if issue_type['subtask']: + types_bindings['task'].append(issue_type) + elif issue_type['name'].upper() == "EPIC": + types_bindings["epic"].append(issue_type) + elif issue_type['name'].upper() in ["US", "USERSTORY", "USER STORY"]: + types_bindings["us"].append(issue_type) + elif issue_type['name'].upper() in ["ISSUE", "BUG", "ENHANCEMENT"]: + types_bindings["issue"].append(issue_type) + else: + types_bindings["us"].append(issue_type) + elif project_type == "issues": + # Set the type bindings + if issue_type['subtask']: + continue + types_bindings["issue"].append(issue_type) + elif project_type == "issues-with-subissues": + types_bindings["issue"].append(issue_type) + else: + raise exc.WrongArguments(_("Invalid project_type {}").format(project_type)) + + options["types_bindings"] = types_bindings + + if settings.CELERY_ENABLED: + task = tasks.import_project.delay(request.user.id, url, token, project_id, options, importer_type) + return response.Accepted({"task_id": task.id}) + + 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) + jira_url = request.QUERY_PARAMS.get('url', None) + + if not jira_url: + raise exc.WrongArguments(_("The url param is needed")) + + (oauth_token, oauth_secret, url) = JiraNormalImporter.get_auth_url( + jira_url, + settings.JIRA_CONSUMER_KEY, + settings.JIRA_CERT, + True + ) + + (auth_data, created) = AuthData.objects.get_or_create( + user=request.user, + key="jira-oauth", + defaults={ + "value": "", + "extra": {}, + } + ) + auth_data.extra = { + "oauth_token": oauth_token, + "oauth_secret": oauth_secret, + "url": jira_url, + } + 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="jira-oauth") + oauth_token = oauth_data.extra['oauth_token'] + oauth_secret = oauth_data.extra['oauth_secret'] + server_url = oauth_data.extra['url'] + oauth_data.delete() + + jira_token = JiraNormalImporter.get_access_token( + server_url, + settings.JIRA_CONSUMER_KEY, + settings.JIRA_CERT, + oauth_token, + oauth_secret, + True + ) + except Exception as e: + raise exc.WrongArguments(_("Invalid or expired auth token")) + + return response.Ok({ + "token": jira_token['access_token'] + "." + jira_token['access_token_secret'], + "url": server_url + }) diff --git a/taiga/importers/jira/common.py b/taiga/importers/jira/common.py new file mode 100644 index 00000000..c38d1993 --- /dev/null +++ b/taiga/importers/jira/common.py @@ -0,0 +1,748 @@ +import requests +from urllib.parse import parse_qsl +from oauthlib.oauth1 import SIGNATURE_RSA + +from requests_oauthlib import OAuth1 +from django.core.files.base import ContentFile +from django.contrib.contenttypes.models import ContentType + +from taiga.users.models import User +from taiga.projects.models import Project, ProjectTemplate, Membership, Points +from taiga.projects.userstories.models import UserStory +from taiga.projects.tasks.models import Task +from taiga.projects.issues.models import Issue +from taiga.projects.milestones.models import Milestone +from taiga.projects.epics.models import Epic +from taiga.projects.attachments.models import Attachment +from taiga.projects.history.services import take_snapshot +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.custom_attributes.models import (UserStoryCustomAttribute, + TaskCustomAttribute, + IssueCustomAttribute, + EpicCustomAttribute) +from taiga.projects.history.models import HistoryEntry +from taiga.projects.history.choices import HistoryType +from taiga.mdrender.service import render as mdrender + +EPIC_COLORS = { + "ghx-label-0": "#ffffff", + "ghx-label-1": "#815b3a", + "ghx-label-2": "#f79232", + "ghx-label-3": "#d39c3f", + "ghx-label-4": "#3b7fc4", + "ghx-label-5": "#4a6785", + "ghx-label-6": "#8eb021", + "ghx-label-7": "#ac707a", + "ghx-label-8": "#654982", + "ghx-label-9": "#f15c75", +} + + +def links_to_richtext(importer, issue, links): + richtext = "" + importing_project_key = issue['key'].split("-")[0] + for link in links: + if "inwardIssue" in link: + (project_key, issue_key) = link['inwardIssue']['key'].split("-") + action = link['type']['inward'] + elif "outwardIssue" in link: + (project_key, issue_key) = link['outwardIssue']['key'].split("-") + action = link['type']['outward'] + else: + continue + + if importing_project_key == project_key: + richtext += " * This item {} #{}\n".format(action, issue_key) + else: + url = importer._client.server + "/projects/{}/issues/{}-{}".format( + project_key, + project_key, + issue_key + ) + richtext += " * This item {} [{}-{}]({})\n".format(action, project_key, issue_key, url) + + for link in links: + if "object" in link: + richtext += " * [{}]({})\n".format( + link['object']['title'] or link['object']['url'], + link['object']['url'], + ) + + return richtext + + +class JiraClient: + def __init__(self, server, oauth): + self.server = server + self.api_url = server + "/rest/agile/1.0/{}" + self.main_api_url = server + "/rest/api/2/{}" + if oauth: + self.oauth = OAuth1( + oauth['consumer_key'], + signature_method=SIGNATURE_RSA, + rsa_key=oauth['key_cert'], + resource_owner_key=oauth['access_token'], + resource_owner_secret=oauth['access_token_secret'] + ) + else: + self.oauth = None + + def get(self, uri_path, query_params=None): + headers = { + 'Content-Type': "application/json" + } + if query_params is None: + query_params = {} + + if uri_path[0] == '/': + uri_path = uri_path[1:] + url = self.main_api_url.format(uri_path) + + response = requests.get(url, params=query_params, headers=headers, auth=self.oauth) + + if response.status_code == 401: + raise Exception("Unauthorized: %s at %s" % (response.text, url), response) + if response.status_code != 200: + raise Exception("Resource Unavailable: %s at %s" % (response.text, url), response) + + return response.json() + + def get_agile(self, uri_path, query_params=None): + headers = { + 'Content-Type': "application/json" + } + if query_params is None: + query_params = {} + + if uri_path[0] == '/': + uri_path = uri_path[1:] + url = self.api_url.format(uri_path) + + response = requests.get(url, params=query_params, headers=headers, auth=self.oauth) + + if response.status_code == 401: + raise Exception("Unauthorized: %s at %s" % (response.text, url), response) + if response.status_code != 200: + raise Exception("Resource Unavailable: %s at %s" % (response.text, url), response) + + return response.json() + + def raw_get(self, absolute_uri, query_params=None): + if query_params is None: + query_params = {} + + response = requests.get(absolute_uri, params=query_params, auth=self.oauth) + + if response.status_code == 401: + raise Exception("Unauthorized: %s at %s" % (response.text, absolute_uri), response) + if response.status_code != 200: + raise Exception("Resource Unavailable: %s at %s" % (response.text, absolute_uri), response) + + return response.content + + def get_issue_url(self, key): + (project_key, issue_key) = key.split("-") + return self.server + "/projects/{}/issues/{}".format(project_key, key) + + +class JiraImporterCommon: + def __init__(self, user, server, oauth): + self._user = user + self._client = JiraClient(server=server, oauth=oauth) + + def resolve_user_bindings(self, options): + for option in list(options['users_bindings'].keys()): + try: + user = User.objects.get(id=options['users_bindings'][option]) + options['users_bindings'][option] = user + except User.DoesNotExist: + del(options['users_bindings'][option]) + + def list_users(self): + result = [] + users = self._client.get("/user/picker", { + "query": "@", + "maxResults": 1000, + }) + for user in users['users']: + user_data = self._client.get("/user", { + "key": user['key'] + }) + result.append({ + "id": user_data['key'], + "full_name": user_data['displayName'], + "email": user_data['emailAddress'], + }) + return result + + def _import_comments(self, obj, issue, options): + users_bindings = options.get('users_bindings', {}) + offset = 0 + while True: + comments = self._client.get("/issue/{}/comment".format(issue['key']), {"startAt": offset}) + for comment in comments['comments']: + snapshot = take_snapshot( + obj, + comment=comment['body'], + user=users_bindings.get( + comment['author']['name'], + User(full_name=comment['author']['displayName']) + ), + delete=False + ) + HistoryEntry.objects.filter(id=snapshot.id).update(created_at=comment['created']) + + offset += len(comments['comments']) + if len(comments['comments']) <= comments['maxResults']: + break + + def _create_custom_fields(self, project): + custom_fields = [] + for model in [UserStoryCustomAttribute, TaskCustomAttribute, IssueCustomAttribute, EpicCustomAttribute]: + model.objects.create( + name="Due date", + description="Due date", + type="date", + order=1, + project=project + ) + model.objects.create( + name="Priority", + description="Priority", + type="text", + order=1, + project=project + ) + model.objects.create( + name="Resolution", + description="Resolution", + type="text", + order=1, + project=project + ) + model.objects.create( + name="Resolution date", + description="Resolution date", + type="date", + order=1, + project=project + ) + model.objects.create( + name="Environment", + description="Environment", + type="text", + order=1, + project=project + ) + model.objects.create( + name="Components", + description="Components", + type="text", + order=1, + project=project + ) + model.objects.create( + name="Affects Version/s", + description="Affects Version/s", + type="text", + order=1, + project=project + ) + model.objects.create( + name="Fix Version/s", + description="Fix Version/s", + type="text", + order=1, + project=project + ) + model.objects.create( + name="Links", + description="Links", + type="richtext", + order=1, + project=project + ) + custom_fields.append({ + "history_name": "duedate", + "jira_field_name": "duedate", + "taiga_field_name": "Due date", + }) + custom_fields.append({ + "history_name": "priority", + "jira_field_name": "priority", + "taiga_field_name": "Priority", + "transform": lambda issue, obj: obj.get('name', None) + }) + custom_fields.append({ + "history_name": "resolution", + "jira_field_name": "resolution", + "taiga_field_name": "Resolution", + "transform": lambda issue, obj: obj.get('name', None) + }) + custom_fields.append({ + "history_name": "Resolution date", + "jira_field_name": "resolutiondate", + "taiga_field_name": "Resolution date", + }) + custom_fields.append({ + "history_name": "environment", + "jira_field_name": "environment", + "taiga_field_name": "Environment", + }) + custom_fields.append({ + "history_name": "Component", + "jira_field_name": "components", + "taiga_field_name": "Components", + "transform": lambda issue, obj: ", ".join([c.get('name', None) for c in obj]) + }) + custom_fields.append({ + "history_name": "Version", + "jira_field_name": "versions", + "taiga_field_name": "Affects Version/s", + "transform": lambda issue, obj: ", ".join([c.get('name', None) for c in obj]) + }) + custom_fields.append({ + "history_name": "Fix Version", + "jira_field_name": "fixVersions", + "taiga_field_name": "Fix Version/s", + "transform": lambda issue, obj: ", ".join([c.get('name', None) for c in obj]) + }) + custom_fields.append({ + "history_name": "Link", + "jira_field_name": "issuelinks", + "taiga_field_name": "Links", + "transform": lambda issue, obj: links_to_richtext(self, issue, obj) + }) + + greenhopper_fields = {} + for custom_field in self._client.get("/field"): + if custom_field['custom']: + if custom_field['schema']['custom'] == "com.pyxis.greenhopper.jira:gh-sprint": + greenhopper_fields["sprint"] = custom_field['id'] + elif custom_field['schema']['custom'] == "com.pyxis.greenhopper.jira:gh-epic-link": + greenhopper_fields["link"] = custom_field['id'] + elif custom_field['schema']['custom'] == "com.pyxis.greenhopper.jira:gh-epic-status": + greenhopper_fields["status"] = custom_field['id'] + elif custom_field['schema']['custom'] == "com.pyxis.greenhopper.jira:gh-epic-label": + greenhopper_fields["label"] = custom_field['id'] + elif custom_field['schema']['custom'] == "com.pyxis.greenhopper.jira:gh-epic-color": + greenhopper_fields["color"] = custom_field['id'] + elif custom_field['schema']['custom'] == "com.pyxis.greenhopper.jira:gh-lexo-rank": + greenhopper_fields["rank"] = custom_field['id'] + elif ( + custom_field['name'] == "Story Points" and + custom_field['schema']['custom'] == 'com.atlassian.jira.plugin.system.customfieldtypes:float' + ): + greenhopper_fields["points"] = custom_field['id'] + else: + multiline_types = [ + "com.atlassian.jira.plugin.system.customfieldtypes:textarea" + ] + date_types = [ + "com.atlassian.jira.plugin.system.customfieldtypes:datepicker" + "com.atlassian.jira.plugin.system.customfieldtypes:datetime" + ] + if custom_field['schema']['custom'] in multiline_types: + field_type = "multiline" + elif custom_field['schema']['custom'] in date_types: + field_type = "date" + else: + field_type = "text" + + custom_field_data = { + "name": custom_field['name'][:64], + "description": custom_field['name'], + "type": field_type, + "order": 1, + "project": project + } + + UserStoryCustomAttribute.objects.get_or_create(**custom_field_data) + TaskCustomAttribute.objects.get_or_create(**custom_field_data) + IssueCustomAttribute.objects.get_or_create(**custom_field_data) + EpicCustomAttribute.objects.get_or_create(**custom_field_data) + + custom_fields.append({ + "history_name": custom_field['name'], + "jira_field_name": custom_field['id'], + "taiga_field_name": custom_field['name'][:64], + }) + + self.greenhopper_fields = greenhopper_fields + self.custom_fields = custom_fields + + def _import_to_custom_fields(self, obj, issue, options): + if isinstance(obj, Epic): + custom_att_manager = obj.project.epiccustomattributes + elif isinstance(obj, UserStory): + custom_att_manager = obj.project.userstorycustomattributes + elif isinstance(obj, Task): + custom_att_manager = obj.project.taskcustomattributes + elif isinstance(obj, Issue): + custom_att_manager = obj.project.issuecustomattributes + else: + raise NotImplementedError("Not implemented custom attributes for this object ({})".format(obj)) + + custom_attributes_values = {} + for custom_field in self.custom_fields: + if issue['key'] == "PS-10": + import pprint; pprint.pprint(issue['fields']) + data = issue['fields'].get(custom_field['jira_field_name'], None) + if data and "transform" in custom_field: + data = custom_field['transform'](issue, data) + + if data: + taiga_field = custom_att_manager.get(name=custom_field['taiga_field_name']) + custom_attributes_values[taiga_field.id] = data + + if custom_attributes_values != {}: + obj.custom_attributes_values.attributes_values = custom_attributes_values + obj.custom_attributes_values.save() + + def _import_attachments(self, obj, issue, options): + users_bindings = options.get('users_bindings', {}) + + for attachment in issue['fields']['attachment']: + try: + data = self._client.raw_get(attachment['content']) + att = Attachment( + owner=users_bindings.get(attachment['author']['name'], self._user), + project=obj.project, + content_type=ContentType.objects.get_for_model(obj), + object_id=obj.id, + name=attachment['filename'], + size=attachment['size'], + created_date=attachment['created'], + is_deprecated=False, + ) + att.attached_file.save(attachment['filename'], ContentFile(data), save=True) + except Exception: + print("ERROR getting attachment url {}".format(attachment['content'])) + + + def _import_changelog(self, project, obj, issue, options): + obj.cummulative_attachments = [] + for history in sorted(issue['changelog']['histories'], key=lambda h: h['created']): + self._import_history(project, obj, history, options) + + def _import_history(self, project, obj, history, options): + key = make_key_from_model_object(obj) + typename = get_typename_for_model_class(obj.__class__) + history_data = self._transform_history_data(project, obj, history, options) + if history_data is None: + return + + change_old = history_data['change_old'] + change_new = history_data['change_new'] + hist_type = history_data['hist_type'] + comment = history_data['comment'] + user = history_data['user'] + + diff = make_diff_from_dicts(change_old, change_new) + fdiff = FrozenDiff(key, diff, {}) + + values = make_diff_values(typename, fdiff) + values.update(history_data['update_values']) + + entry = HistoryEntry.objects.create( + user=user, + project_id=obj.project.id, + key=key, + type=hist_type, + snapshot=None, + diff=fdiff.diff, + values=values, + comment=comment, + comment_html=mdrender(obj.project, comment), + is_hidden=False, + is_snapshot=False, + ) + HistoryEntry.objects.filter(id=entry.id).update(created_at=history['created']) + return HistoryEntry.objects.get(id=entry.id) + + def _transform_history_data(self, project, obj, history, options): + users_bindings = options.get('users_bindings', {}) + + user = {"pk": None, "name": history.get('author', {}).get('displayName', None)} + taiga_user = users_bindings.get(history.get('author', {}).get('key', None), None) + if taiga_user: + user = {"pk": taiga_user.id, "name": taiga_user.get_full_name()} + + result = { + "change_old": {}, + "change_new": {}, + "update_values": {}, + "hist_type": HistoryType.change, + "comment": "", + "user": user + } + custom_fields_by_names = {f["history_name"]: f for f in self.custom_fields} + has_data = False + for history_item in history['items']: + if history_item['field'] == "Attachment": + result['change_old']["attachments"] = [] + for att in obj.cummulative_attachments: + result['change_old']["attachments"].append({ + "id": 0, + "filename": att + }) + + if history_item['from'] is not None: + try: + idx = obj.cummulative_attachments.index(history_item['fromString']) + obj.cummulative_attachments.pop(idx) + except ValueError: + print("ERROR: Removing attachment that doesn't exist in the history ({})".format(history_item['fromString'])) + if history_item['to'] is not None: + obj.cummulative_attachments.append(history_item['toString']) + + result['change_new']["attachments"] = [] + for att in obj.cummulative_attachments: + result['change_new']["attachments"].append({ + "id": 0, + "filename": att + }) + has_data = True + elif history_item['field'] == "description": + result['change_old']["description"] = history_item['fromString'] + result['change_new']["description"] = history_item['toString'] + result['change_old']["description_html"] = mdrender(obj.project, history_item['fromString'] or "") + result['change_new']["description_html"] = mdrender(obj.project, history_item['toString'] or "") + has_data = True + elif history_item['field'] == "Epic Link": + pass + elif history_item['field'] == "Workflow": + pass + elif history_item['field'] == "Link": + pass + elif history_item['field'] == "labels": + result['change_old']["tags"] = history_item['fromString'].split() + result['change_new']["tags"] = history_item['toString'].split() + has_data = True + elif history_item['field'] == "Rank": + pass + elif history_item['field'] == "RemoteIssueLink": + pass + elif history_item['field'] == "Sprint": + old_milestone = None + if history_item['fromString']: + try: + old_milestone = obj.project.milestones.get(name=history_item['fromString']).id + except Milestone.DoesNotExist: + old_milestone = -1 + + new_milestone = None + if history_item['toString']: + try: + new_milestone = obj.project.milestones.get(name=history_item['toString']).id + except Milestone.DoesNotExist: + new_milestone = -2 + + result['change_old']["milestone"] = old_milestone + result['change_new']["milestone"] = new_milestone + + if old_milestone == -1 or new_milestone == -2: + result['update_values']["milestone"] = {} + + if old_milestone == -1: + result['update_values']["milestone"]["-1"] = history_item['fromString'] + if new_milestone == -2: + result['update_values']["milestone"]["-2"] = history_item['toString'] + has_data = True + elif history_item['field'] == "status": + if isinstance(obj, Task): + try: + old_status = obj.project.task_statuses.get(name=history_item['fromString']).id + except Exception: + old_status = -1 + try: + new_status = obj.project.task_statuses.get(name=history_item['toString']).id + except Exception: + new_status = -2 + elif isinstance(obj, UserStory): + try: + old_status = obj.project.us_statuses.get(name=history_item['fromString']).id + except Exception: + old_status = -1 + try: + new_status = obj.project.us_statuses.get(name=history_item['toString']).id + except Exception: + new_status = -2 + elif isinstance(obj, Issue): + try: + old_status = obj.project.issue_statuses.get(name=history_item['fromString']).id + except Exception: + old_status = -1 + try: + new_status = obj.project.us_statuses.get(name=history_item['toString']).id + except Exception: + new_status = -2 + elif isinstance(obj, Epic): + try: + old_status = obj.project.epic_statuses.get(name=history_item['fromString']).id + except Exception: + old_status = -1 + try: + new_status = obj.project.epic_statuses.get(name=history_item['toString']).id + except Exception: + new_status = -2 + + if old_status == -1 or new_status == -2: + result['update_values']["status"] = {} + + if old_status == -1: + result['update_values']["status"]["-1"] = history_item['fromString'] + if new_status == -2: + result['update_values']["status"]["-2"] = history_item['toString'] + + result['change_old']["status"] = old_status + result['change_new']["status"] = new_status + has_data = True + elif history_item['field'] == "Story Points": + old_points = None + if history_item['fromString']: + estimation = float(history_item['fromString']) + (old_points, _) = Points.objects.get_or_create( + project=project, + value=estimation, + defaults={ + "name": str(estimation), + "order": estimation, + } + ) + old_points = old_points.id + new_points = None + if history_item['toString']: + estimation = float(history_item['toString']) + (new_points, _) = Points.objects.get_or_create( + project=project, + value=estimation, + defaults={ + "name": str(estimation), + "order": estimation, + } + ) + new_points = new_points.id + result['change_old']["points"] = {project.roles.get(slug="main").id: old_points} + result['change_new']["points"] = {project.roles.get(slug="main").id: new_points} + has_data = True + elif history_item['field'] == "summary": + result['change_old']["subject"] = history_item['fromString'] + result['change_new']["subject"] = history_item['toString'] + has_data = True + elif history_item['field'] == "Epic Color": + if isinstance(obj, Epic): + result['change_old']["color"] = EPIC_COLORS.get(history_item['fromString'], None) + result['change_new']["color"] = EPIC_COLORS.get(history_item['toString'], None) + Epic.objects.filter(id=obj.id).update( + color=EPIC_COLORS.get(history_item['toString'], "#999999") + ) + has_data = True + elif history_item['field'] == "assignee": + old_assigned_to = None + if history_item['from'] is not None: + old_assigned_to = users_bindings.get(history_item['from'], -1) + if old_assigned_to != -1: + old_assigned_to = old_assigned_to.id + + new_assigned_to = None + if history_item['to'] is not None: + new_assigned_to = users_bindings.get(history_item['to'], -2) + if new_assigned_to != -2: + new_assigned_to = new_assigned_to.id + + result['change_old']["assigned_to"] = old_assigned_to + result['change_new']["assigned_to"] = new_assigned_to + + if old_assigned_to == -1 or new_assigned_to == -2: + result['update_values']["users"] = {} + + if old_assigned_to == -1: + result['update_values']["users"]["-1"] = history_item['fromString'] + if new_assigned_to == -2: + result['update_values']["users"]["-2"] = history_item['toString'] + has_data = True + elif history_item['field'] in custom_fields_by_names: + custom_field = custom_fields_by_names[history_item['field']] + if isinstance(obj, Task): + field_obj = obj.project.taskcustomattributes.get(name=custom_field['taiga_field_name']) + elif isinstance(obj, UserStory): + field_obj = obj.project.userstorycustomattributes.get(name=custom_field['taiga_field_name']) + elif isinstance(obj, Issue): + field_obj = obj.project.issuecustomattributes.get(name=custom_field['taiga_field_name']) + elif isinstance(obj, Epic): + field_obj = obj.project.epiccustomattributes.get(name=custom_field['taiga_field_name']) + + result['change_old']["custom_attributes"] = [{ + "name": custom_field['taiga_field_name'], + "value": history_item['fromString'], + "id": field_obj.id + }] + result['change_new']["custom_attributes"] = [{ + "name": custom_field['taiga_field_name'], + "value": history_item['toString'], + "id": field_obj.id + }] + has_data = True + else: + import pprint; pprint.pprint(history_item) + + if not has_data: + return None + + return result + + def _cleanup(self, project, options): + for epic_custom_field in project.epiccustomattributes.all(): + if project.epics.filter(custom_attributes_values__attributes_values__has_key=str(epic_custom_field.id)).count() == 0: + epic_custom_field.delete() + for us_custom_field in project.userstorycustomattributes.all(): + if project.user_stories.filter(custom_attributes_values__attributes_values__has_key=str(us_custom_field.id)).count() == 0: + us_custom_field.delete() + for task_custom_field in project.taskcustomattributes.all(): + if project.tasks.filter(custom_attributes_values__attributes_values__has_key=str(task_custom_field.id)).count() == 0: + task_custom_field.delete() + for issue_custom_field in project.issuecustomattributes.all(): + if project.issues.filter(custom_attributes_values__attributes_values__has_key=str(issue_custom_field.id)).count() == 0: + issue_custom_field.delete() + + @classmethod + def get_auth_url(cls, server, consumer_key, key_cert_data, verify=None): + if verify is None: + verify = server.startswith('https') + + oauth = OAuth1(consumer_key, signature_method=SIGNATURE_RSA, rsa_key=key_cert_data) + r = requests.post( + server + '/plugins/servlet/oauth/request-token', verify=verify, auth=oauth) + request = dict(parse_qsl(r.text)) + request_token = request['oauth_token'] + request_token_secret = request['oauth_token_secret'] + + return ( + request_token, + request_token_secret, + '{}/plugins/servlet/oauth/authorize?oauth_token={}'.format(server, request_token) + ) + + @classmethod + def get_access_token(cls, server, consumer_key, key_cert_data, request_token, request_token_secret, verify=False): + oauth = OAuth1( + consumer_key, + signature_method=SIGNATURE_RSA, + rsa_key=key_cert_data, + resource_owner_key=request_token, + resource_owner_secret=request_token_secret + ) + r = requests.post(server + '/plugins/servlet/oauth/access-token', verify=verify, auth=oauth) + access = dict(parse_qsl(r.text)) + + return { + 'access_token': access['oauth_token'], + 'access_token_secret': access['oauth_token_secret'], + 'consumer_key': consumer_key, + 'key_cert': key_cert_data + } diff --git a/taiga/importers/jira/normal.py b/taiga/importers/jira/normal.py new file mode 100644 index 00000000..45584545 --- /dev/null +++ b/taiga/importers/jira/normal.py @@ -0,0 +1,439 @@ +from collections import OrderedDict + +from django.template.defaultfilters import slugify +from taiga.projects.references.models import recalc_reference_counter +from taiga.projects.models import Project, ProjectTemplate, Membership, Points +from taiga.projects.userstories.models import UserStory, RolePoints +from taiga.projects.tasks.models import Task +from taiga.projects.issues.models import Issue +from taiga.projects.epics.models import Epic, RelatedUserStory +from taiga.projects.history.services import take_snapshot +from taiga.timeline.rebuilder import rebuild_timeline +from taiga.timeline.models import Timeline +from .common import JiraImporterCommon + + +class JiraNormalImporter(JiraImporterCommon): + def list_projects(self): + return [{"id": project['id'], + "name": project['name'], + "description": project['description'], + "is_private": True, + "importer_type": "normal"} for project in self._client.get('/project', {"expand": "description"})] + + def list_issue_types(self, project_id): + statuses = self._client.get("/project/{}/statuses".format(project_id)) + return statuses + + def import_project(self, project_id, options): + self.resolve_user_bindings(options) + project = self._import_project_data(project_id, options) + self._import_user_stories_data(project_id, project, options) + self._import_epics_data(project_id, project, options) + self._link_epics_with_user_stories(project_id, project, options) + self._import_issues_data(project_id, project, options) + self._cleanup(project, options) + Timeline.objects.filter(project=project).delete() + rebuild_timeline(None, None, project.id) + recalc_reference_counter(project) + return project + + def _import_project_data(self, project_id, options): + project = self._client.get("/project/{}".format(project_id)) + project_template = ProjectTemplate.objects.get(slug=options['template']) + + epic_statuses = OrderedDict() + for issue_type in options.get('types_bindings', {}).get("epic", []): + for status in issue_type['statuses']: + epic_statuses[status['name']] = status + + us_statuses = OrderedDict() + for issue_type in options.get('types_bindings', {}).get("us", []): + for status in issue_type['statuses']: + us_statuses[status['name']] = status + + task_statuses = OrderedDict() + for issue_type in options.get('types_bindings', {}).get("task", []): + for status in issue_type['statuses']: + task_statuses[status['name']] = status + + issue_statuses = OrderedDict() + for issue_type in options.get('types_bindings', {}).get("issue", []): + for status in issue_type['statuses']: + issue_statuses[status['name']] = status + + counter = 0 + if epic_statuses: + project_template.epic_statuses = [] + project_template.is_epics_activated = True + for epic_status in epic_statuses.values(): + project_template.epic_statuses.append({ + "name": epic_status['name'], + "slug": slugify(epic_status['name']), + "is_closed": False, + "color": "#999999", + "order": counter, + }) + counter += 1 + if epic_statuses: + project_template.default_options["epic_status"] = list(epic_statuses.values())[0]['name'] + + project_template.points = [{ + "value": None, + "name": "?", + "order": 0, + }] + + main_permissions = project_template.roles[0]['permissions'] + project_template.roles = [{ + "name": "Main", + "slug": "main", + "computable": True, + "permissions": main_permissions, + "order": 70, + }] + + counter = 0 + if us_statuses: + project_template.us_statuses = [] + for us_status in us_statuses.values(): + project_template.us_statuses.append({ + "name": us_status['name'], + "slug": slugify(us_status['name']), + "is_closed": False, + "is_archived": False, + "color": "#999999", + "wip_limit": None, + "order": counter, + }) + counter += 1 + if us_statuses: + project_template.default_options["us_status"] = list(us_statuses.values())[0]['name'] + + counter = 0 + if task_statuses: + project_template.task_statuses = [] + for task_status in task_statuses.values(): + project_template.task_statuses.append({ + "name": task_status['name'], + "slug": slugify(task_status['name']), + "is_closed": False, + "color": "#999999", + "order": counter, + }) + counter += 1 + if task_statuses: + project_template.default_options["task_status"] = list(task_statuses.values())[0]['name'] + + counter = 0 + if issue_statuses: + project_template.issue_statuses = [] + for issue_status in issue_statuses.values(): + project_template.issue_statuses.append({ + "name": issue_status['name'], + "slug": slugify(issue_status['name']), + "is_closed": False, + "color": "#999999", + "order": counter, + }) + counter += 1 + if issue_statuses: + project_template.default_options["issue_status"] = list(issue_statuses.values())[0]['name'] + + + main_permissions = project_template.roles[0]['permissions'] + project_template.roles = [{ + "name": "Main", + "slug": "main", + "computable": True, + "permissions": main_permissions, + "order": 70, + }] + + project = Project.objects.create( + name=options.get('name', None) or project['name'], + description=options.get('description', None) or project.get('description', ''), + owner=self._user, + creation_template=project_template, + is_private=options.get('is_private', False), + ) + + self._create_custom_fields(project) + + for user in options.get('users_bindings', {}).values(): + if user != self._user: + Membership.objects.get_or_create( + user=user, + project=project, + role=project.get_roles().get(slug="main"), + is_admin=False, + ) + return project + + def _import_user_stories_data(self, project_id, project, options): + users_bindings = options.get('users_bindings', {}) + + types = options.get('types_bindings', {}).get("us", []) + for issue_type in types: + counter = 0 + offset = 0 + while True: + issues = self._client.get("/search", { + "jql": "project={} AND issuetype={}".format(project_id, issue_type['id']), + "startAt": offset, + "fields": "*all", + "expand": "changelog,attachment", + }) + offset += issues['maxResults'] + + for issue in issues['issues']: + issue['fields']['issuelinks'] += self._client.get("/issue/{}/remotelink".format(issue['key'])) + assigned_to = users_bindings.get(issue['fields']['assignee']['key'] if issue['fields']['assignee'] else None, None) + owner = users_bindings.get(issue['fields']['creator']['key'] if issue['fields']['creator'] else None, self._user) + + external_reference = None + if options.get('keep_external_reference', False): + external_reference = ["jira", issue['fields']['url']] + + + us = UserStory.objects.create( + project=project, + owner=owner, + assigned_to=assigned_to, + status=project.us_statuses.get(name=issue['fields']['status']['name']), + kanban_order=counter, + sprint_order=counter, + backlog_order=counter, + subject=issue['fields']['summary'], + description=issue['fields']['description'] or '', + tags=issue['fields']['labels'], + external_reference=external_reference, + ) + + points_value = issue['fields'].get(self.greenhopper_fields.get('points', None), None) + if points_value: + (points, _) = Points.objects.get_or_create( + project=project, + value=points_value, + defaults={ + "name": str(points_value), + "order": points_value, + } + ) + RolePoints.objects.filter(user_story=us, role__slug="main").update(points_id=points.id) + else: + points = Points.objects.get(project=project, value__isnull=True) + RolePoints.objects.filter(user_story=us, role__slug="main").update(points_id=points.id) + + self._import_to_custom_fields(us, issue, options) + + us.ref = issue['key'].split("-")[1] + UserStory.objects.filter(id=us.id).update( + ref=us.ref, + modified_date=issue['fields']['updated'], + created_date=issue['fields']['created'] + ) + take_snapshot(us, comment="", user=None, delete=False) + self._import_subtasks(project_id, project, us, issue, options) + self._import_comments(us, issue, options) + self._import_attachments(us, issue, options) + self._import_changelog(project, us, issue, options) + counter += 1 + + if len(issues['issues']) < issues['maxResults']: + break + + def _import_subtasks(self, project_id, project, us, issue, options): + users_bindings = options.get('users_bindings', {}) + + if len(issue['fields']['subtasks']) == 0: + return + + counter = 0 + offset = 0 + while True: + issues = self._client.get("/search", { + "jql": "parent={}".format(issue['key']), + "startAt": offset, + "fields": "*all", + "expand": "changelog,attachment", + }) + offset += issues['maxResults'] + + for issue in issues['issues']: + issue['fields']['issuelinks'] += self._client.get("/issue/{}/remotelink".format(issue['key'])) + assigned_to = users_bindings.get(issue['fields']['assignee']['key'] if issue['fields']['assignee'] else None, None) + owner = users_bindings.get(issue['fields']['creator']['key'] if issue['fields']['creator'] else None, self._user) + + external_reference = None + if options.get('keep_external_reference', False): + external_reference = ["jira", issue['fields']['url']] + + task = Task.objects.create( + user_story=us, + project=project, + owner=owner, + assigned_to=assigned_to, + status=project.task_statuses.get(name=issue['fields']['status']['name']), + subject=issue['fields']['summary'], + description=issue['fields']['description'] or '', + tags=issue['fields']['labels'], + external_reference=external_reference, + ) + + self._import_to_custom_fields(task, issue, options) + + task.ref = issue['key'].split("-")[1] + Task.objects.filter(id=task.id).update( + ref=task.ref, + modified_date=issue['fields']['updated'], + created_date=issue['fields']['created'] + ) + take_snapshot(task, comment="", user=None, delete=False) + for subtask in issue['fields']['subtasks']: + print("WARNING: Ignoring subtask {} because parent isn't a User Story".format(subtask['key'])) + self._import_comments(task, issue, options) + self._import_attachments(task, issue, options) + self._import_changelog(project, task, issue, options) + counter += 1 + if len(issues['issues']) < issues['maxResults']: + break + + def _import_issues_data(self, project_id, project, options): + users_bindings = options.get('users_bindings', {}) + + types = options.get('types_bindings', {}).get("issue", []) + for issue_type in types: + counter = 0 + offset = 0 + while True: + issues = self._client.get("/search", { + "jql": "project={} AND issuetype={}".format(project_id, issue_type['id']), + "startAt": offset, + "fields": "*all", + "expand": "changelog,attachment", + }) + offset += issues['maxResults'] + + for issue in issues['issues']: + issue['fields']['issuelinks'] += self._client.get("/issue/{}/remotelink".format(issue['key'])) + assigned_to = users_bindings.get(issue['fields']['assignee']['key'] if issue['fields']['assignee'] else None, None) + owner = users_bindings.get(issue['fields']['creator']['key'] if issue['fields']['creator'] else None, self._user) + + external_reference = None + if options.get('keep_external_reference', False): + external_reference = ["jira", issue['fields']['url']] + + taiga_issue = Issue.objects.create( + project=project, + owner=owner, + assigned_to=assigned_to, + status=project.issue_statuses.get(name=issue['fields']['status']['name']), + subject=issue['fields']['summary'], + description=issue['fields']['description'] or '', + tags=issue['fields']['labels'], + external_reference=external_reference, + ) + + self._import_to_custom_fields(taiga_issue, issue, options) + + taiga_issue.ref = issue['key'].split("-")[1] + Issue.objects.filter(id=taiga_issue.id).update( + ref=taiga_issue.ref, + modified_date=issue['fields']['updated'], + created_date=issue['fields']['created'] + ) + take_snapshot(taiga_issue, comment="", user=None, delete=False) + for subtask in issue['fields']['subtasks']: + print("WARNING: Ignoring subtask {} because parent isn't a User Story".format(subtask['key'])) + self._import_comments(taiga_issue, issue, options) + self._import_attachments(taiga_issue, issue, options) + self._import_changelog(project, taiga_issue, issue, options) + counter += 1 + + if len(issues['issues']) < issues['maxResults']: + break + + def _import_epics_data(self, project_id, project, options): + users_bindings = options.get('users_bindings', {}) + + types = options.get('types_bindings', {}).get("epic", []) + for issue_type in types: + counter = 0 + offset = 0 + while True: + issues = self._client.get("/search", { + "jql": "project={} AND issuetype={}".format(project_id, issue_type['id']), + "startAt": offset, + "fields": "*all", + "expand": "changelog,attachment", + }) + offset += issues['maxResults'] + + for issue in issues['issues']: + issue['fields']['issuelinks'] += self._client.get("/issue/{}/remotelink".format(issue['key'])) + assigned_to = users_bindings.get(issue['fields']['assignee']['key'] if issue['fields']['assignee'] else None, None) + owner = users_bindings.get(issue['fields']['creator']['key'] if issue['fields']['creator'] else None, self._user) + + external_reference = None + if options.get('keep_external_reference', False): + external_reference = ["jira", issue['fields']['url']] + + epic = Epic.objects.create( + project=project, + owner=owner, + assigned_to=assigned_to, + status=project.epic_statuses.get(name=issue['fields']['status']['name']), + subject=issue['fields']['summary'], + description=issue['fields']['description'] or '', + epics_order=counter, + tags=issue['fields']['labels'], + external_reference=external_reference, + ) + + self._import_to_custom_fields(epic, issue, options) + + epic.ref = issue['key'].split("-")[1] + Epic.objects.filter(id=epic.id).update( + ref=epic.ref, + modified_date=issue['fields']['updated'], + created_date=issue['fields']['created'] + ) + take_snapshot(epic, comment="", user=None, delete=False) + for subtask in issue['fields']['subtasks']: + print("WARNING: Ignoring subtask {} because parent isn't a User Story".format(subtask['key'])) + self._import_comments(epic, issue, options) + self._import_attachments(epic, issue, options) + issue_with_changelog = self._client.get("/issue/{}".format(issue['key']), { + "expand": "changelog" + }) + self._import_changelog(project, epic, issue_with_changelog, options) + counter += 1 + + if len(issues['issues']) < issues['maxResults']: + break + + def _link_epics_with_user_stories(self, project_id, project, options): + types = options.get('types_bindings', {}).get("us", []) + for issue_type in types: + offset = 0 + while True: + issues = self._client.get("/search", { + "jql": "project={} AND issuetype={}".format(project_id, issue_type['id']), + "startAt": offset + }) + offset += issues['maxResults'] + + for issue in issues['issues']: + epic_key = issue['fields'][self.greenhopper_fields['link']] + if epic_key: + epic = project.epics.get(ref=int(epic_key.split("-")[1])) + us = project.user_stories.get(ref=int(issue['key'].split("-")[1])) + RelatedUserStory.objects.create( + user_story=us, + epic=epic, + order=1 + ) + + if len(issues['issues']) < issues['maxResults']: + break diff --git a/taiga/importers/jira/tasks.py b/taiga/importers/jira/tasks.py new file mode 100644 index 00000000..a1551f1b --- /dev/null +++ b/taiga/importers/jira/tasks.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2014-2016 Andrey Antukh +# Copyright (C) 2014-2016 Jesús Espino +# Copyright (C) 2014-2016 David Barragán +# Copyright (C) 2014-2016 Alejandro Alonso +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import 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 .normal import JiraNormalImporter + +logger = logging.getLogger('taiga.importers.jira') + + +@app.task(bind=True) +def import_project(self, user_id, url, token, project_id, options, importer_type): + user = User.object.get(id=user_id) + + if importer_type == "agile": + importer = JiraAgileImporter(user, url, token) + else: + importer = JiraNormalImporter(user, url, token) + + try: + project = importer.import_project(project_id, options) + except Exception as e: + # Error + ctx = { + "user": user, + "error_subject": _("Error importing jira project"), + "error_message": _("Error importing jira project"), + "project": project_id, + "exception": e + } + email = mail_builder.jira_import_error(admin, ctx) + email.send() + logger.error('Error importing jira project %s (by %s)', project_id, user, exc_info=sys.exc_info()) + else: + ctx = { + "project": project, + "user": user, + } + email = mail_builder.jira_import_success(user, ctx) + email.send() diff --git a/taiga/importers/management/commands/import_from_jira.py b/taiga/importers/management/commands/import_from_jira.py new file mode 100644 index 00000000..972e6ba6 --- /dev/null +++ b/taiga/importers/management/commands/import_from_jira.py @@ -0,0 +1,146 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2014-2016 Andrey Antukh +# Copyright (C) 2014-2016 Jesús Espino +# Copyright (C) 2014-2016 David Barragán +# Copyright (C) 2014-2016 Alejandro Alonso +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from django.core.management.base import BaseCommand +from django.db.models import Q +from django.conf import settings + +from taiga.importers.jira.agile import JiraAgileImporter +from taiga.importers.jira.normal import JiraNormalImporter +from taiga.users.models import User +from taiga.projects.services import projects as service + +import unittest.mock +import timeit +import json + + +class Command(BaseCommand): + def add_arguments(self, parser): + parser.add_argument('--token', dest="token", type=str, + help='Auth token') + parser.add_argument('--server', dest="server", type=str, + help='Server address (default: https://jira.atlassian.com)', + default="https://jira.atlassian.com") + parser.add_argument('--project-id', dest="project_id", type=str, + help='Project ID or full name (ex: taigaio/taiga-back)') + parser.add_argument('--project-type', dest="project_type", type=str, + help='Project type in jira: project or board') + parser.add_argument('--template', dest='template', default="scrum", + help='template to use: scrum or scrum (default scrum)') + 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") + server = options.get("server") + + if options.get('token', None) == "anon": + token = None + elif options.get('token', None): + token = json.loads(options.get('token')) + else: + (rtoken, rtoken_secret, url) = JiraNormalImporter.get_auth_url(server, settings.JIRA_CONSUMER_KEY, settings.JIRA_CERT, True) + print(url) + code = input("Go to the url and get back the code") + token = JiraNormalImporter.get_access_token(server, settings.JIRA_CONSUMER_KEY, settings.JIRA_CERT, rtoken, rtoken_secret, True) + print("Auth token: {}".format(json.dumps(token))) + + + if options.get('project_type', None) is None: + print("Select the type of project to import (project or board): ") + project_type = input("Project type: ") + else: + project_type = options.get('project_type') + + if project_type not in ["project", "board"]: + print("ERROR: Bad project type.") + return + + if project_type == "project": + importer = JiraNormalImporter(admin, server, token) + else: + importer = JiraAgileImporter(admin, server, 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 or key: ") + + users_bindings = {} + if options.get('ask_for_users', None): + print("Add the username or email for next jira users:") + for user in importer.list_users(): + try: + users_bindings[user['key']] = User.objects.get(Q(email=user['email'])) + break + except User.DoesNotExist: + pass + + while True: + username_or_email = input("{}: ".format(user['full_name'])) + if username_or_email == "": + break + try: + users_bindings[user['key']] = 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'), + } + + if project_type == "project": + print("Bind jira issue types to (epic, us, issue)") + types_bindings = { + "epic": [], + "us": [], + "task": [], + "issue": [], + } + + for issue_type in importer.list_issue_types(project_id): + while True: + if issue_type['subtask']: + types_bindings['task'].append(issue_type) + break + + taiga_type = input("{}: ".format(issue_type['name'])) + if taiga_type not in ['epic', 'us', 'issue']: + print("use a valid taiga type (epic, us, issue)") + continue + + types_bindings[taiga_type].append(issue_type) + break + options["types_bindings"] = types_bindings + + importer.import_project(project_id, options) diff --git a/taiga/routers.py b/taiga/routers.py index 528f7696..1546ef8b 100644 --- a/taiga/routers.py +++ b/taiga/routers.py @@ -285,8 +285,10 @@ router.register(r"application-tokens", ApplicationToken, base_name="application- # Third party importers from taiga.importers.trello.api import TrelloImporterViewSet +from taiga.importers.jira.api import JiraImporterViewSet router.register(r"importers/trello", TrelloImporterViewSet, base_name="importers-trello") +router.register(r"importers/jira", JiraImporterViewSet, base_name="importers-jira") # Stats diff --git a/tests/integration/test_importers_jira_api.py b/tests/integration/test_importers_jira_api.py new file mode 100644 index 00000000..ebb96a11 --- /dev/null +++ b/tests/integration/test_importers_jira_api.py @@ -0,0 +1,259 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2014-2016 Andrey Antukh +# Copyright (C) 2014-2016 Jesús Espino +# Copyright (C) 2014-2016 David Barragán +# Copyright (C) 2014-2016 Alejandro Alonso +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import 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 +from taiga.users.models import AuthData + + +pytestmark = pytest.mark.django_db + + +fake_token = "access.secret" + + +def test_auth_url(client): + user = f.UserFactory.create() + client.login(user) + + url = reverse("importers-jira-auth-url")+"?url=http://jiraserver" + + with mock.patch('taiga.importers.jira.api.JiraNormalImporter') as JiraNormalImporter: + JiraNormalImporter.get_auth_url.return_value = ("test_oauth_token", "test_oauth_secret", "http://jira-server-url") + response = client.get(url, content_type="application/json") + + auth_data = user.auth_data.get(key="jira-oauth") + assert auth_data.extra['oauth_token'] == "test_oauth_token" + assert auth_data.extra['oauth_secret'] == "test_oauth_secret" + assert auth_data.extra['url'] == "http://jiraserver" + + assert response.status_code == 200 + assert 'url' in response.data + assert response.data['url'] == "http://jira-server-url" + + +def test_authorize(client): + user = f.UserFactory.create() + client.login(user) + + url = reverse("importers-jira-auth-url") + authorize_url = reverse("importers-jira-authorize") + + AuthData.objects.get_or_create( + user=user, + key="jira-oauth", + value="", + extra={ + "oauth_token": "test-oauth-token", + "oauth_secret": "test-oauth-secret", + "url": "http://jiraserver", + } + ) + + with mock.patch('taiga.importers.jira.api.JiraNormalImporter') as JiraNormalImporter: + JiraNormalImporter.get_access_token.return_value = { + "access_token": "test-access-token", + "access_token_secret": "test-access-token-secret" + } + response = client.post(authorize_url, content_type="application/json", data={}) + + assert response.status_code == 200 + assert 'token' in response.data + assert response.data['token'] == "test-access-token.test-access-token-secret" + assert 'url' in response.data + assert response.data['url'] == "http://jiraserver" + + +def test_authorize_without_token_and_secret(client): + user = f.UserFactory.create() + client.login(user) + + authorize_url = reverse("importers-jira-authorize") + AuthData.objects.filter(user=user, key="jira-oauth").delete() + + response = client.post(authorize_url, content_type="application/json", data={}) + + assert response.status_code == 400 + assert 'token' not in response.data + + +def test_import_jira_list_users(client, settings): + user = f.UserFactory.create() + client.login(user) + + url = reverse("importers-jira-list-users") + + with mock.patch('taiga.importers.jira.api.JiraNormalImporter') as JiraNormalImporterMock: + instance = mock.Mock() + instance.list_users.return_value = [ + {"id": 1, "fullName": "user1", "email": None}, + {"id": 2, "fullName": "user2", "email": None} + ] + JiraNormalImporterMock.return_value = instance + response = client.post(url, content_type="application/json", data=json.dumps({"token": "access.secret", "url": "http://jiraserver", "project": 1})) + + assert response.status_code == 200 + assert response.data[0]["id"] == 1 + assert response.data[1]["id"] == 2 + + +def test_import_jira_list_users_without_project(client, settings): + user = f.UserFactory.create() + client.login(user) + + url = reverse("importers-jira-list-users") + + with mock.patch('taiga.importers.jira.api.JiraNormalImporter') as JiraNormalImporterMock: + instance = mock.Mock() + instance.list_users.return_value = [ + {"id": 1, "fullName": "user1", "email": None}, + {"id": 2, "fullName": "user2", "email": None} + ] + JiraNormalImporterMock.return_value = instance + response = client.post(url, content_type="application/json", data=json.dumps({"token": "access.secret", "url": "http://jiraserver"})) + + assert response.status_code == 400 + + +def test_import_jira_list_users_with_problem_on_request(client, settings): + user = f.UserFactory.create() + client.login(user) + + url = reverse("importers-jira-list-users") + + with mock.patch('taiga.importers.jira.common.JiraClient') as JiraClientMock: + instance = mock.Mock() + instance.get.side_effect = exc.WrongArguments("Invalid Request") + JiraClientMock.return_value = instance + response = client.post(url, content_type="application/json", data=json.dumps({"token": "access.secret", "url": "http://jiraserver", "project": 1})) + + assert response.status_code == 400 + + +def test_import_jira_list_projects(client, settings): + user = f.UserFactory.create() + client.login(user) + + url = reverse("importers-jira-list-projects") + + with mock.patch('taiga.importers.jira.api.JiraNormalImporter') as JiraNormalImporterMock: + with mock.patch('taiga.importers.jira.api.JiraAgileImporter') as JiraAgileImporterMock: + instance = mock.Mock() + instance.list_projects.return_value = [{"name": "project1"}, {"name": "project2"}] + JiraNormalImporterMock.return_value = instance + instance_agile = mock.Mock() + instance_agile.list_projects.return_value = [{"name": "agile1"}, {"name": "agile2"}] + JiraAgileImporterMock.return_value = instance_agile + response = client.post(url, content_type="application/json", data=json.dumps({"token": "access.secret", "url": "http://jiraserver"})) + + assert response.status_code == 200 + assert response.data[0] == {"name": "agile1"} + assert response.data[1] == {"name": "agile2"} + assert response.data[2] == {"name": "project1"} + assert response.data[3] == {"name": "project2"} + + +def test_import_jira_list_projects_with_problem_on_request(client, settings): + user = f.UserFactory.create() + client.login(user) + + url = reverse("importers-jira-list-projects") + + with mock.patch('taiga.importers.jira.common.JiraClient') as JiraClientMock: + instance = mock.Mock() + instance.get.side_effect = exc.WrongArguments("Invalid Request") + JiraClientMock.return_value = instance + response = client.post(url, content_type="application/json", data=json.dumps({"token": "access.secret", "url": "http://jiraserver"})) + + assert response.status_code == 400 + + +def test_import_jira_project_without_project_id(client, settings): + settings.CELERY_ENABLED = True + + user = f.UserFactory.create() + client.login(user) + + url = reverse("importers-jira-import-project") + + with mock.patch('taiga.importers.jira.tasks.JiraNormalImporter') as JiraNormalImporterMock: + response = client.post(url, content_type="application/json", data=json.dumps({"token": "access.secret", "url": "http://jiraserver"})) + + assert response.status_code == 400 + + +def test_import_jira_project_without_url(client, settings): + settings.CELERY_ENABLED = True + + user = f.UserFactory.create() + client.login(user) + + url = reverse("importers-jira-import-project") + + with mock.patch('taiga.importers.jira.tasks.JiraNormalImporter') as JiraNormalImporterMock: + response = client.post(url, content_type="application/json", data=json.dumps({"token": "access.secret", "project_id": 1})) + + assert response.status_code == 400 + + +def test_import_jira_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-jira-import-project") + + with mock.patch('taiga.importers.jira.api.JiraNormalImporter') as ApiJiraNormalImporterMock: + with mock.patch('taiga.importers.jira.tasks.JiraNormalImporter') as TasksJiraNormalImporterMock: + TasksJiraNormalImporterMock.return_value.import_project.return_value = project + ApiJiraNormalImporterMock.return_value.list_issue_types.return_value = [] + response = client.post(url, content_type="application/json", data=json.dumps({"token": "access.secret", "url": "http://jiraserver", "project": 1})) + + assert response.status_code == 202 + assert "task_id" in response.data + + +def test_import_jira_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-jira-import-project") + + with mock.patch('taiga.importers.jira.api.JiraNormalImporter') as JiraNormalImporterMock: + instance = mock.Mock() + instance.import_project.return_value = project + instance.list_issue_types.return_value = [] + JiraNormalImporterMock.return_value = instance + response = client.post(url, content_type="application/json", data=json.dumps({"token": "access.secret", "url": "http://jiraserver", "project": 1})) + + assert response.status_code == 200 + assert "slug" in response.data + assert response.data['slug'] == "imported-project"