From 2edaecae686376688541328f1332eb2d1d1c238d Mon Sep 17 00:00:00 2001 From: Patrick Tombez Date: Tue, 29 Jan 2019 15:50:25 +0100 Subject: [PATCH 01/19] Add connector_jira_servicedesk 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 https://github.com/pycontribs/jira/pull/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. --- connector_jira_servicedesk/__init__.py | 1 + connector_jira_servicedesk/__manifest__.py | 20 +++++ connector_jira_servicedesk/models/__init__.py | 5 ++ .../models/account_analytic_line/__init__.py | 1 + .../models/account_analytic_line/importer.py | 16 ++++ .../models/jira_backend/__init__.py | 1 + .../models/jira_backend/common.py | 50 +++++++++++++ .../models/jira_organization/__init__.py | 3 + .../models/jira_organization/adapter.py | 61 +++++++++++++++ .../models/jira_organization/common.py | 30 ++++++++ .../models/jira_organization/importer.py | 36 +++++++++ .../models/project_project/__init__.py | 3 + .../models/project_project/binder.py | 74 +++++++++++++++++++ .../models/project_project/common.py | 49 ++++++++++++ .../project_project/project_link_jira.py | 34 +++++++++ .../models/project_task/__init__.py | 1 + .../models/project_task/importer.py | 59 +++++++++++++++ .../security/ir.model.access.csv | 3 + .../views/jira_backend_views.xml | 55 ++++++++++++++ .../views/project_link_jira_views.xml | 31 ++++++++ .../views/project_project_views.xml | 28 +++++++ 21 files changed, 561 insertions(+) create mode 100644 connector_jira_servicedesk/__init__.py create mode 100644 connector_jira_servicedesk/__manifest__.py create mode 100644 connector_jira_servicedesk/models/__init__.py create mode 100644 connector_jira_servicedesk/models/account_analytic_line/__init__.py create mode 100644 connector_jira_servicedesk/models/account_analytic_line/importer.py create mode 100644 connector_jira_servicedesk/models/jira_backend/__init__.py create mode 100644 connector_jira_servicedesk/models/jira_backend/common.py create mode 100644 connector_jira_servicedesk/models/jira_organization/__init__.py create mode 100644 connector_jira_servicedesk/models/jira_organization/adapter.py create mode 100644 connector_jira_servicedesk/models/jira_organization/common.py create mode 100644 connector_jira_servicedesk/models/jira_organization/importer.py create mode 100644 connector_jira_servicedesk/models/project_project/__init__.py create mode 100644 connector_jira_servicedesk/models/project_project/binder.py create mode 100644 connector_jira_servicedesk/models/project_project/common.py create mode 100644 connector_jira_servicedesk/models/project_project/project_link_jira.py create mode 100644 connector_jira_servicedesk/models/project_task/__init__.py create mode 100644 connector_jira_servicedesk/models/project_task/importer.py create mode 100644 connector_jira_servicedesk/security/ir.model.access.csv create mode 100644 connector_jira_servicedesk/views/jira_backend_views.xml create mode 100644 connector_jira_servicedesk/views/project_link_jira_views.xml create mode 100644 connector_jira_servicedesk/views/project_project_views.xml diff --git a/connector_jira_servicedesk/__init__.py b/connector_jira_servicedesk/__init__.py new file mode 100644 index 00000000..0650744f --- /dev/null +++ b/connector_jira_servicedesk/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/connector_jira_servicedesk/__manifest__.py b/connector_jira_servicedesk/__manifest__.py new file mode 100644 index 00000000..cbfef064 --- /dev/null +++ b/connector_jira_servicedesk/__manifest__.py @@ -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, +} diff --git a/connector_jira_servicedesk/models/__init__.py b/connector_jira_servicedesk/models/__init__.py new file mode 100644 index 00000000..6b2bb84b --- /dev/null +++ b/connector_jira_servicedesk/models/__init__.py @@ -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 diff --git a/connector_jira_servicedesk/models/account_analytic_line/__init__.py b/connector_jira_servicedesk/models/account_analytic_line/__init__.py new file mode 100644 index 00000000..35099a47 --- /dev/null +++ b/connector_jira_servicedesk/models/account_analytic_line/__init__.py @@ -0,0 +1 @@ +from . import importer diff --git a/connector_jira_servicedesk/models/account_analytic_line/importer.py b/connector_jira_servicedesk/models/account_analytic_line/importer.py new file mode 100644 index 00000000..b8004751 --- /dev/null +++ b/connector_jira_servicedesk/models/account_analytic_line/importer.py @@ -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] diff --git a/connector_jira_servicedesk/models/jira_backend/__init__.py b/connector_jira_servicedesk/models/jira_backend/__init__.py new file mode 100644 index 00000000..e4193cf0 --- /dev/null +++ b/connector_jira_servicedesk/models/jira_backend/__init__.py @@ -0,0 +1 @@ +from . import common diff --git a/connector_jira_servicedesk/models/jira_backend/common.py b/connector_jira_servicedesk/models/jira_backend/common.py new file mode 100644 index 00000000..1cf70d6f --- /dev/null +++ b/connector_jira_servicedesk/models/jira_backend/common.py @@ -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 diff --git a/connector_jira_servicedesk/models/jira_organization/__init__.py b/connector_jira_servicedesk/models/jira_organization/__init__.py new file mode 100644 index 00000000..c6fe6b53 --- /dev/null +++ b/connector_jira_servicedesk/models/jira_organization/__init__.py @@ -0,0 +1,3 @@ +from . import common +from . import importer +from . import adapter diff --git a/connector_jira_servicedesk/models/jira_organization/adapter.py b/connector_jira_servicedesk/models/jira_organization/adapter.py new file mode 100644 index 00000000..6d558462 --- /dev/null +++ b/connector_jira_servicedesk/models/jira_organization/adapter.py @@ -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] diff --git a/connector_jira_servicedesk/models/jira_organization/common.py b/connector_jira_servicedesk/models/jira_organization/common.py new file mode 100644 index 00000000..255ce52c --- /dev/null +++ b/connector_jira_servicedesk/models/jira_organization/common.py @@ -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() diff --git a/connector_jira_servicedesk/models/jira_organization/importer.py b/connector_jira_servicedesk/models/jira_organization/importer.py new file mode 100644 index 00000000..92120070 --- /dev/null +++ b/connector_jira_servicedesk/models/jira_organization/importer.py @@ -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) diff --git a/connector_jira_servicedesk/models/project_project/__init__.py b/connector_jira_servicedesk/models/project_project/__init__.py new file mode 100644 index 00000000..87c19778 --- /dev/null +++ b/connector_jira_servicedesk/models/project_project/__init__.py @@ -0,0 +1,3 @@ +from . import common +from . import binder +from . import project_link_jira diff --git a/connector_jira_servicedesk/models/project_project/binder.py b/connector_jira_servicedesk/models/project_project/binder.py new file mode 100644 index 00000000..103807c4 --- /dev/null +++ b/connector_jira_servicedesk/models/project_project/binder.py @@ -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 diff --git a/connector_jira_servicedesk/models/project_project/common.py b/connector_jira_servicedesk/models/project_project/common.py new file mode 100644 index 00000000..857a9de2 --- /dev/null +++ b/connector_jira_servicedesk/models/project_project/common.py @@ -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)) diff --git a/connector_jira_servicedesk/models/project_project/project_link_jira.py b/connector_jira_servicedesk/models/project_project/project_link_jira.py new file mode 100644 index 00000000..e6b4f779 --- /dev/null +++ b/connector_jira_servicedesk/models/project_project/project_link_jira.py @@ -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 diff --git a/connector_jira_servicedesk/models/project_task/__init__.py b/connector_jira_servicedesk/models/project_task/__init__.py new file mode 100644 index 00000000..35099a47 --- /dev/null +++ b/connector_jira_servicedesk/models/project_task/__init__.py @@ -0,0 +1 @@ +from . import importer diff --git a/connector_jira_servicedesk/models/project_task/importer.py b/connector_jira_servicedesk/models/project_task/importer.py new file mode 100644 index 00000000..76d98201 --- /dev/null +++ b/connector_jira_servicedesk/models/project_task/importer.py @@ -0,0 +1,59 @@ +# Copyright 2016 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo.addons.component.core import Component + + +class ProjectTaskProjectMatcher(Component): + _inherit = 'jira.task.project.matcher' + + def find_project_binding(self, jira_task_data, unwrap=False): + organizations = self.env['jira.organization'].browse() + jira_org_ids = self.component( + usage='organization.from.task' + ).get_jira_org_ids(jira_task_data) + binder = self.binder_for('jira.organization') + for jira_org_id in jira_org_ids: + organizations |= binder.to_internal(jira_org_id) + jira_project_id = jira_task_data['fields']['project']['id'] + binder = self.binder_for('jira.project.project') + return binder.to_internal( + jira_project_id, + unwrap=unwrap, + organizations=organizations, + ) + + +class OrganizationsFromTask(Component): + _name = 'jira.organization.from.task' + _inherit = ['jira.base'] + _usage = 'organization.from.task' + + def get_jira_org_ids(self, jira_task_data): + organization_field_name = self.backend_record.organization_field_name + if not organization_field_name: + return [] + + task_fields = jira_task_data.get('fields', {}) + return [ + rec['id'] for rec in + task_fields.get(organization_field_name) or [] + ] + + +class ProjectTaskImporter(Component): + _inherit = 'jira.project.task.importer' + + def _get_external_data(self): + """Return the raw Jira data for ``self.external_id``""" + result = super()._get_external_data() + return result + + def _import_dependencies(self): + """Import the dependencies for the record""" + super()._import_dependencies() + jira_org_ids = self.component( + usage='organization.from.task' + ).get_jira_org_ids(self.external_record) + for jira_org_id in jira_org_ids: + self._import_dependency(jira_org_id, 'jira.organization') diff --git a/connector_jira_servicedesk/security/ir.model.access.csv b/connector_jira_servicedesk/security/ir.model.access.csv new file mode 100644 index 00000000..58bb1663 --- /dev/null +++ b/connector_jira_servicedesk/security/ir.model.access.csv @@ -0,0 +1,3 @@ +"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink" +access_jira_organization,access_jira_organization,model_jira_organization,base.group_user,1,0,0,0 +access_jira_organization_manager,access_jira_organization connector manager,model_jira_organization,connector.group_connector_manager,1,1,1,1 diff --git a/connector_jira_servicedesk/views/jira_backend_views.xml b/connector_jira_servicedesk/views/jira_backend_views.xml new file mode 100644 index 00000000..88f3fc81 --- /dev/null +++ b/connector_jira_servicedesk/views/jira_backend_views.xml @@ -0,0 +1,55 @@ + + + + + + jira.backend.form + jira.backend + + + + +
+
+