Add pivotal importer prototype
parent
477d964770
commit
15c2868d24
|
@ -0,0 +1,93 @@
|
||||||
|
# -*- 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.pivotal import PivotalImporter
|
||||||
|
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="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")
|
||||||
|
|
||||||
|
if options.get('token', None):
|
||||||
|
token = options.get('token')
|
||||||
|
else:
|
||||||
|
print("You need a user token")
|
||||||
|
return
|
||||||
|
|
||||||
|
importer = PivotalImporter(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['project_id'], project['project_name']))
|
||||||
|
project_id = input("Project id: ")
|
||||||
|
|
||||||
|
users_bindings = {}
|
||||||
|
if options.get('ask_for_users', None):
|
||||||
|
print("Add the username or email for next pivotal users:")
|
||||||
|
for user in importer.list_users(project_id):
|
||||||
|
try:
|
||||||
|
users_bindings[user['id']] = User.objects.get(Q(email=user['person'].get('email', "not-valid")))
|
||||||
|
break
|
||||||
|
except User.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
while True:
|
||||||
|
username_or_email = input("{}: ".format(user['person']['name']))
|
||||||
|
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,143 @@
|
||||||
|
# -*- 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 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 taiga.importers import permissions
|
||||||
|
from .importer import PivotalImporter
|
||||||
|
from . import tasks
|
||||||
|
|
||||||
|
|
||||||
|
class PivotalImporterViewSet(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 = PivotalImporter(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 = PivotalImporter(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 = {
|
||||||
|
"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({"pivotal_import_id": task.id})
|
||||||
|
|
||||||
|
importer = PivotalImporter(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) = PivotalImporter.get_auth_url()
|
||||||
|
|
||||||
|
(auth_data, created) = AuthData.objects.get_or_create(
|
||||||
|
user=request.user,
|
||||||
|
key="pivotal-oauth",
|
||||||
|
defaults={
|
||||||
|
"value": "",
|
||||||
|
"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="pivotal-oauth")
|
||||||
|
oauth_token = oauth_data.extra['oauth_token']
|
||||||
|
oauth_secret = oauth_data.extra['oauth_secret']
|
||||||
|
oauth_verifier = request.DATA.get('code')
|
||||||
|
oauth_data.delete()
|
||||||
|
pivotal_token = PivotalImporter.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": pivotal_token
|
||||||
|
})
|
|
@ -0,0 +1,702 @@
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from taiga.users.models import User
|
||||||
|
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.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.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
|
||||||
|
|
||||||
|
|
||||||
|
class PivotalClient:
|
||||||
|
def __init__(self, token):
|
||||||
|
self.api_url = "https://www.pivotaltracker.com/services/v5/{}"
|
||||||
|
self.token = token
|
||||||
|
self.me = self.get('/me')
|
||||||
|
|
||||||
|
def get(self, uri_path, query_params=None):
|
||||||
|
headers = {
|
||||||
|
'X-TrackerToken': self.token
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
|
||||||
|
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_attachment(self, attachment_id):
|
||||||
|
headers = {
|
||||||
|
'X-TrackerToken': self.token
|
||||||
|
}
|
||||||
|
url = "https://www.pivotaltracker.com/file_attachments/{}/download".format(attachment_id)
|
||||||
|
response = requests.get(url, headers=headers)
|
||||||
|
return response.content
|
||||||
|
|
||||||
|
|
||||||
|
class PivotalImporter:
|
||||||
|
def __init__(self, user, token):
|
||||||
|
self._user = user
|
||||||
|
self._client = PivotalClient(token=token)
|
||||||
|
|
||||||
|
def list_projects(self):
|
||||||
|
return self._client.me['projects']
|
||||||
|
|
||||||
|
def list_users(self, project_id):
|
||||||
|
return self._client.get("/projects/{}/memberships".format(project_id))
|
||||||
|
|
||||||
|
def import_project(self, project_id, options={"template": "scrum", "users_bindings": {}, "keep_external_reference": False}):
|
||||||
|
(project, project_data) = self._import_project_data(project_id, options)
|
||||||
|
self._import_epics_data(project_data, project, options)
|
||||||
|
self._import_user_stories_data(project_data, project, options)
|
||||||
|
Timeline.objects.filter(project=project).delete()
|
||||||
|
rebuild_timeline(None, None, project.id)
|
||||||
|
recalc_reference_counter(project)
|
||||||
|
|
||||||
|
def _import_project_data(self, project_id, options):
|
||||||
|
project_data = self._client.get(
|
||||||
|
"/projects/{}".format(project_id),
|
||||||
|
{
|
||||||
|
"fields": ",".join([
|
||||||
|
"point_scale",
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"labels(name)",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
)
|
||||||
|
project_data['iterations'] = self._client.get(
|
||||||
|
"/projects/{}/iterations".format(project_id),
|
||||||
|
{
|
||||||
|
"fields": ",".join([
|
||||||
|
"number",
|
||||||
|
"start",
|
||||||
|
"finish",
|
||||||
|
"stories",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
)
|
||||||
|
project_data['epics'] = self._client.get(
|
||||||
|
"/projects/{}/epics".format(project_data['id']),
|
||||||
|
{
|
||||||
|
"fields": ",".join([
|
||||||
|
"name",
|
||||||
|
"label",
|
||||||
|
"description",
|
||||||
|
"comments(text,file_attachments,google_attachments,person,created_at)",
|
||||||
|
"follower_ids",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"url",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
project_template = ProjectTemplate.objects.get(slug=options['template'])
|
||||||
|
project_template.is_epics_activated = True
|
||||||
|
project_template.us_statuses = []
|
||||||
|
project_template.points = [{
|
||||||
|
"value": None,
|
||||||
|
"name": "?",
|
||||||
|
"order": 1,
|
||||||
|
}]
|
||||||
|
|
||||||
|
counter = 2
|
||||||
|
for points in project_data['point_scale'].split(","):
|
||||||
|
project_template.points.append({
|
||||||
|
"value": int(points),
|
||||||
|
"name": points,
|
||||||
|
"order": counter
|
||||||
|
})
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
project_template.us_statuses.append({
|
||||||
|
"name": "Unscheduled",
|
||||||
|
"slug": "unscheduled",
|
||||||
|
"is_closed": True,
|
||||||
|
"is_archived": False,
|
||||||
|
"color": "#999999",
|
||||||
|
"wip_limit": None,
|
||||||
|
"order": 1,
|
||||||
|
})
|
||||||
|
project_template.us_statuses.append({
|
||||||
|
"name": "Unstarted",
|
||||||
|
"slug": "unstarted",
|
||||||
|
"is_closed": False,
|
||||||
|
"is_archived": False,
|
||||||
|
"color": "#999999",
|
||||||
|
"wip_limit": None,
|
||||||
|
"order": 2,
|
||||||
|
})
|
||||||
|
project_template.us_statuses.append({
|
||||||
|
"name": "Planned",
|
||||||
|
"slug": "planned",
|
||||||
|
"is_closed": False,
|
||||||
|
"is_archived": False,
|
||||||
|
"color": "#999999",
|
||||||
|
"wip_limit": None,
|
||||||
|
"order": 3,
|
||||||
|
})
|
||||||
|
project_template.us_statuses.append({
|
||||||
|
"name": "Started",
|
||||||
|
"slug": "started",
|
||||||
|
"is_closed": False,
|
||||||
|
"is_archived": False,
|
||||||
|
"color": "#999999",
|
||||||
|
"wip_limit": None,
|
||||||
|
"order": 4,
|
||||||
|
})
|
||||||
|
project_template.us_statuses.append({
|
||||||
|
"name": "Finished",
|
||||||
|
"slug": "finished",
|
||||||
|
"is_closed": False,
|
||||||
|
"is_archived": False,
|
||||||
|
"color": "#999999",
|
||||||
|
"wip_limit": None,
|
||||||
|
"order": 5,
|
||||||
|
})
|
||||||
|
project_template.us_statuses.append({
|
||||||
|
"name": "Delivered",
|
||||||
|
"slug": "delivered",
|
||||||
|
"is_closed": True,
|
||||||
|
"is_archived": False,
|
||||||
|
"color": "#999999",
|
||||||
|
"wip_limit": None,
|
||||||
|
"order": 6,
|
||||||
|
})
|
||||||
|
project_template.us_statuses.append({
|
||||||
|
"name": "Rejected",
|
||||||
|
"slug": "rejected",
|
||||||
|
"is_closed": True,
|
||||||
|
"is_archived": True,
|
||||||
|
"color": "#999999",
|
||||||
|
"wip_limit": None,
|
||||||
|
"order": 7,
|
||||||
|
})
|
||||||
|
project_template.us_statuses.append({
|
||||||
|
"name": "Accepted",
|
||||||
|
"slug": "accepted",
|
||||||
|
"is_closed": True,
|
||||||
|
"is_archived": True,
|
||||||
|
"color": "#999999",
|
||||||
|
"wip_limit": None,
|
||||||
|
"order": 8,
|
||||||
|
})
|
||||||
|
project_template.default_options["us_status"] = "Unscheduled"
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
main_permissions = project_template.roles[0]['permissions']
|
||||||
|
project_template.roles = [{
|
||||||
|
"name": "Main",
|
||||||
|
"slug": "main",
|
||||||
|
"computable": True,
|
||||||
|
"permissions": main_permissions,
|
||||||
|
"order": 70,
|
||||||
|
}]
|
||||||
|
|
||||||
|
tags_colors = []
|
||||||
|
for label in project_data['labels']:
|
||||||
|
name = label['name'].lower()
|
||||||
|
tags_colors.append([name, None])
|
||||||
|
|
||||||
|
project = Project.objects.create(
|
||||||
|
name=project_data['name'],
|
||||||
|
description=project_data.get('description', ''),
|
||||||
|
owner=self._user,
|
||||||
|
tags_colors=tags_colors,
|
||||||
|
creation_template=project_template
|
||||||
|
)
|
||||||
|
|
||||||
|
UserStoryCustomAttribute.objects.create(
|
||||||
|
name="Due date",
|
||||||
|
description="Due date",
|
||||||
|
type="date",
|
||||||
|
order=1,
|
||||||
|
project=project
|
||||||
|
)
|
||||||
|
UserStoryCustomAttribute.objects.create(
|
||||||
|
name="Type",
|
||||||
|
description="Story type",
|
||||||
|
type="text",
|
||||||
|
order=2,
|
||||||
|
project=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,
|
||||||
|
)
|
||||||
|
|
||||||
|
for iteration in project_data['iterations']:
|
||||||
|
milestone = Milestone.objects.create(
|
||||||
|
name="Sprint {}".format(iteration['number']),
|
||||||
|
slug="sprint-{}".format(iteration['number']),
|
||||||
|
owner=self._user,
|
||||||
|
project=project,
|
||||||
|
estimated_start=iteration['start'][:10],
|
||||||
|
estimated_finish=iteration['finish'][:10],
|
||||||
|
)
|
||||||
|
Milestone.objects.filter(id=milestone.id).update(
|
||||||
|
created_date=iteration['start'],
|
||||||
|
modified_date=iteration['start'],
|
||||||
|
)
|
||||||
|
return (project, project_data)
|
||||||
|
|
||||||
|
def _import_user_stories_data(self, project_data, project, options):
|
||||||
|
users_bindings = options.get('users_bindings', {})
|
||||||
|
epics = {e['label']['id']: e for e in project_data['epics']}
|
||||||
|
due_date_field = project.userstorycustomattributes.get(name="Due date")
|
||||||
|
story_type_field = project.userstorycustomattributes.get(name="Type")
|
||||||
|
story_milestone_binding = {}
|
||||||
|
for iteration in project_data['iterations']:
|
||||||
|
for story in iteration['stories']:
|
||||||
|
story_milestone_binding[story['id']] = Milestone.objects.get(
|
||||||
|
project=project,
|
||||||
|
slug="sprint-{}".format(iteration['number'])
|
||||||
|
)
|
||||||
|
|
||||||
|
counter = 0
|
||||||
|
offset = 0
|
||||||
|
while True:
|
||||||
|
stories = self._client.get("/projects/{}/stories".format(project_data['id']), {
|
||||||
|
"envelope": "true",
|
||||||
|
"limit": 300,
|
||||||
|
"offset": offset,
|
||||||
|
"fields": ",".join([
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"estimate",
|
||||||
|
"story_type",
|
||||||
|
"current_state",
|
||||||
|
"deadline",
|
||||||
|
"requested_by_id",
|
||||||
|
"owner_ids",
|
||||||
|
"labels(id,name)",
|
||||||
|
"comments(text,file_attachments,google_attachments,person,created_at)",
|
||||||
|
"tasks(id,description,position,complete,created_at,updated_at)",
|
||||||
|
"follower_ids",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"url",
|
||||||
|
])})
|
||||||
|
offset += 300
|
||||||
|
for story in stories['data']:
|
||||||
|
tags = []
|
||||||
|
for label in story['labels']:
|
||||||
|
tags.append(label['name'])
|
||||||
|
|
||||||
|
assigned_to = None
|
||||||
|
if len(story['owner_ids']) > 0:
|
||||||
|
assigned_to = users_bindings.get(story['owner_ids'][0], None)
|
||||||
|
|
||||||
|
owner = users_bindings.get(story['requested_by_id'], self._user)
|
||||||
|
|
||||||
|
external_reference = None
|
||||||
|
if options.get('keep_external_reference', False):
|
||||||
|
external_reference = ["pivotal", story['url']]
|
||||||
|
|
||||||
|
us = UserStory.objects.create(
|
||||||
|
project=project,
|
||||||
|
owner=owner,
|
||||||
|
assigned_to=assigned_to,
|
||||||
|
status=project.us_statuses.get(slug=story['current_state']),
|
||||||
|
kanban_order=counter,
|
||||||
|
sprint_order=counter,
|
||||||
|
backlog_order=counter,
|
||||||
|
subject=story['name'],
|
||||||
|
description=story.get('description', ''),
|
||||||
|
tags=tags,
|
||||||
|
external_reference=external_reference,
|
||||||
|
milestone=story_milestone_binding.get(story['id'], None)
|
||||||
|
)
|
||||||
|
|
||||||
|
points = Points.objects.get(project=project, value=story.get('estimate', None))
|
||||||
|
RolePoints.objects.filter(user_story=us, role__slug="main").update(points_id=points.id)
|
||||||
|
|
||||||
|
if len(story['owner_ids']) > 1:
|
||||||
|
watchers = list(set(story['owner_ids'][1:] + story['follower_ids']))
|
||||||
|
else:
|
||||||
|
watchers = story['follower_ids']
|
||||||
|
|
||||||
|
for watcher in watchers:
|
||||||
|
watcher_user = users_bindings.get(watcher, None)
|
||||||
|
if watcher_user:
|
||||||
|
us.add_watcher(watcher_user)
|
||||||
|
|
||||||
|
if story.get('deadline', None):
|
||||||
|
us.custom_attributes_values.attributes_values = {due_date_field.id: story['deadline']}
|
||||||
|
us.custom_attributes_values.save()
|
||||||
|
if story.get('story_type', None):
|
||||||
|
us.custom_attributes_values.attributes_values = {story_type_field.id: story['story_type']}
|
||||||
|
us.custom_attributes_values.save()
|
||||||
|
|
||||||
|
UserStory.objects.filter(id=us.id).update(
|
||||||
|
ref=story['id'],
|
||||||
|
modified_date=story['updated_at'],
|
||||||
|
created_date=story['created_at']
|
||||||
|
)
|
||||||
|
take_snapshot(us, comment="", user=None, delete=False)
|
||||||
|
|
||||||
|
for label in story['labels']:
|
||||||
|
if epics.get(label['id'], None):
|
||||||
|
RelatedUserStory.objects.create(
|
||||||
|
epic=Epic.objects.get(project=project, ref=epics.get(label['id'])['id']),
|
||||||
|
user_story=us,
|
||||||
|
order=us.backlog_order
|
||||||
|
)
|
||||||
|
self._import_tasks(project_data, us, story)
|
||||||
|
self._import_user_story_activity(project_data, us, story, options)
|
||||||
|
self._import_comments(project_data, us, story, options)
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
if len(stories['data']) < 300:
|
||||||
|
break
|
||||||
|
|
||||||
|
def _import_epics_data(self, project_data, project, options):
|
||||||
|
users_bindings = options.get('users_bindings', {})
|
||||||
|
counter = 0
|
||||||
|
|
||||||
|
for epic in project_data['epics']:
|
||||||
|
external_reference = None
|
||||||
|
if options.get('keep_external_reference', False):
|
||||||
|
external_reference = ["pivotal", epic['url']]
|
||||||
|
|
||||||
|
taiga_epic = Epic.objects.create(
|
||||||
|
project=project,
|
||||||
|
owner=self._user,
|
||||||
|
status=project.epic_statuses.get(slug="new"),
|
||||||
|
epics_order=counter,
|
||||||
|
subject=epic['name'],
|
||||||
|
description=epic.get('description', ''),
|
||||||
|
tags=[],
|
||||||
|
external_reference=external_reference
|
||||||
|
)
|
||||||
|
|
||||||
|
Epic.objects.filter(id=taiga_epic.id).update(
|
||||||
|
ref=epic['id'],
|
||||||
|
modified_date=epic['updated_at'],
|
||||||
|
created_date=epic['created_at']
|
||||||
|
)
|
||||||
|
|
||||||
|
for watcher in epic['follower_ids']:
|
||||||
|
watcher_user = users_bindings.get(watcher, None)
|
||||||
|
if watcher_user:
|
||||||
|
taiga_epic.add_watcher(watcher_user)
|
||||||
|
|
||||||
|
take_snapshot(taiga_epic, comment="", user=None, delete=False)
|
||||||
|
self._import_comments(project_data, taiga_epic, epic, options)
|
||||||
|
self._import_epic_activity(project_data, taiga_epic, epic, options)
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
def _import_tasks(self, project_data, us, story):
|
||||||
|
for task in story['tasks']:
|
||||||
|
taiga_task = Task.objects.create(
|
||||||
|
subject=task['description'],
|
||||||
|
status=us.project.task_statuses.get(slug="complete" if task['complete'] else "incomplete"),
|
||||||
|
project=us.project,
|
||||||
|
us_order=task['position'],
|
||||||
|
taskboard_order=task['position'],
|
||||||
|
user_story=us
|
||||||
|
)
|
||||||
|
|
||||||
|
Task.objects.filter(id=taiga_task.id).update(
|
||||||
|
ref=task['id'],
|
||||||
|
modified_date=task['updated_at'],
|
||||||
|
created_date=task['created_at']
|
||||||
|
)
|
||||||
|
take_snapshot(taiga_task, comment="", user=None, delete=False)
|
||||||
|
|
||||||
|
def _import_attachment(self, obj, attachment_id, attachment_name, created_at, person_id, options):
|
||||||
|
users_bindings = options.get('users_bindings', {})
|
||||||
|
|
||||||
|
data = self._client.get_attachment(attachment_id)
|
||||||
|
att = Attachment(
|
||||||
|
owner=users_bindings.get(person_id, self._user),
|
||||||
|
project=obj.project,
|
||||||
|
content_type=ContentType.objects.get_for_model(obj),
|
||||||
|
object_id=obj.id,
|
||||||
|
name=attachment_name,
|
||||||
|
size=len(data),
|
||||||
|
created_date=created_at,
|
||||||
|
is_deprecated=False,
|
||||||
|
)
|
||||||
|
att.attached_file.save(attachment_name, ContentFile(data), save=True)
|
||||||
|
|
||||||
|
def _import_comments(self, project_data, obj, story, options):
|
||||||
|
users_bindings = options.get('users_bindings', {})
|
||||||
|
|
||||||
|
for comment in story['comments']:
|
||||||
|
if 'text' in comment:
|
||||||
|
snapshot = take_snapshot(
|
||||||
|
obj,
|
||||||
|
comment=comment['text'],
|
||||||
|
user=users_bindings.get(comment['person']['id'], User(full_name=comment['person']['name'])),
|
||||||
|
delete=False
|
||||||
|
)
|
||||||
|
HistoryEntry.objects.filter(id=snapshot.id).update(created_at=comment['created_at'])
|
||||||
|
for attachment in comment['file_attachments']:
|
||||||
|
self._import_attachment(
|
||||||
|
obj,
|
||||||
|
attachment['id'],
|
||||||
|
attachment['filename'],
|
||||||
|
comment['created_at'],
|
||||||
|
comment['person']['id'],
|
||||||
|
options
|
||||||
|
)
|
||||||
|
|
||||||
|
def _import_user_story_activity(self, project_data, us, story, options):
|
||||||
|
offset = 0
|
||||||
|
while True:
|
||||||
|
activities = self._client.get(
|
||||||
|
"/projects/{}/stories/{}/activity".format(
|
||||||
|
project_data['id'],
|
||||||
|
story['id'],
|
||||||
|
),
|
||||||
|
{"envelope": "true", "limit": 300, "offset": offset}
|
||||||
|
)
|
||||||
|
offset += 300
|
||||||
|
for activity in activities['data']:
|
||||||
|
self._import_activity(us, activity, options)
|
||||||
|
|
||||||
|
if len(activities['data']) < 300:
|
||||||
|
break
|
||||||
|
|
||||||
|
def _import_epic_activity(self, project_data, taiga_epic, epic, options):
|
||||||
|
offset = 0
|
||||||
|
while True:
|
||||||
|
activities = self._client.get(
|
||||||
|
"/projects/{}/epics/{}/activity".format(
|
||||||
|
project_data['id'],
|
||||||
|
epic['id'],
|
||||||
|
),
|
||||||
|
{"envelope": "true", "limit": 300, "offset": offset}
|
||||||
|
)
|
||||||
|
offset += 300
|
||||||
|
for activity in activities['data']:
|
||||||
|
self._import_activity(taiga_epic, activity, options)
|
||||||
|
|
||||||
|
if len(activities['data']) < 300:
|
||||||
|
break
|
||||||
|
|
||||||
|
def _import_activity(self, obj, activity, options):
|
||||||
|
activity_data = self._transform_activity_data(obj, activity, options)
|
||||||
|
if activity_data is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
change_old = activity_data['change_old']
|
||||||
|
change_new = activity_data['change_new']
|
||||||
|
hist_type = activity_data['hist_type']
|
||||||
|
comment = activity_data['comment']
|
||||||
|
user = activity_data['user']
|
||||||
|
|
||||||
|
key = make_key_from_model_object(activity_data['obj'])
|
||||||
|
typename = get_typename_for_model_class(type(activity_data['obj']))
|
||||||
|
|
||||||
|
diff = make_diff_from_dicts(change_old, change_new)
|
||||||
|
fdiff = FrozenDiff(key, diff, {})
|
||||||
|
|
||||||
|
entry = HistoryEntry.objects.create(
|
||||||
|
user=user,
|
||||||
|
project_id=obj.project.id,
|
||||||
|
key=key,
|
||||||
|
type=hist_type,
|
||||||
|
snapshot=None,
|
||||||
|
diff=fdiff.diff,
|
||||||
|
values=make_diff_values(typename, fdiff),
|
||||||
|
comment=comment,
|
||||||
|
comment_html=mdrender(obj.project, comment),
|
||||||
|
is_hidden=False,
|
||||||
|
is_snapshot=False,
|
||||||
|
)
|
||||||
|
HistoryEntry.objects.filter(id=entry.id).update(created_at=activity['occurred_at'])
|
||||||
|
return HistoryEntry.objects.get(id=entry.id)
|
||||||
|
|
||||||
|
def _transform_activity_data(self, obj, activity, options):
|
||||||
|
users_bindings = options.get('users_bindings', {})
|
||||||
|
due_date_field = obj.project.userstorycustomattributes.get(name="Due date")
|
||||||
|
story_type_field = obj.project.userstorycustomattributes.get(name="Type")
|
||||||
|
|
||||||
|
user = {"pk": None, "name": activity.get('performed_by', {}).get('name', None)}
|
||||||
|
taiga_user = users_bindings.get(activity.get('performed_by', {}).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,
|
||||||
|
"obj": obj
|
||||||
|
}
|
||||||
|
|
||||||
|
if activity['kind'] == "story_create_activity":
|
||||||
|
UserStory.objects.filter(id=obj.id, created_date__gt=activity['occurred_at']).update(
|
||||||
|
created_date=activity['occurred_at'],
|
||||||
|
owner=users_bindings.get(activity["performed_by"]["id"], self._user)
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
elif activity['kind'] == "epic_create_activity":
|
||||||
|
Epic.objects.filter(id=obj.id, created_date__gt=activity['occurred_at']).update(
|
||||||
|
created_date=activity['occurred_at'],
|
||||||
|
owner=users_bindings.get(activity["performed_by"]["id"], self._user)
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
elif activity['kind'] in ["story_update_activity", "epic_update_activity"]:
|
||||||
|
for change in activity['changes']:
|
||||||
|
if change['change_type'] != "update" or change['kind'] not in ["story", "epic"]:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if 'description' in change['new_values']:
|
||||||
|
result['change_old']["description"] = str(change['original_values']['description'])
|
||||||
|
result['change_new']["description"] = str(change['new_values']['description'])
|
||||||
|
result['change_old']["description_html"] = mdrender(obj.project, str(change['original_values']['description']))
|
||||||
|
result['change_new']["description_html"] = mdrender(obj.project, str(change['new_values']['description']))
|
||||||
|
|
||||||
|
if 'estimate' in change['new_values']:
|
||||||
|
old_points = None
|
||||||
|
if change['original_values']['estimate']:
|
||||||
|
estimation = change['original_values']['estimate']
|
||||||
|
(old_points, _) = Points.objects.get_or_create(
|
||||||
|
project=obj.project,
|
||||||
|
value=estimation,
|
||||||
|
defaults={
|
||||||
|
"name": str(estimation),
|
||||||
|
"order": estimation,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
old_points = old_points.id
|
||||||
|
new_points = None
|
||||||
|
if change['new_values']['estimate']:
|
||||||
|
estimation = change['new_values']['estimate']
|
||||||
|
(new_points, _) = Points.objects.get_or_create(
|
||||||
|
project=obj.project,
|
||||||
|
value=estimation,
|
||||||
|
defaults={
|
||||||
|
"name": str(estimation),
|
||||||
|
"order": estimation,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
new_points = new_points.id
|
||||||
|
result['change_old']["points"] = {obj.project.roles.get(slug="main").id: old_points}
|
||||||
|
result['change_new']["points"] = {obj.project.roles.get(slug="main").id: new_points}
|
||||||
|
|
||||||
|
if 'name' in change['new_values']:
|
||||||
|
result['change_old']["subject"] = change['original_values']['name']
|
||||||
|
result['change_new']["subject"] = change['new_values']['name']
|
||||||
|
|
||||||
|
if 'labels' in change['new_values']:
|
||||||
|
result['change_old']["tags"] = [l.lower() for l in change['original_values']['labels']]
|
||||||
|
result['change_new']["tags"] = [l.lower() for l in change['new_values']['labels']]
|
||||||
|
|
||||||
|
if 'current_state' in change['new_values']:
|
||||||
|
result['change_old']["status"] = obj.project.us_statuses.get(slug=change['original_values']['current_state']).id
|
||||||
|
result['change_new']["status"] = obj.project.us_statuses.get(slug=change['new_values']['current_state']).id
|
||||||
|
|
||||||
|
if 'story_type' in change['new_values']:
|
||||||
|
if "custom_attributes" not in result['change_old']:
|
||||||
|
result['change_old']["custom_attributes"] = []
|
||||||
|
if "custom_attributes" not in result['change_new']:
|
||||||
|
result['change_new']["custom_attributes"] = []
|
||||||
|
|
||||||
|
result['change_old']["custom_attributes"].append({
|
||||||
|
"name": "Type",
|
||||||
|
"value": change['original_values']['story_type'],
|
||||||
|
"id": story_type_field.id
|
||||||
|
})
|
||||||
|
result['change_new']["custom_attributes"].append({
|
||||||
|
"name": "Type",
|
||||||
|
"value": change['new_values']['story_type'],
|
||||||
|
"id": story_type_field.id
|
||||||
|
})
|
||||||
|
|
||||||
|
if 'deadline' in change['new_values']:
|
||||||
|
if "custom_attributes" not in result['change_old']:
|
||||||
|
result['change_old']["custom_attributes"] = []
|
||||||
|
if "custom_attributes" not in result['change_new']:
|
||||||
|
result['change_new']["custom_attributes"] = []
|
||||||
|
|
||||||
|
result['change_old']["custom_attributes"].append({
|
||||||
|
"name": "Due date",
|
||||||
|
"value": change['original_values']['deadline'],
|
||||||
|
"id": due_date_field.id
|
||||||
|
})
|
||||||
|
result['change_new']["custom_attributes"].append({
|
||||||
|
"name": "Due date",
|
||||||
|
"value": change['new_values']['deadline'],
|
||||||
|
"id": due_date_field.id
|
||||||
|
})
|
||||||
|
|
||||||
|
# TODO: Process owners_ids
|
||||||
|
|
||||||
|
elif activity['kind'] == "task_create_activity":
|
||||||
|
return None
|
||||||
|
elif activity['kind'] == "task_update_activity":
|
||||||
|
for change in activity['changes']:
|
||||||
|
if change['change_type'] != "update" or change['kind'] != "task":
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
task = Task.objects.get(project=obj.project, ref=change['id'])
|
||||||
|
if 'description' in change['new_values']:
|
||||||
|
result['change_old']["subject"] = change['original_values']['description']
|
||||||
|
result['change_new']["subject"] = change['new_values']['description']
|
||||||
|
result['obj'] = task
|
||||||
|
if 'complete' in change['new_values']:
|
||||||
|
result['change_old']["status"] = obj.project.task_statuses.get(slug="complete" if change['original_values']['complete'] else "incomplete").id
|
||||||
|
result['change_new']["status"] = obj.project.task_statuses.get(slug="complete" if change['new_values']['complete'] else "incomplete").id
|
||||||
|
result['obj'] = task
|
||||||
|
except Task.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
elif activity['kind'] == "comment_create_activity":
|
||||||
|
return None
|
||||||
|
elif activity['kind'] == "comment_update_activity":
|
||||||
|
return None
|
||||||
|
elif activity['kind'] == "story_move_activity":
|
||||||
|
return None
|
||||||
|
return result
|
|
@ -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 PivotalImporter
|
||||||
|
|
||||||
|
logger = logging.getLogger('taiga.importers.pivotal')
|
||||||
|
|
||||||
|
|
||||||
|
@app.task(bind=True)
|
||||||
|
def import_project(self, user_id, token, project_id, options):
|
||||||
|
user = User.object.get(id=user_id)
|
||||||
|
importer = PivotalImporter(user, token)
|
||||||
|
try:
|
||||||
|
project = importer.import_project(project_id, options)
|
||||||
|
except Exception as e:
|
||||||
|
# Error
|
||||||
|
ctx = {
|
||||||
|
"user": user,
|
||||||
|
"error_subject": _("Error importing pivotal project"),
|
||||||
|
"error_message": _("Error importing pivotal project"),
|
||||||
|
"project": project_id,
|
||||||
|
"exception": e
|
||||||
|
}
|
||||||
|
email = mail_builder.pivotal_import_error(admin, ctx)
|
||||||
|
email.send()
|
||||||
|
logger.error('Error importing pivotal project %s (by %s)', project_id, user, exc_info=sys.exc_info())
|
||||||
|
else:
|
||||||
|
ctx = {
|
||||||
|
"project": project,
|
||||||
|
"user": user,
|
||||||
|
}
|
||||||
|
email = mail_builder.pivotal_import_success(user, ctx)
|
||||||
|
email.send()
|
Loading…
Reference in New Issue