Skip to content

Commit

Permalink
Add connector_jira_servicedesk
Browse files Browse the repository at this point in the history
Map projects by external_id + set of jira orgs

Project bindings now can be assigned to one or more jira organizations.
The binding for the project accept an additional argument for
organizations. A task will be linked with the project having the exact
same set of organizations that it has, or fallback to a project without
organization.

A constraint ensures that you cannot have several projects with the same
set of organizations or 2 projects without organization.

The link wizard has a new step to select the organization.

The REST API for Serviced Desk is a different one.

The former code was based on pycontribs/jira#388
which is closed and unmaintained. We only need to read the organizations
from the servicedesk REST API and the local code is minimal.

We can now use the normal jira library.
  • Loading branch information
p-tombez authored and leemannd committed Feb 10, 2021
1 parent 7856ada commit 5b5ea4e
Show file tree
Hide file tree
Showing 21 changed files with 561 additions and 0 deletions.
1 change: 1 addition & 0 deletions connector_jira_servicedesk/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import models
20 changes: 20 additions & 0 deletions connector_jira_servicedesk/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html)

{
'name': 'JIRA Connector ServiceDesk',
'version': '11.0.1.0.0',
'author': 'Camptocamp,Odoo Community Association (OCA)',
'license': 'AGPL-3',
'category': 'Connector',
'depends': [
'connector_jira',
],
'website': 'https://www.camptocamp.com',
'data': [
'views/jira_backend_views.xml',
'views/project_project_views.xml',
'views/project_link_jira_views.xml',
'security/ir.model.access.csv',
],
'installable': True,
}
5 changes: 5 additions & 0 deletions connector_jira_servicedesk/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from . import account_analytic_line
from . import jira_backend
from . import project_project
from . import jira_organization
from . import project_task
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import importer
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Copyright 2019 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html)

from odoo.addons.component.core import Component


class AnalyticLineImporter(Component):
_inherit = 'jira.analytic.line.importer'

@property
def _issue_fields_to_read(self):
issue_fields = super()._issue_fields_to_read
organization_field_name = self.backend_record.organization_field_name
if not organization_field_name:
return issue_fields
return issue_fields + [organization_field_name]
1 change: 1 addition & 0 deletions connector_jira_servicedesk/models/jira_backend/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import common
50 changes: 50 additions & 0 deletions connector_jira_servicedesk/models/jira_backend/common.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Copyright 2016 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html)

from odoo import models, api, fields


class JiraBackend(models.Model):
_inherit = 'jira.backend'

organization_ids = fields.One2many(
comodel_name='jira.organization',
inverse_name='backend_id',
string='Organizations',
readonly=True,
)

organization_field_name = fields.Char(
string='Organization Field',
help="The 'Organization' field on JIRA is a custom field. "
"The name of the field is something like 'customfield_10002'. "
)

@api.model
def _selection_project_template(self):
selection = super()._selection_project_template()
selection += [
('Basic', 'Basic (Service Desk)'),
('IT Service Desk', 'IT Service Desk (Service Desk)'),
('Customer service', 'Customer Service (Service Desk)'),
]
return selection

@api.multi
def import_organization(self):
self.env['jira.organization'].import_batch(self)
return True

@api.multi
def activate_organization(self):
"""Get organization field name from JIRA web-service"""
self.ensure_one()
org_field = 'com.atlassian.servicedesk:sd-customer-organizations'
with self.work_on('jira.backend') as work:
adapter = work.component(usage='backend.adapter')
jira_fields = adapter.list_fields()
for field in jira_fields:
custom_ref = field.get('schema', {}).get('custom')
if custom_ref == org_field:
self.organization_field_name = field['id']
break
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from . import common
from . import importer
from . import adapter
61 changes: 61 additions & 0 deletions connector_jira_servicedesk/models/jira_organization/adapter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Copyright 2018 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html)

import jira
from jira.utils import CaseInsensitiveDict

from odoo.addons.component.core import Component


class Organization(jira.resources.Resource):
"""A Service Desk Organization."""

def __init__(self, options, session, raw=None):
super().__init__(
'organization/{0}',
options,
session,
'{server}/rest/servicedeskapi/{path}'
)
if raw:
self._parse_raw(raw)


class OrganizationAdapter(Component):
_name = 'jira.organization.adapter'
_inherit = ['jira.webservice.adapter']
_apply_on = ['jira.organization']

# The Service Desk REST API returns an error if this header
# is not used. The API may change so they want an agreement for
# the client about this.
_desk_headers = CaseInsensitiveDict({'X-ExperimentalApi': 'opt-in'})

def __init__(self, work_context):
super().__init__(work_context)
self.client._session.headers.update(self._desk_headers)

def read(self, id_):
organization = Organization(
self.client._options,
self.client._session
)
organization.find(id_)
return organization.raw

def search(self):
base = (self.client._options['server'] +
'/rest/servicedeskapi/organization')
# By default, a GET on the REST API returns only one page with the
# first 50 rows. Here, client is an instance of the jira library's JIRA
# class, which provides a _fetch_pages method to fetch pages.
# maxResults=False means it will try to get all pages.
orgs = self.client._fetch_pages(
Organization,
'values',
'organization',
# limit to False will get them in batch
maxResults=False,
base=base
)
return [org.id for org in orgs]
30 changes: 30 additions & 0 deletions connector_jira_servicedesk/models/jira_organization/common.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Copyright 2016 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html)


from odoo import fields, models
from odoo.addons.queue_job.job import job


class JiraOrganization(models.Model):
_name = 'jira.organization'
_inherit = 'jira.binding'
_description = 'Jira Organization'

name = fields.Char('Name', required=True, readonly=True)
backend_id = fields.Many2one(
ondelete='cascade'
)
project_ids = fields.Many2many(
comodel_name='jira.project.project'
)

@job(default_channel='root.connector_jira.import')
def import_batch(self, backend, from_date=None, to_date=None):
""" Prepare a batch import of organization from Jira
from_date and to_date are ignored for organization
"""
with backend.work_on(self._name) as work:
importer = work.component(usage='batch.importer')
importer.run()
36 changes: 36 additions & 0 deletions connector_jira_servicedesk/models/jira_organization/importer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Copyright 2016 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html)

from odoo.addons.connector.components.mapper import mapping

from odoo.addons.component.core import Component


class OrganizationMapper(Component):
_name = 'jira.organization.mapper'
_inherit = ['base.import.mapper']
_apply_on = 'jira.organization'

direct = [
('name', 'name'),
]

@mapping
def backend_id(self, record):
return {'backend_id': self.backend_record.id}


class OrganizationBatchImporter(Component):
""" Import the Jira Organizations
For every id in in the list of organizations, a direct import is done.
"""
_name = 'jira.organization.batch.importer'
_inherit = 'jira.direct.batch.importer'
_apply_on = ['jira.organization']

def run(self):
""" Run the synchronization """
record_ids = self.backend_adapter.search()
for record_id in record_ids:
self._import_record(record_id)
3 changes: 3 additions & 0 deletions connector_jira_servicedesk/models/project_project/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from . import common
from . import binder
from . import project_link_jira
74 changes: 74 additions & 0 deletions connector_jira_servicedesk/models/project_project/binder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Copyright 2016-2018 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html)

import logging

from odoo import tools
from odoo.addons.component.core import Component

_logger = logging.getLogger(__name__)


class JiraProjectBinder(Component):
_name = 'jira.project.binder'
_inherit = 'jira.binder'

_apply_on = [
'jira.project.project',
]

def to_internal(self, external_id, unwrap=False, organizations=None):
""" Give the Odoo recordset for an external ID
When organizations are passed (ids are odoo ids), the binder
will return:
* a project linked with JIRA with the exact set of organizations
* if no project has the exact same set, a project linked without
organization set on the binding
If no organizations are passed, only project bindings
without organization match.
:param external_id: external ID for which we want
the Odoo ID
:param unwrap: if True, returns the normal record
else return the binding record
:param organizations: jira.organization recordset
:return: a recordset, depending on the value of unwrap,
or an empty recordset if the external_id is not mapped
:rtype: recordset
"""
domain = [
(self._external_field, '=', tools.ustr(external_id)),
(self._backend_field, '=', self.backend_record.id),
]
if not organizations:
domain.append(
('organization_ids', '=', False),
)
candidates = self.model.with_context(active_test=False).search(domain)
if organizations:
fallback = self.model.browse()
binding = self.model.browse()
for candidate in candidates:
if not candidate.organization_ids:
fallback = candidate
continue

if candidate.organization_ids == organizations:
binding = candidate
break
if not binding:
binding = fallback
else:
binding = candidates

if not binding:
if unwrap:
return self.model.browse()[self._odoo_field]
return self.model.browse()
binding.ensure_one()
if unwrap:
binding = binding[self._odoo_field]
return binding
49 changes: 49 additions & 0 deletions connector_jira_servicedesk/models/project_project/common.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Copyright 2019 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html)

from odoo import api, fields, models, exceptions, _


class JiraProjectBaseFields(models.AbstractModel):
"""JIRA Project Base fields
Shared by the binding jira.project.project
and the wizard to link/create a JIRA project
"""
_inherit = 'jira.project.base.mixin'

organization_ids = fields.Many2many(
comodel_name='jira.organization',
string='Organization(s) on Jira',
domain="[('backend_id', '=', backend_id)]",
help="If organizations are set, a task will be "
"added to the project only if the project AND "
"the organization match with the selection."
)


class JiraProjectProject(models.Model):
_inherit = 'jira.project.project'

@api.constrains('backend_id', 'external_id', 'organization_ids')
@api.multi
def _constrains_jira_uniq(self):
for binding in self:
same_link_bindings = self.search([
('id', '!=', self.id),
('backend_id', '=', self.backend_id.id),
('external_id', '=', self.external_id),
])
for other in same_link_bindings:
my_orgs = binding.organization_ids
other_orgs = other.organization_ids
if not my_orgs and not other_orgs:
raise exceptions.ValidationError(_(
"The project %s is already linked with the same"
" JIRA project without organization."
) % (other.display_name))
if set(my_orgs.ids) == set(other_orgs.ids):
raise exceptions.ValidationError(_(
"The project %s is already linked with this "
"JIRA project and similar organizations."
) % (other.display_name))
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Copyright 2018 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html)

import logging

from odoo import api, models

_logger = logging.getLogger(__name__)


class ProjectLinkJira(models.TransientModel):
_inherit = 'project.link.jira'

@api.model
def _selection_state(self):
states = super()._selection_state()
states.append(('link_organizations', 'Link Organizations'))
return states

def state_exit_start(self):
if self.sync_action == 'link':
self.state = 'link_organizations'
else:
super().state_exit_start()

def state_exit_link_organizations(self):
if not self.jira_project_id:
self._link_binding()
self.state = 'issue_types'

def _prepare_link_binding_values(self, jira_project):
values = super()._prepare_link_binding_values(jira_project)
values['organization_ids'] = [(6, 0, self.organization_ids.ids)]
return values
Loading

0 comments on commit 5b5ea4e

Please sign in to comment.