diff --git a/connector_jira/README.rst b/connector_jira/README.rst new file mode 100644 index 000000000..af9694024 --- /dev/null +++ b/connector_jira/README.rst @@ -0,0 +1,284 @@ +============== +JIRA Connector +============== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:f21e9da36fa047bba211c31662246c7856a1144d0f66ee163b201f26f7f1934f + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fconnector--jira-lightgray.png?logo=github + :target: https://github.com/OCA/connector-jira/tree/17.0/connector_jira + :alt: OCA/connector-jira +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/connector-jira-17-0/connector-jira-17-0-connector_jira + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/connector-jira&target_branch=17.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module adds Jira synchronization feature. It works with Jira Cloud +by behaving as an Atlassian Connect App. + +**Table of contents** + +.. contents:: + :local: + +Installation +============ + +You need the following Python packages: + +- requests +- jira +- oauthlib +- requests-oauthlib +- requests-toolbelt +- PyJWT +- cryptography +- atlassian-jwt + +Once the addon is installed, follow these steps: + +Job Queue +--------- + +In ``odoo.conf``, configure similarly: + +:: + + [queue_job] + channels = root:1,root.connector_jira.import:2 + +Backend +------- + +1. Open the menu Connectors > Jira > Backends +2. Create a new Jira Backend + + - Put the name you want + - You can also select the company where records will be created and + the default project template used when Odoo will create the + projects in Jira + - Save + +3. Make note of the value of the App Descriptor URL (important: make + sure that the system parameter web.base.url is set properly. For + local development you will want to use ngrok to make your computer + reachable over https from Jira Cloud). + +Installing the backend as a Jira App +------------------------------------ + +In case this gets outdated, refer to +https://developer.atlassian.com/platform/marketplace/listing-connect-apps/#list-a-connect-app + +1. Login on marketplace.atlassian.com (possibly create an account) +2. On the top right corner, the icon with your avatar leads to a menu -> + select the Publish an App entry +3. On the Publish a new app screen: + + - select a Vendor (normally your company) + - upload your app: select Provide a URL to your artifact + - click on the Enter URL button + - paste the App Descriptor URL in the pop-up and click on the Done + button + - the Name field should get populated from the name of your backend + - Compatible application: select Jira + - build number: can be kept as is + +4. Click on the Save as private button (!) Important: do not click the + "Next: Make public" button. That flow would allow anyone on Jira + Cloud to install your backend. +5. On the next screen, you can go to the "Private Listings" page, and + click on the "Create a token" button: this token can be used to + install the app on your Jira instance. + +Installing the Jira App on your Jira Cloud instance +--------------------------------------------------- + +1. Connect to your Jira instance with an account with Admin access +2. In the Apps menu, select Manage your apps +3. In the Apps screen, click on the Settings link which is under the + User-installed apps list +4. In the settings screen, check the Enable private listings box, and + click on Apply +5. Refresh the Apps page: you should see an Upload app link: click on it +6. On the Upload app dialog, paste the token URL you received in the + previous procedure, and click on Upload + +Configuration of the Backend +---------------------------- + +Going back to Odoo, the backend should now be in the Running state, with +some information filled in, such as the Jira URI. + +**Configure the Epic Link** + +If you use Epics, you need to click on "Configure Epic Link", Odoo will +search the name of the custom field used for the Epic Link. + +Congratulations, you're done! + +Usage +===== + +The tasks and worklogs are always imported from JIRA to Odoo, there is +no synchronization in the other direction. + +Initial synchronizations +------------------------ + +You can already select the "Imports" tab in the Backend and click on +"Link users" and "Import issue types". The users will be matched either +by login or by email. + +Create and export a project +--------------------------- + +Projects can be created in Odoo and exported to Jira. You can then +create a project, and use the action "Link with JIRA" and use the +"Export to JIRA" action. + +When you choose to export a project to JIRA, if you change the name or +the key of the project, the new values will be pushed to JIRA. + +Link a project with JIRA +------------------------ + +If you already have a project on JIRA or prefer to create it first on +JIRA, you can link an Odoo project. Use the "Link with JIRA" action on +the project and select the "Link with JIRA" action. + +This action establish the link, then changes of the name or the key on +either side are not pushed. + +Issue Types on Projects +----------------------- + +When you link a project, you have to select which issue types are +synchronized. Only tasks of the selected types will be created in Odoo. + +If a JIRA worklog is added to a type of issue that is not synchronized, +will attach to the closest task following these rules: + +- if a subtask, find the parent task +- if no parent task, find the epic task (only if it is on the same + project) +- if no epic, attach to the project without being linked to a task + +Change synchronization configuration on a project +------------------------------------------------- + +If you want to change the configuration of a project, such as which +issue types are synchronized, you can open the "Connector" tab in the +project settings and edit the "binding" with the backend. + +Synchronize tasks and worklogs +------------------------------ + +If the webhooks are active, as soon as they are created in Jira they +should appear in Odoo. If they are not active, you can open the Jira +Backend and run the synchronizations manually, or activate the Scheduled +Actions to run the batch imports. It is important to select the issue +types so don't miss this step (need improvement). + +Known issues / Roadmap +====================== + +- If an odoo user has no linked employee, worklogs will still be + imported but with no employee. + +**Allowing several bindings per project** + +The design evolved to allow more than one Jira binding per project in +Odoo. This conveniently allows to fetch tasks and worklogs for many +projects in Jira, which will be tracked in only one project in Odoo. + +In order to push data to Jira, we have to apply restrictions on these +"multi-bindings" projects, as we cannot know to which binding data must +be pushed: + +- Not more than one project (can be zero) can have a "Sync Action" set + to "Export to JIRA". As this configuration pushes the name and key of + the project to Jira, we cannot push it to more than one project. +- If we implement push of tasks to Jira, we'll have to add a way to + restrict or choose to which project we push the task, this is not + supported yet (for instance, add a Boolean "export tasks" on the + project binding, or explicitly select the target binding on the task) +- Now that the webhooks are authenticated, use the values sent by the + webhooks rather than querying them back +- We now can have multiple backends, registering multiple webhooks. If + we want to use this in practice, testing must be done and probably + some things will need fixing. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Camptocamp + +Contributors +------------ + +- `Camptocamp `__: + + - Damien Crier + - Thierry Ducrest + - Tonow-c2c + - Simone Orsi + - Timon Tschanz + - jcoux + - Patrick Tombez + - Guewen Baconnier + - Akim Juillerat + - Alexandre Fayolle + +- `CorporateHub `__ + + - Alexey Pelykh + +- `Trobz `__: + + - Son Ho + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/connector-jira `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/connector_jira/__init__.py b/connector_jira/__init__.py new file mode 100644 index 000000000..bc1168d20 --- /dev/null +++ b/connector_jira/__init__.py @@ -0,0 +1,8 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from . import fields +from . import components +from . import controllers +from . import models +from . import reports +from . import wizards diff --git a/connector_jira/__manifest__.py b/connector_jira/__manifest__.py new file mode 100644 index 000000000..4b3fa04a7 --- /dev/null +++ b/connector_jira/__manifest__.py @@ -0,0 +1,65 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +{ + "name": "JIRA Connector", + "version": "17.0.1.0.0", + "author": "Camptocamp,Odoo Community Association (OCA)", + "license": "AGPL-3", + "category": "Connector", + "depends": [ + # Odoo community + "project", + "hr_timesheet", + "web", + # OCA/connector + "connector", + # OCA/queue + "queue_job", + # OCA/server-ux + "multi_step_wizard", + # OCA/web + "web_widget_url_advanced", + ], + "external_dependencies": { + "python": [ + "requests>=2.21.0", + "jira==3.6.0", + "oauthlib>=2.1.0", + "requests-oauthlib>=1.1.0", + "requests-toolbelt>=0.9.1", + "requests-jwt>=0.6.0", + "PyJWT>=1.7.1,<2.9.0", + "cryptography>=38,<39", # Compatibility w/ Odoo 17.0 requirements + "atlassian_jwt>=3.0.0", + ], + }, + "website": "https://github.com/OCA/connector-jira", + "data": [ + # SECURITY + "security/ir.model.access.csv", + # DATA + "data/cron.xml", + "data/queue_job_channel.xml", + "data/queue_job_function.xml", + # VIEWS + # This file contains the root menu, import it first + "views/jira_menus.xml", + # Views, actions, menus + "views/account_analytic_line.xml", + "views/jira_backend.xml", + "views/jira_backend_report_templates.xml", + "views/jira_issue_type.xml", + "views/jira_project_project.xml", + "views/jira_project_task.xml", + "views/jira_res_users.xml", + "views/project_project.xml", + "views/project_task.xml", + "views/res_users.xml", + # Wizard views + "wizards/jira_account_analytic_line_import.xml", + "wizards/project_link_jira.xml", + "wizards/task_link_jira.xml", + ], + "demo": ["demo/jira_backend_demo.xml"], + "installable": True, +} diff --git a/connector_jira/components/__init__.py b/connector_jira/components/__init__.py new file mode 100644 index 000000000..c866b2b27 --- /dev/null +++ b/connector_jira/components/__init__.py @@ -0,0 +1,53 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +# ⚠️⚠️⚠️ +# 1) in order to ease readability and maintainability, components have been split into +# multiple files, each containing exactly 1 component +# 2) components' import is sorted so that no dependency issue should arise +# 3) next to each import, a comment will describe the components' dependencies +# 4) when adding new components, please make sure it inherits (directly or indirectly) +# from ``jira.base`` +# ⚠️⚠️⚠️ + +# Base abstract component +from . import jira_base # base.connector + +# Inheriting abstract components +from . import jira_base_exporter # base.exporter, jira.base +from . import jira_batch_importer # base.importer, jira.base +from . import jira_delayed_batch_importer # jira.batch.importer +from . import jira_direct_batch_importer # jira.batch.importer +from . import jira_import_mapper # base.import.mapper, jira.base +from . import jira_timestamp_batch_importer # base.importer, jira.base + +# Generic components +from . import jira_binder # base.binder, jira.base +from . import jira_deleter # base.deleter, jira.base +from . import jira_exporter # jira.base.exporter +from . import jira_importer # base.importer, jira.base +from . import jira_webservice_adapter # base.backend.adapter.crud, jira.base + +# Specific components +from . import jira_analytic_line_batch_importer # jira.timestamp.batch.importer +from . import jira_analytic_line_importer # jira.importer +from . import jira_analytic_line_mapper # jira.import.mapper +from . import jira_analytic_line_timestamp_batch_deleter # base.synchronizer, jira.base +from . import jira_backend_adapter # jira.webservice.adapter +from . import jira_issue_type_adapter # jira.webservice.adapter +from . import jira_issue_type_batch_importer # jira.direct.batch.importer +from . import jira_issue_type_mapper # jira.import.mapper +from . import jira_mapper_from_attrs # jira.base +from . import jira_model_binder # base.binder, jira.base +from . import jira_project_adapter # jira.webservice.adapter +from . import jira_project_binder # jira.binder +from . import jira_project_project_listener # base.connector.listener, jira.base +from . import jira_project_project_exporter # jira.exporter +from . import jira_project_task_adapter # jira.webservice.adapter +from . import jira_project_task_batch_importer # jira.timestamp.batch.importer +from . import jira_project_task_importer # jira.importer +from . import jira_project_task_mapper # jira.import.mapper +from . import jira_res_users_adapter # jira.webservice.adapter +from . import jira_res_users_importer # jira.importer +from . import jira_task_project_matcher # jira.base +from . import jira_worklog_adapter # jira.webservice.adapter +from . import project_project_listener # base.connector.listener, jira.base diff --git a/connector_jira/components/common.py b/connector_jira/components/common.py new file mode 100644 index 000000000..bb6c7cc9d --- /dev/null +++ b/connector_jira/components/common.py @@ -0,0 +1,161 @@ +# Copyright 2016 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from datetime import datetime + +import pytz +from dateutil import parser + +from odoo import fields + +JIRA_JQL_DATETIME_FORMAT = "%Y-%m-%d %H:%M" # no seconds :-( +RETRY_ON_ADVISORY_LOCK = 1 # seconds +RETRY_WHEN_CONCURRENT_DETECTED = 1 # seconds +# when we import using JQL, we always import tasks from +# slightly before the last batch import, because Jira +# does not send the results from the past minute and +# maybe sometimes more +IMPORT_DELTA = 300 # seconds + + +def iso8601_to_utc_datetime(isodate): + """Returns the UTC datetime from an iso8601 date + + A JIRA date is formatted using the ISO 8601 format. + Example: 2013-11-04T13:52:01+0100 + """ + parsed = parser.parse(isodate) + if not parsed.tzinfo: + return parsed + # set as UTC and then remove the tzinfo so the date becomes naive + return parsed.astimezone(pytz.UTC).replace(tzinfo=None) + + +def utc_datetime_to_iso8601(dt): + """Returns an iso8601 datetime from a datetime. + + Example: 2013-11-04 12:52:01 → 2013-11-04T12:52:01+0000 + + """ + utc_dt = pytz.UTC.localize(dt, is_dst=False) # UTC = no DST + return utc_dt.isoformat() + + +def iso8601_to_utc(field): + """A modifier intended to be used on the ``direct`` mappings for + importers. + + A Jira date is formatted using the ISO 8601 format. + Convert an ISO 8601 timestamp to an UTC datetime as string + as expected by Odoo. + + Example: 2013-11-04T13:52:01+0100 -> 2013-11-04 12:52:01 + + Usage:: + + direct = [(iso8601_to_utc('date_field'), 'date_field')] + + :param field: name of the source field in the record + + """ + + def modifier(self, record, to_attr): + value = record.get(field) + if not value: + return False + utc_date = iso8601_to_utc_datetime(value) + return fields.Datetime.to_string(utc_date) + + return modifier + + +def iso8601_to_naive_date(isodate): + """Returns the naive date from an iso8601 date + + Keep only the date, when we want to keep only the naive date. + It's safe to extract it directly from the tz-aware timestamp. + Example with 2014-10-07T00:34:59+0200: we want 2014-10-07 and not + 2014-10-06 that we would have using the timestamp converted to UTC. + """ + return datetime.strptime(isodate[:10], "%Y-%m-%d").date() + + +def iso8601_naive_date(field): + """A modifier intended to be used on the ``direct`` mappings for + importers. + + A JIRA datetime is formatted using the ISO 8601 format. + Returns the naive date from an iso8601 datetime. + + Keep only the date, when we want to keep only the naive date. + It's safe to extract it directly from the tz-aware timestamp. + Example with 2014-10-07T00:34:59+0200: we want 2014-10-07 and not + 2014-10-06 that we would have using the timestamp converted to UTC. + + Usage:: + + direct = [(iso8601_naive_date('name'), 'name')] + + :param field: name of the source field in the record + + """ + + def modifier(self, record, to_attr): + value = record.get(field) + if not value: + return False + naive_date = iso8601_to_naive_date(value) + return fields.Date.to_string(naive_date) + + return modifier + + +def follow_dict_path(field): + """A modifier intended to be used on ``direct`` mappings. + + 'Follows' children keys in dictionaries + If a key is missing along the path, ``None`` is returned. + + Examples: + Assuming a dict `{'a': {'b': 1}} + + direct = [ + (follow_dict_path('a.b'), 'cat')] + + Then 'cat' will be 1. + + :param field: field "path", using dots for subkeys + """ + + def modifier(self, record, to_attr): + attrs = field.split(".") + value = record + for attr in attrs: + value = value.get(attr) + if not value: + break + return value + + return modifier + + +def whenempty(field, default_value): + """Set a default value when the value is evaluated to False + + A modifier intended to be used on the ``direct`` mappings. + + Example:: + + direct = [(whenempty('source', 'default value'), 'target')] + + :param field: name of the source field in the record + :param default_value: value to set when the source value is False-ish + """ + + def modifier(self, record, to_attr): + value = record[field] + if not value: + return default_value + return value + + return modifier diff --git a/connector_jira/components/jira_analytic_line_batch_importer.py b/connector_jira/components/jira_analytic_line_batch_importer.py new file mode 100644 index 000000000..d19cee401 --- /dev/null +++ b/connector_jira/components/jira_analytic_line_batch_importer.py @@ -0,0 +1,78 @@ +# Copyright 2016 Camptocamp SA +# Copyright 2019 Brainbean Apps (https://brainbeanapps.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo.addons.component.core import Component + +from ..fields import MilliDatetime + + +class JiraAnalyticLineBatchImporter(Component): + """Import the Jira worklogs + + For every ID in the list, a delayed job is created. + Import is executed starting from a given date. + """ + + _name = "jira.analytic.line.batch.importer" + _inherit = "jira.timestamp.batch.importer" + _apply_on = ["jira.account.analytic.line"] + + def _search(self, timestamp): + unix_timestamp = MilliDatetime.to_timestamp(timestamp.last_timestamp) + result = self.backend_adapter.updated_since(since=unix_timestamp) + worklog_ids = self._filter_update(result.updated_worklogs) + # We need issue_id + worklog_id for the worklog importer (the jira + # "read" method for worklogs asks both), get it from yield_read. + # TODO we might consider to optimize the import process here: + # yield_read reads worklogs data, then the individual + # import will do a request again (and 2 with the tempo module) + next_timestamp = MilliDatetime.from_timestamp(result.until) + return next_timestamp, self.backend_adapter.yield_read(worklog_ids) + + def _handle_records(self, records, force=False): + number = 0 # Cannot use ``len(records)`` cause ``records`` is a generator + for worklog in records: + number += 1 + self._import_record(worklog["issueId"], worklog["id"], force=force) + return number + + def _filter_update(self, updated_worklogs): + """Filter only the worklogs needing an update + + The result from Jira contains the worklog id and + the last update on Jira. So we keep only the worklog + ids with a sync_date before the Jira last update. + """ + if not updated_worklogs: + return [] + self.env.cr.execute( + """ + SELECT external_id, jira_updated_at + FROM jira_account_analytic_line + WHERE external_id IN %s + """, + (tuple(str(r.worklog_id) for r in updated_worklogs),), + ) + bindings = dict(self.env.cr.fetchall()) + td, ft = MilliDatetime.to_datetime, MilliDatetime.from_timestamp + worklog_ids = [] + for worklog in updated_worklogs: + worklog_id = worklog.worklog_id + # we store the latest "updated_at" value on the binding + # so we can check if we already know the latest value, + # for instance because we imported the record from a + # webhook before, we can skip the import + binding_updated_at = bindings.get(str(worklog_id)) + if not binding_updated_at or td(binding_updated_at) < ft(worklog.updated): + worklog_ids.append(worklog_id) + return worklog_ids + + def _import_record(self, issue_id, worklog_id, force=False, **kwargs): + """Delay the import of the records""" + self.model.with_delay(**kwargs).import_record( + self.backend_record, + issue_id, + worklog_id, + force=force, + ) diff --git a/connector_jira/components/jira_analytic_line_importer.py b/connector_jira/components/jira_analytic_line_importer.py new file mode 100644 index 000000000..482bcc05b --- /dev/null +++ b/connector_jira/components/jira_analytic_line_importer.py @@ -0,0 +1,173 @@ +# Copyright 2016-2022 Camptocamp SA +# Copyright 2019 Brainbean Apps (https://brainbeanapps.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +import logging + +from odoo import _ + +from odoo.addons.component.core import Component + +from .common import iso8601_to_utc_datetime + +_logger = logging.getLogger(__name__) + + +class JiraAnalyticLineImporter(Component): + _name = "jira.analytic.line.importer" + _inherit = "jira.importer" + _apply_on = ["jira.account.analytic.line"] + + def __init__(self, work_context): + super().__init__(work_context) + self.external_issue_id = None + self.task_binding = None + self.project_binding = None + self.fallback_project = None + + def _get_external_updated_at(self): + assert self.external_record + external_updated_at = self.external_record.get("updated") + if not external_updated_at: + return None + return iso8601_to_utc_datetime(external_updated_at) + + @property + def _issue_fields_to_read(self): + epic_field_name = self.backend_record.epic_link_field_name + return ["issuetype", "project", "parent", epic_field_name] + + def _recurse_import_task(self): + """Import and return the task of proper type for the worklog + + As we decide which type of issues are imported for a project, + a worklog could be linked to an issue that we don't import. + In that case, we climb the parents of the issue until we find + a issue of a type we synchronize. + + It ensures that the 'to-be-linked' issue is imported and return it. + + """ + issue_adapter = self.component( + usage="backend.adapter", model_name="jira.project.task" + ) + issue_binder = self.binder_for("jira.project.task") + issue_type_binder = self.binder_for("jira.issue.type") + jira_issue_id = self.external_record["issueId"] + epic_field_name = self.backend_record.epic_link_field_name + project_matcher = self.component(usage="jira.task.project.matcher") + current_project_id = self.external_issue["fields"]["project"]["id"] + while jira_issue_id: + issue = issue_adapter.read(jira_issue_id, fields=self._issue_fields_to_read) + jira_project_id = issue["fields"]["project"]["id"] + jira_issue_type_id = issue["fields"]["issuetype"]["id"] + project_binding = project_matcher.find_project_binding(issue) + issue_type_binding = issue_type_binder.to_internal(jira_issue_type_id) + # JIRA allows to set an EPIC of a different project. + # If it happens, we discard it. + if ( + jira_project_id == current_project_id + and issue_type_binding.is_sync_for_project(project_binding) + ): + break + if issue["fields"].get("parent"): + # 'parent' is used on sub-tasks relating to their parent task + jira_issue_id = issue["fields"]["parent"]["id"] + elif issue["fields"].get(epic_field_name): + # the epic link is set on a jira custom field + epic_key = issue["fields"][epic_field_name] + epic = issue_adapter.read(epic_key, fields="id") + # we got the key of the epic issue, so we translate + # it to the ID with a call to the API + jira_issue_id = epic["id"] + else: + # no parent issue of a type we are synchronizing has been + # found, the worklog will be assigned to no task + jira_issue_id = None + + if jira_issue_id: + self._import_dependency(jira_issue_id, "jira.project.task") + return issue_binder.to_internal(jira_issue_id) + + def _create_data(self, map_record, **kwargs): + return super()._create_data( + map_record, + **dict( + kwargs or [], + task_binding=self.task_binding, + project_binding=self.project_binding, + fallback_project=self.fallback_project, + linked_issue=self.external_issue, + ), + ) + + def _update_data(self, map_record, **kwargs): + return super()._update_data( + map_record, + **dict( + kwargs or [], + task_binding=self.task_binding, + project_binding=self.project_binding, + fallback_project=self.fallback_project, + linked_issue=self.external_issue, + ), + ) + + def run(self, external_id, force=False, record=None, **kwargs): + assert "issue_id" in kwargs + self.external_issue_id = kwargs.pop("issue_id") + return super().run(external_id, force=force, record=record, **kwargs) + + def _handle_record_missing_on_jira(self): + """Hook called when we are importing a record missing on Jira + + For worklogs, we drop the analytic line if we discover it doesn't exist + on Jira, as the latter is the master. + """ + binding = self._get_binding() + if binding: + record = binding.odoo_id + binding.unlink() + record.unlink() + return _("Record does no longer exist in Jira") + + def _get_external_data(self): + """Return the raw Jira data for ``self.external_id``""" + adapt = self.component(usage="backend.adapter", model_name="jira.project.task") + self.external_issue = adapt.read(self.external_issue_id) + return self.backend_adapter.read(self.external_issue_id, self.external_id) + + def _before_import(self): + task_binding = self._recurse_import_task() + if task_binding and task_binding.active: + self.task_binding = task_binding + if not self.task_binding: + # when no task exists in Odoo (because we don't synchronize + # the issue type for instance), we link the line directly + # to the corresponding project, not linked to any task + issue = self.external_issue + assert issue + matcher = self.component(usage="jira.task.project.matcher") + project_binding = matcher.find_project_binding(issue) + if project_binding and project_binding.active: + self.project_binding = project_binding + else: + self.fallback_project = matcher.fallback_project_for_worklogs() + + def _import(self, binding, **kwargs): + if not (self.task_binding or self.project_binding or self.fallback_project): + _logger.debug( + "No task or project synchronized for attaching worklog %s", + self.external_record["id"], + ) + return + return super()._import(binding, **kwargs) + + def _import_dependency_assignee(self): + jira_assignee = self.external_record["author"] + jira_key = jira_assignee.get("accountId") + self._import_dependency(jira_key, "jira.res.users", record=jira_assignee) + + def _import_dependencies(self): + """Import the dependencies for the record""" + self._import_dependency_assignee() diff --git a/connector_jira/components/jira_analytic_line_mapper.py b/connector_jira/components/jira_analytic_line_mapper.py new file mode 100644 index 000000000..ac2030a3d --- /dev/null +++ b/connector_jira/components/jira_analytic_line_mapper.py @@ -0,0 +1,110 @@ +# Copyright 2016-2022 Camptocamp SA +# Copyright 2019 Brainbean Apps (https://brainbeanapps.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from pytz import timezone, utc + +from odoo import _ + +from odoo.addons.component.core import Component +from odoo.addons.connector.components.mapper import mapping +from odoo.addons.connector.exception import MappingError + +from .common import iso8601_to_naive_date, iso8601_to_utc_datetime, whenempty + + +class JiraAnalyticLineMapper(Component): + _name = "jira.analytic.line.mapper" + _inherit = "jira.import.mapper" + _apply_on = ["jira.account.analytic.line"] + + direct = [(whenempty("comment", _("missing description")), "name")] + + @mapping + def issue(self, record): + issue = self.options.linked_issue + assert issue + refs = {"jira_issue_id": record["issueId"], "jira_issue_key": issue["key"]} + mapper = self.component(usage="import.mapper", model_name="jira.project.task") + issue_type_dict = mapper.issue_type(issue) + refs.update(issue_type_dict) + epic_field_name = self.backend_record.epic_link_field_name + if epic_field_name and epic_field_name in issue["fields"]: + refs["jira_epic_issue_key"] = issue["fields"][epic_field_name] + if self.backend_record.epic_link_on_epic: + issue_type_id = issue_type_dict.get("jira_issue_type_id") + issue_type = self.env["jira.issue.type"].browse(issue_type_id) + if issue_type.exists() and issue_type.name == "Epic": + refs["jira_epic_issue_key"] = issue.get("key") + return refs + + @mapping + def date(self, record): + mode = self.backend_record.worklog_date_timezone_mode + started = record["started"] + if not mode or mode == "naive": + return {"date": iso8601_to_naive_date(started)} + started = iso8601_to_utc_datetime(started).replace(tzinfo=utc) + if mode == "user": + tz = timezone(record["author"]["timeZone"]) + elif mode == "specific": + tz = timezone(self.backend_record.worklog_date_timezone) + else: + raise NotImplementedError("Cannot parse date with mode '%s'", mode) + return {"date": started.astimezone(tz).date()} + + @mapping + def duration(self, record): + # amount is in float in odoo... 9000.00s = 2h30m00s = 2.5h + return {"unit_amount": float(record["timeSpentSeconds"]) / 3600} + + @mapping + def author(self, record): + author = record["author"] + key = author["accountId"] + user = self.binder_for("jira.res.users").to_internal(key, unwrap=True) + if not user: + raise MappingError( + _( + "No user found with login '%(key)s' or email '%(mail)s'." + " You must create a user or link it manually if the" + " login/email differs.", + key=key, + mail=author.get("emailAddress", ""), + ) + ) + # NB: in v15.0, the employee was retrieved via a ``search()`` on ``hr.employee`` + # with no constraints on the company; we change this to accessing field + # ``employee_id`` which is a computed field whose value depend on the + # environment's company to fetch the correct employee and avoids multi-company + # consistency issues. + # (We keep the ``active_test=False`` anyway) + employee = user.with_context(active_test=False).employee_id + if not employee: + # In case no employee is found, fallback to the v15.0 behavior, which is: + # find any employee linked to the user, regardless of the company + employee = employee.search([("user_id", "=", user.id)], limit=1) + return {"user_id": user.id, "employee_id": employee.id} + + @mapping + def project_and_task(self, record): + if self.options.task_binding: + task_binding = self.options.task_binding + return { + "task_id": task_binding.odoo_id.id, + "project_id": task_binding.project_id.id, + "jira_project_bind_id": task_binding.jira_project_bind_id.id, + } + elif self.options.project_binding: + project_binding = self.options.project_binding + return { + "project_id": project_binding.odoo_id.id, + "jira_project_bind_id": project_binding.id, + } + elif self.options.fallback_project: + return {"project_id": self.options.fallback_project.id} + raise ValueError("No task binding, project binding or fallback project found.") + + @mapping + def backend_id(self, record): + return {"backend_id": self.backend_record.id} diff --git a/connector_jira/components/jira_analytic_line_timestamp_batch_deleter.py b/connector_jira/components/jira_analytic_line_timestamp_batch_deleter.py new file mode 100644 index 000000000..b021fb3b2 --- /dev/null +++ b/connector_jira/components/jira_analytic_line_timestamp_batch_deleter.py @@ -0,0 +1,72 @@ +# Copyright 2016-2019 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +import logging + +from odoo import _ + +from odoo.addons.component.core import Component +from odoo.addons.queue_job.exception import RetryableJobError + +from ..fields import MilliDatetime + +_logger = logging.getLogger(__name__) + + +class JiraAnalyticLineTimestampBatchDeleter(Component): + """Batch Deleter working with a jira.backend.timestamp.record + + It locks the timestamp to ensure no other job is working on it, + and uses the latest timestamp value as reference for the search. + + The role of a BatchDeleter is to search for a list of + items to delete and schedule jobs for the deletions. + """ + + _name = "jira.analytic.line.timestamp.batch.deleter" + _inherit = ["base.synchronizer", "jira.base"] + _usage = "timestamp.batch.deleter" + + def run(self, timestamp, **kwargs): + """Run the synchronization using the timestamp""" + original_timestamp_value = timestamp.last_timestamp + if not timestamp._lock(): + self._handle_lock_failed(timestamp) + + next_timestamp_value, records = self._search(timestamp) + timestamp._update_timestamp(next_timestamp_value) + number = self._handle_records(records) + return _( + f"Batch from {original_timestamp_value} UTC to {next_timestamp_value} UTC " + f"generated {number} delete jobs" + ) + + def _handle_records(self, records): + """Handle the records to import and return the number handled""" + number = 0 # Cannot use ``len(records)`` cause ``records`` is a generator + for record_id in records: + number += 1 + self._delete_record(record_id) + return number + + def _handle_lock_failed(self, timestamp): + _logger.warning("Failed to acquire timestamps %s", timestamp, exc_info=True) + raise RetryableJobError("Concurrent process already syncing", ignore_retry=True) + + def _search(self, timestamp): + unix_timestamp = MilliDatetime.to_timestamp(timestamp.last_timestamp) + result = self.backend_adapter.deleted_since(since=unix_timestamp) + return MilliDatetime.from_timestamp(result.until), result.deleted_worklog_ids + + def _delete_record(self, record_id, **kwargs): + """Delay the delete of the records""" + kwargs.pop("description", None) + self.model.with_delay( + description=_("Delete a local worklog which has " "been deleted on JIRA"), + **kwargs, + ).delete_record( + self.backend_record, + record_id, + only_binding=False, + set_inactive=False, + ) diff --git a/connector_jira/components/jira_backend_adapter.py b/connector_jira/components/jira_backend_adapter.py new file mode 100644 index 000000000..d897ffb69 --- /dev/null +++ b/connector_jira/components/jira_backend_adapter.py @@ -0,0 +1,17 @@ +# Copyright: 2015 LasLabs, Inc. +# Copyright 2016-2022 Camptocamp SA +# Copyright 2019 Brainbean Apps (https://brainbeanapps.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo.addons.component.core import Component + + +class JiraBackendAdapter(Component): + _name = "jira.backend.adapter" + _inherit = "jira.webservice.adapter" + _apply_on = ["jira.backend"] + + webhook_base_path = "{server}/rest/webhooks/1.0/{path}" + + def list_fields(self): + return self.client._get_json("field") diff --git a/connector_jira/components/jira_base.py b/connector_jira/components/jira_base.py new file mode 100644 index 000000000..64f53c907 --- /dev/null +++ b/connector_jira/components/jira_base.py @@ -0,0 +1,15 @@ +# Copyright 2018-2019 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo.addons.component.core import AbstractComponent + + +class JiraBase(AbstractComponent): + """Base Jira Connector Component + + All components of this connector should inherit from it. + """ + + _name = "jira.base" + _inherit = "base.connector" + _collection = "jira.backend" diff --git a/connector_jira/components/jira_base_exporter.py b/connector_jira/components/jira_base_exporter.py new file mode 100644 index 000000000..d46f0bae7 --- /dev/null +++ b/connector_jira/components/jira_base_exporter.py @@ -0,0 +1,103 @@ +# Copyright 2016-2022 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +""" + +Exporters for Jira. + +In addition to its export job, an exporter has to: + +* check in Jira if the record has been updated more recently than the + last sync date and if yes, delay an import +* call the ``bind`` method of the binder to update the last sync date + +""" + +from odoo import _, fields, tools + +from odoo.addons.component.core import AbstractComponent + +from .common import iso8601_to_utc_datetime + + +class JiraBaseExporter(AbstractComponent): + """Base exporter for Jira""" + + _name = "jira.base.exporter" + _inherit = ["base.exporter", "jira.base"] + _usage = "record.exporter" + + def __init__(self, work_context): + super().__init__(work_context) + self.binding = None + self.external_id = None + + def _delay_import(self): + """Schedule an import of the record. + + Adapt in the sub-classes when the model is not imported + using ``import_record``. + """ + assert self.external_id + # force is True because the sync_date will be more recent + # so the import would be skipped if it was not forced + self.binding.import_record(self.backend_record, self.external_id, force=True) + + def _should_import(self): + """Before the export, compare the update date + in Jira and the last sync date in Odoo, + if the former is more recent, schedule an import + to not miss changes done in Jira. + """ + if not self.external_id: + return False + assert self.binding + sync = self.binder.sync_date(self.binding) + if not sync: + return True + vals = self.backend_adapter.read(self.external_id, fields=["updated"]) + jira_updated = vals["fields"]["updated"] + return fields.Datetime.to_datetime(sync) < iso8601_to_utc_datetime(jira_updated) + + def _lock(self): + """Lock the binding record. + + Lock the binding record so we are sure that only one export + job is running for this record if concurrent jobs have to export the + same record. + + When concurrent jobs try to export the same record, the first one + will lock and proceed, the others will fail to lock and will be + retried later. + + This behavior works also when the export becomes multilevel + with :meth:`_export_dependencies`. Each level will set its own lock + on the binding record it has to export. + """ + self.component("record.locker").lock(self.binding) + + def run(self, binding, *args, **kwargs): + """Run the synchronization + + :param binding: binding record to export + """ + self.binding = binding + if not self.binding.exists(): + return _("Record to export does no longer exist.") + + # prevent other jobs to export the same record + # will be released on commit (or rollback) + self._lock() + + self.external_id = self.binder.to_external(self.binding) + result = self._run(*args, **kwargs) + self.binder.bind(self.external_id, self.binding) + # commit so we keep the external ID if several exports + # are called and one of them fails + if not tools.config["test_enable"]: + self.env.cr.commit() # pylint: disable=invalid-commit + return result + + def _run(self, *args, **kwargs): + """Flow of the synchronization, implemented in inherited classes""" + raise NotImplementedError diff --git a/connector_jira/components/jira_batch_importer.py b/connector_jira/components/jira_batch_importer.py new file mode 100644 index 000000000..5e335042a --- /dev/null +++ b/connector_jira/components/jira_batch_importer.py @@ -0,0 +1,43 @@ +# Copyright 2016-2022 Camptocamp SA +# Copyright 2019 Brainbean Apps (https://brainbeanapps.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +""" + +Importers for Jira. + +An import can be skipped if the last sync date is more recent than +the last update in Jira. + +They should call the ``bind`` method if the binder even if the records +are already bound, to update the last sync date. + +""" + +from odoo.addons.component.core import AbstractComponent + + +class JiraBatchImporter(AbstractComponent): + """The role of a BatchImporter is to search for a list of + items to import, then it can either import them directly or delay + the import of each item separately. + """ + + _name = "jira.batch.importer" + _inherit = ["base.importer", "jira.base"] + _usage = "batch.importer" + + def run(self): + """Run the synchronization, search all JIRA records""" + for record_id in self._search(): + self._import_record(record_id) + + def _search(self): + return self.backend_adapter.search() + + def _import_record(self, record_id, **kwargs): + """Import a record directly or delay the import of the record. + + Method to implement in sub-classes. + """ + raise NotImplementedError diff --git a/connector_jira/components/jira_binder.py b/connector_jira/components/jira_binder.py new file mode 100644 index 000000000..5b790c2a1 --- /dev/null +++ b/connector_jira/components/jira_binder.py @@ -0,0 +1,26 @@ +# Copyright 2016-2019 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import fields + +from odoo.addons.component.core import Component + + +class JiraBinder(Component): + """Binder for Odoo models + + Where we create an additional model holding the external id. + The advantages to have a second models are: + * we can link more than 1 JIRA instance to the same record + * we can work with, lock, edit the jira binding without touching the + normal record + + Default binder when no specific binder is defined for a model. + """ + + _name = "jira.binder" + _inherit = ["base.binder", "jira.base"] + + def sync_date(self, binding): + assert self._sync_date_field + return fields.Datetime.to_datetime(binding[self._sync_date_field]) diff --git a/connector_jira/components/jira_delayed_batch_importer.py b/connector_jira/components/jira_delayed_batch_importer.py new file mode 100644 index 000000000..e38d97de3 --- /dev/null +++ b/connector_jira/components/jira_delayed_batch_importer.py @@ -0,0 +1,30 @@ +# Copyright 2016-2022 Camptocamp SA +# Copyright 2019 Brainbean Apps (https://brainbeanapps.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +""" + +Importers for Jira. + +An import can be skipped if the last sync date is more recent than +the last update in Jira. + +They should call the ``bind`` method if the binder even if the records +are already bound, to update the last sync date. + +""" + +from odoo.addons.component.core import AbstractComponent + + +class JiraDelayedBatchImporter(AbstractComponent): + """Delay import of the records""" + + _name = "jira.delayed.batch.importer" + _inherit = ["jira.batch.importer"] + + def _import_record(self, record_id, force=False, record=None, **kwargs): + """Delay the import of the records""" + self.model.with_delay(**kwargs).import_record( + self.backend_record, record_id, force=force, record=record + ) diff --git a/connector_jira/components/jira_deleter.py b/connector_jira/components/jira_deleter.py new file mode 100644 index 000000000..66f21ad13 --- /dev/null +++ b/connector_jira/components/jira_deleter.py @@ -0,0 +1,29 @@ +# Copyright 2016-2022 Camptocamp SA +# Copyright 2019 Brainbean Apps (https://brainbeanapps.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import _ + +from odoo.addons.component.core import Component + + +class JiraDeleter(Component): + _name = "jira.deleter" + _inherit = ["base.deleter", "jira.base"] + _usage = "record.deleter" + + def run(self, external_id, only_binding=False, set_inactive=False): + binding = self.binder.to_internal(external_id) + if not binding.exists(): + return _("Binding not found") + if set_inactive and binding._active_name: # Cannot archive it + binding.action_archive() + return _("Binding deactivated") + else: + record = binding.odoo_id + # emptying the external_id allows to unlink the binding + binding.external_id = False + binding.unlink() + if not only_binding: + record.unlink() + return _("Record deleted") diff --git a/connector_jira/components/jira_direct_batch_importer.py b/connector_jira/components/jira_direct_batch_importer.py new file mode 100644 index 000000000..8783f6f2e --- /dev/null +++ b/connector_jira/components/jira_direct_batch_importer.py @@ -0,0 +1,30 @@ +# Copyright 2016-2022 Camptocamp SA +# Copyright 2019 Brainbean Apps (https://brainbeanapps.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +""" + +Importers for Jira. + +An import can be skipped if the last sync date is more recent than +the last update in Jira. + +They should call the ``bind`` method if the binder even if the records +are already bound, to update the last sync date. + +""" + +from odoo.addons.component.core import AbstractComponent + + +class JiraDirectBatchImporter(AbstractComponent): + """Import the records directly, without delaying the jobs.""" + + _name = "jira.direct.batch.importer" + _inherit = ["jira.batch.importer"] + + def _import_record(self, record_id, force=False, record=None): + """Import the record directly""" + self.model.import_record( + self.backend_record, record_id, force=force, record=record + ) diff --git a/connector_jira/components/jira_exporter.py b/connector_jira/components/jira_exporter.py new file mode 100644 index 000000000..f410c92bc --- /dev/null +++ b/connector_jira/components/jira_exporter.py @@ -0,0 +1,229 @@ +# Copyright 2016-2022 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +""" + +Exporters for Jira. + +In addition to its export job, an exporter has to: + +* check in Jira if the record has been updated more recently than the + last sync date and if yes, delay an import +* call the ``bind`` method of the binder to update the last sync date + +""" + +from contextlib import contextmanager + +import psycopg2 + +from odoo import _, tools + +from odoo.addons.component.core import Component +from odoo.addons.queue_job.exception import RetryableJobError + + +class JiraExporter(Component): + """Common exporter flow for Jira + + If no specific exporter overrides the exporter for a model, this one is + used. + """ + + _name = "jira.exporter" + _inherit = ["jira.base.exporter"] + _usage = "record.exporter" + + def _has_to_skip(self): + """Return True if the export can be skipped""" + return False + + @contextmanager + def _retry_unique_violation(self): + """Context manager: catch Unique constraint error and retry the + job later. + + When we execute several jobs workers concurrently, it happens + that 2 jobs are creating the same record at the same time (binding + record created by :meth:`_export_dependency`), resulting in: + + IntegrityError: duplicate key value violates unique + constraint "jira_project_project_odoo_uniq" + DETAIL: Key (backend_id, odoo_id)=(1, 4851) already exists. + + In that case, we'll retry the import just later. + + """ + try: + yield + except psycopg2.IntegrityError as err: + if err.pgcode == psycopg2.errorcodes.UNIQUE_VIOLATION: + raise RetryableJobError( + "A database error caused the failure of the job:\n" + "%s\n\n" + "Likely due to 2 concurrent jobs wanting to create " + "the same record. The job will be retried later." % err + ) from err + else: + raise + + def _export_dependency(self, relation, binding_model, component=None): + """Export a dependency. + + .. warning:: a commit is done at the end of the export of each + dependency. The reason for that is that we pushed a record + on the backend and we absolutely have to keep its ID. + + So you *must* take care to not modify the Odoo database + excepted when writing back the external ID or eventual + external data to keep on this side. + + You should call this method only in the beginning of + exporter synchronization (in `~._export_dependencies`) + and do not write data which should be rollbacked in case + of error. + + :param relation: record to export if not already exported + :type relation: :py:class:`odoo.models.BaseModel` + :param binding_model: name of the binding model for the relation + :type binding_model: str | unicode + :param component: component to use for the export + By default: lookup a component by usage + 'record.exporter' and model + """ + if not relation: + return + rel_binder = self.binder_for(binding_model) + # wrap is typically True if the relation is a 'project.project' + # record but the binding model is 'jira.project.project' + wrap = relation._model._name != binding_model + + if wrap and hasattr(relation, "jira_bind_ids"): + domain = [ + ("odoo_id", "=", relation.id), + ("backend_id", "=", self.backend_record.id), + ] + model = self.env[binding_model].with_context(active_test=False) + binding = model.search(domain) + if binding: + binding.ensure_one() + else: + # we are working with a unwrapped record (e.g. + # product.template) and the binding does not exist yet. + # Example: I created a product.product and its binding + # jira.project.project, it is exported, but we need to + # create the binding for the template. + bind_values = { + "backend_id": self.backend_record.id, + "odoo_id": relation.id, + } + # If 2 jobs create it at the same time, retry + # one later. A unique constraint (backend_id, + # odoo_id) should exist on the binding model + with self._retry_unique_violation(): + model_c = ( + self.env[binding_model] + .sudo() + .with_context(connector_no_export=True) + ) + binding = model_c.create(bind_values) + # Eager commit to avoid having 2 jobs + # exporting at the same time. + if not tools.config["test_enable"]: + self.env.cr.commit() # pylint: disable=invalid-commit + else: + # If jira_bind_ids does not exist we are typically in a + # "direct" binding (the binding record is the same record). + # If wrap is True, relation is already a binding record. + binding = relation + + if not rel_binder.to_external(binding): + if component is None: + component = self.component( + usage="record.exporter", model_name=binding_model + ) + component.run(binding.id) + + def _export_dependencies(self): + """Export the dependencies for the record""" + return + + def _map_data(self, fields=None): + """Returns an instance of + :py:class:`~odoo.addons.component.core.Component` + + """ + return self.mapper.map_record(self.binding) + + def _validate_data(self, data): + """Check if the values to import are correct + + Pro-actively check before the ``Model.create`` or + ``Model.update`` if some fields are missing + + Raise `InvalidDataError` + """ + return + + def _create_data(self, map_record, fields=None, **kwargs): + """Get the data to pass to :py:meth:`_create`. + + Jira expect that we pass always all the fields, not only + the modified fields. That's why the `fields` argument + is None. + + """ + return map_record.values(for_create=True, fields=None, **kwargs) + + def _create(self, data): + """Create the Jira record""" + self._validate_data(data) + return self.backend_adapter.create(data) + + def _update_data(self, map_record, fields=None, **kwargs): + """Get the data to pass to :py:meth:`_update`. + + Jira expect that we pass always all the fields, not only + the modified fields. That's why the `fields` argument + is None. + + """ + return map_record.values(fields=None, **kwargs) + + def _update(self, data): + """Update a Jira record""" + assert self.external_id + self._validate_data(data) + self.backend_adapter.write(self.external_id, data) + + def _run(self, fields=None): + """Flow of the synchronization, implemented in inherited classes. + + `~._export_dependencies` might commit exported ids to the database, + so please do not do changes in the database before the export of the + dependencies because they won't be rollbacked. + """ + assert self.binding + + if self._has_to_skip(): + return + + if not self.external_id: + fields = None # should be created with all the fields + + # export the missing linked resources + self._export_dependencies() + + map_record = self._map_data(fields=fields) + + if self.external_id: + record = self._update_data(map_record, fields=fields) + if not record: + return _("Nothing to export.") + self._update(record) + else: + record = self._create_data(map_record, fields=fields) + if not record: + return _("Nothing to export.") + self.external_id = self._create(record) + return _("Record exported with ID %s on Jira.") % self.external_id diff --git a/connector_jira/components/jira_import_mapper.py b/connector_jira/components/jira_import_mapper.py new file mode 100644 index 000000000..8a45374e8 --- /dev/null +++ b/connector_jira/components/jira_import_mapper.py @@ -0,0 +1,17 @@ +# Copyright 2016-2019 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo.addons.component.core import AbstractComponent +from odoo.addons.connector.components.mapper import mapping + + +class JiraImportMapper(AbstractComponent): + """Base Import Mapper for Jira""" + + _name = "jira.import.mapper" + _inherit = ["base.import.mapper", "jira.base"] + + @mapping + def jira_updated_at(self, record): + if self.options.external_updated_at: + return {"jira_updated_at": self.options.external_updated_at} diff --git a/connector_jira/components/jira_importer.py b/connector_jira/components/jira_importer.py new file mode 100644 index 000000000..62c943a3b --- /dev/null +++ b/connector_jira/components/jira_importer.py @@ -0,0 +1,385 @@ +# Copyright 2016-2022 Camptocamp SA +# Copyright 2019 Brainbean Apps (https://brainbeanapps.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +""" + +Importers for Jira. + +An import can be skipped if the last sync date is more recent than +the last update in Jira. + +They should call the ``bind`` method if the binder even if the records +are already bound, to update the last sync date. + +""" + +import logging +from contextlib import closing, contextmanager + +from psycopg2 import IntegrityError, errorcodes + +import odoo +from odoo import _, tools + +from odoo.addons.component.core import Component +from odoo.addons.connector.exception import IDMissingInBackend +from odoo.addons.queue_job.exception import RetryableJobError + +from .common import ( + RETRY_ON_ADVISORY_LOCK, + RETRY_WHEN_CONCURRENT_DETECTED, + iso8601_to_utc_datetime, +) + +_logger = logging.getLogger(__name__) + + +class JiraImporter(Component): + """Base importer for Jira + + If no specific importer is defined for a model, this one is used. + """ + + _name = "jira.importer" + _inherit = ["base.importer", "jira.base"] + _usage = "record.importer" + + def __init__(self, work_context): + super().__init__(work_context) + self.external_id = None + self.external_record = None + + def _get_external_data(self): + """Return the raw Jira data for ``self.external_id``""" + return self.backend_adapter.read(self.external_id) + + def must_skip(self, force=False): + """Returns a reason as string if the import must be skipped. + + Returns an empty string to continue with the import. + + :rtype: str + """ + assert self.external_record + return "" + + def _before_import(self): + """Hook called before the import, when we have the Jira data""" + + def _get_external_updated_at(self): + assert self.external_record + updated_at = self.external_record.get("fields", {}).get("updated") + return updated_at and iso8601_to_utc_datetime(updated_at) + + def _is_uptodate(self, binding): + """Return True if the binding is already up-to-date in Odoo""" + external_date = self._get_external_updated_at() + # We store the jira "updated_at" field in the binding, + # so for further imports, we can check accurately if the + # record is already up-to-date (this field has a millisecond + # precision). + internal_date = bool(binding) and binding.jira_updated_at + # No update date on Jira, no binding or no last update on the binding => the + # record does not exist or is not up-to-date, so it should be imported + return external_date and internal_date and external_date < internal_date + + def _import_dependency( + self, external_id, binding_model, component=None, record=None, always=False + ): + """ + Import a dependency. + + The component that will be used for the dependency can be injected + with the ``component``. + + :param external_id: id of the related binding to import + :param binding_model: name of the binding model for the relation + :type binding_model: str | unicode + :param component: component to use for the importer + By default: lookup component for the model with + usage ``record.importer`` + :param record: if we already have the data of the dependency, we + can pass it along to the dependency's importer + :type record: dict + :param always: if True, the record is updated even if it already + exists, + it is still skipped if it has not been modified on Jira + :type always: boolean + """ + if external_id: + binder = self.binder_for(binding_model) + if always or not binder.to_internal(external_id): + if component is None: + component = self.component( + usage="record.importer", model_name=binding_model + ) + component.run(external_id, record=record, force=True) + + def _import_dependencies(self): + """Import the dependencies for the record""" + return + + def _map_data(self): + """Returns an instance of + :py:class:`~odoo.addons.component.core.Component` + + """ + return self.mapper.map_record(self.external_record) + + def _validate_data(self, data): + """Check if the values to import are correct + + Pro-actively check before the ``_create`` or + ``_update`` if some fields are missing or invalid. + + Raise `InvalidDataError` + """ + return + + def _filter_data(self, binding, data): + """Filters out values that aren't actually changing""" + binding.ensure_one() + fields = list(data.keys()) + new_values = binding._convert_to_write(data) + old_binding_values = binding.read(fields, load="_classic_write")[0] + old_values = binding._convert_to_write(old_binding_values) + return {f: data[f] for f in fields if new_values[f] != old_values[f]} + + def _get_binding(self): + """Return the binding id from the jira id""" + return self.binder.to_internal(self.external_id) + + def _create_data(self, map_record, **kwargs): + """Get the data to pass to :py:meth:`_create`""" + return map_record.values( + for_create=True, + external_updated_at=self._get_external_updated_at(), + **kwargs, + ) + + @contextmanager + def _retry_unique_violation(self): + """Context manager: catch Unique constraint error and retry the + job later. + + When we execute several jobs workers concurrently, it happens + that 2 jobs are creating the same record at the same time + (especially product templates as they are shared by a lot of + sales orders), resulting in: + + IntegrityError: duplicate key value violates unique + constraint "jira_project_project_external_id_uniq" + DETAIL: Key (backend_id, external_id)=(1, 4851) already exists. + + In that case, we'll retry the import just later. + + """ + try: + yield + except IntegrityError as err: + if err.pgcode == errorcodes.UNIQUE_VIOLATION: + raise RetryableJobError( + "A database error caused the failure of the job:\n" + "%s\n\n" + "Likely due to 2 concurrent jobs wanting to create " + "the same record. The job will be retried later." % err + ) from err + else: + raise + + def _create_context(self): + return { + "connector_jira": True, + "connector_no_export": True, + "tracking_disable": True, + } + + def _create(self, data): + """Create the Odoo record""" + # special check on data before import + self._validate_data(data) + with self._retry_unique_violation(): + model_ctx = self.model.with_context(**self._create_context()) + binding = model_ctx.sudo().create(data) + + _logger.debug("%s created from Jira %s", binding, self.external_id) + return binding + + def _update_data(self, map_record, **kwargs): + """Get the data to pass to :py:meth:`_update`""" + return map_record.values( + external_updated_at=self._get_external_updated_at(), **kwargs + ) + + def _update_context(self): + return { + "connector_jira": True, + "connector_no_export": True, + "tracking_disable": True, + } + + def _update(self, binding, data): + """Update an Odoo record""" + data = self._filter_data(binding, data) + if not data: + _logger.debug( + "%s not updated from Jira %s as nothing changed", + binding, + self.external_id, + ) + return + self._validate_data(data) + binding_ctx = binding.with_context(**self._update_context()) + binding_ctx.sudo().write(data) + _logger.debug("%s updated from Jira %s", binding, self.external_id) + return + + def _after_import(self, binding): + """Hook called at the end of the import""" + return + + @contextmanager + def do_in_new_work_context(self, model_name=None): + """Context manager that yields a new component work context + + Using a new Odoo Environment thus a new PG transaction. + + This can be used to make a preemptive check in a new transaction, + for instance to see if another transaction already made the work. + """ + registry = odoo.registry(self.env.cr.dbname) + with closing(registry.cursor()) as cr: + try: + new_env = odoo.api.Environment(cr, self.env.uid, self.env.context) + backend = self.backend_record.with_env(new_env) + with backend.work_on(model_name or self.model._name) as work: + yield work + except Exception: + cr.rollback() + raise + else: + if not tools.config["test_enable"]: + cr.commit() # pylint: disable=invalid-commit + + def _handle_record_missing_on_jira(self): + """Hook called when we are importing a record missing on Jira + + By default, it deletes the matching record or binding if it exists on + Odoo and returns a result to show on the job, job will be done. + """ + binding = self._get_binding() + if binding: + # emptying the external_id allows to unlink the binding + binding.external_id = False + binding.unlink() + return _("Record does no longer exist in Jira") + + def run(self, external_id, force=False, record=None, **kwargs): + """Run the synchronization + + A record can be given, reducing number of calls when + a call already returns data (example: user returns addresses) + + :param external_id: identifier of the record on Jira + """ + self.external_id = external_id + lock_name = "import({}, {}, {}, {})".format( + self.backend_record._name, + self.backend_record.id, + self.model._name, + self.external_id, + ) + # Keep a lock on this import until the transaction is committed + self.advisory_lock_or_retry(lock_name, retry_seconds=RETRY_ON_ADVISORY_LOCK) + if record is not None: + self.external_record = record + else: + try: + self.external_record = self._get_external_data() + except IDMissingInBackend: + return self._handle_record_missing_on_jira() + binding = self._get_binding() + if not binding: + with self.do_in_new_work_context() as new_work: + # Even when we use an advisory lock, we may have + # concurrent issues. + # Explanation: + # We import Partner A and B, both of them import a + # partner category X. + # + # The squares represent the duration of the advisory + # lock, the transactions starts and ends on the + # beginnings and endings of the 'Import Partner' + # blocks. + # T1 and T2 are the transactions. + # + # ---Time---> + # > T1 /------------------------\ + # > T1 | Import Partner A | + # > T1 \------------------------/ + # > T1 /-----------------\ + # > T1 | Imp. Category X | + # > T1 \-----------------/ + # > T2 /------------------------\ + # > T2 | Import Partner B | + # > T2 \------------------------/ + # > T2 /-----------------\ + # > T2 | Imp. Category X | + # > T2 \-----------------/ + # + # As you can see, the locks for Category X do not + # overlap, and the transaction T2 starts before the + # commit of T1. So no lock prevents T2 to import the + # category X and T2 does not see that T1 already + # imported it. + # + # The workaround is to open a new DB transaction at the + # beginning of each import (e.g. at the beginning of + # "Imp. Category X") and to check if the record has been + # imported meanwhile. If it has been imported, we raise + # a Retryable error so T2 is rollbacked and retried + # later (and the new T3 will be aware of the category X + # from its inception). + binder = new_work.component(usage="binder") + if binder.to_internal(self.external_id): + raise RetryableJobError( + "Concurrent error. The job will be retried later", + seconds=RETRY_WHEN_CONCURRENT_DETECTED, + ignore_retry=True, + ) + + reason = self.must_skip(force=force) + if reason: + return reason + + if not force and self._is_uptodate(binding): + return _("Already up-to-date.") + + self._before_import() + + # import the missing linked resources + self._import_dependencies() + + self._import(binding, **kwargs) + + def _import(self, binding, **kwargs): + """Import the external record. + + Can be inherited to modify for instance the environment + (change current user, values in context, ...) + + """ + map_record = self._map_data() + + if binding: + record = self._update_data(map_record) + self._update(binding, record) + else: + record = self._create_data(map_record) + binding = self._create(record) + + with self._retry_unique_violation(): + self.binder.bind(self.external_id, binding) + + self._after_import(binding) diff --git a/connector_jira/components/jira_issue_type_adapter.py b/connector_jira/components/jira_issue_type_adapter.py new file mode 100644 index 000000000..a5f762d3c --- /dev/null +++ b/connector_jira/components/jira_issue_type_adapter.py @@ -0,0 +1,20 @@ +# Copyright 2016-2022 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo.addons.component.core import Component + + +class JiraIssueTypeAdapter(Component): + _name = "jira.issue.type.adapter" + _inherit = ["jira.webservice.adapter"] + _apply_on = ["jira.issue.type"] + + # pylint: disable=W8106 + def read(self, id_): + # No ``super()``: MRO will end up calling ``base.backend.adapter.crud.read()`` + # methods that will raise a ``NotImplementedError`` exception + with self.handle_404(): + return self.client.issue_type(id_).raw + + def search(self): + return [issue.id for issue in self.client.issue_types()] diff --git a/connector_jira/components/jira_issue_type_batch_importer.py b/connector_jira/components/jira_issue_type_batch_importer.py new file mode 100644 index 000000000..4ca5eb528 --- /dev/null +++ b/connector_jira/components/jira_issue_type_batch_importer.py @@ -0,0 +1,21 @@ +# Copyright 2016-2019 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo.addons.component.core import Component + + +class JiraIssueTypeBatchImporter(Component): + """Import the Jira Issue Types + + For every id in in the list of issue types, a direct import is done. + Import from a date + """ + + _name = "jira.issue.type.batch.importer" + _inherit = "jira.direct.batch.importer" + _apply_on = ["jira.issue.type"] + + def run(self): + """Run the synchronization""" + for record_id in self.backend_adapter.search(): + self._import_record(record_id) diff --git a/connector_jira/components/jira_issue_type_mapper.py b/connector_jira/components/jira_issue_type_mapper.py new file mode 100644 index 000000000..a16d66432 --- /dev/null +++ b/connector_jira/components/jira_issue_type_mapper.py @@ -0,0 +1,17 @@ +# Copyright 2016-2019 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo.addons.component.core import Component +from odoo.addons.connector.components.mapper import mapping + + +class JiraIssueTypeMapper(Component): + _name = "jira.issue.type.mapper" + _inherit = ["jira.import.mapper"] + _apply_on = "jira.issue.type" + + direct = [("name", "name"), ("description", "description")] + + @mapping + def backend_id(self, record): + return {"backend_id": self.backend_record.id} diff --git a/connector_jira/components/jira_mapper_from_attrs.py b/connector_jira/components/jira_mapper_from_attrs.py new file mode 100644 index 000000000..e96bf1bda --- /dev/null +++ b/connector_jira/components/jira_mapper_from_attrs.py @@ -0,0 +1,17 @@ +# Copyright 2016-2019 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo.addons.component.core import Component + + +class JiraMapperFromAttrs(Component): + _name = "jira.mapper.from.attrs" + _inherit = ["jira.base"] + _usage = "map.from.attrs" + + def values(self, record, mapper_): + fields_values = record.get("fields", {}) + return { + target: mapper_._map_direct(fields_values, source, target) + for source, target in getattr(mapper_, "from_fields", []) + } diff --git a/connector_jira/components/jira_model_binder.py b/connector_jira/components/jira_model_binder.py new file mode 100644 index 000000000..be5e52037 --- /dev/null +++ b/connector_jira/components/jira_model_binder.py @@ -0,0 +1,45 @@ +# Copyright 2016-2019 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +import logging + +from odoo import models + +from odoo.addons.component.core import Component + +_logger = logging.getLogger(__name__) + + +class JiraModelBinder(Component): + """Binder for standalone models + + When we synchronize a model that has no equivalent + in Odoo, we create a model that hold the Jira records + without `_inherits`. + + """ + + _name = "jira.model.binder" + _inherit = ["base.binder", "jira.base"] + _apply_on = ["jira.issue.type"] + _odoo_field = "id" + + def to_internal(self, external_id, unwrap=False): + if unwrap: + _logger.warning( + "unwrap has no effect when the " + "binding is not an inherits " + "(model %s)", + self.model._name, + ) + return super().to_internal(external_id, unwrap=False) + + def unwrap_binding(self, binding): + if isinstance(binding, models.BaseModel): + binding.ensure_one() + else: + binding = self.model.browse(binding) + return binding + + def unwrap_model(self): + return self.model diff --git a/connector_jira/components/jira_project_adapter.py b/connector_jira/components/jira_project_adapter.py new file mode 100644 index 000000000..b0566b71e --- /dev/null +++ b/connector_jira/components/jira_project_adapter.py @@ -0,0 +1,97 @@ +# Copyright 2016-2022 Camptocamp SA +# Copyright 2019 Brainbean Apps (https://brainbeanapps.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +import json +import logging +import tempfile + +from odoo import _, exceptions + +from odoo.addons.component.core import Component + +_logger = logging.getLogger(__name__) + +try: + from jira import JIRAError + from jira.utils import json_loads +except ImportError as err: + _logger.debug(err) + + +class JiraProjectAdapter(Component): + _name = "jira.project.adapter" + _inherit = ["jira.webservice.adapter"] + _apply_on = ["jira.project.project"] + + # pylint: disable=W8106 + def read(self, id_): + # No ``super()``: MRO will end up calling ``base.backend.adapter.crud.read()`` + # methods that will raise a ``NotImplementedError`` exception + with self.handle_404(): + return self.get(id_).raw + + def get(self, id_): + with self.handle_404(): + return self.client.project(id_) + + # pylint: disable=W8106 + def write(self, id_, values): + # No ``super()``: MRO will end up calling ``base.backend.adapter.crud.write()`` + # methods that will raise a ``NotImplementedError`` exception + with self.handle_404(): + return self.get(id_).update(values) + + # pylint: disable=W8106 + def create(self, key=None, name=None, template_name=None, values=None): + # No ``super()``: MRO will end up calling ``base.backend.adapter.crud.create()`` + # methods that will raise a ``NotImplementedError`` exception + project = self.client.create_project( + key=key, + name=name, + template_name=template_name, + ) + if values: + project.update(values) + return project + + def create_shared(self, key=None, name=None, shared_key=None, lead=None): + assert key and name and shared_key + # There is no public method for creating a shared project: + # https://jira.atlassian.com/browse/JRA-45929 + # People found a private method for doing so, which is explained on: + # https://jira.atlassian.com/browse/JRASERVER-27256 + + try: + project = self.read(shared_key) + project_id = project["id"] + except JIRAError as err: + if err.status_code == 404: + raise exceptions.UserError( + _('Project template with key "%s" not found.') % shared_key + ) from err + else: + raise + + server_url = self.client._options["server"] + url = server_url + "/rest/project-templates/1.0/createshared/%s" % project_id + payload = {"name": name, "key": key, "lead": lead} + + response = self.client._session.post(url, data=json.dumps(payload)) + if response.status_code == 200: + return json_loads(response) + + tmp_file = tempfile.NamedTemporaryFile( + prefix="python-jira-error-create-shared-project-", + suffix=".html", + delete=False, + ) + tmp_file.write(response.text) + + if self.logging: + _logger.error( + "Unexpected result while running create shared project." + f" Server response saved in {tmp_file.name} for further investigation" + f" [HTTP response={response.status_code}]." + ) + return False diff --git a/connector_jira/components/jira_project_binder.py b/connector_jira/components/jira_project_binder.py new file mode 100644 index 000000000..cc68f9749 --- /dev/null +++ b/connector_jira/components/jira_project_binder.py @@ -0,0 +1,46 @@ +# Copyright 2016-2019 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import models + +from odoo.addons.component.core import Component + + +class JiraProjectBinder(Component): + _name = "jira.project.binder" + _inherit = "jira.binder" + + _apply_on = ["jira.project.project"] + + def _domain_to_external(self, binding): + return [ + (self._odoo_field, "=", binding.id), + (self._backend_field, "=", self.backend_record.id), + ("sync_action", "=", "export"), + ] + + def to_external(self, binding, wrap=False): + """Give the external ID for an Odoo binding ID + + More than one jira binding is possible on projects, but we still + have to know to which one we have to export. Currently, we'll only + pick the binding with Sync. Action "export". However, if later we + add, for instance, a push of tasks, we may consider adding other + means to get the external id. + + :param binding: Odoo binding for which we want the external id + :param wrap: if True, binding is a normal record, the + method will search the corresponding binding and return + the external id of the binding + :return: external ID of the record + """ + if isinstance(binding, models.BaseModel): + binding.ensure_one() + else: + binding = self.model.browse(binding) + if wrap: + domain = self._domain_to_external(binding) + binding = self.model.with_context(active_test=False).search(domain, limit=1) + if not binding: + return + return binding[self._external_field] diff --git a/connector_jira/components/jira_project_project_exporter.py b/connector_jira/components/jira_project_project_exporter.py new file mode 100644 index 000000000..03f04d2c6 --- /dev/null +++ b/connector_jira/components/jira_project_project_exporter.py @@ -0,0 +1,49 @@ +# Copyright 2016-2019 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo.addons.component.core import Component + + +class JiraProjectProjectExporter(Component): + _name = "jira.project.project.exporter" + _inherit = ["jira.exporter"] + _apply_on = ["jira.project.project"] + + def _create_project(self, adapter, key, name, template, values): + return adapter.create( + key=key, + name=name, + template_name=template, + values=values, + )["projectId"] + + def _create_shared_project(self, adapter, key, name, shared_key, lead): + return adapter.create_shared( + key=key, + name=name, + shared_key=shared_key, + lead=lead, + )["projectId"] + + def _update_project(self, adapter, values): + adapter.write(self.external_id, values) + + def _run(self, fields=None): + adapter = self.component(usage="backend.adapter") + + key = self.binding.jira_key + name = self.binding.name[:80] + template = self.binding.project_template + # TODO: add lead + + if self.external_id: + self._update_project(adapter, {"name": name, "key": key}) + else: + if template == "shared": + self.external_id = self._create_shared_project( + adapter, key, name, self.binding.project_template_shared, lead=None + ) + else: + self.external_id = self._create_project( + adapter, key, name, template, {} + ) diff --git a/connector_jira/components/jira_project_project_listener.py b/connector_jira/components/jira_project_project_listener.py new file mode 100644 index 000000000..79b0cad3e --- /dev/null +++ b/connector_jira/components/jira_project_project_listener.py @@ -0,0 +1,21 @@ +# Copyright 2016-2019 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo.addons.component.core import Component +from odoo.addons.component_event import skip_if + + +class JiraProjectProjectListener(Component): + _name = "jira.project.project.listener" + _inherit = ["base.connector.listener", "jira.base"] + _apply_on = ["jira.project.project"] + + @skip_if(lambda self, record, **kwargs: self.no_connector_export(record)) + def on_record_create(self, record, fields=None): + if record.sync_action == "export": + record.with_delay(priority=10).export_record(fields=fields) + + @skip_if(lambda self, record, **kwargs: self.no_connector_export(record)) + def on_record_write(self, record, fields=None): + if record.sync_action == "export": + record.with_delay(priority=10).export_record(fields=fields) diff --git a/connector_jira/components/jira_project_task_adapter.py b/connector_jira/components/jira_project_task_adapter.py new file mode 100644 index 000000000..cd6305f50 --- /dev/null +++ b/connector_jira/components/jira_project_task_adapter.py @@ -0,0 +1,27 @@ +# Copyright 2016-2019 Camptocamp SA +# Copyright 2019 Brainbean Apps (https://brainbeanapps.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo.addons.component.core import Component + + +class JiraProjectTaskAdapter(Component): + _name = "jira.project.task.adapter" + _inherit = ["jira.webservice.adapter"] + _apply_on = ["jira.project.task"] + + # pylint: disable=W8106 + def read(self, id_, fields=None): + # No ``super()``: MRO will end up calling ``base.backend.adapter.crud.read()`` + # methods that will raise a ``NotImplementedError`` exception + return self.get(id_, fields=fields).raw + + def get(self, id_, fields=None): + with self.handle_404(): + return self.client.issue(id_, fields=fields, expand=["renderedFields"]) + + def search(self, jql): + # we need to have at least one field which is not 'id' or 'key' + # due to this bug: https://github.com/pycontribs/jira/pull/289 + issues = self.client.search_issues(jql, fields="id,updated", maxResults=None) + return [issue.id for issue in issues] diff --git a/connector_jira/components/jira_project_task_batch_importer.py b/connector_jira/components/jira_project_task_batch_importer.py new file mode 100644 index 000000000..0818e2f4f --- /dev/null +++ b/connector_jira/components/jira_project_task_batch_importer.py @@ -0,0 +1,17 @@ +# Copyright 2016-2022 Camptocamp SA +# Copyright 2019 Brainbean Apps (https://brainbeanapps.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo.addons.component.core import Component + + +class JiraProjectTaskBatchImporter(Component): + """Import the Jira tasks + + For every id in the list of tasks, a delayed job is created. + Import from a given date. + """ + + _name = "jira.project.task.batch.importer" + _inherit = ["jira.timestamp.batch.importer"] + _apply_on = ["jira.project.task"] diff --git a/connector_jira/components/jira_project_task_importer.py b/connector_jira/components/jira_project_task_importer.py new file mode 100644 index 000000000..413a39653 --- /dev/null +++ b/connector_jira/components/jira_project_task_importer.py @@ -0,0 +1,106 @@ +# Copyright 2016-2022 Camptocamp SA +# Copyright 2019 Brainbean Apps (https://brainbeanapps.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +import logging + +from odoo.addons.component.core import Component + +_logger = logging.getLogger(__name__) + + +class JiraProjectTaskImporter(Component): + _name = "jira.project.task.importer" + _inherit = ["jira.importer"] + _apply_on = ["jira.project.task"] + + def __init__(self, work_context): + super().__init__(work_context) + self.jira_epic = None + self.project_binding = None + + def _get_external_data(self): + # OVERRIDE: return the raw Jira data for ``self.external_id`` + result = super()._get_external_data() + epic_field_name = self.backend_record.epic_link_field_name + if epic_field_name: + issue_adapter = self.component( + usage="backend.adapter", model_name="jira.project.task" + ) + epic_key = result["fields"][epic_field_name] + if epic_key: + self.jira_epic = issue_adapter.read(epic_key) + return result + + def _find_project_binding(self): + matcher = self.component(usage="jira.task.project.matcher") + self.project_binding = matcher.find_project_binding(self.external_record) + + def _is_issue_type_sync(self): + type_id = self.external_record["fields"]["issuetype"]["id"] + binding = self.binder_for("jira.issue.type").to_internal(type_id) + return binding.is_sync_for_project(self.project_binding) + + def _create_data(self, map_record, **kwargs): + return super()._create_data( + map_record, + **dict( + kwargs or [], + jira_epic=self.jira_epic, + project_binding=self.project_binding, + ), + ) + + def _update_data(self, map_record, **kwargs): + return super()._update_data( + map_record, + **dict( + kwargs or [], + jira_epic=self.jira_epic, + project_binding=self.project_binding, + ), + ) + + def _import(self, binding, **kwargs): + # called at the beginning of _import because we must be sure + # that dependencies are there (project and issue type) + self._find_project_binding() + if not self._is_issue_type_sync(): + _logger.debug( + "Project or issue type %s is not synchronized.", + self.external_record["id"], + ) + return + return super()._import(binding, **kwargs) + + def _import_dependency_assignee(self): + jira_assignee = self.external_record["fields"].get("assignee") or {} + if jira_assignee: + jira_key = jira_assignee.get("accountId") + self._import_dependency(jira_key, "jira.res.users", record=jira_assignee) + + def _import_dependency_issue_type(self): + jira_issue_type = self.external_record["fields"]["issuetype"] + jira_issue_type_id = jira_issue_type["id"] + self._import_dependency( + jira_issue_type_id, "jira.issue.type", record=jira_issue_type + ) + + def _import_dependency_parent(self): + jira_parent = self.external_record["fields"].get("parent") + if jira_parent: + jira_parent_id = jira_parent["id"] + self._import_dependency(jira_parent_id, "jira.project.task") + + def _import_dependency_epic(self): + if self.jira_epic: + self._import_dependency( + self.jira_epic["id"], "jira.project.task", record=self.jira_epic + ) + + def _import_dependencies(self): + """Import the dependencies for the record""" + self._import_dependency_assignee() + self._import_dependency_issue_type() + self._import_dependency_parent() + self._import_dependency_epic() diff --git a/connector_jira/components/jira_project_task_mapper.py b/connector_jira/components/jira_project_task_mapper.py new file mode 100644 index 000000000..cddef769f --- /dev/null +++ b/connector_jira/components/jira_project_task_mapper.py @@ -0,0 +1,126 @@ +# Copyright 2016-2022 Camptocamp SA +# Copyright 2019 Brainbean Apps (https://brainbeanapps.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import _, fields + +from odoo.addons.component.core import Component +from odoo.addons.connector.components.mapper import mapping +from odoo.addons.connector.exception import MappingError + + +class JiraProjectTaskMapper(Component): + _name = "jira.project.task.mapper" + _inherit = "jira.import.mapper" + _apply_on = ["jira.project.task"] + + direct = [("key", "jira_key")] + + from_fields = [("duedate", "date_deadline")] + + @mapping + def from_attributes(self, record): + return self.component(usage="map.from.attrs").values(record, self) + + @mapping + def name(self, record): + name = "" + # On an Epic, you have 2 fields: + # - a field like 'customfield_10003' labelled "Epic Name" + # - a field 'summary' labelled "Summary" + # The other types of tasks have only the 'summary' field, the other is + # empty. To simplify, we always try to read the Epic Name, which + # will always be empty for other types. + epic_name_field = self.backend_record.epic_name_field_name + if epic_name_field: + name = record["fields"].get(epic_name_field) or "" + if not name: + name = record["fields"]["summary"] + return {"name": name} + + @mapping + def issue_type(self, record): + jira_type_id = record["fields"]["issuetype"]["id"] + binding = self.binder_for("jira.issue.type").to_internal(jira_type_id) + return {"jira_issue_type_id": binding.id} + + @mapping + def assignee(self, record): + assignee = record["fields"].get("assignee") + if not assignee: + return {"user_ids": [fields.Command.set([])]} + jira_key = assignee["accountId"] + user = self.binder_for("jira.res.users").to_internal(jira_key, unwrap=True) + if not user: + raise MappingError( + _( + 'No user found with accountId "%(jira_key)s" or email "%(email)s".' + "You must create a user or link it manually if the " + "login/email differs.", + jira_key=jira_key, + email=assignee.get("emailAddress"), + ) + ) + return {"user_ids": [fields.Command.set(user.ids)]} + + @mapping + def description(self, record): + return {"description": record["renderedFields"]["description"]} + + @mapping + def project(self, record): + proj_binding = self.options.project_binding + project = self.binder_for("jira.project.project").unwrap_binding(proj_binding) + values = { + "project_id": project.id, + "company_id": project.company_id.id, + "jira_project_bind_id": proj_binding.id, + } + if not project.active: + values["active"] = False + return values + + @mapping + def epic(self, record): + if not self.options.jira_epic: + return {} + binder = self.binder_for("jira.project.task") + binding = binder.to_internal(self.options.jira_epic["id"]) + return {"jira_epic_link_id": binding.id} + + @mapping + def parent(self, record): + jira_parent = record["fields"].get("parent") + if not jira_parent: + return {} + binding = self.binder_for("jira.project.task").to_internal(jira_parent["id"]) + return {"jira_parent_id": binding.id} + + @mapping + def backend_id(self, record): + return {"backend_id": self.backend_record.id} + + @mapping + def status(self, record): + status_name = record["fields"].get("status", {}).get("name") + if not status_name: + return {"stage_id": False} + project_binder = self.binder_for("jira.project.project") + project = project_binder.unwrap_binding(self.options.project_binding) + domain = [("name", "=", status_name), ("project_ids", "=", project.id)] + return {"stage_id": self.env["project.task.type"].search(domain, limit=1).id} + + @mapping + def time_estimate(self, record): + est = record["fields"].get("timeoriginalestimate") or 0.0 + return {"allocated_hours": float(est) / 3600.0} + + def finalize(self, map_record, values): + values = values.copy() + if values.get("odoo_id"): + # If a mapping binds the issue to an existing odoo + # task, we should not change the project. + # It's not only unexpected, but would fail as soon + # as we have invoiced timesheet lines on the task. + values.pop("project_id") + return values diff --git a/connector_jira/components/jira_res_users_adapter.py b/connector_jira/components/jira_res_users_adapter.py new file mode 100644 index 000000000..c3b218e49 --- /dev/null +++ b/connector_jira/components/jira_res_users_adapter.py @@ -0,0 +1,53 @@ +# Copyright 2016-2022 Camptocamp SA +# Copyright 2019 Brainbean Apps (https://brainbeanapps.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html) + +from itertools import groupby + +from odoo.addons.component.core import Component + + +class JiraResUsersAdapter(Component): + _name = "jira.res.users.adapter" + _inherit = ["jira.webservice.adapter"] + _apply_on = ["jira.res.users"] + + # pylint: disable=W8106 + def read(self, id_): + # No ``super()``: MRO will end up calling ``base.backend.adapter.crud.read()`` + # methods that will raise a ``NotImplementedError`` exception + with self.handle_404(): + return self.client.user(id_).raw + + def search(self, fragment: str = "") -> list: + """Search users + + :param fragment: a string to match usernames, name or email against. + If GDPR strict mode is active, email only is checked. + If ``fragment`` is an empty string, an empty list is returned . + """ + # Avoid searching for empty strings, or the client will raise a JIRAError + # when ``search_users()`` is called + if not fragment: + return [] + + # Param ``query`` only checks the fragment against the emails, while ``user`` + # also checks against usernames and names. + # However, ``user`` cannot be used when in GDPR strict mode, else a JIRAError + # will be raised by the client. + params = dict(maxResults=None, includeActive=True, includeInactive=True) + if self.backend_record._uses_gdpr_strict_mode(): + params["query"] = fragment + else: + params["user"] = fragment + users = self.client.search_users(**params) + + # User 'accountId' is unique, and if the same key appears several times, + # it means that the same user is found in multiple User Directories: we group + # the users by ``accountId`` and then fetch the first user for each group + return list( + map( + lambda group: list(group[1])[0], + groupby(users, key=lambda user: user.accountId), + ) + ) diff --git a/connector_jira/components/jira_res_users_importer.py b/connector_jira/components/jira_res_users_importer.py new file mode 100644 index 000000000..0255f0758 --- /dev/null +++ b/connector_jira/components/jira_res_users_importer.py @@ -0,0 +1,49 @@ +# Copyright 2016-2022 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import _ + +from odoo.addons.component.core import Component +from odoo.addons.queue_job.exception import JobError + + +class JiraResUsersImporter(Component): + _name = "jira.res.users.importer" + _inherit = ["jira.importer"] + _apply_on = ["jira.res.users"] + + def _import(self, binding): + jira_key = self.external_id + user = self.binder_for("jira.res.users").to_internal(jira_key, unwrap=True) + if not user: + email = self.external_record.get("emailAddress") + if email is None: + raise JobError( + _( + "Unable to find a user from account Id (%s)" + " and no email provided", + jira_key, + ) + ) + user = self.env["res.users"].search([("email", "=", email)]) + if not user: + raise JobError( + _( + "No user found for jira account %(key)s (%(mail)s)." + " Please link it manually from the Odoo user's form.", + key=jira_key, + mail=email, + ) + ) + elif len(user) > 1: + raise JobError( + _( + "Several users found (%(login)s) for jira account %(key)s" + " (%(mail)s). Please link it manually from the Odoo user's" + " form.", + login=", ".join(user.mapped("login")), + key=jira_key, + mail=email, + ) + ) + return user.link_with_jira(backends=self.backend_record) diff --git a/connector_jira/components/jira_task_project_matcher.py b/connector_jira/components/jira_task_project_matcher.py new file mode 100644 index 000000000..28035ccc2 --- /dev/null +++ b/connector_jira/components/jira_task_project_matcher.py @@ -0,0 +1,20 @@ +# Copyright 2016-2022 Camptocamp SA +# Copyright 2019 Brainbean Apps (https://brainbeanapps.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + + +from odoo.addons.component.core import Component + + +class JiraTaskProjectMatcher(Component): + _name = "jira.task.project.matcher" + _inherit = ["jira.base"] + _usage = "jira.task.project.matcher" + + def find_project_binding(self, jira_task_data, unwrap=False): + 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) + + def fallback_project_for_worklogs(self): + return self.backend_record.worklog_fallback_project_id diff --git a/connector_jira/components/jira_timestamp_batch_importer.py b/connector_jira/components/jira_timestamp_batch_importer.py new file mode 100644 index 000000000..3baca3029 --- /dev/null +++ b/connector_jira/components/jira_timestamp_batch_importer.py @@ -0,0 +1,88 @@ +# Copyright 2016-2022 Camptocamp SA +# Copyright 2019 Brainbean Apps (https://brainbeanapps.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +""" + +Importers for Jira. + +An import can be skipped if the last sync date is more recent than +the last update in Jira. + +They should call the ``bind`` method if the binder even if the records +are already bound, to update the last sync date. + +""" + +import logging +from datetime import datetime, timedelta + +from odoo import _ + +from odoo.addons.component.core import AbstractComponent +from odoo.addons.queue_job.exception import RetryableJobError + +from .common import IMPORT_DELTA, JIRA_JQL_DATETIME_FORMAT + +_logger = logging.getLogger(__name__) + + +class JiraTimestampBatchImporter(AbstractComponent): + """Batch Importer working with a jira.backend.timestamp.record + + It locks the timestamp to ensure no other job is working on it, + and uses the latest timestamp value as reference for the search. + + The role of a BatchImporter is to search for a list of + items to import, then it can either import them directly or delay + the import of each item separately. + """ + + _name = "jira.timestamp.batch.importer" + _inherit = ["base.importer", "jira.base"] + _usage = "timestamp.batch.importer" + + def run(self, timestamp, force=False, **kwargs): + """Run the synchronization using the timestamp""" + original_timestamp_value = timestamp.last_timestamp + if not timestamp._lock(): + self._handle_lock_failed(timestamp) + + next_timestamp_value, records = self._search(timestamp) + timestamp._update_timestamp(next_timestamp_value) + number = self._handle_records(records, force=force) + return _( + f"Batch from {original_timestamp_value} UTC to {next_timestamp_value} UTC " + f"generated {number} imports" + ) + + def _handle_records(self, records, force=False): + """Handle the records to import and return the number handled""" + number = 0 # Cannot use ``len(records)`` cause ``records`` is a generator + for record_id in records: + number += 1 + self._import_record(record_id, force=force) + return number + + def _handle_lock_failed(self, timestamp): + _logger.warning("Failed to acquire timestamps %s", timestamp, exc_info=True) + raise RetryableJobError("Concurrent process already syncing", ignore_retry=True) + + def _search(self, timestamp): + """Return a tuple (next timestamp value, jira record ids)""" + adapter = self.backend_adapter + since, until = timestamp.last_timestamp, datetime.now() + since_str = since.strftime(JIRA_JQL_DATETIME_FORMAT) + until_str = until.strftime(JIRA_JQL_DATETIME_FORMAT) + next_timestamp_value = max(until - timedelta(seconds=IMPORT_DELTA), since) + recs = adapter.search(f'updated >= "{since_str}" and updated <= "{until_str}"') + return next_timestamp_value, recs + + def _import_record(self, record_id, force=False, record=None, **kwargs): + """Delay the import of the records""" + self.model.with_delay(**kwargs).import_record( + self.backend_record, + record_id, + force=force, + record=record, + ) diff --git a/connector_jira/components/jira_webservice_adapter.py b/connector_jira/components/jira_webservice_adapter.py new file mode 100644 index 000000000..655c264dc --- /dev/null +++ b/connector_jira/components/jira_webservice_adapter.py @@ -0,0 +1,92 @@ +# Copyright 2016-2022 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +import logging +from contextlib import contextmanager + +import requests + +from odoo import _, exceptions + +from odoo.addons.component.core import Component +from odoo.addons.connector.exception import IDMissingInBackend + +_logger = logging.getLogger(__name__) + +try: + import jira +except ImportError as err: + _logger.debug(err) + + +class JiraWebserviceAdapter(Component): + """Generic adapter for using the JIRA backend""" + + _name = "jira.webservice.adapter" + _inherit = ["base.backend.adapter.crud", "jira.base"] + _usage = "backend.adapter" + + def __init__(self, work_context): + super().__init__(work_context) + self._client = None + + @property + def client(self): + # lazy load the client, initialize only when actually needed + if not self._client: + self._client = self.backend_record.get_api_client() + return self._client + + def _post_get_json( + self, + path, + params=None, + base=jira.client.JIRA.JIRA_BASE_URL, + ): + """Get the json for a given path and payload + + :param path: The subpath required + :type path: str + :param params: a payload for the method + :type params: A json payload + :param base: The Base JIRA URL, defaults to the instance base. + :type base: Optional[str] + :rtype: Union[Dict[str, Any], List[Dict[str, str]]] + """ + return self.client._get_json(path=path, base=base, params=params, use_post=True) + + @contextmanager + def handle_404(self): + """Context manager to handle 404 errors on the API + + 404 (no record found) on the API are re-raised as: + ``odoo.addons.connector.exception.IDMissingInBackend`` + """ + try: + yield + except jira.exceptions.JIRAError as err: + if err.status_code == 404: + raise IDMissingInBackend(f"{err.text} (url: {err.url})") from err + raise + + @contextmanager + def handle_user_api_errors(self): + """Contextmanager to use when the API is used user-side + + It catches the common network or Jira errors and reraise them + to the user using the Odoo UserError. + """ + try: + yield + except requests.exceptions.ConnectionError as err: + _logger.exception("Jira ConnectionError") + message = _("Error during connection with Jira: %s") % (err,) + raise exceptions.UserError(message) from err + except jira.exceptions.JIRAError as err: + _logger.exception("Jira JIRAError") + message = _("Jira Error: %s") % (err,) + raise exceptions.UserError(message) from err + except IDMissingInBackend as err: + _logger.exception("Jira 404 for an ID") + message = _("Record does not exist in Jira: %s") % (err,) + raise exceptions.UserError(message) from err diff --git a/connector_jira/components/jira_worklog_adapter.py b/connector_jira/components/jira_worklog_adapter.py new file mode 100644 index 000000000..3da8237cd --- /dev/null +++ b/connector_jira/components/jira_worklog_adapter.py @@ -0,0 +1,82 @@ +# Copyright 2016-2022 Camptocamp SA +# Copyright 2019 Brainbean Apps (https://brainbeanapps.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from collections import namedtuple + +from odoo.addons.component.core import Component + +UpdatedWorklog = namedtuple( + "UpdatedWorklog", + # id as integer, timestamp + "worklog_id updated", +) + +UpdatedWorklogSince = namedtuple( + "UpdatedWorklogSince", + # timestamp, timestamp, list[UpdatedWorklog] + "since until updated_worklogs", +) + + +DeletedWorklogSince = namedtuple( + "DeletedWorklogSince", + # timestamp, timestamp, list[ids as integer] + "since until deleted_worklog_ids", +) + + +class WorklogAdapter(Component): + _name = "jira.worklog.adapter" + _inherit = "jira.webservice.adapter" + _apply_on = ["jira.account.analytic.line"] + + # pylint: disable=W8106 + def read(self, issue_id, worklog_id): + # No ``super()``: MRO will end up calling ``base.backend.adapter.crud.read()`` + # methods that will raise a ``NotImplementedError`` exception + with self.handle_404(): + return self.client.worklog(issue_id, worklog_id).raw + + def search(self, issue_id): + """Search worklogs of an issue""" + return [worklog.id for worklog in self.client.worklogs(issue_id)] + + @staticmethod + def _chunks(whole, size): + """Yield successive n-sized chunks from l.""" + for i in range(0, len(whole), size): + yield whole[i : i + size] + + def yield_read(self, worklog_ids): + """Generator returning worklog ids data""" + # the method returns max 1000 results + for chunk in self._chunks(worklog_ids, 1000): + yield from self._post_get_json("worklog/list", params={"ids": chunk}) + + def updated_since(self, since=None): + original_since, until = since, since + updated_worklogs = [] + result = {"lastPage": False} + while not result["lastPage"]: + result = self.client._get_json("worklog/updated", params={"since": since}) + updated_worklogs += [ + UpdatedWorklog(worklog_id=row["worklogId"], updated=row["updatedTime"]) + for row in result["values"] + ] + until = since = result["until"] + return UpdatedWorklogSince( + since=original_since, until=until, updated_worklogs=updated_worklogs + ) + + def deleted_since(self, since=None): + original_since, until = since, since + deleted_worklog_ids = [] + result = {"lastPage": False} + while not result["lastPage"]: + result = self.client._get_json("worklog/deleted", params={"since": since}) + deleted_worklog_ids += [row["worklogId"] for row in result["values"]] + until = since = result["until"] + return DeletedWorklogSince( + since=original_since, until=until, deleted_worklog_ids=deleted_worklog_ids + ) diff --git a/connector_jira/components/project_project_listener.py b/connector_jira/components/project_project_listener.py new file mode 100644 index 000000000..32239edd1 --- /dev/null +++ b/connector_jira/components/project_project_listener.py @@ -0,0 +1,30 @@ +# Copyright 2016-2019 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo.addons.component.core import Component +from odoo.addons.component_event import skip_if + + +class ProjectProjectListener(Component): + _name = "project.project.listener" + _inherit = ["base.connector.listener", "jira.base"] + _apply_on = ["project.project"] + + @skip_if(lambda self, record, **kwargs: self.no_connector_export(record)) + def on_record_write(self, record, fields=None): + # Remove ``jira_bind_ids`` and ``message_follower_ids`` from the fields: + # - ``jira_bind_ids``: when this field has been modified, an export is triggered + # by ``jira.project.project.listener`` after the field's values have been + # written to the proper ``jira.project.project`` records, so we ignore this + # field to avoid duplicated exports + # - ``message_follower_ids``: when ``mail.thread.message_subscribe()`` has been + # called, it does a ``write()`` on field ``message_follower_ids``, but we + # never want to export that + fields = set(fields or []) + fields.difference_update({"jira_bind_ids", "message_follower_ids"}) + # After cleaning the fields, if we still have some fields to export, do it + if fields: + fields = list(fields) + for binding in record.jira_bind_ids: + if binding.sync_action == "export": + binding.with_delay(priority=10).export_record(fields=fields) diff --git a/connector_jira/controllers/__init__.py b/connector_jira/controllers/__init__.py new file mode 100644 index 000000000..f8a4a557c --- /dev/null +++ b/connector_jira/controllers/__init__.py @@ -0,0 +1,4 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from . import jira_connect_app_controller +from . import jira_webhook_controller diff --git a/connector_jira/controllers/jira_connect_app_controller.py b/connector_jira/controllers/jira_connect_app_controller.py new file mode 100644 index 000000000..090e018d5 --- /dev/null +++ b/connector_jira/controllers/jira_connect_app_controller.py @@ -0,0 +1,191 @@ +# Copyright 2016-2024 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +""" + +Receive webhooks from Jira + + +(Outdated) JIRA could well send all the data in the webhook request's body, +which would avoid Odoo to make another GET to get this data, but +JIRA webhooks are potentially insecure as we don't know if it really +comes from JIRA. So we don't use the data sent by the webhook and the job +gets the data by itself (with the nice side-effect that the job is retryable). + +TODO: we now have authenticated calls from Jira through the JWT tokens, so we + could move back to a setup where we avoid querying the data back to Jira. + Changing this is on the roadmap. + +""" + +import json +import logging + +import jwt +import requests +from werkzeug.exceptions import Forbidden + +import odoo +from odoo import http +from odoo.http import request + +from odoo.addons.web.controllers.utils import ensure_db + +_logger = logging.getLogger(__name__) + + +class JiraConnectAppController(http.Controller): + """Manage the lifecyle of the App + + The app-descriptor endpoint when called returns the app descriptor, + which lists the endpoints for installation / uninstallation / + enabling / disabling the app on a Jira cloud server. + + The lifecycle requests all receive a payload with the following keys: + + { + "key": "installed-addon-key", + "clientKey": "unique-client-identifier", + "sharedSecret": "a-secret-key-not-to-be-lost", + "serverVersion": "server-version", # DEPRECATED + "pluginsVersion": "version-of-connect", + "baseUrl": "https://example.atlassian.net", + "displayUrl": "https://issues.example.com", + "displayUrlServicedeskHelpCenter": "https://support.example.com", + "productType": "jira", + "description": "Atlassian Jira at https://example.atlassian.net", + "serviceEntitlementNumber": "SEN-number", + "entitlementId": "Entitlement-Id", + "entitlementNumber": "Entitlement-Number", + "eventType": "installed", + "installationId": + "ari:cloud:ecosystem::installation/uuid-of-forge-installation-identifier" + } + + Upon reception of an "installed" lifecycle call, we create a backend record + for the app, in state "disabled". + Upon reception of an "enabled" lifecycle call, we set the backend to "enabled". + Upon reception of a "disabled" lifecycle call, we set the backend to "disabled". + Upon reception of an "uninstalled" lifecycle call, we unlink the backend record. + + Documentation: + https://developer.atlassian.com/cloud/jira/platform/connect-app-descriptor/#lifecycle + """ + + def _get_backend(self, backend_id): + backend = request.env["jira.backend"].search([("id", "=", backend_id)]) + if not backend: + _logger.warning("Cannot retrieve Jira backend with ID %s" % backend_id) + return backend + + @http.route( + "/jira//app-descriptor.json", + type="http", + methods=["GET"], + auth="public", + csrf=False, + ) + def app_descriptor(self, backend_id, **kwargs): + ensure_db() + request.update_env(user=odoo.SUPERUSER_ID) + backend = self._get_backend(backend_id) + data = json.dumps(backend._get_app_descriptor() if backend else {}) + headers = [("Content-Type", "application/json"), ("Content-Length", len(data))] + return request.make_response(data, headers) + + def _validate_jwt_token(self): + """Use authorization header to validate the request + + The process is described in + https://developer.atlassian.com/cloud/jira/platform/security-for-connect-apps/ + """ + auth_header = request.httprequest.headers["Authorization"] + assert auth_header.startswith("JWT "), "unexpected content in Auth header" + jwt_token = auth_header[4:] + headers = jwt.get_unverified_header(jwt_token) + if "kid" not in headers: + raise Forbidden() + kid = headers["kid"] + # pylint: disable=E8106 + response = requests.get(f"https://connect-install-keys.atlassian.com/{kid}") + response.raise_for_status() + public_key = response.text + response.close() + _logger.info("public key:\n%s", public_key) + decoded = jwt.decode( + jwt_token, + public_key, + algorithms=[headers["alg"]], + audience=request.env["jira.backend"].sudo()._get_base_url(), + ) + _logger.warning("decoded JWT Token: %s", decoded) + return True + + @http.route( + "/jira//installed", + type="json", + methods=["POST"], + auth="public", # security implemented by self._validate_jwt_token() + csrf=False, + ) + def install_app(self, backend_id, **kwargs): + self._validate_jwt_token() + payload = request.get_json_data() + _logger.info("installed: %s", payload) + assert payload["eventType"] == "installed" + ensure_db() + request.update_env(user=odoo.SUPERUSER_ID) + return {"status": self._get_backend(backend_id)._install_app(payload)} + + @http.route( + "/jira//uninstalled", + type="json", + methods=["POST"], + auth="public", # security implemented by self._validate_jwt_token() + csrf=False, + ) + def uninstall_app(self, backend_id, **kwargs): + self._validate_jwt_token() + payload = request.get_json_data() + _logger.info("uninstalled: %s", payload) + assert payload["eventType"] == "uninstalled" + request.update_env(user=odoo.SUPERUSER_ID) + return {"status": self._get_backend(backend_id)._uninstall_app(payload)} + + @http.route( + "/jira//enabled", + type="json", + methods=["POST"], + auth="public", # security implemented by backend._validate_jwt_from_request() + csrf=False, + ) + def enable_app(self, backend_id, **kwargs): + payload = request.get_json_data() + _logger.info("enabled: %s", payload) + assert payload["eventType"] == "enabled" + request.update_env(user=odoo.SUPERUSER_ID) + backend = self._get_backend(backend_id) + status = "ko" + if backend: + backend._validate_jwt_from_request() + status = backend._enable_app(payload) + return {"status": status} + + @http.route( + "/jira//disabled", + type="json", + methods=["POST"], + auth="public", # security implemented by backend._validate_jwt_from_request() + csrf=False, + ) + def disable_app(self, backend_id, **kwargs): + payload = request.get_json_data() + _logger.info("disabled: %s", payload) + assert payload["eventType"] == "disabled" + request.update_env(user=odoo.SUPERUSER_ID) + backend = self._get_backend(backend_id) + status = "ko" + if backend: + backend._validate_jwt_from_request() + status = backend._disable_app(payload) + return {"status": status} diff --git a/connector_jira/controllers/jira_webhook_controller.py b/connector_jira/controllers/jira_webhook_controller.py new file mode 100644 index 000000000..82cc162e2 --- /dev/null +++ b/connector_jira/controllers/jira_webhook_controller.py @@ -0,0 +1,93 @@ +# Copyright 2016-2024 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +""" + +Receive webhooks from Jira + + +(Outdated) JIRA could well send all the data in the webhook request's body, +which would avoid Odoo to make another GET to get this data, but +JIRA webhooks are potentially insecure as we don't know if it really +comes from JIRA. So we don't use the data sent by the webhook and the job +gets the data by itself (with the nice side-effect that the job is retryable). + +TODO: we now have authenticated calls from Jira through the JWT tokens, so we + could move back to a setup where we avoid querying the data back to Jira. + Changing this is on the roadmap. + +""" + +import logging +import pprint + +import odoo +from odoo import _, http +from odoo.http import request + +from odoo.addons.web.controllers.utils import ensure_db + +_logger = logging.getLogger(__name__) + + +class JiraWebhookController(http.Controller): + def _get_backend(self, backend_id): + backend = request.env["jira.backend"].search( + [("id", "=", backend_id), ("state", "=", "running")] + ) + if not backend: + _logger.warning( + "Cannot retrieve running Jira backend with ID %s" % backend_id + ) + return backend + + @http.route( + "/connector_jira//webhooks/issue", + type="json", + auth="none", # security implemented by backend._validate_jwt_from_request() + csrf=False, + ) + def webhook_issue(self, backend_id, issue_id=None, **kw): + ensure_db() + data = request.get_json_data() + pprint.pprint(data) + request.update_env(user=odoo.SUPERUSER_ID) + backend = self._get_backend(backend_id) + if not backend: + return + backend._validate_jwt_from_request() + model = request.env["jira.project.task"] + args = (backend, data["issue"]["id"]) + if data["webhookEvent"] == "jira:issue_deleted": + delay_msg = _("Delete a local issue which has been deleted on JIRA") + method = "delete_record" + else: + delay_msg = _("Import a issue from JIRA") + method = "import_record" + getattr(model.with_delay(description=delay_msg), method)(*args) + + @http.route( + "/connector_jira//webhooks/worklog", + type="json", + auth="none", # security implemented by backend._validate_jwt_from_request() + csrf=False, + ) + def webhook_worklog(self, backend_id, **kw): + ensure_db() + data = request.get_json_data() + pprint.pprint(data) + request.update_env(user=odoo.SUPERUSER_ID) + backend = self._get_backend(backend_id) + if not backend: + return + backend._validate_jwt_from_request() + model = request.env["jira.account.analytic.line"] + if data["webhookEvent"] == "worklog_deleted": + delay_msg = _("Delete a local worklog which has been deleted on JIRA") + method = "delete_record" + args = (backend, data["worklog"]["id"]) + else: + delay_msg = _("Import a worklog from JIRA") + method = "import_record" + args = (backend, data["worklog"]["issueId"], data["worklog"]["id"]) + getattr(model.with_delay(description=delay_msg), method)(*args) diff --git a/connector_jira/data/cron.xml b/connector_jira/data/cron.xml new file mode 100644 index 000000000..008626da7 --- /dev/null +++ b/connector_jira/data/cron.xml @@ -0,0 +1,51 @@ + + + + JIRA - Import Project Tasks + + code + model._scheduler_import_project_task() + + + 10 + minutes + -1 + + + + JIRA - Import Users + + code + model._scheduler_import_res_users() + + + 10 + minutes + -1 + + + + JIRA - Import Worklogs + + code + model._scheduler_import_analytic_line() + + + 10 + minutes + -1 + + + + JIRA - Import Deleted Worklogs + + code + model._scheduler_delete_analytic_line() + + + 10 + minutes + -1 + + + diff --git a/connector_jira/data/queue_job_channel.xml b/connector_jira/data/queue_job_channel.xml new file mode 100644 index 000000000..3726d3230 --- /dev/null +++ b/connector_jira/data/queue_job_channel.xml @@ -0,0 +1,8 @@ + + + + + connector_jira.import + + + diff --git a/connector_jira/data/queue_job_function.xml b/connector_jira/data/queue_job_function.xml new file mode 100644 index 000000000..2b19f8c11 --- /dev/null +++ b/connector_jira/data/queue_job_function.xml @@ -0,0 +1,52 @@ + + + + + + import_record + + + + + + + + import_batch + + + + + run_batch_timestamp + + + + + delete_record + + + + + import_record + + + + + + export_record + + + + + + + + import_batch + + + diff --git a/connector_jira/demo/jira_backend_demo.xml b/connector_jira/demo/jira_backend_demo.xml new file mode 100644 index 000000000..a979d23bd --- /dev/null +++ b/connector_jira/demo/jira_backend_demo.xml @@ -0,0 +1,6 @@ + + + + JiraTest + + diff --git a/connector_jira/fields.py b/connector_jira/fields.py new file mode 100644 index 000000000..a045e2014 --- /dev/null +++ b/connector_jira/fields.py @@ -0,0 +1,66 @@ +import time +from datetime import date, datetime + +from odoo import fields + +MILLI_DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S.%f" +MILLI_DATETIME_LENGTH = len(MILLI_DATETIME_FORMAT) + + +class MilliDatetime(fields.Field): + """Field storing Datetime with milliseconds precision + + There are no widgets for this field, it is only technical + for storing Jira timestamps. + + As Jira uses Unix Timestamps on some webservices methods, + this field provides conversions utilities. + + Beware, unlike the Datetime field (prior 12.0), the MilliDatetime + field works with datetime objects. + """ + + type = "millidatetime" + column_type = ("timestamp", "timestamp") + + @staticmethod + def to_datetime(value): + """Convert a string to :class:`datetime` including milliseconds""" + if not value: + return None + if isinstance(value, datetime): + if value.tzinfo: + raise ValueError( + f"MilliDatetime field expects a naive datetime: {value}" + ) + return value + if len(value) > fields.DATETIME_LENGTH: + return datetime.strptime(value, MILLI_DATETIME_FORMAT) + else: + return fields.Datetime.to_datetime(value) + + # Backward compatibility and consistency w/ fields.Datetime + from_string = to_datetime + + @staticmethod + def to_string(value): + """Convert a :class:`datetime` including milliseconds to a string""" + return value.strftime(MILLI_DATETIME_FORMAT) if value else False + + @staticmethod + def from_timestamp(value): + return datetime.fromtimestamp(value / 1000) + + @staticmethod + def to_timestamp(value): + assert not value.tzinfo + return int(time.mktime(value.timetuple()) * 1000 + value.microsecond / 1000) + + def convert_to_cache(self, value, record, validate=True): + if not value: + return False + if isinstance(value, date) and not isinstance(value, datetime): + raise TypeError( + f"{value} (field {self}) must be string or datetime, not date." + ) + return self.to_datetime(value) diff --git a/connector_jira/i18n/connector_jira.pot b/connector_jira/i18n/connector_jira.pot new file mode 100644 index 000000000..092ef8814 --- /dev/null +++ b/connector_jira/i18n/connector_jira.pot @@ -0,0 +1,3844 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * connector_jira +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 15.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__accesses_count +msgid "# Access Rights" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__collaborator_count +msgid "# Collaborators" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__groups_count +msgid "# Groups" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__rating_count +msgid "# Ratings" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__rules_count +msgid "# Record Rules" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__task_count +msgid "# Tasks" +msgstr "" + +#. module: connector_jira +#: code:addons/connector_jira/models/jira_backend/common.py:0 +#: code:addons/connector_jira/models/project_project/common.py:0 +#: code:addons/connector_jira/models/project_project/common.py:0 +#: code:addons/connector_jira/models/project_project/project_link_jira.py:0 +#, python-format +msgid "%s is not a valid JIRA Key" +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.backend_report_user_sync +msgid "" +"-\n" +" detail:" +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.view_jira_backend_form +msgid "(" +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.view_jira_backend_form +msgid ")" +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.backend_report_user_sync +msgid "error:" +msgstr "" + +#. module: connector_jira +#: code:addons/connector_jira/models/project_task/common.py:0 +#, python-format +msgid "A Jira task cannot be deleted." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_project__alias_defaults +msgid "" +"A Python dictionary that will be evaluated to provide default values when " +"creating new records for this alias." +msgstr "" + +#. module: connector_jira +#: model:ir.model.constraint,message:connector_jira.constraint_jira_account_analytic_line_jira_binding_uniq +#: model:ir.model.constraint,message:connector_jira.constraint_jira_binding_jira_binding_uniq +#: model:ir.model.constraint,message:connector_jira.constraint_jira_issue_type_jira_binding_uniq +#: model:ir.model.constraint,message:connector_jira.constraint_jira_organization_jira_binding_uniq +#: model:ir.model.constraint,message:connector_jira.constraint_jira_project_project_jira_binding_uniq +#: model:ir.model.constraint,message:connector_jira.constraint_jira_project_task_jira_binding_uniq +#: model:ir.model.constraint,message:connector_jira.constraint_jira_res_users_jira_binding_uniq +msgid "A binding already exists for this Jira record" +msgstr "" + +#. module: connector_jira +#: model:ir.model.constraint,message:connector_jira.constraint_jira_account_analytic_line_jira_binding_backend_uniq +msgid "A binding already exists for this line and this backend." +msgstr "" + +#. module: connector_jira +#: model:ir.model.constraint,message:connector_jira.constraint_jira_project_task_jira_binding_backend_uniq +msgid "A binding already exists for this task and this backend." +msgstr "" + +#. module: connector_jira +#: model:ir.model.constraint,message:connector_jira.constraint_jira_backend_timestamp_timestamp_field_uniq +msgid "A timestamp already exists." +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.view_jira_backend_form +msgid "API Configuration" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__api_key_ids +msgid "API Keys" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend__access_secret +msgid "Access Secret" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend__access_token +msgid "Access Token" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__access_warning +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__access_warning +msgid "Access warning" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__message_needaction +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__message_needaction +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__message_needaction +msgid "Action Needed" +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.view_jira_backend_form +msgid "" +"Activate the synchronization of the Epic Link field.\n" +" Only on JIRA Software. The field contains the name of\n" +" the JIRA custom field that contains the Epic Link.\n" +"\n" +" Note that if a project does not synchronize the Epics,\n" +" the field will be empty." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__active +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__active +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__active +msgid "Active" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__analytic_account_active +msgid "Active Analytic Account" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__active_lang_count +msgid "Active Lang Count" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__activity_ids +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__activity_ids +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__activity_ids +msgid "Activities" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__activity_exception_decoration +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__activity_exception_decoration +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__activity_exception_decoration +msgid "Activity Exception Decoration" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__activity_state +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__activity_state +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__activity_state +msgid "Activity State" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__activity_type_icon +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__activity_type_icon +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__activity_type_icon +msgid "Activity Type Icon" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__additional_note +msgid "Additional Note" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__additional_info +msgid "Additional info" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__address_home_id +msgid "Address" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__type +msgid "Address Type" +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.view_jira_backend_form +msgid "Advanced Configuration" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__alias_id +msgid "Alias" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__alias_contact +msgid "Alias Contact Security" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__alias_name +msgid "Alias Name" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__alias_domain +msgid "Alias domain" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__alias_value +msgid "Alias email" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__alias_model_id +msgid "Aliased Model" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_res_users__lang +#: model:ir.model.fields,help:connector_jira.field_jira_res_users__private_lang +msgid "" +"All the emails and documents sent to this contact will be translated in this" +" language." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_project_link_jira__allow_back +#: model:ir.model.fields,field_description:connector_jira.field_task_link_jira__allow_back +msgid "Allow Back" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__allow_subtasks +msgid "Allow Sub-tasks" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__allow_timesheets +msgid "Allow timesheets" +msgstr "" + +#. module: connector_jira +#: code:addons/connector_jira/components/importer.py:0 +#, python-format +msgid "Already up-to-date." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_account_analytic_line__amount +msgid "Amount" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_account_analytic_line__account_id +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__analytic_account_id +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__analytic_account_id +msgid "Analytic Account" +msgstr "" + +#. module: connector_jira +#: model:ir.model,name:connector_jira.model_account_analytic_line +msgid "Analytic Line" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__analytic_tag_ids +msgid "Analytic Tag" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_project__analytic_account_id +#: model:ir.model.fields,help:connector_jira.field_jira_project_task__project_analytic_account_id +msgid "" +"Analytic account to which this project is linked for financial management. " +"Use an analytic account to record cost and revenue on your project." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_task__analytic_account_id +msgid "" +"Analytic account to which this task is linked for financial management. Use " +"an analytic account to record cost and revenue on your task. If empty, the " +"analytic account of the project will be used." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields.selection,name:connector_jira.selection__jira_backend__worklog_date_timezone_mode__naive +msgid "As-is (naive)" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__user_ids +msgid "Assignees" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__date_assign +msgid "Assigning Date" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__timesheet_ids +msgid "Associated Timesheets" +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.view_jira_aa_line_import +msgid "" +"At confirmation, the selected lines will be reimported from Jira in\n" +" background. If a line was linked to the wrong project (e.g. the fallback\n" +" project) and the project binding has been corrected meanwhile, the line\n" +" will be moved to the expected target project." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__message_attachment_count +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__message_attachment_count +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__message_attachment_count +msgid "Attachment Count" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_task__attachment_ids +msgid "Attachments that don't come from a message." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend_auth__auth_uri +msgid "Auth Uri" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields.selection,name:connector_jira.selection__jira_backend__state__authenticate +#: model_terms:ir.ui.view,arch_db:connector_jira.view_jira_backend_form +msgid "Authenticate" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__avatar_1920 +msgid "Avatar" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__avatar_1024 +msgid "Avatar 1024" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__avatar_128 +msgid "Avatar 128" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__avatar_256 +msgid "Avatar 256" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__avatar_512 +msgid "Avatar 512" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend_auth__backend_id +msgid "Backend" +msgstr "" + +#. module: connector_jira +#: model:ir.ui.menu,name:connector_jira.menu_jira_backend +msgid "Backends" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__barcode +msgid "Badge ID" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__analytic_account_balance +msgid "Balance" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__bank_ids +msgid "Banks" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend__odoo_webhook_base_url +msgid "Base Odoo URL for Webhooks" +msgstr "" + +#. module: connector_jira +#: code:addons/connector_jira/models/account_analytic_line/deleter.py:0 +#, python-format +msgid "Batch from {} UTC to {} UTC generated {} delete jobs" +msgstr "" + +#. module: connector_jira +#: code:addons/connector_jira/components/importer.py:0 +#, python-format +msgid "Batch from {} UTC to {} UTC generated {} imports" +msgstr "" + +#. module: connector_jira +#: code:addons/connector_jira/components/importer.py:0 +#, python-format +msgid "Binding not found" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__is_blacklisted +msgid "Blacklist" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__mobile_blacklisted +msgid "Blacklisted Phone Is Mobile" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__phone_blacklisted +msgid "Blacklisted Phone is Phone" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__dependent_ids +msgid "Block" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__depend_on_ids +msgid "Blocked By" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__message_bounce +msgid "Bounce" +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.view_jira_backend_form +msgid "" +"By clicking on the buttons,\n" +" you will initiate the synchronizations\n" +" with Jira.\n" +" Note that the import or exports\n" +" won't be done directly,\n" +" they will create 'Jobs'\n" +" executed as soon as possible." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__can_edit +msgid "Can Edit" +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.view_jira_aa_line_import +#: model_terms:ir.ui.view,arch_db:connector_jira.view_jira_backend_auth_form +msgid "Cancel" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_account_analytic_line__category +msgid "Category" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__certificate +msgid "Certificate Level" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__channel_ids +msgid "Channels" +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.view_jira_backend_form +msgid "Check Connection" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_task__partner_is_company +#: model:ir.model.fields,help:connector_jira.field_jira_res_users__is_company +msgid "Check if the contact is a company, otherwise it is a person" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_res_users__employee +msgid "Check this box if this contact is an Employee." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__child_text +msgid "Child Text" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__partner_city +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__city +msgid "City" +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.view_jira_backend_auth_form +msgid "Close" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__is_closed +msgid "Closing Stage" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__coach_id +msgid "Coach" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__collaborator_ids +msgid "Collaborators" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__color +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__color +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__color +msgid "Color Index" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__commercial_partner_id +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__commercial_partner_id +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__commercial_partner_id +msgid "Commercial Entity" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__company_ids +msgid "Companies" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_account_analytic_line__company_id +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend__company_id +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__company_id +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__company_id +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__company_id +msgid "Company" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__company_name +msgid "Company Name" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__commercial_company_name +msgid "Company Name Entity" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__company_type +msgid "Company Type" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__partner_gid +msgid "Company database ID" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__employee_id +msgid "Company employee" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields.selection,name:connector_jira.selection__jira_backend_auth__state__done +msgid "Complete" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__contact_address +msgid "Complete Address" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend_timestamp__component_usage +msgid "Component Usage" +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.view_jira_backend_form +msgid "Configuration Done" +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.view_jira_backend_form +msgid "Configure Epic Link" +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.view_jira_backend_form +msgid "Configure worklog fields" +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.view_jira_aa_line_import +msgid "Confirm" +msgstr "" + +#. module: connector_jira +#: code:addons/connector_jira/models/jira_backend/common.py:0 +#, python-format +msgid "Connection successful" +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.edit_project +#: model_terms:ir.ui.view,arch_db:connector_jira.view_task_form2 +#: model_terms:ir.ui.view,arch_db:connector_jira.view_users_form +msgid "Connector" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend__consumer_key +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend_auth__consumer_key +msgid "Consumer Key" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__child_ids +msgid "Contact" +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.view_jira_backend_auth_form +msgid "Continue" +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.view_jira_backend_auth_form +msgid "Continue (Only After Above Authorization)" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_account_analytic_line__product_uom_category_id +msgid "" +"Conversion between Units of Measure can only occur if they belong to the " +"same category. The conversion will be made based on the ratios." +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.view_jira_backend_auth_form +msgid "Copy the values in the JIRA application link" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_res_users__message_bounce +msgid "Counter of the number of bounced emails for this contact" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__country_id +msgid "Country" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__country_code +msgid "Country Code" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__country_of_birth +msgid "Country of Birth" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__displayed_image_id +msgid "Cover Image" +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.project_project_view_form_simplified +msgid "Create and Link with JIRA" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_account_analytic_line__create_uid +#: model:ir.model.fields,field_description:connector_jira.field_jira_account_analytic_line_import__create_uid +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend__create_uid +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend_auth__create_uid +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend_timestamp__create_uid +#: model:ir.model.fields,field_description:connector_jira.field_jira_issue_type__create_uid +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__create_uid +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__create_uid +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__create_uid +#: model:ir.model.fields,field_description:connector_jira.field_project_link_jira__create_uid +#: model:ir.model.fields,field_description:connector_jira.field_task_link_jira__create_uid +msgid "Created by" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_account_analytic_line__create_date +#: model:ir.model.fields,field_description:connector_jira.field_jira_account_analytic_line_import__create_date +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend__create_date +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend_auth__create_date +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend_timestamp__create_date +#: model:ir.model.fields,field_description:connector_jira.field_jira_issue_type__create_date +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__create_date +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__create_date +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__create_date +#: model:ir.model.fields,field_description:connector_jira.field_project_link_jira__create_date +#: model:ir.model.fields,field_description:connector_jira.field_task_link_jira__create_date +msgid "Created on" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__credit_limit +msgid "Credit Limit" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_account_analytic_line__currency_id +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__currency_id +msgid "Currency" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__alias_bounced_content +msgid "Custom Bounced Message" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__partner_id +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__partner_id +msgid "Customer" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_project__access_url +#: model:ir.model.fields,help:connector_jira.field_jira_project_task__access_url +msgid "Customer Portal URL" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__rating_active +msgid "Customer Ratings" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__rating_status +msgid "Customer Ratings Status" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_account_analytic_line__date +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__date +msgid "Date" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__birthday +msgid "Date of Birth" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__repeat_weekday +msgid "Day Of The Week" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__date_deadline +msgid "Deadline" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend__project_template +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_base_mixin__project_template +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__project_template +#: model:ir.model.fields,field_description:connector_jira.field_project_link_jira__project_template +msgid "Default Project Template" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_base_mixin__project_template_shared +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__project_template_shared +#: model:ir.model.fields,field_description:connector_jira.field_project_link_jira__project_template_shared +msgid "Default Shared Template" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend__project_template_shared +msgid "Default Shared Template Key" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__alias_defaults +msgid "Default Values" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__resource_calendar_id +msgid "Default Working Hours" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_res_users__resource_calendar_id +msgid "Define the schedule of resource" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_base_mixin__sync_action +#: model:ir.model.fields,help:connector_jira.field_jira_project_project__sync_action +#: model:ir.model.fields,help:connector_jira.field_project_link_jira__sync_action +msgid "" +"Defines if the information of the project (name and key) are exported to " +"JIRA when changed. Link meansthe project already exists on JIRA, no sync of " +"the project details once the link is established. Tasks are always imported " +"from JIRA, not pushed." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend__delete_analytic_line_from_date +msgid "Delete Extra Worklogs from date" +msgstr "" + +#. module: connector_jira +#: code:addons/connector_jira/controllers/main.py:0 +#: code:addons/connector_jira/models/account_analytic_line/deleter.py:0 +#, python-format +msgid "Delete a local worklog which has been deleted on JIRA" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_account_analytic_line__department_id +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__department_id +msgid "Department" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__dependent_tasks_count +msgid "Dependent Tasks" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_account_analytic_line__name +#: model:ir.model.fields,field_description:connector_jira.field_jira_issue_type__description +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__description +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__description +msgid "Description" +msgstr "" + +#. module: connector_jira +#: code:addons/connector_jira/wizards/jira_backend_auth.py:0 +#: code:addons/connector_jira/wizards/jira_backend_auth.py:0 +#, python-format +msgid "" +"Did not get token (%(token)s) or secret (%(secret)s)" +" from Jira. Resp %(resp)s" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_account_analytic_line__display_name +#: model:ir.model.fields,field_description:connector_jira.field_jira_account_analytic_line_import__display_name +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend__display_name +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend_auth__display_name +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend_timestamp__display_name +#: model:ir.model.fields,field_description:connector_jira.field_jira_issue_type__display_name +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__display_name +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__display_name +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__display_name +#: model:ir.model.fields,field_description:connector_jira.field_project_link_jira__display_name +#: model:ir.model.fields,field_description:connector_jira.field_task_link_jira__display_name +msgid "Display Name" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__display_project_id +msgid "Display Project" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_task__progress +msgid "Display progress of current task." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_res_users__partner_share +msgid "" +"Either customer (not a user), either shared user. Indicated the current " +"partner is a customer without access or with a limited access created for " +"sharing data." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__partner_email +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__partner_email +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__email +msgid "Email" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__email_from +msgid "Email From" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__signature +msgid "Email Signature" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__email_cc +msgid "Email cc" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__emergency_contact +msgid "Emergency Contact" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__emergency_phone +msgid "Emergency Phone" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_account_analytic_line__employee_id +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__employee +msgid "Employee" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__employee_count +msgid "Employee Count" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__private_lang +msgid "Employee Lang" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__category_ids +msgid "Employee Tags" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__employee_type +msgid "Employee Type" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_res_users__employee_bank_account_id +msgid "Employee bank salary account" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__employee_bank_account_id +msgid "Employee's Bank Account Number" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__employee_country_id +msgid "Employee's Country" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__employees_count +msgid "Employees Count" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_project__allow_timesheets +msgid "Enable timesheeting on the project." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__encode_uom_in_days +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__encode_uom_in_days +msgid "Encode Uom In Days" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_account_analytic_line__encoding_uom_id +msgid "Encoding Uom" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__repeat_until +msgid "End Date" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__date_end +msgid "Ending Date" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_res_users__address_home_id +msgid "" +"Enter here the private address of the employee, not the one linked to your " +"company." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__jira_epic_link_id +msgid "Epic" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend__epic_link_field_name +msgid "Epic Link Field" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend__epic_link_on_epic +msgid "Epic Link On Epic" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend__epic_name_field_name +msgid "Epic Name Field" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_backend__epic_link_on_epic +msgid "" +"Epics on JIRA cannot be linked to another epic. Check this boxto fill the " +"epic field with itself on Odoo." +msgstr "" + +#. module: connector_jira +#: code:addons/connector_jira/components/backend_adapter.py:0 +#, python-format +msgid "Error during connection with Jira: %s" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__date +msgid "Expiration Date" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields.selection,name:connector_jira.selection__jira_project_base_mixin__sync_action__export +#: model:ir.model.fields.selection,name:connector_jira.selection__jira_project_project__sync_action__export +#: model:ir.model.fields.selection,name:connector_jira.selection__project_link_jira__sync_action__export +msgid "Export to JIRA" +msgstr "" + +#. module: connector_jira +#: code:addons/connector_jira/models/project_project/common.py:0 +#, python-format +msgid "Exported project cannot be deleted." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_res_users__share +msgid "" +"External user with limited access, created only for the purpose of sharing " +"data." +msgstr "" + +#. module: connector_jira +#: code:addons/connector_jira/models/jira_backend/common.py:0 +#: code:addons/connector_jira/models/jira_backend/common.py:0 +#, python-format +msgid "Failed to connect (%s)" +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.backend_report_user_sync +msgid "Failed user sync" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend__worklog_fallback_project_id +msgid "Fallback for Worklogs" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__study_field +msgid "Field of Study" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_res_users__phone_sanitized +msgid "" +"Field used to store sanitized phone number. Helps speeding up searches and " +"comparisons." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__message_follower_ids +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__message_follower_ids +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__message_follower_ids +msgid "Followers" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__message_partner_ids +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__message_partner_ids +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__message_partner_ids +msgid "Followers (Partners)" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_project__activity_type_icon +#: model:ir.model.fields,help:connector_jira.field_jira_project_task__activity_type_icon +#: model:ir.model.fields,help:connector_jira.field_jira_res_users__activity_type_icon +msgid "Font awesome icon e.g. fa-tasks" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_res_users__email_formatted +msgid "Format email address \"Name \"" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__email_formatted +msgid "Formatted Email" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__fri +msgid "Fri" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend_timestamp__from_date_field +msgid "From Date Field" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__gender +msgid "Gender" +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.view_jira_backend_auth_form +msgid "Generate new key" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__partner_latitude +msgid "Geo Latitude" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__partner_longitude +msgid "Geo Longitude" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_project__sequence +msgid "Gives the sequence order when displaying a list of Projects." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_task__sequence +msgid "Gives the sequence order when displaying a list of tasks." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_account_analytic_line__group_id +msgid "Group" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__groups_id +msgid "Groups" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__has_message +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__has_message +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__has_message +msgid "Has Message" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__has_planned_hours_tasks +msgid "Has Planned Hours Tasks" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__action_id +msgid "Home Action" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__km_home_work +msgid "Home-Work Distance" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__effective_hours +msgid "Hours Spent" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_project__rating_status +msgid "" +"How to get customer feedback?\n" +"- Rating when changing stage: an email will be sent when a task is pulled to another stage.\n" +"- Periodic rating: an email will be sent periodically.\n" +"\n" +"Don't forget to set up the email templates on the stages for which you want to get customer feedback." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__hr_presence_state +msgid "Hr Presence State" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_account_analytic_line__id +#: model:ir.model.fields,field_description:connector_jira.field_jira_account_analytic_line_import__id +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend__id +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend_auth__id +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend_timestamp__id +#: model:ir.model.fields,field_description:connector_jira.field_jira_issue_type__id +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__id +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__id +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__id +#: model:ir.model.fields,field_description:connector_jira.field_project_link_jira__id +#: model:ir.model.fields,field_description:connector_jira.field_task_link_jira__id +msgid "ID" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_project__alias_parent_thread_id +msgid "" +"ID of the parent record holding the alias (example: project holding the task" +" creation alias)" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_account_analytic_line__external_id +#: model:ir.model.fields,field_description:connector_jira.field_jira_binding__external_id +#: model:ir.model.fields,field_description:connector_jira.field_jira_issue_type__external_id +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__external_id +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__external_id +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__external_id +msgid "ID on Jira" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_res_users__barcode +msgid "ID used for employee identification." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__im_status +msgid "IM Status" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__activity_exception_icon +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__activity_exception_icon +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__activity_exception_icon +msgid "Icon" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_project__activity_exception_icon +#: model:ir.model.fields,help:connector_jira.field_jira_project_task__activity_exception_icon +#: model:ir.model.fields,help:connector_jira.field_jira_res_users__activity_exception_icon +msgid "Icon to indicate an exception activity." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__identification_id +msgid "Identification No" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_project__message_needaction +#: model:ir.model.fields,help:connector_jira.field_jira_project_project__message_unread +#: model:ir.model.fields,help:connector_jira.field_jira_project_task__message_needaction +#: model:ir.model.fields,help:connector_jira.field_jira_project_task__message_unread +#: model:ir.model.fields,help:connector_jira.field_jira_res_users__message_needaction +#: model:ir.model.fields,help:connector_jira.field_jira_res_users__message_unread +msgid "If checked, new messages require your attention." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_project__message_has_error +#: model:ir.model.fields,help:connector_jira.field_jira_project_project__message_has_sms_error +#: model:ir.model.fields,help:connector_jira.field_jira_project_task__message_has_error +#: model:ir.model.fields,help:connector_jira.field_jira_project_task__message_has_sms_error +#: model:ir.model.fields,help:connector_jira.field_jira_res_users__message_has_error +#: model:ir.model.fields,help:connector_jira.field_jira_res_users__message_has_sms_error +msgid "If checked, some messages have a delivery error." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_project__alias_bounced_content +msgid "" +"If set, this content will automatically be sent out to unauthorized users " +"instead of the default message." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_res_users__action_id +msgid "" +"If specified, this action will be opened at log on for this user, in " +"addition to the standard menu." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_project__active +msgid "" +"If the active field is set to False, it will allow you to hide the project " +"without removing it." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_res_users__is_blacklisted +msgid "" +"If the email address is on the blacklist, the contact won't receive mass " +"mailing anymore, from any list" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_res_users__phone_sanitized_blacklisted +msgid "" +"If the sanitized phone number is on the blacklist, the contact won't receive" +" mass mailing sms anymore, from any list" +msgstr "" + +#. module: connector_jira +#: code:addons/connector_jira/models/jira_backend/common.py:0 +#, python-format +msgid "" +"If you change the base URL, you must delete and create the Webhooks again." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__image_1920 +msgid "Image" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__image_1024 +msgid "Image 1024" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__image_128 +msgid "Image 128" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__image_256 +msgid "Image 256" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__image_512 +msgid "Image 512" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend__import_analytic_line_force +msgid "Import Analytic Line Force" +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.view_jira_backend_form +msgid "Import Issue Types" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend__import_project_task_force +msgid "Import Project Task Force" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend__import_project_task_from_date +msgid "Import Project Tasks from date" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend__import_analytic_line_from_date +msgid "Import Worklogs from date" +msgstr "" + +#. module: connector_jira +#: code:addons/connector_jira/controllers/main.py:0 +#, python-format +msgid "Import a worklog from JIRA" +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.view_jira_backend_form +msgid "Import deleted worklogs since" +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.view_jira_backend_form +msgid "Import project tasks since" +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.view_jira_backend_form +msgid "Import worklogs since" +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.view_jira_backend_form +msgid "Imports" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_res_users__mobile_blacklisted +msgid "" +"Indicates if a blacklisted sanitized phone number is a mobile number. Helps " +"distinguish which number is blacklisted when there is both a " +"mobile and phone field in a model." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_res_users__phone_blacklisted +msgid "" +"Indicates if a blacklisted sanitized phone number is a phone number. Helps " +"distinguish which number is blacklisted when there is both a " +"mobile and phone field in a model." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__industry_id +msgid "Industry" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__planned_hours +msgid "Initially Planned Hours" +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.view_jira_backend_form +msgid "Install Webhooks" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_project__alias_id +msgid "" +"Internal email associated with this project. Incoming emails are " +"automatically synchronized with Tasks (or optionally Issues if the Issue " +"Tracker module is installed)." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_res_users__type +msgid "" +"Invoice & Delivery addresses are used in sales orders. Private addresses are" +" only visible by authorized users." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__message_is_follower +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__message_is_follower +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__message_is_follower +msgid "Is Follower" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__is_internal_project +msgid "Is Internal Project" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__is_private +msgid "Is Private" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__is_system +msgid "Is System" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__partner_is_company +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__is_company +msgid "Is a Company" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_base_mixin__sync_issue_type_ids +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__sync_issue_type_ids +#: model:ir.model.fields,field_description:connector_jira.field_project_link_jira__sync_issue_type_ids +msgid "Issue Levels to Synchronize" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__jira_issue_type_id +msgid "Issue Type" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend__issue_type_ids +#: model_terms:ir.ui.view,arch_db:connector_jira.view_jira_backend_form +msgid "Issue Types" +msgstr "" + +#. module: connector_jira +#: model:ir.actions.server,name:connector_jira.ir_cron_jira_delete_analytic_line_ir_actions_server +#: model:ir.cron,cron_name:connector_jira.ir_cron_jira_delete_analytic_line +#: model:ir.cron,name:connector_jira.ir_cron_jira_delete_analytic_line +msgid "JIRA - Import Deleted Worklogs" +msgstr "" + +#. module: connector_jira +#: model:ir.actions.server,name:connector_jira.ir_cron_jira_import_project_task_ir_actions_server +#: model:ir.cron,cron_name:connector_jira.ir_cron_jira_import_project_task +#: model:ir.cron,name:connector_jira.ir_cron_jira_import_project_task +msgid "JIRA - Import Project Tasks" +msgstr "" + +#. module: connector_jira +#: model:ir.actions.server,name:connector_jira.ir_cron_jira_import_res_users_ir_actions_server +#: model:ir.cron,cron_name:connector_jira.ir_cron_jira_import_res_users +#: model:ir.cron,name:connector_jira.ir_cron_jira_import_res_users +msgid "JIRA - Import Users" +msgstr "" + +#. module: connector_jira +#: model:ir.actions.server,name:connector_jira.ir_cron_jira_import_analytic_line_ir_actions_server +#: model:ir.cron,cron_name:connector_jira.ir_cron_jira_import_analytic_line +#: model:ir.cron,name:connector_jira.ir_cron_jira_import_analytic_line +msgid "JIRA - Import Worklogs" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__jira_epic_link_task_id +#: model:ir.model.fields,field_description:connector_jira.field_project_task__jira_epic_link_task_id +#: model_terms:ir.ui.view,arch_db:connector_jira.hr_timesheet_line_search +#: model_terms:ir.ui.view,arch_db:connector_jira.view_task_search_form +msgid "JIRA Epic" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__jira_issue_type +#: model:ir.model.fields,field_description:connector_jira.field_project_task__jira_issue_type +#: model_terms:ir.ui.view,arch_db:connector_jira.view_task_search_form +msgid "JIRA Issue Type" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_base_mixin__jira_key +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__jira_key +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__jira_compound_key +#: model:ir.model.fields,field_description:connector_jira.field_project_link_jira__jira_key +#: model:ir.model.fields,field_description:connector_jira.field_project_project__jira_key +#: model:ir.model.fields,field_description:connector_jira.field_project_task__jira_compound_key +#: model:ir.model.fields,field_description:connector_jira.field_task_link_jira__jira_key +#: model_terms:ir.ui.view,arch_db:connector_jira.project_link_jira_form +#: model_terms:ir.ui.view,arch_db:connector_jira.task_link_jira_form +msgid "JIRA Key" +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.hr_timesheet_line_search +msgid "JIRA Original Epic" +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.hr_timesheet_line_search +msgid "JIRA Original Issue" +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.hr_timesheet_line_search +msgid "JIRA Original Issue Type" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__jira_parent_task_id +#: model:ir.model.fields,field_description:connector_jira.field_project_task__jira_parent_task_id +msgid "JIRA Parent" +msgstr "" + +#. module: connector_jira +#: model:ir.model,name:connector_jira.model_jira_project_base_mixin +msgid "JIRA Project Base Mixin" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__jira_issue_url +#: model:ir.model.fields,field_description:connector_jira.field_project_task__jira_issue_url +#: model_terms:ir.ui.view,arch_db:connector_jira.hr_timesheet_line_search +msgid "JIRA issue" +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.view_task_kanban +msgid "JIRA:" +msgstr "" + +#. module: connector_jira +#: model:ir.ui.menu,name:connector_jira.menu_jira_root +#: model_terms:ir.ui.view,arch_db:connector_jira.edit_project +#: model_terms:ir.ui.view,arch_db:connector_jira.view_task_form2 +#: model_terms:ir.ui.view,arch_db:connector_jira.view_users_form +msgid "Jira" +msgstr "" + +#. module: connector_jira +#: model:ir.actions.act_window,name:connector_jira.action_jira_backend_auth +#: model_terms:ir.ui.view,arch_db:connector_jira.view_jira_backend_auth_form +msgid "Jira Auth" +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.view_jira_backend_auth_form +msgid "Jira Authentication" +msgstr "" + +#. module: connector_jira +#: model:ir.model,name:connector_jira.model_jira_backend +#: model:ir.model.fields,field_description:connector_jira.field_jira_account_analytic_line__backend_id +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend_timestamp__backend_id +#: model:ir.model.fields,field_description:connector_jira.field_jira_binding__backend_id +#: model:ir.model.fields,field_description:connector_jira.field_jira_issue_type__backend_id +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__backend_id +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__backend_id +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__backend_id +#: model:ir.model.fields,field_description:connector_jira.field_project_link_jira__backend_id +#: model:ir.model.fields,field_description:connector_jira.field_task_link_jira__backend_id +#: model_terms:ir.ui.view,arch_db:connector_jira.view_jira_backend_form +msgid "Jira Backend" +msgstr "" + +#. module: connector_jira +#: model:ir.model,name:connector_jira.model_jira_backend_auth +msgid "Jira Backend Auth" +msgstr "" + +#. module: connector_jira +#: model:ir.model,name:connector_jira.model_jira_backend_timestamp +msgid "Jira Backend Import Timestamps" +msgstr "" + +#. module: connector_jira +#: model:ir.actions.act_window,name:connector_jira.action_jira_backend +msgid "Jira Backends" +msgstr "" + +#. module: connector_jira +#: model:ir.model,name:connector_jira.model_jira_binding +msgid "Jira Binding (abstract)" +msgstr "" + +#. module: connector_jira +#: code:addons/connector_jira/components/backend_adapter.py:0 +#, python-format +msgid "Jira Error: %s" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_account_analytic_line__jira_issue_id +#: model_terms:ir.ui.view,arch_db:connector_jira.view_jira_project_task_form +msgid "Jira Issue" +msgstr "" + +#. module: connector_jira +#: model:ir.model,name:connector_jira.model_jira_issue_type +#: model_terms:ir.ui.view,arch_db:connector_jira.view_jira_backend_form +#: model_terms:ir.ui.view,arch_db:connector_jira.view_jira_issue_type_form +msgid "Jira Issue Type" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_project_link_jira__jira_project_id +#: model_terms:ir.ui.view,arch_db:connector_jira.view_jira_project_project_form +msgid "Jira Project" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_account_analytic_line__jira_project_bind_id +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__jira_project_bind_id +msgid "Jira Project Bind" +msgstr "" + +#. module: connector_jira +#: model:ir.model,name:connector_jira.model_jira_project_project +msgid "Jira Projects" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_task_link_jira__jira_task_id +msgid "Jira Task" +msgstr "" + +#. module: connector_jira +#: model:ir.model,name:connector_jira.model_jira_project_task +msgid "Jira Tasks" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend__uri +msgid "Jira URI" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_account_analytic_line__jira_updated_at +#: model:ir.model.fields,field_description:connector_jira.field_jira_binding__jira_updated_at +#: model:ir.model.fields,field_description:connector_jira.field_jira_issue_type__jira_updated_at +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__jira_updated_at +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__jira_updated_at +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__jira_updated_at +msgid "Jira Updated At" +msgstr "" + +#. module: connector_jira +#: model:ir.model,name:connector_jira.model_jira_res_users +#: model:ir.model.fields.selection,name:connector_jira.selection__jira_backend__worklog_date_timezone_mode__user +#: model_terms:ir.ui.view,arch_db:connector_jira.view_jira_res_users_form +msgid "Jira User" +msgstr "" + +#. module: connector_jira +#: model:ir.model,name:connector_jira.model_jira_account_analytic_line +msgid "Jira Worklog" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__function +msgid "Job Position" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__job_title +msgid "Job Title" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__legend_blocked +msgid "Kanban Blocked Explanation" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__legend_normal +msgid "Kanban Ongoing Explanation" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__kanban_state_label +msgid "Kanban State Label" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__legend_done +msgid "Kanban Valid Explanation" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_res_users__password +msgid "" +"Keep empty if you don't want the user to be able to connect on the system." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__jira_key +msgid "Key" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_project__label_tasks +msgid "Label used for the tasks of the project." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__lang +msgid "Language" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__last_activity +msgid "Last Activity" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__last_activity_time +msgid "Last Activity Time" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_account_analytic_line____last_update +#: model:ir.model.fields,field_description:connector_jira.field_jira_account_analytic_line_import____last_update +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend____last_update +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend_auth____last_update +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend_timestamp____last_update +#: model:ir.model.fields,field_description:connector_jira.field_jira_issue_type____last_update +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project____last_update +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task____last_update +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users____last_update +#: model:ir.model.fields,field_description:connector_jira.field_project_link_jira____last_update +#: model:ir.model.fields,field_description:connector_jira.field_task_link_jira____last_update +msgid "Last Modified on" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__date_last_stage_update +msgid "Last Stage Update" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend_timestamp__last_timestamp +msgid "Last Timestamp" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__last_update_id +msgid "Last Update" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__last_update_color +msgid "Last Update Color" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__last_update_status +msgid "Last Update Status" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_account_analytic_line__write_uid +#: model:ir.model.fields,field_description:connector_jira.field_jira_account_analytic_line_import__write_uid +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend__write_uid +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend_auth__write_uid +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend_timestamp__write_uid +#: model:ir.model.fields,field_description:connector_jira.field_jira_issue_type__write_uid +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__write_uid +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__write_uid +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__write_uid +#: model:ir.model.fields,field_description:connector_jira.field_project_link_jira__write_uid +#: model:ir.model.fields,field_description:connector_jira.field_task_link_jira__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_account_analytic_line__write_date +#: model:ir.model.fields,field_description:connector_jira.field_jira_account_analytic_line_import__write_date +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend__write_date +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend_auth__write_date +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend_timestamp__write_date +#: model:ir.model.fields,field_description:connector_jira.field_jira_issue_type__write_date +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__write_date +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__write_date +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__write_date +#: model:ir.model.fields,field_description:connector_jira.field_project_link_jira__write_date +#: model:ir.model.fields,field_description:connector_jira.field_task_link_jira__write_date +msgid "Last Updated on" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_account_analytic_line__sync_date +#: model:ir.model.fields,field_description:connector_jira.field_jira_binding__sync_date +#: model:ir.model.fields,field_description:connector_jira.field_jira_issue_type__sync_date +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__sync_date +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__sync_date +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__sync_date +msgid "Last synchronization date" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__login_date +msgid "Latest authentication" +msgstr "" + +#. module: connector_jira +#: model:ir.model,name:connector_jira.model_project_link_jira +msgid "Link Project with JIRA" +msgstr "" + +#. module: connector_jira +#: model:ir.model,name:connector_jira.model_task_link_jira +msgid "Link Task with JIRA" +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.view_jira_backend_form +msgid "Link users" +msgstr "" + +#. module: connector_jira +#: model:ir.actions.act_window,name:connector_jira.open_project_link_jira +#: model:ir.actions.act_window,name:connector_jira.open_task_link_jira +#: model:ir.model.fields.selection,name:connector_jira.selection__jira_project_base_mixin__sync_action__link +#: model:ir.model.fields.selection,name:connector_jira.selection__jira_project_project__sync_action__link +#: model:ir.model.fields.selection,name:connector_jira.selection__project_link_jira__sync_action__link +#: model_terms:ir.ui.view,arch_db:connector_jira.view_project_kanban_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.view_task_kanban +msgid "Link with JIRA" +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.view_users_form +msgid "Link with Jira" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_task_link_jira__linked_backend_ids +msgid "Linked Backend" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_task__email_cc +msgid "List of cc from incoming emails." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__login +msgid "Login" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__message_main_attachment_id +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__message_main_attachment_id +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__message_main_attachment_id +msgid "Main Attachment" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__attachment_ids +msgid "Main Attachments" +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.view_jira_backend_form +msgid "Main Configuration" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__employee_parent_id +msgid "Manager" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__marital +msgid "Marital Status" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__favorite_user_ids +msgid "Members" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__message_has_error +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__message_has_error +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__message_has_error +msgid "Message Delivery error" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__message_ids +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__message_ids +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__message_ids +msgid "Messages" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__milestone_ids +msgid "Milestone" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__milestone_count +msgid "Milestone Count" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__mobile +msgid "Mobile" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__mon +msgid "Mon" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__my_activity_date_deadline +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__my_activity_date_deadline +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__my_activity_date_deadline +msgid "My Activity Deadline" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend__name +#: model:ir.model.fields,field_description:connector_jira.field_jira_issue_type__name +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__name +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__name +msgid "Name" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__activity_date_deadline +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__activity_date_deadline +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__activity_date_deadline +msgid "Next Activity Deadline" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__activity_summary +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__activity_summary +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__activity_summary +msgid "Next Activity Summary" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__activity_type_id +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__activity_type_id +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__activity_type_id +msgid "Next Activity Type" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__recurrence_message +msgid "Next Recurrencies" +msgstr "" + +#. module: connector_jira +#: code:addons/connector_jira/models/res_users/common.py:0 +#, python-format +msgid "No JIRA user could be found" +msgstr "" + +#. module: connector_jira +#: code:addons/connector_jira/models/res_users/importer.py:0 +#, python-format +msgid "" +"No user found for jira account %(jira_key)s (%(email)s). Please link it " +"manually from the Odoo user's form." +msgstr "" + +#. module: connector_jira +#: code:addons/connector_jira/models/account_analytic_line/importer.py:0 +#, python-format +msgid "" +"No user found with login \"%(jira_author_key)s\" or email \"%(email)s\".You " +"must create a user or link it manually if the login/email differs." +msgstr "" + +#. module: connector_jira +#: code:addons/connector_jira/models/project_task/importer.py:0 +#, python-format +msgid "" +"No user found with login \"%(jira_key)s\" or email \"%(email)s\".You must " +"create a user or link it manually if the login/email differs." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__email_normalized +msgid "Normalized Email" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__comment +msgid "Notes" +msgstr "" + +#. module: connector_jira +#: code:addons/connector_jira/components/exporter.py:0 +#: code:addons/connector_jira/components/exporter.py:0 +#, python-format +msgid "Nothing to export." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__notification_type +msgid "Notification" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__message_needaction_counter +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__message_needaction_counter +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__message_needaction_counter +msgid "Number of Actions" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__children +msgid "Number of Children" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__companies_count +msgid "Number of Companies" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_res_users__accesses_count +msgid "Number of access rights that apply to the current user" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__doc_count +msgid "Number of documents attached" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__message_has_error_counter +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__message_has_error_counter +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__message_has_error_counter +msgid "Number of errors" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_res_users__groups_count +msgid "Number of groups that apply to the current user" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_project__message_needaction_counter +#: model:ir.model.fields,help:connector_jira.field_jira_project_task__message_needaction_counter +#: model:ir.model.fields,help:connector_jira.field_jira_res_users__message_needaction_counter +msgid "Number of messages which requires an action" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_project__message_has_error_counter +#: model:ir.model.fields,help:connector_jira.field_jira_project_task__message_has_error_counter +#: model:ir.model.fields,help:connector_jira.field_jira_res_users__message_has_error_counter +msgid "Number of messages with delivery error" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_res_users__rules_count +msgid "Number of record rules that apply to the current user" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_project__message_unread_counter +#: model:ir.model.fields,help:connector_jira.field_jira_project_task__message_unread_counter +#: model:ir.model.fields,help:connector_jira.field_jira_res_users__message_unread_counter +msgid "Number of unread messages" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields.selection,name:connector_jira.selection__jira_backend_auth__state__leg_2 +msgid "OAuth Remote Auth" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields.selection,name:connector_jira.selection__jira_backend_auth__state__leg_1 +msgid "OAuth Remote Config" +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.view_jira_backend_auth_form +msgid "OAuth configuration complete" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__odoobot_state +msgid "OdooBot Status" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__odoobot_failed +msgid "Odoobot Failed" +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.view_jira_backend_form +msgid "" +"Once imported,\n" +" some types of records,\n" +" like the products or categories,\n" +" need a manual review.\n" +" You will find the list\n" +" of the new records to review\n" +" in the menu 'Connectors > Checkpoint'." +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.view_jira_backend_form +msgid "" +"Once you have checked the configuration and\n" +" activated the webhooks and the Epic Link (if\n" +" wanted), click on the \"Configuration Done\"\n" +" button." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_base_mixin__sync_issue_type_ids +#: model:ir.model.fields,help:connector_jira.field_jira_project_project__sync_issue_type_ids +#: model:ir.model.fields,help:connector_jira.field_project_link_jira__sync_issue_type_ids +msgid "" +"Only issues of these levels are imported. When a worklog is imported no a " +"level which is not sync'ed, it is attached to the nearest sync'ed parent " +"level. If no parent can be found, it is attached to a special 'Unassigned' " +"task." +msgstr "" + +#. module: connector_jira +#: code:addons/connector_jira/models/jira_backend/common.py:0 +#, python-format +msgid "" +"Only one JIRA backend can use the webhook at a time. You must disable them " +"on the backend \"%s\" before activating them here." +msgstr "" + +#. module: connector_jira +#: code:addons/connector_jira/models/project_project/common.py:0 +#, python-format +msgid "" +"Only one Jira binding can be configured with the Sync. Action \"Export\" for" +" a project. \"%s\" already has one." +msgstr "" + +#. module: connector_jira +#: code:addons/connector_jira/models/jira_backend/common.py:0 +#, python-format +msgid "Only one backend can listen to webhooks" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_project__alias_force_thread_id +msgid "" +"Optional ID of a thread (record) to which all incoming messages will be " +"attached, even if they did not reply to it. If set, this will disable the " +"creation of new records completely." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_account_analytic_line__jira_epic_issue_key +msgid "Original Epic Key" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_account_analytic_line__jira_issue_type_id +msgid "Original Issue Type" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_account_analytic_line__jira_epic_issue_key +msgid "Original JIRA Epic Key" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_account_analytic_line__jira_epic_issue_url +#: model:ir.model.fields,field_description:connector_jira.field_jira_account_analytic_line__jira_epic_issue_url +msgid "Original JIRA Epic Link" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_account_analytic_line__jira_issue_key +msgid "Original JIRA Issue Key" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_account_analytic_line__jira_issue_type_id +msgid "Original JIRA Issue Type" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_account_analytic_line__jira_issue_url +#: model:ir.model.fields,field_description:connector_jira.field_jira_account_analytic_line__jira_issue_url +msgid "Original JIRA issue Link" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_account_analytic_line__jira_issue_key +msgid "Original Task Key" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_task__legend_blocked +msgid "" +"Override the default value displayed for the blocked state for kanban " +"selection when the task or issue is in that stage." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_task__legend_done +msgid "" +"Override the default value displayed for the done state for kanban selection" +" when the task or issue is in that stage." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_task__legend_normal +msgid "" +"Override the default value displayed for the normal state for kanban " +"selection when the task or issue is in that stage." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__overtime +msgid "Overtime" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__alias_user_id +msgid "Owner" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__pin +msgid "PIN" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_res_users__pin +msgid "" +"PIN used to Check In/Out in the Kiosk Mode of the Attendance application (if" +" enabled in Configuration) and to change the cashier in the Point of Sale " +"application." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__jira_parent_id +msgid "Parent Issue" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__alias_parent_model_id +msgid "Parent Model" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__alias_parent_thread_id +msgid "Parent Record Thread ID" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__parent_id +msgid "Parent Task" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_task__jira_parent_id +msgid "" +"Parent issue when the issue is a subtask. Empty if the type of parent is " +"filtered out of the synchronizations." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_project__alias_parent_model_id +msgid "" +"Parent model holding the alias. The model holding the alias reference is not" +" necessarily the model given by alias_model_id (example: project " +"(parent_model) and task (model))" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__parent_name +msgid "Parent name" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_account_analytic_line__partner_id +msgid "Partner" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__active_partner +msgid "Partner is Active" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__same_vat_partner_id +msgid "Partner with same Tax ID" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_res_users__partner_id +msgid "Partner-related data of the user" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__passport_id +msgid "Passport No" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__password +msgid "Password" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_project__privacy_visibility +#: model:ir.model.fields,help:connector_jira.field_jira_project_task__project_privacy_visibility +msgid "" +"People to whom this project and its tasks will be visible.\n" +"\n" +"- Invited internal users: when following a project, internal users will get access to all of its tasks without distinction. Otherwise, they will only get access to the specific tasks they are following.\n" +" A user with the project > administrator access right level can still access this project and its tasks, even if they are not explicitly part of the followers.\n" +"\n" +"- All internal users: all internal users can access the project and all of its tasks without distinction.\n" +"\n" +"- Invited portal users and all internal users: all internal users can access the project and all of its tasks without distinction.\n" +"When following a project, portal users will get access to all of its tasks without distinction. Otherwise, they will only get access to the specific tasks they are following.\n" +"\n" +"When a project is shared in read-only, the portal user is redirected to their portal. They can view the tasks, but not edit them.\n" +"When a project is shared in edit, the portal user is redirected to the kanban and list views of the tasks. They can modify a selected number of fields on the tasks.\n" +"\n" +"In any case, an internal user with no project access rights can still access a task, provided that they are given the corresponding URL (and that they are part of the followers if the project is private)." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_project__rating_percentage_satisfaction +msgid "Percentage of happy ratings" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__personal_stage_type_ids +msgid "Personal Stage" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__personal_stage_id +msgid "Personal Stage State" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__personal_stage_type_id +msgid "Personal User Stage" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__partner_phone +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__partner_phone +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__phone +msgid "Phone" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__phone_sanitized_blacklisted +msgid "Phone Blacklisted" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__phone_mobile_search +msgid "Phone/Mobile" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__place_of_birth +msgid "Place of Birth" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_res_users__notification_type +msgid "" +"Policy on how to handle Chatter notifications:\n" +"- Handle by Emails: notifications are sent to your email address\n" +"- Handle in Odoo: notifications appear in your Odoo Inbox" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_project__alias_contact +msgid "" +"Policy to post a message on the document using the mailgateway.\n" +"- everyone: everyone can post\n" +"- partners: only authenticated partners\n" +"- followers: only followers of the related document or members of following channels\n" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__access_url +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__access_url +msgid "Portal Access URL" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__portal_user_names +msgid "Portal User Names" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__private_city +msgid "Private City" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__private_country_id +msgid "Private Country" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__private_email +msgid "Private Email" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend__private_key +msgid "Private Key" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__employee_phone +msgid "Private Phone" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__private_state_id +msgid "Private State" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__private_street +msgid "Private Street" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__private_street2 +msgid "Private Street2" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__private_zip +msgid "Private Zip" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__progress +msgid "Progress" +msgstr "" + +#. module: connector_jira +#: model:ir.model,name:connector_jira.model_project_project +#: model:ir.model.fields,field_description:connector_jira.field_jira_account_analytic_line__project_id +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__odoo_id +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__project_id +#: model:ir.model.fields,field_description:connector_jira.field_project_link_jira__project_id +msgid "Project" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__project_analytic_account_id +msgid "Project Analytic Account" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__jira_bind_ids +#: model:ir.model.fields,field_description:connector_jira.field_project_project__jira_bind_ids +msgid "Project Bindings" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__user_id +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__manager_id +msgid "Project Manager" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__project_type +msgid "Project Type" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__project_privacy_visibility +msgid "Project Visibility" +msgstr "" + +#. module: connector_jira +#: code:addons/connector_jira/models/project_task/importer.py:0 +#, python-format +msgid "Project or issue type is not synchronized." +msgstr "" + +#. module: connector_jira +#: code:addons/connector_jira/models/project_project/common.py:0 +#, python-format +msgid "Project template with key \"%s\" not found." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend__public_key +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend_auth__public_key +msgid "Public Key" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_account_analytic_line__unit_amount +msgid "Quantity" +msgstr "" + +#. module: connector_jira +#: model:ir.model,name:connector_jira.model_queue_job +msgid "Queue Job" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__rating_ids +msgid "Rating" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__rating_avg +msgid "Rating Average" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__rating_status_period +msgid "Rating Frequency" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__rating_last_feedback +msgid "Rating Last Feedback" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__rating_last_image +msgid "Rating Last Image" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__rating_last_value +msgid "Rating Last Value" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__rating_request_deadline +msgid "Rating Request Deadline" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__rating_percentage_satisfaction +msgid "Rating Satisfaction" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__rating_count +msgid "Rating count" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__rating_ids +msgid "Ratings" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_task__rating_last_feedback +msgid "Reason of the rating" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__alias_force_thread_id +msgid "Record Thread ID" +msgstr "" + +#. module: connector_jira +#: code:addons/connector_jira/components/importer.py:0 +#, python-format +msgid "Record deleted" +msgstr "" + +#. module: connector_jira +#: code:addons/connector_jira/components/importer.py:0 +#: code:addons/connector_jira/models/account_analytic_line/importer.py:0 +#, python-format +msgid "Record does no longer exist in Jira" +msgstr "" + +#. module: connector_jira +#: code:addons/connector_jira/components/backend_adapter.py:0 +#, python-format +msgid "Record does not exist in Jira: %s" +msgstr "" + +#. module: connector_jira +#: code:addons/connector_jira/components/exporter.py:0 +#, python-format +msgid "Record exported with ID %s on Jira." +msgstr "" + +#. module: connector_jira +#: code:addons/connector_jira/components/exporter.py:0 +#, python-format +msgid "Record to export does no longer exist." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__recurrence_id +msgid "Recurrence" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__recurrence_update +msgid "Recurrence Update" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__recurring_task +msgid "Recurrent" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__allow_recurring_tasks +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__allow_recurring_tasks +msgid "Recurring Tasks" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__ref +msgid "Reference" +msgstr "" + +#. module: connector_jira +#: model:ir.actions.act_window,name:connector_jira.act_server_jira_aa_line_import +msgid "Refresh Worklogs from Jira" +msgstr "" + +#. module: connector_jira +#: model:ir.model,name:connector_jira.model_jira_account_analytic_line_import +msgid "Reimport Jira Worklogs" +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.view_jira_aa_line_import +msgid "Reimport lines" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__parent_id +msgid "Related Company" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__partner_id +msgid "Related Partner" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__employee_ids +msgid "Related employee" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__remaining_hours +msgid "Remaining Hours" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__remaining_hours +msgid "Remaining Invoiced Time" +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.view_jira_backend_form +msgid "Remove Webhooks" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__repeat_day +msgid "Repeat Day" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__repeat_interval +msgid "Repeat Every" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__repeat_month +msgid "Repeat Month" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__repeat_on_month +msgid "Repeat On Month" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__repeat_on_year +msgid "Repeat On Year" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__repeat_show_day +msgid "Repeat Show Day" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__repeat_show_dow +msgid "Repeat Show Dow" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__repeat_show_month +msgid "Repeat Show Month" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__repeat_show_week +msgid "Repeat Show Week" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__repeat_unit +msgid "Repeat Unit" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__repeat_week +msgid "Repeat Week" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__repeat_number +msgid "Repetitions" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend__report_user_sync +msgid "Report User Sync" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend_auth__request_secret +msgid "Request Secret" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend_auth__request_token +msgid "Request Token" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__res_users_settings_ids +msgid "Res Users Settings" +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.view_jira_backend_form +msgid "Reset Authentication" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__resource_ids +msgid "Resources" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__activity_user_id +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__activity_user_id +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__activity_user_id +msgid "Responsible User" +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.view_jira_backend_form +msgid "Run" +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.view_jira_backend_form +msgid "Run in background" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields.selection,name:connector_jira.selection__jira_backend__state__running +msgid "Running" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__message_has_sms_error +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__message_has_sms_error +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__message_has_sms_error +msgid "SMS Delivery error" +msgstr "" + +#. module: connector_jira +#: code:addons/connector_jira/wizards/jira_backend_auth.py:0 +#: code:addons/connector_jira/wizards/jira_backend_auth.py:0 +#, python-format +msgid "SSL error during negociation: %s" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__user_id +msgid "Salesperson" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__phone_sanitized +msgid "Sanitized Number" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__sat +msgid "Sat" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__study_school +msgid "School" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__access_token +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__access_token +msgid "Security Token" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_res_users__coach_id +msgid "" +"Select the \"Employee\" who is the coach of this employee.\n" +"The \"Coach\" has no specific rights or responsibilities by default." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__self +msgid "Self" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__sequence +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__sequence +msgid "Sequence" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__new_password +msgid "Set Password" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields.selection,name:connector_jira.selection__jira_backend__state__setup +msgid "Setup" +msgstr "" + +#. module: connector_jira +#: code:addons/connector_jira/models/res_users/importer.py:0 +#, python-format +msgid "" +"Several users found (%(login)s) for jira account%(jira_key)s (%(email)s). " +"Please link it manually from the Odoo user's form." +msgstr "" + +#. module: connector_jira +#: code:addons/connector_jira/models/res_users/common.py:0 +#, python-format +msgid "" +"Several users found with \"%(resolve_by_key)s\"set to " +"\"%(resolve_by_value)s\". Set it manually." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__partner_share +msgid "Share Partner" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__share +msgid "Share User" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__is_favorite +msgid "Show Project on Dashboard" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__signup_expiration +msgid "Signup Expiration" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__signup_token +msgid "Signup Token" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__signup_type +msgid "Signup Token Type" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__signup_valid +msgid "Signup Token is Valid" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__signup_url +msgid "Signup URL" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields.selection,name:connector_jira.selection__jira_backend__worklog_date_timezone_mode__specific +msgid "Specific" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_res_users__new_password +msgid "" +"Specify a value only when creating a user or if you're changing the user's " +"password, otherwise leave empty. After a change of password, the user has to" +" login again." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__spouse_birthdate +msgid "Spouse Birthdate" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__spouse_complete_name +msgid "Spouse Complete Name" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__stage_id +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__stage_id +msgid "Stage" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__priority +msgid "Starred" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__date_start +msgid "Start Date" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend__state +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend_auth__state +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__state_id +#: model:ir.model.fields,field_description:connector_jira.field_project_link_jira__state +#: model:ir.model.fields,field_description:connector_jira.field_task_link_jira__state +msgid "State" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__kanban_state +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__state +msgid "Status" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_project__activity_state +#: model:ir.model.fields,help:connector_jira.field_jira_project_task__activity_state +#: model:ir.model.fields,help:connector_jira.field_jira_res_users__activity_state +msgid "" +"Status based on activities\n" +"Overdue: Due date is already passed\n" +"Today: Activity date is today\n" +"Planned: Future activities." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__street +msgid "Street" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__street2 +msgid "Street2" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__subtask_count +msgid "Sub-task Count" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__allow_subtasks +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__child_ids +msgid "Sub-tasks" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__subtask_effective_hours +msgid "Sub-tasks Hours Spent" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__subtask_planned_hours +msgid "Sub-tasks Planned Hours" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_task__subtask_planned_hours +msgid "" +"Sum of the time planned of all the sub-tasks linked to this task. Usually " +"less than or equal to the initially planned time of this task." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__sun +msgid "Sun" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_base_mixin__sync_action +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__sync_action +#: model:ir.model.fields,field_description:connector_jira.field_project_link_jira__sync_action +msgid "Sync Action" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_account_analytic_line__tag_ids +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__tag_ids +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__tag_ids +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__category_id +msgid "Tags" +msgstr "" + +#. module: connector_jira +#: model:ir.model,name:connector_jira.model_project_task +#: model:ir.model.fields,field_description:connector_jira.field_jira_account_analytic_line__task_id +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__odoo_id +#: model:ir.model.fields,field_description:connector_jira.field_task_link_jira__task_id +msgid "Task" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__tasks +msgid "Task Activities" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__jira_bind_ids +#: model:ir.model.fields,field_description:connector_jira.field_project_task__jira_bind_ids +msgid "Task Bindings" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__task_count +msgid "Task Count" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__task_count_with_subtasks +msgid "Task Count With Subtasks" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__allow_task_dependencies +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__allow_task_dependencies +msgid "Task Dependencies" +msgstr "" + +#. module: connector_jira +#: code:addons/connector_jira/models/project_task/common.py:0 +#, python-format +msgid "Task can not be created in project linked to JIRA!" +msgstr "" + +#. module: connector_jira +#: code:addons/connector_jira/models/project_task/common.py:0 +#, python-format +msgid "Task linked to JIRA Issue can not be deleted!" +msgstr "" + +#. module: connector_jira +#: code:addons/connector_jira/models/project_task/common.py:0 +#, python-format +msgid "Task linked to JIRA Issue can not be modified!" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__task_ids +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__task_ids +msgid "Tasks" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__type_ids +msgid "Tasks Stages" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__recurring_count +msgid "Tasks in Recurrence" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_task__is_closed +msgid "Tasks in this stage are considered as closed." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__vat +msgid "Tax ID" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_backend__epic_link_field_name +msgid "" +"The 'Epic Link' field on JIRA is a custom field. The name of the field is " +"something like 'customfield_10002'. " +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_backend__epic_name_field_name +msgid "" +"The 'Epic Name' field on JIRA is a custom field. The name of the field is " +"something like 'customfield_10003'. " +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_res_users__country_code +msgid "" +"The ISO country code in two chars. \n" +"You can use this field for quick search." +msgstr "" + +#. module: connector_jira +#: code:addons/connector_jira/models/project_project/common.py:0 +#, python-format +msgid "The JIRA Key is mandatory in order to link a project" +msgstr "" + +#. module: connector_jira +#: code:addons/connector_jira/models/jira_backend/common.py:0 +#, python-format +msgid "The Odoo Webhook base URL must be set." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_res_users__vat +msgid "" +"The Tax Identification Number. Complete it if the contact is subjected to " +"government taxes. Used in some legal statements." +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.project_link_jira_form +#: model_terms:ir.ui.view,arch_db:connector_jira.view_jira_project_project_form +msgid "" +"The checkboxes define which types of JIRA issues will be\n" +" imported\n" +" into Odoo. For instance, if you check 'Story', only issues of type\n" +" Story will be imported. Several choices possible." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_task__personal_stage_id +msgid "The current user's personal stage." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_task__personal_stage_type_id +msgid "The current user's personal task stage." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_res_users__company_id +msgid "The default company for this user." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__is_address_home_a_company +msgid "The employee address has a company linked" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_res_users__employee_type +msgid "" +"The employee type. Although the primary purpose may seem to categorize " +"employees, this field has also an impact in the Contract History. Only " +"Employee type is supposed to be under contract and will have a Contract " +"History." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_res_users__user_id +msgid "The internal user in charge of this contact." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_project__alias_model_id +msgid "" +"The model (Odoo Document Kind) to which this alias corresponds. Any incoming" +" email that does not reply to an existing record will cause the creation of " +"a new record of this model (e.g. a Project Task)" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_project__alias_name +msgid "" +"The name of the email alias, e.g. 'jobs' if you want to catch emails for " +"" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_project__alias_user_id +msgid "" +"The owner of records created upon receiving emails on this alias. If this " +"field is not set the system will attempt to find the right owner based on " +"the sender (From) address, or will use the Administrator account if no " +"system user is found for that address." +msgstr "" + +#. module: connector_jira +#: code:addons/connector_jira/models/project_project/common.py:0 +#, python-format +msgid "The project %s is already linked with the same JIRA project." +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.project_link_jira_form +msgid "The project is now linked with JIRA." +msgstr "" + +#. module: connector_jira +#: code:addons/connector_jira/models/project_project/common.py:0 +#, python-format +msgid "The project template cannot be modified." +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.project_link_jira_form +msgid "The project will be created on JIRA in background." +msgstr "" + +#. module: connector_jira +#: code:addons/connector_jira/models/jira_backend/common.py:0 +#, python-format +msgid "" +"The synchronization timestamp is currently locked, probably due to an " +"ongoing synchronization." +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.task_link_jira_form +msgid "The task is now linked with JIRA." +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.project_link_jira_form +#: model_terms:ir.ui.view,arch_db:connector_jira.view_jira_project_project_form +msgid "" +"There is a direct implication on the Worklogs.\n" +" When a worklog is done on a JIRA Sub-Task and this type is not\n" +" sync'ed, the worklog will be attached to the parent Task of the\n" +" Sub-Task. If the Task is not sync'ed, it will be attached to the\n" +" Epic. Finally, if there is no Epic, the worklog will not be\n" +" attached to any task." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_task__email_from +msgid "These people will receive email." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_res_users__email_normalized +msgid "" +"This field is used to search on email address as the primary email field can" +" contain more than strictly an email address." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_project__timesheet_encode_uom_id +msgid "" +"This will set the unit of measure used to encode timesheet. This will simply provide tools\n" +" and widgets to help the encoding. All reporting will still be expressed in hours (default value)." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__thu +msgid "Thu" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_task__planned_hours +msgid "Time planned to achieve this task (including its sub-tasks)." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_task__subtask_effective_hours +msgid "Time spent on the sub-tasks (and their own sub-tasks) of this task." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_task__effective_hours +msgid "Time spent on this task, excluding its sub-tasks." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_task__total_hours_spent +msgid "Time spent on this task, including its sub-tasks." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__timesheet_count +msgid "Timesheet Count" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__timesheet_encode_uom_id +msgid "Timesheet Encoding Unit" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_account_analytic_line__odoo_id +msgid "Timesheet Line" +msgstr "" + +#. module: connector_jira +#: code:addons/connector_jira/models/account_analytic_line/common.py:0 +#, python-format +msgid "Timesheet can not be created in project linked to JIRA!" +msgstr "" + +#. module: connector_jira +#: code:addons/connector_jira/models/account_analytic_line/common.py:0 +#, python-format +msgid "Timesheet linked to JIRA Worklog can not be deleted!" +msgstr "" + +#. module: connector_jira +#: code:addons/connector_jira/models/account_analytic_line/common.py:0 +#, python-format +msgid "Timesheet linked to JIRA Worklog can not be modified!" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__allow_timesheets +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__timesheet_ids +msgid "Timesheets" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_task__allow_timesheets +msgid "Timesheets can be logged on this task." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__tz +msgid "Timezone" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__tz_offset +msgid "Timezone offset" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__name +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__title +msgid "Title" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__total_hours_spent +msgid "Total Hours" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__total_timesheet_time +msgid "Total Timesheet Time" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_project__total_timesheet_time +msgid "" +"Total number of time (in the proper UoM) recorded in the project, rounded to" +" the unit." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_task__remaining_hours +msgid "" +"Total remaining time, can be re-estimated periodically by the assignee of " +"the task." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__totp_secret +msgid "Totp Secret" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_project__has_planned_hours_tasks +msgid "True if any of the project's task has a set planned hours" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__totp_trusted_device_ids +msgid "Trusted Devices" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__tue +msgid "Tue" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__totp_enabled +msgid "Two-factor authentication" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_project__activity_exception_decoration +#: model:ir.model.fields,help:connector_jira.field_jira_project_task__activity_exception_decoration +#: model:ir.model.fields,help:connector_jira.field_jira_res_users__activity_exception_decoration +msgid "Type of the exception activity on record." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_account_analytic_line__product_uom_id +msgid "Unit of Measure" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__message_unread +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__message_unread +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__message_unread +msgid "Unread Messages" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__message_unread_counter +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__message_unread_counter +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__message_unread_counter +msgid "Unread Messages Counter" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__repeat_type +msgid "Until" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_account_analytic_line__product_uom_category_id +msgid "UoM Category" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__update_ids +msgid "Update" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__alias_enabled +msgid "Use Email Alias" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__label_tasks +msgid "Use Tasks as" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend__use_webhooks +msgid "Use Webhooks" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_backend_timestamp__component_usage +msgid "" +"Used by the connector to find which component execute the batch import " +"(technical)." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_res_users__login +msgid "Used to log into the system" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_account_analytic_line__user_id +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__odoo_id +msgid "User" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__jira_bind_ids +#: model:ir.model.fields,field_description:connector_jira.field_res_users__jira_bind_ids +msgid "User Bindings" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__log_ids +msgid "User log entries" +msgstr "" + +#. module: connector_jira +#: model:ir.model,name:connector_jira.model_res_users +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__user_ids +msgid "Users" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend__verify_ssl +msgid "Verify SSL?" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__visa_expire +msgid "Visa Expire Date" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__visa_no +msgid "Visa No" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__privacy_visibility +msgid "Visibility" +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.view_jira_backend_auth_form +msgid "Visit this URL, authorize and continue" +msgstr "" + +#. module: connector_jira +#: code:addons/connector_jira/models/jira_backend/common.py:0 +#, python-format +msgid "Warning" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend__webhook_issue_jira_id +msgid "Webhook Issue Jira" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend__webhook_worklog_jira_id +msgid "Webhook Worklog Jira" +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.view_jira_backend_form +msgid "" +"Webhooks can be created only on one instance of\n" +" JIRA.\n" +" When webhooks are activated, each\n" +" modification of tasks and worklogs are\n" +" directly transmitted to Odoo." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_backend__use_webhooks +msgid "" +"Webhooks need to be configured on the Jira instance. When activated, " +"synchronization from Jira is blazing fast. It can be activated only on one " +"Jira backend at a time. " +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__website +msgid "Website Link" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__website_message_ids +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__website_message_ids +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__website_message_ids +msgid "Website Messages" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_project__website_message_ids +#: model:ir.model.fields,help:connector_jira.field_jira_project_task__website_message_ids +#: model:ir.model.fields,help:connector_jira.field_jira_res_users__website_message_ids +msgid "Website communication history" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__wed +msgid "Wed" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_res_users__tz +msgid "" +"When printing documents and exporting/importing data, time values are computed according to this timezone.\n" +"If the timezone is not set, UTC (Coordinated Universal Time) is used.\n" +"Anywhere else, time values are computed according to the time offset of your web client." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_project_project__is_favorite +msgid "Whether this project should be displayed on your dashboard." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__address_id +msgid "Work Address" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__work_email +msgid "Work Email" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__work_location_id +msgid "Work Location" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__mobile_phone +msgid "Work Mobile" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__permit_no +msgid "Work Permit No" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__work_phone +msgid "Work Phone" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__working_days_open +msgid "Working Days to Assign" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__working_days_close +msgid "Working Days to Close" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__working_hours_open +msgid "Working Hours to Assign" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_task__working_hours_close +msgid "Working Hours to Close" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_project_project__resource_calendar_id +msgid "Working Time" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_account_analytic_line__jira_bind_ids +#: model:ir.model.fields,field_description:connector_jira.field_jira_account_analytic_line__jira_bind_ids +msgid "Worklog Bindings" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend__worklog_date_timezone +msgid "Worklog Date Timezone" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_backend__worklog_date_timezone_mode +msgid "Worklog Date Timezone Mode" +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.view_jira_backend_form +msgid "Worklog date timezone" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_backend__worklog_date_timezone_mode +msgid "" +"Worklog/Timesheet date timezone modes:\n" +" - As-is (naive): ignore timezone information\n" +" - Jira User: use author's timezone\n" +" - Specific: use pre-configured timezone\n" +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,help:connector_jira.field_jira_backend__worklog_fallback_project_id +msgid "" +"Worklogs which could not be linked to any project will be created in this " +"project. Worklogs landing in the fallback project can be reassigned to the " +"correct project by: 1. linking the expected project with the Jira one, 2. " +"using 'Refresh Worklogs from Jira' on the timesheet lines." +msgstr "" + +#. module: connector_jira +#: model:ir.model.fields,field_description:connector_jira.field_jira_res_users__zip +msgid "Zip" +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.view_jira_backend_form +msgid "e.g. https://example.com/jira" +msgstr "" + +#. module: connector_jira +#: model_terms:ir.ui.view,arch_db:connector_jira.view_jira_backend_form +msgid "forced" +msgstr "" + +#. module: connector_jira +#: code:addons/connector_jira/models/account_analytic_line/importer.py:0 +#, python-format +msgid "missing description" +msgstr "" diff --git a/connector_jira/models/__init__.py b/connector_jira/models/__init__.py new file mode 100644 index 000000000..a54119535 --- /dev/null +++ b/connector_jira/models/__init__.py @@ -0,0 +1,19 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +# Must imported be before the others to instantiate the abstract model inherited by +# other models +from . import jira_binding + +from . import account_analytic_line +from . import jira_account_analytic_line +from . import jira_backend +from . import jira_backend_timestamp +from . import jira_issue_type +from . import jira_project_base_mixin +from . import jira_project_project +from . import jira_project_task +from . import jira_res_users +from . import project_project +from . import project_task +from . import queue_job +from . import res_users diff --git a/connector_jira/models/account_analytic_line.py b/connector_jira/models/account_analytic_line.py new file mode 100644 index 000000000..e3c1d007a --- /dev/null +++ b/connector_jira/models/account_analytic_line.py @@ -0,0 +1,152 @@ +# Copyright 2016-2022 Camptocamp SA +# Copyright 2019 Brainbean Apps (https://brainbeanapps.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import _, api, exceptions, fields, models + + +class AccountAnalyticLine(models.Model): + _inherit = "account.analytic.line" + + jira_bind_ids = fields.One2many( + comodel_name="jira.account.analytic.line", + inverse_name="odoo_id", + copy=False, + string="Worklog Bindings", + context={"active_test": False}, + ) + # fields needed to display JIRA issue link in views + jira_issue_key = fields.Char( + string="Original JIRA Issue Key", + compute="_compute_jira_references", + store=True, + ) + jira_issue_url = fields.Char( + string="Original JIRA issue Link", + compute="_compute_jira_references", + compute_sudo=True, + store=True, + ) + jira_epic_issue_key = fields.Char( + compute="_compute_jira_references", + string="Original JIRA Epic Key", + store=True, + ) + jira_epic_issue_url = fields.Char( + string="Original JIRA Epic Link", + compute="_compute_jira_references", + compute_sudo=True, + store=True, + ) + + jira_issue_type_id = fields.Many2one( + comodel_name="jira.issue.type", + string="Original JIRA Issue Type", + compute="_compute_jira_references", + store=True, + ) + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + self._connector_jira_create_validate(vals) + return super().create(vals_list) + + @api.model + def _connector_jira_create_validate(self, vals): + project_id = vals.get("project_id") + if project_id: + project = self.env["project.project"].sudo().browse(project_id).exists() + if ( + not self.env.context.get("connector_jira") + and project.jira_bind_ids._is_linked() + ): + raise exceptions.UserError( + _("Timesheet can not be created in project linked to JIRA!") + ) + + def write(self, vals): + self._connector_jira_write_validate(vals) + return super().write(vals) + + def _connector_jira_write_validate(self, vals): + if ( + not self.env.context.get("connector_jira") + and self.jira_bind_ids._is_linked() + ): + new_values = self._convert_to_write(vals) + for old_values in self.read(list(vals.keys()), load="_classic_write"): + old_values.pop("id", None) + old_values = self._convert_to_write(old_values) + for field in self._get_connector_jira_fields(): + if field in vals and new_values[field] != old_values[field]: + raise exceptions.UserError( + _("Timesheet linked to JIRA Worklog cannot be modified!") + ) + + @api.ondelete(at_uninstall=False) + def _unlink_except_records_are_linked(self): + if ( + not self.env.context.get("connector_jira") + and self.jira_bind_ids._is_linked() + ): + raise exceptions.UserError( + _("Timesheet linked to JIRA Worklog can not be deleted!") + ) + + @api.depends( + "jira_bind_ids", + "jira_bind_ids.jira_issue_key", + "jira_bind_ids.jira_issue_url", + "jira_bind_ids.jira_issue_type_id", + "jira_bind_ids.jira_epic_issue_key", + "jira_bind_ids.jira_epic_issue_url", + ) + def _compute_jira_references(self): + """Compute the various references to JIRA. + + We assume that we have only one external record for a line + """ + with_bind = self.filtered("jira_bind_ids") + for record in with_bind: + main_binding = record.jira_bind_ids[0] + record.jira_issue_key = main_binding.jira_issue_key + record.jira_issue_url = main_binding.jira_issue_url + record.jira_issue_type_id = main_binding.jira_issue_type_id + record.jira_epic_issue_key = main_binding.jira_epic_issue_key + record.jira_epic_issue_url = main_binding.jira_epic_issue_url + + no_bind = self - with_bind + if no_bind: + no_bind.update( + { + "jira_issue_key": "", + "jira_issue_url": "", + "jira_issue_type_id": False, + "jira_epic_issue_key": "", + "jira_epic_issue_url": "", + } + ) + + @api.model + def _get_connector_jira_fields(self): + return [ + "jira_bind_ids", + "project_id", + "task_id", + "user_id", + "employee_id", + "name", + "date", + "unit_amount", + ] + + def action_open_refresh_worklogs_from_jira_wizard(self): + return { + "name": _("Refresh Worklogs from Jira"), + "type": "ir.actions.act_window", + "target": "new", + "view_mode": "form", + "res_model": "jira.account.analytic.line.import", + "context": {"default_analytic_line_ids": [fields.Command.set(self.ids)]}, + } diff --git a/connector_jira/models/jira_account_analytic_line.py b/connector_jira/models/jira_account_analytic_line.py new file mode 100644 index 000000000..cd78d6311 --- /dev/null +++ b/connector_jira/models/jira_account_analytic_line.py @@ -0,0 +1,97 @@ +# Copyright 2016-2022 Camptocamp SA +# Copyright 2019 Brainbean Apps (https://brainbeanapps.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import api, fields, models +from odoo.tools import groupby + + +class JiraAccountAnalyticLine(models.Model): + _name = "jira.account.analytic.line" + _inherit = "jira.binding" + _inherits = {"account.analytic.line": "odoo_id"} + _description = "Jira Worklog" + + odoo_id = fields.Many2one( + comodel_name="account.analytic.line", + string="Timesheet", + required=True, + index=True, + ondelete="restrict", + ) + # The REST API needs issue id + worklog id, so we keep it along + # in case we'll need it for an eventual export + jira_issue_id = fields.Char() + + # As we can have more than one jira binding on a project.project, we store + # to which one a task binding is related. + jira_project_bind_id = fields.Many2one( + comodel_name="jira.project.project", + ondelete="restrict", + ) + + # we have to store these fields on the analytic line because + # they may be different than the ones on their odoo task: + # for instance, we do not import "Tasks" but we import "Epics", + # the analytic line for a "Task" will be linked to an "Epic" on + # Odoo, but we still want to know the original task here + jira_issue_key = fields.Char(string="Original Task Key") + jira_issue_type_id = fields.Many2one( + comodel_name="jira.issue.type", + string="Original Issue Type", + ) + jira_issue_url = fields.Char( + string="Original JIRA issue Link", + compute="_compute_jira_issue_url", + store=True, + ) + jira_epic_issue_key = fields.Char(string="Original Epic Key") + jira_epic_issue_url = fields.Char( + string="Original JIRA Epic Link", + compute="_compute_jira_issue_url", + store=True, + ) + + _sql_constraints = [ + ( + "jira_binding_backend_uniq", + "unique(backend_id, odoo_id)", + "A binding already exists for this line and this backend.", + ), + ] + + def _is_linked(self): + return self.jira_project_bind_id._is_linked() + + @api.depends( + "backend_id", "backend_id.uri", "jira_issue_key", "jira_epic_issue_key" + ) + def _compute_jira_issue_url(self): + """Compute the external URL to JIRA.""" + for backend, records in groupby(self, key=lambda r: r.backend_id): + if backend: + urlmaker = backend.make_issue_url + else: + + def urlmaker(*args, **kwargs): + return "" + + for record in records: + record.jira_issue_url = urlmaker(record.jira_issue_key) + record.jira_epic_issue_url = urlmaker(record.jira_epic_issue_key) + + @api.model + def import_record(self, backend, issue_id, worklog_id, force=False): + """Import a worklog from JIRA""" + with backend.work_on(self._name) as work: + importer = work.component(usage="record.importer") + return importer.run(worklog_id, issue_id=issue_id, force=force) + + def force_reimport(self): + for binding in self.sudo(): + binding.with_delay(priority=8).import_record( + binding.backend_id, + binding.jira_issue_id, + binding.external_id, + force=True, + ) diff --git a/connector_jira/models/jira_backend.py b/connector_jira/models/jira_backend.py new file mode 100644 index 000000000..2142fcb59 --- /dev/null +++ b/connector_jira/models/jira_backend.py @@ -0,0 +1,604 @@ +# Copyright: 2015 LasLabs, Inc. +# Copyright 2016-2022 Camptocamp SA +# Copyright 2019 Brainbean Apps (https://brainbeanapps.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +import logging +import urllib.parse +from datetime import datetime + +import jwt +import pytz +import requests +from atlassian_jwt import url_utils + +from odoo import _, api, exceptions, fields, models +from odoo.http import request +from odoo.tools import config + +_logger = logging.getLogger(__name__) + +JIRA_TIMEOUT = 30 # seconds + +try: + from jira import JIRA, JIRAError +except ImportError as err: + _logger.debug(err) + +try: + pass +except ImportError as err: + _logger.debug(err) + + +class JiraBackend(models.Model): + _name = "jira.backend" + _description = "Jira Backend" + _inherit = "connector.backend" + + RSA_BITS = 4096 + RSA_PUBLIC_EXPONENT = 65537 + KEY_LEN = 255 # 255 == max Atlassian db col len + + # def _default_consumer_key(self): + # """Generate a rnd consumer key of length self.KEY_LEN""" + # return binascii.hexlify(urandom(self.KEY_LEN))[: self.KEY_LEN] + + uri = fields.Char( + string="Jira URI", + help="the value is provided when the app is installed on Jira Cloud.", + ) + name = fields.Char( + size=60, + help="Display name of the app on the Atlassian Marketplace. Max 60 chars.", + ) + app_descriptor_url = fields.Char( + string="App Descriptor URL", + help="URL to use when registering the backend as an app on the marketplace", + compute="_compute_app_descriptor_url", + ) + display_url = fields.Char(help="Url used for the Jira app in messages") + application_key = fields.Char( + compute="_compute_application_key", + store=True, + help="The name that will be used as application key to register the app on the" + " Atlassian marketplace website.\n" + "It has to be unique among all apps on the marketplace.", + ) + company_id = fields.Many2one( + comodel_name="res.company", + string="Company", + required=True, + default=lambda self: self.env.company, + ) + worklog_fallback_project_id = fields.Many2one( + comodel_name="project.project", + string="Fallback for Worklogs", + help="Worklogs which could not be linked to any project " + "will be created in this project. Worklogs landing in " + "the fallback project can be reassigned to the correct " + "project by: 1. linking the expected project with the Jira one, " + "2. using 'Refresh Worklogs from Jira' on the timesheet lines.", + ) + worklog_date_timezone_mode = fields.Selection( + selection=[ + ("naive", "As-is (naive)"), + ("user", "Jira User"), + ("specific", "Specific"), + ], + default="naive", + help=( + "Worklog/Timesheet date timezone modes:\n" + " - As-is (naive): ignore timezone information\n" + " - Jira User: use author's timezone\n" + " - Specific: use pre-configured timezone\n" + ), + ) + worklog_date_timezone = fields.Selection( + selection=lambda self: [(x, x) for x in pytz.all_timezones], + default=(lambda self: self._context.get("tz") or self.env.user.tz or "UTC"), + ) + state = fields.Selection( + selection=[ + # ("authenticate", "Authenticate"), + ("setup", "Setup"), + ("running", "Running"), + ], + default="setup", + required=True, + help="State of the Backend.\n" + "Setup: in this state you can register the backend on " + "https://marketplace.atlassian.com/ as an app, using the app descriptor url.\n" + "Running: when you have installed the backend on a Jira cloud instance " + "(transition is automatic).", + ) + private_key = fields.Char( + groups="connector.group_connector_manager", + help="The shared secret for JWT, provided at app installation", + ) + public_key = fields.Text( + help="The Client Key for JWT, provided at app installation" + ) + + verify_ssl = fields.Boolean(default=True, string="Verify SSL?") + + project_template = fields.Selection( + selection="_selection_project_template", + string="Default Project Template", + default="Scrum software development", + required=True, + ) + project_template_shared = fields.Char( + string="Default Shared Template Key", + ) + + import_project_task_from_date = fields.Datetime( + compute="_compute_last_import_date", + inverse="_inverse_import_project_task_from_date", + string="Import Project Tasks from date", + ) + import_project_task_force = fields.Boolean() + + import_analytic_line_from_date = fields.Datetime( + compute="_compute_last_import_date", + inverse="_inverse_import_analytic_line_from_date", + string="Import Worklogs from date", + ) + import_analytic_line_force = fields.Boolean() + + delete_analytic_line_from_date = fields.Datetime( + compute="_compute_last_import_date", + inverse="_inverse_delete_analytic_line_from_date", + string="Delete Extra Worklogs from date", + ) + + issue_type_ids = fields.One2many( + comodel_name="jira.issue.type", + inverse_name="backend_id", + string="Issue Types", + ) + + epic_link_field_name = fields.Char( + string="Epic Link Field", + help="The 'Epic Link' field on JIRA is a custom field. " + "The name of the field is something like 'customfield_10002'. ", + ) + epic_name_field_name = fields.Char( + string="Epic Name Field", + help="The 'Epic Name' field on JIRA is a custom field. " + "The name of the field is something like 'customfield_10003'. ", + ) + epic_link_on_epic = fields.Boolean( + help="Epics on JIRA cannot be linked to another epic. Check this box" + "to fill the epic field with itself on Odoo.", + ) + + # TODO: use something better to show this info + # For instance, we could use web_notify to simply show a system msg. + report_user_sync = fields.Html() + + @api.model_create_multi + def create(self, vals_list): + records = super().create(vals_list) + records._compute_application_key() + return records + + def _compute_application_key(self): + db_name = config["db_name"] + for rec in self: + rec.application_key = f"odoo-jira-connector-{db_name}-{rec.id}" + + def _compute_app_descriptor_url(self): + base_url = self._get_base_url() + for rec in self: + rec.app_descriptor_url = f"{base_url}/jira/{rec.id}/app-descriptor.json" + + @api.model + def _selection_project_template(self): + return [ + ("Scrum software development", "Scrum software development (Software)"), + ("Kanban software development", "Kanban software development (Software)"), + ("Basic software development", "Basic software development (Software)"), + ("Project management", "Project management (Business)"), + ("Task management", "Task management (Business)"), + ("Process management", "Process management (Business)"), + ("shared", "From a shared template"), + ] + + @api.constrains("project_template_shared") + def _check_jira_key(self): + valid = self.env["jira.project.project"]._jira_key_valid + for backend in self.filtered("project_template_shared"): + if not valid(backend.project_template_shared): + raise exceptions.ValidationError( + _("%s is not a valid JIRA Key") % backend.project_template_shared + ) + + @api.depends() + def _compute_last_import_date(self): + for backend in self: + self.env.cr.execute( + """ + SELECT from_date_field, last_timestamp + FROM jira_backend_timestamp + WHERE backend_id = %s + """, + (backend.id,), + ) + rows = self.env.cr.dictfetchall() + if rows: + for row in rows: + field = row["from_date_field"] + if field in self._fields: + backend[field] = row["last_timestamp"] + else: + backend.update( + { + "import_project_task_from_date": False, + "import_analytic_line_from_date": False, + "delete_analytic_line_from_date": False, + } + ) + + def _inverse_date_fields(self, field_name, component_usage): + ts_model = self.env["jira.backend.timestamp"] + for rec in self: + timestamp = ts_model._timestamp_for_field(rec, field_name, component_usage) + if not timestamp._lock(): + raise exceptions.UserError( + _( + "The synchronization timestamp is currently locked, " + "probably due to an ongoing synchronization." + ) + ) + value = rec[field_name] + # As the timestamp field is using MilliDatetime, we lose + # the milliseconds precision when a user writes a new + # date on the backend. This is not really an issue as we + # expect mostly to use the milliseconds precision for + # the dates coming from the Jira webservices (they use + # milliseconds unix timestamp on some -only some- methods) + if not value: + value = datetime.fromtimestamp(0) + timestamp._update_timestamp(value) + + def _inverse_import_project_task_from_date(self): + self._inverse_date_fields( + "import_project_task_from_date", + "timestamp.batch.importer", + ) + + def _inverse_import_analytic_line_from_date(self): + self._inverse_date_fields( + "import_analytic_line_from_date", + "timestamp.batch.importer", + ) + + def _inverse_delete_analytic_line_from_date(self): + self._inverse_date_fields( + "delete_analytic_line_from_date", + "timestamp.batch.deleter", + ) + + def _run_background_from_date( + self, model, from_date_field, component_usage, force=False + ): + """Import records from a date + + Create jobs and update the sync timestamp in a savepoint; if a + concurrency issue arises, it will be logged and rollbacked silently. + """ + self.ensure_one() + timestamp = self.env["jira.backend.timestamp"]._timestamp_for_field( + self, + from_date_field, + component_usage, + ) + self.env[model].with_delay(priority=9).run_batch_timestamp( + self, timestamp, force=force + ) + + # XXX check this + def button_setup(self): + self.state_running() + + def activate_epic_link(self): + self.ensure_one() + with self.work_on("jira.backend") as work: + for field in work.component(usage="backend.adapter").list_fields(): + custom_ref = field.get("schema", {}).get("custom") + if custom_ref == "com.pyxis.greenhopper.jira:gh-epic-link": + self.epic_link_field_name = field["id"] + elif custom_ref == "com.pyxis.greenhopper.jira:gh-epic-label": + self.epic_name_field_name = field["id"] + + # XXX check this + def state_setup(self): + for backend in self: + if backend.state == "authenticate": + backend.state = "setup" + + # XXX check this + def state_running(self): + for backend in self: + if backend.state == "setup": + backend.state = "running" + + @api.onchange("worklog_date_timezone_mode") + def _onchange_worklog_date_import_timezone_mode(self): + for jira_backend in self: + if jira_backend.worklog_date_timezone_mode == "specific": + jira_backend.worklog_date_timezone = self.env.user.tz or "UTC" + else: + jira_backend.worklog_date_timezone = False + + def check_connection(self): + self.ensure_one() + try: + self.get_api_client().myself() + except (ValueError, requests.exceptions.ConnectionError) as e: + raise exceptions.UserError(_("Failed to connect (%s)", e)) from e + except JIRAError as e: + raise exceptions.UserError(_("Failed to connect (%s)", e.text)) from e + raise exceptions.UserError(_("Connection successful")) + + def import_project_task(self): + self._run_background_from_date( + "jira.project.task", + "import_project_task_from_date", + "timestamp.batch.importer", + force=self.import_project_task_force, + ) + return True + + def import_analytic_line(self): + self._run_background_from_date( + "jira.account.analytic.line", + "import_analytic_line_from_date", + "timestamp.batch.importer", + force=self.import_analytic_line_force, + ) + return True + + def delete_analytic_line(self): + self._run_background_from_date( + "jira.account.analytic.line", + "delete_analytic_line_from_date", + "timestamp.batch.deleter", + ) + return True + + def import_res_users(self): + self.report_user_sync = None + result = self.env["res.users"].search([]).link_with_jira(backends=self) + for bknd_result in result.values(): + if bknd_result.get("error"): + self.report_user_sync = self.env["ir.ui.view"]._render_template( + "connector_jira.backend_report_user_sync", + {"backend": self, "result": bknd_result}, + ) + return True + + def get_user_resolution_order(self): + return ["email"] + + def import_issue_type(self): + self.env["jira.issue.type"].import_batch(self) + return True + + def get_api_client(self): + self.ensure_one() + # tokens are only readable by connector managers + backend = self.sudo() + # application key in the app descriptor + app_key = self.application_key + return JIRA( + options={"server": backend.uri, "verify": backend.verify_ssl}, + jwt={"secret": backend.private_key, "payload": {"iss": app_key}}, + timeout=JIRA_TIMEOUT, + get_server_info=True, + ) + + @api.model + def _scheduler_import_project_task(self): + for backend in self.search([("state", "=", "running")]): + backend.import_project_task() + + @api.model + def _scheduler_import_res_users(self): + for backend in self.search([("state", "=", "running")]): + backend.import_res_users() + + @api.model + def _scheduler_import_analytic_line(self): + for backend in self.search([("state", "=", "running")]): + backend.import_analytic_line() + + @api.model + def _scheduler_delete_analytic_line(self): + for backend in self.search([("state", "=", "running")]): + backend.delete_analytic_line() + + def make_issue_url(self, jira_issue_id): + return urllib.parse.urljoin(self.uri, f"/browse/{jira_issue_id}") + + @api.model + def _get_base_url(self): + base_url = self.env["ir.config_parameter"].get_param("web.base.url", "") + if "://" in base_url: + base_url = base_url.split("://", maxsplit=1)[-1] + return "https://" + base_url + + def _get_app_descriptor(self) -> dict: + self.ensure_one() + base_url = self._get_base_url() + bcknd_id = self.id + key = self.application_key + name = self.name + return { + "key": key, + "name": name, + "description": "Connect your Odoo instance to Jira, manage linking " + "Jira Cards with Odoo projects and tasks, and Tempo worklogs with Odoo " + f"Timesheets.\nBuilt for {name} ({key})", + "vendor": {"name": "Camptocamp", "url": "https://www.camptocamp.com/"}, + "baseUrl": base_url, + "authentication": {"type": "jwt"}, + "lifecycle": { + "installed": f"{base_url}/jira/{bcknd_id}/installed", + "uninstalled": f"{base_url}/jira/{bcknd_id}/uninstalled", + "enabled": f"{base_url}/jira/{bcknd_id}/enabled", + "disabled": f"{base_url}/jira/{bcknd_id}/disabled", + }, + "modules": { + "webhooks": [ + { + "event": "jira:issue_created", + "url": f"{base_url}/connector_jira/{bcknd_id}/webhooks/issue", + }, + { + "event": "jira:issue_deleted", + "url": f"{base_url}/connector_jira/{bcknd_id}/webhooks/issue", + }, + { + "event": "jira:issue_updated", + "url": f"{base_url}/connector_jira/{bcknd_id}/webhooks/issue", + }, + { + "event": "worklog_updated", + "url": f"{base_url}/connector_jira/{bcknd_id}/webhooks/worklog", + }, + { + "event": "worklog_deleted", + "url": f"{base_url}/connector_jira/{bcknd_id}/webhooks/worklog", + }, + { + "event": "worklog_created", + "url": f"{base_url}/connector_jira/{bcknd_id}/webhooks/worklog", + }, + ], + }, + "apiMigrations": {"gdpr": True, "signed-install": True}, + "scopes": ["project_admin"], + } + + def _uses_gdpr_strict_mode(self) -> bool: + """Defines whether JIRA's GDPR strict mode is active + + Hook method, can be overridden. + ``True`` by default for security reasons. + """ + return True + + def _install_app(self, payload): + """Update the backend with proper settings after Jira app installation + + Payload keys: + 'key': 'odoo-connector-jira' + 'clientKey': Identifying key but could vary WTF + 'publicKey': DEPRECATED DO NOT USE, + 'sharedSecret': Use to sign JWT tokens + 'serverVersion': DEPRECATED + 'pluginsVersion': DEPRECATED + 'baseUrl': URL prefix for this Atlassian product instance. All of its REST + endpoints begin with this `baseUrl`. Do not use the `baseUrl` as an + identifier for the Atlassian product as this value may not be unique. + 'displayUrl': If the Atlassian product instance has an associated custom + domain, this is the URL through which users will access the product. + 'productType': 'jira', + 'description': 'Atlassian JIRA at https: //testcamptocamp.atlassian.net ', + 'eventType': 'installed', + + """ + self.ensure_one() + self.write(self._prepare_backend_values(payload)) + _logger.info("Updated Jira backend for uri %s", self.uri) + assert self.private_key + return "ok" + + def _prepare_backend_values(self, payload): + values = { + "display_url": payload.get("displayUrl", False), + "uri": payload.get("baseUrl", False), + "state": "setup", + "public_key": payload.get("clientKey", False), + } + if "sharedSecret" in payload: + values["private_key"] = payload["sharedSecret"] + return values + + def _uninstall_app(self, payload): + """Update the backend with proper settings after Jira app uninstallation""" + self.ensure_one() + # wait for disabled to complete + query = "SELECT id FROM jira_backend WHERE id = %s FOR UPDATE" + self.env.cr.execute(query, (self.id,)) + self.write({"public_key": False, "private_key": False, "state": "setup"}) + _logger.info("Uninstalled Jira backend for uri %s", self.uri) + return "ok" + + def _enable_app(self, payload): + """Update the backend with proper settings after Jira app enablement""" + self.ensure_one() + values = self._prepare_backend_values(payload) + values["state"] = "running" + self.write(values) + _logger.info("enable %s -> %s", self.ids, values) + _logger.info("Enabled Jira backend for uri %s", self.mapped("uri")) + return "ok" + + def _disable_app(self, payload): + """Update the backend with proper settings after Jira app disablement""" + self.ensure_one() + query = "SELECT id from jira_backend WHERE id = %s FOR UPDATE" + self.env.cr.execute(query, (self.id,)) + values = self._prepare_backend_values(payload) + values["state"] = "setup" + _logger.info("disable %s -> %s", self.ids, values) + self.write(values) + _logger.info("Disabled Jira backend for uri %s", self.mapped("uri")) + return "ok" + + def _validate_jwt(self, auth_header, query_url=None): + """Validation for the JSON Web Token + + Use the algorithm provided by the Atlassian module to compute the 'iss' hash + from the URL and compare it to the value in the token, in addition to the + standard claims checks. + """ + self.ensure_one() + assert auth_header.startswith("JWT "), "Unexpected content in Auth header" + # see https://developer.atlassian.com/cloud/jira/software/understanding-jwt/ + # for more info + decoded = jwt.decode( + auth_header[4:], + self.private_key, + algorithms=["HS256"], + # audience=self._get_base_url(), + issuer=self.public_key, + options={ + "require": [ + "iss", # issuer of the claim + "exp", # expiration time + "iat", # issued at time + "qsh", # query string hash + # sub: is optional + # aud: is optional + # context: is optional + ] + }, + ) + if query_url is not None: + expected_hash = url_utils.hash_url("POST", query_url) + if decoded["iss"] != expected_hash: + return False + return True + + def _validate_jwt_from_request(self): + query_string = request.httprequest.query_string + if isinstance(query_string, bytes): + query_string = query_string.decode("utf-8") + return self._validate_jwt( + auth_header=request.httprequest.headers["Authorization"], + query_url=f"{request.httprequest.path}?{query_string}", + ) diff --git a/connector_jira/models/jira_backend_timestamp.py b/connector_jira/models/jira_backend_timestamp.py new file mode 100644 index 000000000..b08c21c55 --- /dev/null +++ b/connector_jira/models/jira_backend_timestamp.py @@ -0,0 +1,89 @@ +# Copyright: 2015 LasLabs, Inc. +# Copyright 2016-2022 Camptocamp SA +# Copyright 2019 Brainbean Apps (https://brainbeanapps.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from datetime import datetime + +import psycopg2 + +from odoo import api, fields, models + +from ..fields import MilliDatetime + + +class JiraBackendTimestamp(models.Model): + _name = "jira.backend.timestamp" + _description = "Jira Backend Import Timestamps" + + backend_id = fields.Many2one( + comodel_name="jira.backend", + string="Jira Backend", + required=True, + ) + from_date_field = fields.Char(required=True) + + # For worklogs, jira allows to work with milliseconds + # unix timestamps, we keep this precision by using a new type + # of field. The ORM values for this field are Unix timestamps the + # same way Jira use them: unix timestamp as integer multiplied * 1000 + # to keep the milli precision with 3 digits (example 1554318348000). + last_timestamp = MilliDatetime(string="Last Timestamp", required=True) + + # The content of this field must match to the "usage" of a component. + # The method JiraBinding.run_batch_timestamp() will find the matching + # component for the model and call "run()" on it. + component_usage = fields.Char( + required=True, + help="Used by the connector to find which component " + "execute the batch import (technical).", + ) + + _sql_constraints = [ + ( + "timestamp_field_uniq", + "unique(backend_id, from_date_field, component_usage)", + "A timestamp already exists.", + ), + ] + + @api.model + def _timestamp_for_field(self, backend, field_name, component_usage): + """Return the timestamp for a field""" + timestamp = self.search( + [ + ("backend_id", "=", backend.id), + ("from_date_field", "=", field_name), + ("component_usage", "=", component_usage), + ] + ) + if not timestamp: + timestamp = self.env["jira.backend.timestamp"].create( + { + "backend_id": backend.id, + "from_date_field": field_name, + "component_usage": component_usage, + "last_timestamp": datetime.fromtimestamp(0), + } + ) + return timestamp + + def _update_timestamp(self, timestamp): + self.ensure_one() + self.last_timestamp = timestamp + + def _lock(self): + """Update the timestamp for a synchro + + thus, we prevent 2 synchros to be launched at the same time. + The lock is released at the commit of the transaction. + + Return True if the lock could be acquired. + """ + self.ensure_one() + query = "SELECT id FROM jira_backend_timestamp WHERE id = %s FOR UPDATE NOWAIT" + try: + self.env.cr.execute(query, (self.id,)) + except psycopg2.OperationalError: + return False + return bool(self.env.cr.fetchone()) diff --git a/connector_jira/models/jira_binding.py b/connector_jira/models/jira_binding.py new file mode 100644 index 000000000..84deed5be --- /dev/null +++ b/connector_jira/models/jira_binding.py @@ -0,0 +1,77 @@ +# Copyright 2016-2022 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import api, fields, models + +from ..fields import MilliDatetime + + +class JiraBinding(models.AbstractModel): + """Abstract Model for the Bindings. + + All the models used as bindings between Jira and Odoo + (``jira.product.product``, ...) should ``_inherit`` it. + """ + + _name = "jira.binding" + _inherit = "external.binding" + _description = "Jira Binding (abstract)" + + # odoo-side id must be declared in concrete model + # odoo_id = fields.Many2one(...) + backend_id = fields.Many2one( + comodel_name="jira.backend", + string="Jira Backend", + required=True, + ondelete="restrict", + ) + jira_updated_at = MilliDatetime() + external_id = fields.Char(string="ID on Jira", index="trigram") + + _sql_constraints = [ + ( + "jira_binding_uniq", + "unique(backend_id, external_id)", + "A binding already exists for this Jira record", + ), + ] + + @api.model + def import_batch(self, backend): + """Prepare import of a batch of record""" + with backend.work_on(self._name) as work: + importer = work.component(usage="batch.importer") + return importer.run() + + @api.model + def run_batch_timestamp(self, backend, timestamp, force=False): + """Prepare batch of records""" + with backend.work_on(self._name) as work: + importer = work.component(usage=timestamp.component_usage) + return importer.run(timestamp, force=force) + + @api.model + def import_record(self, backend, external_id, force=False, record=None): + """Import a record""" + with backend.work_on(self._name) as work: + importer = work.component(usage="record.importer") + return importer.run(external_id, force=force, record=record) + + @api.model + def delete_record( + self, backend, external_id, only_binding=False, set_inactive=False + ): + """Delete a record on Odoo""" + with backend.work_on(self._name) as work: + importer = work.component(usage="record.deleter") + return importer.run( + external_id, + only_binding=only_binding, + set_inactive=set_inactive, + ) + + def export_record(self, fields=None): + self.ensure_one() + with self.backend_id.work_on(self._name) as work: + exporter = work.component(usage="record.exporter") + return exporter.run(self, fields=fields) diff --git a/connector_jira/models/jira_issue_type.py b/connector_jira/models/jira_issue_type.py new file mode 100644 index 000000000..203720c61 --- /dev/null +++ b/connector_jira/models/jira_issue_type.py @@ -0,0 +1,26 @@ +# Copyright 2016-2022 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import fields, models + + +class JiraIssueType(models.Model): + _name = "jira.issue.type" + _inherit = "jira.binding" + _description = "Jira Issue Type" + + name = fields.Char(required=True) + description = fields.Char() + backend_id = fields.Many2one(ondelete="cascade") + + def is_sync_for_project(self, project_binding): + self.ensure_one() + return bool(project_binding) and self in project_binding.sync_issue_type_ids + + def import_batch(self, backend, from_date=None, to_date=None): + """Prepare a batch import of issue types from Jira + + from_date and to_date are ignored for issue types + """ + with backend.work_on(self._name) as work: + work.component(usage="batch.importer").run() diff --git a/connector_jira/models/jira_project_base_mixin.py b/connector_jira/models/jira_project_base_mixin.py new file mode 100644 index 000000000..522707c72 --- /dev/null +++ b/connector_jira/models/jira_project_base_mixin.py @@ -0,0 +1,54 @@ +# Copyright 2016-2022 Camptocamp SA +# Copyright 2019 Brainbean Apps (https://brainbeanapps.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import api, fields, models + + +class JiraProjectBaseFields(models.AbstractModel): + """JIRA Project Base fields + + Shared by the binding jira.project.project + and the wizard to link/create a JIRA project + """ + + _name = "jira.project.base.mixin" + _description = "JIRA Project Base Mixin" + + jira_key = fields.Char( + string="JIRA Key", + required=True, + size=10, # limit on JIRA + ) + sync_issue_type_ids = fields.Many2many( + comodel_name="jira.issue.type", + string="Issue Levels to Synchronize", + domain="[('backend_id', '=', backend_id)]", + help="Only issues of these levels are imported. " + "When a worklog is imported no a level which is " + "not sync'ed, it is attached to the nearest " + "sync'ed parent level. If no parent can be found, " + "it is attached to a special 'Unassigned' task.", + ) + project_template = fields.Selection( + selection="_selection_project_template", + string="Default Project Template", + default="Scrum software development", + ) + project_template_shared = fields.Char( + string="Default Shared Template", + ) + sync_action = fields.Selection( + selection=[("link", "Link with JIRA"), ("export", "Export to JIRA")], + default="link", + required=True, + help="Defines if the information of the project (name " + "and key) are exported to JIRA when changed. Link means" + "the project already exists on JIRA, no sync of the project" + " details once the link is established." + " Tasks are always imported from JIRA, not pushed.", + ) + + @api.model + def _selection_project_template(self): + return self.env["jira.backend"]._selection_project_template() diff --git a/connector_jira/models/jira_project_project.py b/connector_jira/models/jira_project_project.py new file mode 100644 index 000000000..740abaf14 --- /dev/null +++ b/connector_jira/models/jira_project_project.py @@ -0,0 +1,142 @@ +# Copyright 2016-2022 Camptocamp SA +# Copyright 2019 Brainbean Apps (https://brainbeanapps.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +import re + +from odoo import _, api, exceptions, fields, models, tools + + +class JiraProjectProject(models.Model): + _name = "jira.project.project" + _inherit = ["jira.binding", "jira.project.base.mixin"] + _inherits = {"project.project": "odoo_id"} + _description = "Jira Projects" + + odoo_id = fields.Many2one( + comodel_name="project.project", + string="Project", + required=True, + index=True, + ondelete="restrict", + ) + project_type = fields.Selection(selection="_selection_project_type") + + @api.model + def _selection_project_type(self): + return [("software", "Software"), ("business", "Business")] + + # Disable and implement the constraint jira_binding_uniq as python because + # we need to override it in connector_jira_service_desk, and it would try + # to create it again at every update because of the base implementation + # in the binding's parent model. + def _add_sql_constraints(self): + # we replace the sql constraint by a python one + # to include the organizations + for key, definition, __ in self._sql_constraints: + conname = f"{self._table}_{key}" + if key == "jira_binding_uniq": + if tools.constraint_definition(self.env.cr, self._table, conname): + tools.drop_constraint(self.env.cr, self._table, conname) + else: + tools.add_constraint(self.env.cr, self._table, conname, definition) + return super()._add_sql_constraints() + + def _export_binding_domain(self): + """Return the domain for the constraints on export bindings""" + self.ensure_one() + return [ + ("odoo_id", "=", self.odoo_id.id), + ("backend_id", "=", self.backend_id.id), + ("sync_action", "=", "export"), + ] + + @api.constrains("backend_id", "odoo_id", "sync_action") + def _constrains_odoo_jira_sync_action_export_uniq(self): + """Add a constraint on backend+odoo id for export action + + Only one binding can have the sync_action "export", as it pushes the + name and key to Jira, we cannot export the same values to several + projects. + """ + for binding in self: + domain = binding._export_binding_domain() + export_bindings = self.with_context(active_test=False).search(domain) + if len(export_bindings) > 1: + raise exceptions.ValidationError( + _( + "Only one Jira binding can be configured with the Sync. Action" + ' "Export" for a project. "%s" already has one.', + binding.display_name, + ) + ) + + @api.constrains("backend_id", "external_id") + def _constrains_jira_uniq(self): + """Add a constraint on backend+jira id + + Defined as a python method rather than a postgres constraint + in order to ease the override in connector_jira_servicedesk + """ + for binding in self.filtered("external_id"): + same_link_bindings = self.with_context(active_test=False).search( + [ + ("id", "!=", binding.id), + ("backend_id", "=", binding.backend_id.id), + ("external_id", "=", binding.external_id), + ] + ) + if same_link_bindings: + raise exceptions.ValidationError( + _( + "The project %s is already linked with the same JIRA project.", + same_link_bindings.display_name, + ) + ) + + @api.constrains("jira_key") + def _check_jira_key(self): + for key in self.filtered("jira_key").mapped("jira_key"): + if not self._jira_key_valid(key): + raise exceptions.ValidationError(_("%s is not a valid JIRA Key", key)) + + @api.onchange("backend_id") + def onchange_project_backend_id(self): + self.project_template = self.backend_id.project_template + self.project_template_shared = self.backend_id.project_template_shared + + @staticmethod + def _jira_key_valid(key): + return bool(re.match(r"^[A-Z][A-Z0-9]{1,9}$", key)) + + @api.constrains("project_template_shared") + def _check_project_template_shared(self): + for tmpl in set(self.mapped("project_template_shared")): + if tmpl and not self._jira_key_valid(tmpl): + raise exceptions.ValidationError(_("%s is not a valid JIRA Key", tmpl)) + + def _is_linked(self): + return bool(self) and any(p.sync_action == "link" for p in self) + + @api.model_create_multi + def create(self, vals_list): + records = super().create(vals_list) + records._ensure_jira_key() + return records + + def write(self, values): + if "project_template" in values: + raise exceptions.UserError(_("The project template cannot be modified.")) + res = super().write(values) + self._ensure_jira_key() + return res + + @api.ondelete(at_uninstall=False) + def _unlink_unless_exported(self): + if any(self.mapped("external_id")): + raise exceptions.UserError(_("Exported project cannot be deleted.")) + + def _ensure_jira_key(self): + if self.env.context.get("connector_no_export") or all(r.jira_key for r in self): + return + raise exceptions.UserError(_("JIRA Key is mandatory to link a project")) diff --git a/connector_jira/models/jira_project_task.py b/connector_jira/models/jira_project_task.py new file mode 100644 index 000000000..b004c44e0 --- /dev/null +++ b/connector_jira/models/jira_project_task.py @@ -0,0 +1,70 @@ +# Copyright 2016-2019 Camptocamp SA +# Copyright 2019 Brainbean Apps (https://brainbeanapps.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import _, api, exceptions, fields, models + + +class JiraProjectTask(models.Model): + _name = "jira.project.task" + _inherit = "jira.binding" + _inherits = {"project.task": "odoo_id"} + _description = "Jira Tasks" + + odoo_id = fields.Many2one( + comodel_name="project.task", + string="Task", + required=True, + index=True, + ondelete="restrict", + ) + # As we can have more than one jira binding on a project.project, we store + # to which one a task binding is related. + jira_project_bind_id = fields.Many2one( + comodel_name="jira.project.project", + ondelete="restrict", + ) + jira_key = fields.Char( + string="Key", + ) + jira_issue_type_id = fields.Many2one( + comodel_name="jira.issue.type", + string="Issue Type", + ) + jira_epic_link_id = fields.Many2one( + comodel_name="jira.project.task", + string="Epic", + ) + jira_parent_id = fields.Many2one( + comodel_name="jira.project.task", + string="Parent Issue", + help="Parent issue when the issue is a subtask. " + "Empty if the type of parent is filtered out " + "of the synchronizations.", + ) + jira_issue_url = fields.Char( + string="JIRA issue", + compute="_compute_jira_issue_url", + ) + + _sql_constraints = [ + ( + "jira_binding_backend_uniq", + "unique(backend_id, odoo_id)", + "A binding already exists for this task and this backend.", + ), + ] + + def _is_linked(self): + return self.jira_project_bind_id._is_linked() + + @api.ondelete(at_uninstall=False) + def _unlink_unless_is_jira_task(self): + if any(self.mapped("external_id")): + raise exceptions.UserError(_("A Jira task cannot be deleted.")) + + @api.depends("backend_id.uri", "jira_key") + def _compute_jira_issue_url(self): + """Compute the external URL to JIRA.""" + for record in self: + record.jira_issue_url = record.backend_id.make_issue_url(record.jira_key) diff --git a/connector_jira/models/jira_res_users.py b/connector_jira/models/jira_res_users.py new file mode 100644 index 000000000..468e1e0f1 --- /dev/null +++ b/connector_jira/models/jira_res_users.py @@ -0,0 +1,20 @@ +# Copyright 2016-2022 Camptocamp SA +# Copyright 2019 Brainbean Apps (https://brainbeanapps.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html) + +from odoo import fields, models + + +class JiraResUsers(models.Model): + _name = "jira.res.users" + _inherit = "jira.binding" + _inherits = {"res.users": "odoo_id"} + _description = "Jira User" + + odoo_id = fields.Many2one( + comodel_name="res.users", + string="User", + required=True, + index=True, + ondelete="restrict", + ) diff --git a/connector_jira/models/project_project.py b/connector_jira/models/project_project.py new file mode 100644 index 000000000..409cd9eef --- /dev/null +++ b/connector_jira/models/project_project.py @@ -0,0 +1,53 @@ +# Copyright 2016-2022 Camptocamp SA +# Copyright 2019 Brainbean Apps (https://brainbeanapps.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import api, fields, models + + +class ProjectProject(models.Model): + _inherit = "project.project" + + @api.model + def _register_hook(self): + # OVERRIDE: add ``jira_key`` to class attribute ``_rec_names_search``, + # allowing using ``_rec_name`` too in method ``name_search()`` + cls = type(self) + search_fnames = list(cls._rec_names_search or []) + search_fnames.insert(0, "jira_key") + if cls._rec_name and cls._rec_name not in search_fnames: + search_fnames.append(cls._rec_name) + cls._rec_names_search = search_fnames + return super()._register_hook() + + jira_bind_ids = fields.One2many( + comodel_name="jira.project.project", + inverse_name="odoo_id", + copy=False, + string="Project Bindings", + context={"active_test": False}, + ) + jira_key = fields.Char( + string="JIRA Key", + compute="_compute_jira_key", + store=True, + ) + + @api.depends("jira_bind_ids.jira_key") + def _compute_jira_key(self): + for project in self: + project.jira_key = ", ".join(project.jira_bind_ids.mapped("jira_key")) + + # pylint: disable=W8110 + @api.depends("jira_key") + def _compute_display_name(self): + super()._compute_display_name() + for project in self.filtered("jira_key"): + project.display_name = f"[{project.jira_key}] {project.display_name}" + + def create_and_link_jira(self): + self.ensure_one() + xmlid = "connector_jira.open_project_link_jira" + action = self.env["ir.actions.act_window"]._for_xml_id(xmlid) + action["context"] = dict(self.env.context, default_project_id=self.id) + return action diff --git a/connector_jira/models/project_task.py b/connector_jira/models/project_task.py new file mode 100644 index 000000000..3b8f29b66 --- /dev/null +++ b/connector_jira/models/project_task.py @@ -0,0 +1,175 @@ +# Copyright 2016-2019 Camptocamp SA +# Copyright 2019 Brainbean Apps (https://brainbeanapps.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import _, api, exceptions, fields, models + + +class ProjectTask(models.Model): + _inherit = "project.task" + + @api.model + def _register_hook(self): + # OVERRIDE: add ``jira_compound_key`` to class attribute ``_rec_names_search``, + # allowing using ``_rec_name`` too in method ``name_search()`` + cls = type(self) + search_fnames = list(cls._rec_names_search or []) + search_fnames.insert(0, "jira_compound_key") + if cls._rec_name and cls._rec_name not in search_fnames: + search_fnames.append(cls._rec_name) + cls._rec_names_search = search_fnames + return super()._register_hook() + + jira_bind_ids = fields.One2many( + comodel_name="jira.project.task", + inverse_name="odoo_id", + copy=False, + string="Task Bindings", + context={"active_test": False}, + ) + jira_issue_type = fields.Char( + compute="_compute_jira_issue_type", + string="JIRA Issue Type", + store=True, + ) + jira_compound_key = fields.Char( + compute="_compute_jira_compound_key", + string="JIRA Key", + store=True, + ) + jira_epic_link_task_id = fields.Many2one( + comodel_name="project.task", + compute="_compute_jira_epic_link_task_id", + string="JIRA Epic", + store=True, + ) + jira_parent_task_id = fields.Many2one( + comodel_name="project.task", + compute="_compute_jira_parent_task_id", + string="JIRA Parent", + store=True, + ) + jira_issue_url = fields.Char( + string="JIRA issue", + compute="_compute_jira_issue_url", + ) + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + self._connector_jira_create_validate(vals) + return super().create(vals_list) + + @api.model + def _connector_jira_create_validate(self, vals): + project_id = vals.get("project_id") + if project_id: + project = self.env["project.project"].sudo().browse(project_id).exists() + if ( + not self.env.context.get("connector_jira") + and project.jira_bind_ids._is_linked() + ): + raise exceptions.UserError( + _("Task can not be created in project linked to JIRA!") + ) + + def write(self, vals): + self._connector_jira_write_validate(vals) + return super().write(vals) + + def _connector_jira_write_validate(self, vals): + if ( + not self.env.context.get("connector_jira") + and self.jira_bind_ids._is_linked() + ): + new_values = self._convert_to_write(vals) + for old_values in self.read(list(vals.keys()), load="_classic_write"): + old_values.pop("id", None) + old_values = self._convert_to_write(old_values) + for field in self._get_connector_jira_fields(): + if field in vals and new_values[field] != old_values[field]: + raise exceptions.UserError( + _("Task linked to JIRA Issue can not be modified!") + ) + + @api.ondelete(at_uninstall=False) + def _unlink_except_records_are_linked(self): + if ( + not self.env.context.get("connector_jira") + and self.jira_bind_ids._is_linked() + ): + raise exceptions.UserError( + _("Task linked to JIRA Issue can not be deleted!") + ) + + @api.depends("jira_bind_ids.jira_issue_type_id.name") + def _compute_jira_issue_type(self): + for record in self: + types = record.jira_bind_ids.jira_issue_type_id.mapped("name") + record.jira_issue_type = ",".join([t for t in types if t]) + + @api.depends("jira_bind_ids.jira_key") + def _compute_jira_compound_key(self): + for record in self: + keys = record.jira_bind_ids.mapped("jira_key") + record.jira_compound_key = ",".join([k for k in keys if k]) + + @api.depends("jira_bind_ids.jira_epic_link_id.odoo_id") + def _compute_jira_epic_link_task_id(self): + self.jira_epic_link_task_id = False + for record in self: + tasks = record.jira_bind_ids.jira_epic_link_id.odoo_id + if len(tasks) == 1: + record.jira_epic_link_task_id = tasks + + @api.depends("jira_bind_ids.jira_parent_id.odoo_id") + def _compute_jira_parent_task_id(self): + self.jira_parent_task_id = False + for record in self: + tasks = record.jira_bind_ids.jira_parent_id.odoo_id + if len(tasks) == 1: + record.jira_parent_task_id = tasks + + @api.depends("jira_bind_ids.jira_issue_url") + def _compute_jira_issue_url(self): + """Compute the external URL to JIRA. + + We assume that we have only one external record. + """ + for record in self: + main_binding = record.jira_bind_ids[:1] + record.jira_issue_url = main_binding.jira_issue_url or "" + + # pylint: disable=W8110 + @api.depends("jira_compound_key") + def _compute_display_name(self): + super()._compute_display_name() + for task in self.filtered("jira_compound_key"): + task.display_name = f"[{task.jira_compound_key}] {task.display_name}" + + @api.model + def _get_connector_jira_fields(self): + return [ + "jira_bind_ids", + "name", + "date_deadline", + "user_id", + "description", + "active", + "project_id", + "allocated_hours", + "stage_id", + ] + + def create_and_link_jira(self): + self.ensure_one() + backends = self.project_id.jira_bind_ids.backend_id + xmlid = "connector_jira.open_task_link_jira" + action = self.env["ir.actions.act_window"]._for_xml_id(xmlid) + action["context"] = dict( + self.env.context, + default_task_id=self.id, + default_linked_backend_ids=[fields.Command.set(backends.ids)], + default_backend_id=backends.id if len(backends) == 1 else False, + ) + return action diff --git a/connector_jira/models/queue_job.py b/connector_jira/models/queue_job.py new file mode 100644 index 000000000..82a683b7f --- /dev/null +++ b/connector_jira/models/queue_job.py @@ -0,0 +1,34 @@ +# Copyright 2016-2019 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import models + + +class QueueJob(models.Model): + _inherit = "queue.job" + + def related_action_jira_link(self) -> dict: + """Open a jira url for an issue""" + self.ensure_one() + + # only tested on issues so far + if self.model_name not in ("jira.project.task", "jira.account.analytic.line"): + return {} + + backend = self.args[0] + jira_id = self.args[1] + + # Get the key of the issue to generate the URI. + # JIRA doesn't have an URI to show an issue by id. + # And at this point, we may be importing a Jira record + # that is not yet imported in Odoo or fails to import, + # so we cannot use the URL computed on the Jira binding. + with backend.work_on("jira.project.task") as work: + adapter = work.component(usage="backend.adapter") + with adapter.handle_user_api_errors(): + jira_record = adapter.get(jira_id) + return { + "type": "ir.actions.act_url", + "target": "new", + "url": backend.make_issue_url(jira_record.key), + } diff --git a/connector_jira/models/res_users.py b/connector_jira/models/res_users.py new file mode 100644 index 000000000..bc765980e --- /dev/null +++ b/connector_jira/models/res_users.py @@ -0,0 +1,124 @@ +# Copyright 2016-2022 Camptocamp SA +# Copyright 2019 Brainbean Apps (https://brainbeanapps.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html) + +from odoo import _, exceptions, fields, models + + +class ResUsers(models.Model): + _inherit = "res.users" + + jira_bind_ids = fields.One2many( + comodel_name="jira.res.users", + inverse_name="odoo_id", + copy=False, + string="User Bindings", + context={"active_test": False}, + ) + + def button_link_with_jira(self): + self.ensure_one() + self.link_with_jira(raise_if_mismatch=True) + if not self.jira_bind_ids: + raise exceptions.UserError(_("No JIRA user could be found")) + + def link_with_jira(self, backends=None, raise_if_mismatch=False): + jira_user_model = self.env["jira.res.users"] + if backends is None: + backends = self.env["jira.backend"].search([]) + + # TODO: try to split this method, though it's quite hard since all its variables + # are used somewhere in the method itself... + result = {} + for backend in backends: + bknd_result = {"success": [], "error": []} + result[backend] = bknd_result + with backend.work_on("jira.res.users") as work: + binder = work.component(usage="binder") + adapter = work.component(usage="backend.adapter") + for user in self: + # Already linked to the current user + if binder.to_external(user, wrap=True): + continue + + # Retrieve users in Jira + jira_users = [] + for resolve_by in backend.get_user_resolution_order(): + resolve_by_key = resolve_by + resolve_by_value = user[resolve_by] + jira_users = adapter.search(fragment=resolve_by_value or "") + if jira_users: + break + + # No user => nothing to do + if not jira_users: + continue + + # Multiple users => raise an error or log the info + elif len(jira_users) > 1: + if raise_if_mismatch: + raise exceptions.UserError( + _( + 'Several users found with "%(resolve_by_key)s"' + 'set to "%(resolve_by_value)s". ' + "Set it manually.", + resolve_by_key=resolve_by_key, + resolve_by_value=resolve_by_value, + ) + ) + bknd_result["error"].append( + { + "key": resolve_by_key, + "value": resolve_by_value, + "error": "multiple_found", + "detail": [x.accountId for x in jira_users], + } + ) + continue + + # Exactly 1 user in Jira => extract it, bind it to the current user + external_id = jira_users[0].accountId + domain = [ + ("backend_id", "=", backend.id), + ("external_id", "=", external_id), + ("odoo_id", "!=", user.id), + ] + existing = jira_user_model.with_context(active=False).search(domain) + + # Jira user is already linked to an Odoo user => log the info + if existing: + bknd_result["error"].append( + { + "key": resolve_by_key, + "value": resolve_by_value, + "error": "other_user_bound", + "detail": f"linked with {existing.login}", + } + ) + continue + + # Create binding + vals = {"backend_id": backend.id, "odoo_id": user.id} + try: + binding = jira_user_model.create(vals) + binder.bind(external_id, binding) + except Exception as err: + # Log errors + bknd_result["error"].append( + { + "key": "login", + "value": user.login, + "error": "binding_error", + "detail": str(err), + } + ) + else: + # Log success + bknd_result["success"].append( + { + "key": "login", + "value": user.login, + "detail": external_id, + } + ) + return result diff --git a/connector_jira/pyproject.toml b/connector_jira/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/connector_jira/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/connector_jira/readme/CONTRIBUTORS.md b/connector_jira/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..b58fa3525 --- /dev/null +++ b/connector_jira/readme/CONTRIBUTORS.md @@ -0,0 +1,20 @@ +- [Camptocamp](https://camptocamp.com): + + - Damien Crier + - Thierry Ducrest + - Tonow-c2c + - Simone Orsi \<\> + - Timon Tschanz \<\> + - jcoux \<\> + - Patrick Tombez \<\> + - Guewen Baconnier \<\> + - Akim Juillerat \<\> + - Alexandre Fayolle \<\> + +- [CorporateHub](https://corporatehub.eu/) + + - Alexey Pelykh \<\> + +- [Trobz](https://trobz.com): + + > - Son Ho \<\> diff --git a/connector_jira/readme/DESCRIPTION.md b/connector_jira/readme/DESCRIPTION.md new file mode 100644 index 000000000..c85b702c1 --- /dev/null +++ b/connector_jira/readme/DESCRIPTION.md @@ -0,0 +1,2 @@ +This module adds Jira synchronization feature. It works with Jira Cloud +by behaving as an Atlassian Connect App. diff --git a/connector_jira/readme/INSTALL.md b/connector_jira/readme/INSTALL.md new file mode 100644 index 000000000..28dd701fe --- /dev/null +++ b/connector_jira/readme/INSTALL.md @@ -0,0 +1,84 @@ +You need the following Python packages: + +- requests +- jira +- oauthlib +- requests-oauthlib +- requests-toolbelt +- PyJWT +- cryptography +- atlassian-jwt + +Once the addon is installed, follow these steps: + +## Job Queue + +In `odoo.conf`, configure similarly: + +``` +[queue_job] +channels = root:1,root.connector_jira.import:2 +``` + +## Backend + +1. Open the menu Connectors \> Jira \> Backends +2. Create a new Jira Backend + - Put the name you want + - You can also select the company where records will be created and + the default project template used when Odoo will create the + projects in Jira + - Save +3. Make note of the value of the App Descriptor URL (important: make + sure that the system parameter web.base.url is set properly. For + local development you will want to use ngrok to make your computer + reachable over https from Jira Cloud). + +## Installing the backend as a Jira App + +In case this gets outdated, refer to + + +1. Login on marketplace.atlassian.com (possibly create an account) +2. On the top right corner, the icon with your avatar leads to a menu + -\> select the Publish an App entry +3. On the Publish a new app screen: + - select a Vendor (normally your company) + - upload your app: select Provide a URL to your artifact + - click on the Enter URL button + - paste the App Descriptor URL in the pop-up and click on the Done + button + - the Name field should get populated from the name of your backend + - Compatible application: select Jira + - build number: can be kept as is +4. Click on the Save as private button (!) Important: do not click the + "Next: Make public" button. That flow would allow anyone on Jira + Cloud to install your backend. +5. On the next screen, you can go to the "Private Listings" page, and + click on the "Create a token" button: this token can be used to + install the app on your Jira instance. + +## Installing the Jira App on your Jira Cloud instance + +1. Connect to your Jira instance with an account with Admin access +2. In the Apps menu, select Manage your apps +3. In the Apps screen, click on the Settings link which is under the + User-installed apps list +4. In the settings screen, check the Enable private listings box, and + click on Apply +5. Refresh the Apps page: you should see an Upload app link: click on + it +6. On the Upload app dialog, paste the token URL you received in the + previous procedure, and click on Upload + +## Configuration of the Backend + +Going back to Odoo, the backend should now be in the Running state, with +some information filled in, such as the Jira URI. + +**Configure the Epic Link** + +If you use Epics, you need to click on "Configure Epic Link", Odoo will +search the name of the custom field used for the Epic Link. + +Congratulations, you're done! diff --git a/connector_jira/readme/ROADMAP.md b/connector_jira/readme/ROADMAP.md new file mode 100644 index 000000000..82bb8bb53 --- /dev/null +++ b/connector_jira/readme/ROADMAP.md @@ -0,0 +1,25 @@ +- If an odoo user has no linked employee, worklogs will still be + imported but with no employee. + +**Allowing several bindings per project** + +The design evolved to allow more than one Jira binding per project in +Odoo. This conveniently allows to fetch tasks and worklogs for many +projects in Jira, which will be tracked in only one project in Odoo. + +In order to push data to Jira, we have to apply restrictions on these +"multi-bindings" projects, as we cannot know to which binding data must +be pushed: + +- Not more than one project (can be zero) can have a "Sync Action" set + to "Export to JIRA". As this configuration pushes the name and key of + the project to Jira, we cannot push it to more than one project. +- If we implement push of tasks to Jira, we'll have to add a way to + restrict or choose to which project we push the task, this is not + supported yet (for instance, add a Boolean "export tasks" on the + project binding, or explicitly select the target binding on the task) +- Now that the webhooks are authenticated, use the values sent by the + webhooks rather than querying them back +- We now can have multiple backends, registering multiple webhooks. If + we want to use this in practice, testing must be done and probably + some things will need fixing. diff --git a/connector_jira/readme/USAGE.md b/connector_jira/readme/USAGE.md new file mode 100644 index 000000000..adc6ae464 --- /dev/null +++ b/connector_jira/readme/USAGE.md @@ -0,0 +1,53 @@ +The tasks and worklogs are always imported from JIRA to Odoo, there is +no synchronization in the other direction. + +## Initial synchronizations + +You can already select the "Imports" tab in the Backend and click on +"Link users" and "Import issue types". The users will be matched either +by login or by email. + +## Create and export a project + +Projects can be created in Odoo and exported to Jira. You can then +create a project, and use the action "Link with JIRA" and use the +"Export to JIRA" action. + +When you choose to export a project to JIRA, if you change the name or +the key of the project, the new values will be pushed to JIRA. + +## Link a project with JIRA + +If you already have a project on JIRA or prefer to create it first on +JIRA, you can link an Odoo project. Use the "Link with JIRA" action on +the project and select the "Link with JIRA" action. + +This action establish the link, then changes of the name or the key on +either side are not pushed. + +## Issue Types on Projects + +When you link a project, you have to select which issue types are +synchronized. Only tasks of the selected types will be created in Odoo. + +If a JIRA worklog is added to a type of issue that is not synchronized, +will attach to the closest task following these rules: + +- if a subtask, find the parent task +- if no parent task, find the epic task (only if it is on the same + project) +- if no epic, attach to the project without being linked to a task + +## Change synchronization configuration on a project + +If you want to change the configuration of a project, such as which +issue types are synchronized, you can open the "Connector" tab in the +project settings and edit the "binding" with the backend. + +## Synchronize tasks and worklogs + +If the webhooks are active, as soon as they are created in Jira they +should appear in Odoo. If they are not active, you can open the Jira +Backend and run the synchronizations manually, or activate the Scheduled +Actions to run the batch imports. It is important to select the issue +types so don't miss this step (need improvement). diff --git a/connector_jira/reports/__init__.py b/connector_jira/reports/__init__.py new file mode 100644 index 000000000..2e30f148d --- /dev/null +++ b/connector_jira/reports/__init__.py @@ -0,0 +1 @@ +from . import timesheet_analysis_report diff --git a/connector_jira/reports/timesheet_analysis_report.py b/connector_jira/reports/timesheet_analysis_report.py new file mode 100644 index 000000000..4db660628 --- /dev/null +++ b/connector_jira/reports/timesheet_analysis_report.py @@ -0,0 +1,22 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import api, fields, models + + +class TimesheetsAnalysisReport(models.Model): + _inherit = "timesheets.analysis.report" + + jira_issue_key = fields.Char(readonly=True) + jira_epic_issue_key = fields.Char(readonly=True) + jira_issue_type_id = fields.Many2one("jira.issue.type", readonly=True) + + @api.model + def _select(self): + return ( + super()._select() + + """, + A.jira_issue_key AS jira_issue_key, + A.jira_epic_issue_key AS jira_epic_issue_key, + A.jira_issue_type_id AS jira_issue_type_id + """ + ) diff --git a/connector_jira/security/ir.model.access.csv b/connector_jira/security/ir.model.access.csv new file mode 100644 index 000000000..7bf75e7fb --- /dev/null +++ b/connector_jira/security/ir.model.access.csv @@ -0,0 +1,24 @@ +"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink" +"access_jira_backend","jira_backend connector manager","model_jira_backend","connector.group_connector_manager",1,1,1,1 +"access_jira_backend_user","jira_backend user","model_jira_backend","base.group_user",1,0,0,0 +"access_jira_backend_timestamp_user","jira_backend_timestamp user","model_jira_backend_timestamp","base.group_user",1,0,0,0 +"access_jira_backend_timestamp","jira_backend_timestamp connector manager","model_jira_backend_timestamp","connector.group_connector_manager",1,1,1,1 +"access_jira_project_project","jira_project_project connector manager","model_jira_project_project","connector.group_connector_manager",1,1,1,1 +"access_jira_project_project_manager","jira_project_project manager","model_jira_project_project","base.group_user",1,0,0,0 +"access_jira_project_project_user","jira_project_project user","model_jira_project_project","project.group_project_manager",1,1,1,1 +"access_jira_project_task","jira_project_task connector manager","model_jira_project_task","connector.group_connector_manager",1,1,1,1 +"access_jira_project_task_user","jira_project_task user","model_jira_project_task","base.group_user",1,0,0,0 +"access_jira_project_task_manager","jira_project_task manager","model_jira_project_task","project.group_project_manager",1,1,1,1 +"access_jira_res_users","jira_res_users connector manager","model_jira_res_users","connector.group_connector_manager",1,1,1,1 +"access_jira_res_users_user","jira_res_users user","model_jira_res_users","base.group_user",1,0,0,0 +"access_jira_res_users_manager","jira_res_users manager","model_jira_res_users","project.group_project_manager",1,1,1,1 +"access_jira_issue_type","jira_issue_type connector manager","model_jira_issue_type","connector.group_connector_manager",1,1,1,1 +"access_jira_issue_type_user","jira_issue_type user","model_jira_issue_type","base.group_user",1,0,0,0 +"access_jira_account_analytic_line","jira_account_analytic_line connector manager","model_jira_account_analytic_line","connector.group_connector_manager",1,1,1,1 +"access_jira_account_analytic_line_manager","jira_account_analytic_line manager","model_jira_account_analytic_line","base.group_user",1,0,0,0 +"access_project_link_jira_manager","access_project_link_jira manager","connector_jira.model_project_link_jira","project.group_project_manager",1,1,1,1 +"access_project_link_jira","access_project_link_jira user",connector_jira.model_project_link_jira,base.group_user,1,0,0,0 +"access_task_link_jira_task_manager","access_task_link_jira manager","connector_jira.model_task_link_jira","connector.group_connector_manager",1,1,1,1 +"access_task_link_jira","access_task_link_jira user","connector_jira.model_task_link_jira",base.group_user,1,0,0,0 +"access_jira_account_analytic_line_import_manager","access_jira_account_analytic_line_import","connector_jira.model_jira_account_analytic_line_import","connector.group_connector_manager",1,1,1,1 +"access_jira_account_analytic_line_import","access_jira_account_analytic_line_import","connector_jira.model_jira_account_analytic_line_import","base.group_user",1,0,0,0 diff --git a/connector_jira/static/description/icon.png b/connector_jira/static/description/icon.png new file mode 100644 index 000000000..3a0328b51 Binary files /dev/null and b/connector_jira/static/description/icon.png differ diff --git a/connector_jira/static/description/index.html b/connector_jira/static/description/index.html new file mode 100644 index 000000000..d0adc69fa --- /dev/null +++ b/connector_jira/static/description/index.html @@ -0,0 +1,640 @@ + + + + + +JIRA Connector + + + +
+

JIRA Connector

+ + +

Beta License: AGPL-3 OCA/connector-jira Translate me on Weblate Try me on Runboat

+

This module adds Jira synchronization feature. It works with Jira Cloud +by behaving as an Atlassian Connect App.

+

Table of contents

+ +
+

Installation

+

You need the following Python packages:

+
    +
  • requests
  • +
  • jira
  • +
  • oauthlib
  • +
  • requests-oauthlib
  • +
  • requests-toolbelt
  • +
  • PyJWT
  • +
  • cryptography
  • +
  • atlassian-jwt
  • +
+

Once the addon is installed, follow these steps:

+
+

Job Queue

+

In odoo.conf, configure similarly:

+
+[queue_job]
+channels = root:1,root.connector_jira.import:2
+
+
+
+

Backend

+
    +
  1. Open the menu Connectors > Jira > Backends
  2. +
  3. Create a new Jira Backend
      +
    • Put the name you want
    • +
    • You can also select the company where records will be created and +the default project template used when Odoo will create the +projects in Jira
    • +
    • Save
    • +
    +
  4. +
  5. Make note of the value of the App Descriptor URL (important: make +sure that the system parameter web.base.url is set properly. For +local development you will want to use ngrok to make your computer +reachable over https from Jira Cloud).
  6. +
+
+
+

Installing the backend as a Jira App

+

In case this gets outdated, refer to +https://developer.atlassian.com/platform/marketplace/listing-connect-apps/#list-a-connect-app

+
    +
  1. Login on marketplace.atlassian.com (possibly create an account)
  2. +
  3. On the top right corner, the icon with your avatar leads to a menu -> +select the Publish an App entry
  4. +
  5. On the Publish a new app screen:
      +
    • select a Vendor (normally your company)
    • +
    • upload your app: select Provide a URL to your artifact
    • +
    • click on the Enter URL button
    • +
    • paste the App Descriptor URL in the pop-up and click on the Done +button
    • +
    • the Name field should get populated from the name of your backend
    • +
    • Compatible application: select Jira
    • +
    • build number: can be kept as is
    • +
    +
  6. +
  7. Click on the Save as private button (!) Important: do not click the +“Next: Make public” button. That flow would allow anyone on Jira +Cloud to install your backend.
  8. +
  9. On the next screen, you can go to the “Private Listings” page, and +click on the “Create a token” button: this token can be used to +install the app on your Jira instance.
  10. +
+
+
+

Installing the Jira App on your Jira Cloud instance

+
    +
  1. Connect to your Jira instance with an account with Admin access
  2. +
  3. In the Apps menu, select Manage your apps
  4. +
  5. In the Apps screen, click on the Settings link which is under the +User-installed apps list
  6. +
  7. In the settings screen, check the Enable private listings box, and +click on Apply
  8. +
  9. Refresh the Apps page: you should see an Upload app link: click on it
  10. +
  11. On the Upload app dialog, paste the token URL you received in the +previous procedure, and click on Upload
  12. +
+
+
+

Configuration of the Backend

+

Going back to Odoo, the backend should now be in the Running state, with +some information filled in, such as the Jira URI.

+

Configure the Epic Link

+

If you use Epics, you need to click on “Configure Epic Link”, Odoo will +search the name of the custom field used for the Epic Link.

+

Congratulations, you’re done!

+
+
+
+

Usage

+

The tasks and worklogs are always imported from JIRA to Odoo, there is +no synchronization in the other direction.

+
+

Initial synchronizations

+

You can already select the “Imports” tab in the Backend and click on +“Link users” and “Import issue types”. The users will be matched either +by login or by email.

+
+
+

Create and export a project

+

Projects can be created in Odoo and exported to Jira. You can then +create a project, and use the action “Link with JIRA” and use the +“Export to JIRA” action.

+

When you choose to export a project to JIRA, if you change the name or +the key of the project, the new values will be pushed to JIRA.

+
+ +
+

Issue Types on Projects

+

When you link a project, you have to select which issue types are +synchronized. Only tasks of the selected types will be created in Odoo.

+

If a JIRA worklog is added to a type of issue that is not synchronized, +will attach to the closest task following these rules:

+
    +
  • if a subtask, find the parent task
  • +
  • if no parent task, find the epic task (only if it is on the same +project)
  • +
  • if no epic, attach to the project without being linked to a task
  • +
+
+
+

Change synchronization configuration on a project

+

If you want to change the configuration of a project, such as which +issue types are synchronized, you can open the “Connector” tab in the +project settings and edit the “binding” with the backend.

+
+
+

Synchronize tasks and worklogs

+

If the webhooks are active, as soon as they are created in Jira they +should appear in Odoo. If they are not active, you can open the Jira +Backend and run the synchronizations manually, or activate the Scheduled +Actions to run the batch imports. It is important to select the issue +types so don’t miss this step (need improvement).

+
+
+
+

Known issues / Roadmap

+
    +
  • If an odoo user has no linked employee, worklogs will still be +imported but with no employee.
  • +
+

Allowing several bindings per project

+

The design evolved to allow more than one Jira binding per project in +Odoo. This conveniently allows to fetch tasks and worklogs for many +projects in Jira, which will be tracked in only one project in Odoo.

+

In order to push data to Jira, we have to apply restrictions on these +“multi-bindings” projects, as we cannot know to which binding data must +be pushed:

+
    +
  • Not more than one project (can be zero) can have a “Sync Action” set +to “Export to JIRA”. As this configuration pushes the name and key of +the project to Jira, we cannot push it to more than one project.
  • +
  • If we implement push of tasks to Jira, we’ll have to add a way to +restrict or choose to which project we push the task, this is not +supported yet (for instance, add a Boolean “export tasks” on the +project binding, or explicitly select the target binding on the task)
  • +
  • Now that the webhooks are authenticated, use the values sent by the +webhooks rather than querying them back
  • +
  • We now can have multiple backends, registering multiple webhooks. If +we want to use this in practice, testing must be done and probably +some things will need fixing.
  • +
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Camptocamp
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/connector-jira project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/connector_jira/tests/__init__.py b/connector_jira/tests/__init__.py new file mode 100644 index 000000000..597e66244 --- /dev/null +++ b/connector_jira/tests/__init__.py @@ -0,0 +1,10 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +# from . import test_auth +# from . import test_backend +# from . import test_delete_analytic_line +# from . import test_import_issue_type +# from . import test_import_task +# from . import test_import_analytic_line +# from . import test_batch_timestamp_import +# from . import test_batch_timestamp_delete diff --git a/connector_jira/tests/common.py b/connector_jira/tests/common.py new file mode 100644 index 000000000..9d9bdbec7 --- /dev/null +++ b/connector_jira/tests/common.py @@ -0,0 +1,189 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +"""Tests for connector_jira + +# Running tests + +Tests are run normally, you can either execute them with odoo's +``--test-enable`` option or using `pytest-odoo +`_ + +The requests to Jira are recorded and simulated using `vcrpy +`_. Which means +the tests can be executed without having a Jira service running. + +However, in order to write new tests or modify the existing ones, you may need +to have a service running to record the Webservice interactions. + +# Recording new tests with vcr.py + +First you will need a running Jira Cloud instance. You can set one up for +development purpose see +https://developer.atlassian.com/cloud/jira/platform/getting-started-with-connect/ +for instructions. + +Once you have access that jira instance, you will need to install the jira +connector modules locally, create a jira backend and register it as a +Connect App on the Atlassian marketplace, and then to install that app in your +Jira Cloud instance. The process is explained in the README.rst of the +connector_jira addon. + +From now on, you can write your tests using the ``recorder.use_cassette`` +decorator or context manager. If you are changing existing tests, you might +need either to manually edit the cassette files in "tests/fixtures/cassettes" +or record the tests again (in such case, IDs may change). + +""" + +import logging +import os +from contextlib import contextmanager +from os.path import dirname, join +from unittest import mock + +from vcr import VCR + +from odoo.addons.component.tests.common import TransactionComponentCase + +_logger = logging.getLogger(__name__) + +jira_test_url = os.environ.get("JIRA_TEST_URL", "http://jira:8080") +# jira_test_token_access = os.environ.get("JIRA_TEST_TOKEN_ACCESS", "") +# jira_test_token_secret = os.environ.get("JIRA_TEST_TOKEN_SECRET", "") + + +def get_recorder(base_path=None, **kw): + base_path = base_path or dirname(__file__) + defaults = dict( + record_mode="once", + cassette_library_dir=join(base_path, "fixtures/cassettes"), + path_transformer=VCR.ensure_suffix(".yaml"), + match_on=["method", "path", "query"], + filter_headers=["Authorization"], + decode_compressed_response=True, + ) + defaults.update(kw) + return VCR(**defaults) + + +recorder = get_recorder() + + +class JiraTransactionComponentCase(TransactionComponentCase): + """Base class for tests with Jira""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + context = cls.env.context.copy() + context["tracking_disable"] = True + cls.env = cls.env(context=context) + + cls.backend_record = cls.env.ref("connector_jira.jira_backend_demo") + cls.backend_record.write( + { + "epic_link_field_name": "customfield_10101", + } + ) + + # Warning: if you add new tests or change the cassettes + # you might need to change these values + # to make issue types match + _base_issue_types = [ + ("Task", "10002"), + ("Sub-task", "10003"), + ("Story", "10001"), + ("Bug", "10004"), + ("Epic", "10000"), + ] + + @classmethod + def _link_user(cls, user, jira_login): + return ( + cls.env["jira.res.users"] + .with_context(no_connector_export=True) + .create( + { + "odoo_id": user.id, + "backend_id": cls.backend_record.id, + "external_id": jira_login, + } + ) + ) + + @classmethod + def _create_issue_type_bindings(cls): + for name, jira_id in cls._base_issue_types: + cls.env["jira.issue.type"].create( + { + "name": name, + "backend_id": cls.backend_record.id, + "external_id": jira_id, + } + ) + + @classmethod + def _create_project_binding( + cls, project, sync_action="link", issue_types=None, **extra + ): + values = { + "odoo_id": project.id, + "jira_key": "TEST", + "sync_action": sync_action, + "backend_id": cls.backend_record.id, + # dummy id + "external_id": "9999", + } + if issue_types: + values.update({"sync_issue_type_ids": [(6, 0, issue_types.ids)]}) + values.update(**extra) + return ( + cls.env["jira.project.project"] + .with_context(no_connector_export=True) + .create(values) + ) + + @classmethod + def _create_task_binding(cls, task, **extra): + values = { + "odoo_id": task.id, + "jira_key": "TEST", + "backend_id": cls.backend_record.id, + # dummy id + "external_id": "9999", + } + values.update(**extra) + return ( + cls.env["jira.project.task"] + .with_context(no_connector_export=True) + .create(values) + ) + + @classmethod + def _create_analytic_line_binding(cls, line, **extra): + values = { + "odoo_id": line.id, + "backend_id": cls.backend_record.id, + # dummy id + "external_id": "9999", + } + values.update(**extra) + return ( + cls.env["jira.account.analytic.line"] + .with_context(no_connector_export=True) + .create(values) + ) + + @contextmanager + def mock_with_delay(self): + with mock.patch( + "odoo.addons.queue_job.models.base.DelayableRecordset", + name="DelayableRecordset", + spec=True, + ) as delayable_cls: + # prepare the mocks + delayable = mock.MagicMock(name="DelayableBinding") + delayable_cls.return_value = delayable + yield delayable_cls, delayable diff --git a/connector_jira/tests/test_auth.py b/connector_jira/tests/test_auth.py new file mode 100644 index 000000000..629addaa2 --- /dev/null +++ b/connector_jira/tests/test_auth.py @@ -0,0 +1,63 @@ +# Copyright 2019 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +import unittest + +from odoo import exceptions + +from .common import JiraTransactionComponentCase, recorder + + +class TestAuth(JiraTransactionComponentCase): + @recorder.use_cassette + def test_auth_oauth(self): + backend = self.backend_record + # reset access + backend.write({"access_token": False, "access_secret": False}) + self.assertEqual(backend.state, "authenticate") + auth_wizard = self.env["jira.backend.auth"].create({"backend_id": backend.id}) + + self.assertEqual(auth_wizard.state, "leg_1") + # Here, the wizard generates a consumer key and + # a private/public key. User has to copy them in Jira. + self.assertTrue(auth_wizard.consumer_key) + self.assertTrue(auth_wizard.public_key) + self.assertTrue(auth_wizard.backend_id.private_key) + # Once copied in Jira, they have to confirm "Leg 1" + auth_wizard.do_oauth_leg_1() + + # during leg 1, Jira validates the keys and returns + # an authentication URL that the user has to open + # (will need to login). + # For this test, I manually validated the auth URI, + # as we record the requests, the recorded interactions + # will work for the test. + self.assertTrue(auth_wizard.auth_uri) + self.assertEqual(auth_wizard.state, "leg_2") + # returned by Jira: + self.assertTrue(auth_wizard.request_token) + self.assertTrue(auth_wizard.request_secret) + + auth_wizard.do_oauth_leg_3() + + # of course, these are dummy tokens recorded for the test + # on a demo Jira + self.assertEqual(backend.access_token, "o7XglNpQdA3pwzGZw9r6WA2X2XZcjaaI") + self.assertEqual(backend.access_secret, "pwq9Qzc7iax0JtoQqZdLvPlv4ReECZGh") + self.assertEqual(auth_wizard.state, "done") + + @recorder.use_cassette + def test_auth_check_connection(self): + with self.assertRaisesRegex(exceptions.UserError, "Connection successful"): + self.backend_record.check_connection() + + @unittest.skip( + "This test is slow because the jira lib retries " + "401 errors with an exponential backoff." + ) + @recorder.use_cassette + def test_auth_check_connection_failure(self): + # reset access + self.backend_record.write({"access_token": False, "access_secret": False}) + with self.assertRaisesRegex(exceptions.UserError, "Failed to connect"): + self.backend_record.check_connection() diff --git a/connector_jira/tests/test_backend.py b/connector_jira/tests/test_backend.py new file mode 100644 index 000000000..6fedceded --- /dev/null +++ b/connector_jira/tests/test_backend.py @@ -0,0 +1,121 @@ +# Copyright 2019 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from datetime import datetime + +from odoo import fields + +from ..fields import MilliDatetime +from .common import JiraTransactionComponentCase + + +class TestBackendTimestamp(JiraTransactionComponentCase): + def _create_timestamp(self): + return self.env["jira.backend.timestamp"].create( + { + "backend_id": self.backend_record.id, + "from_date_field": "import_project_task_from_date", + "last_timestamp": datetime.fromtimestamp(0), + "component_usage": "timestamp.batch.importer", + } + ) + + def test_millidatetime_field(self): + ts = self._create_timestamp() + self.assertEqual(ts.last_timestamp, datetime(1970, 1, 1, 0, 0)) + new_date = datetime(2019, 4, 8, 10, 30, 59, 375000) + ts._update_timestamp(new_date) + # keeps milliseconds precision and return datetime instance + self.assertEqual(ts.last_timestamp, new_date) + + def test_unix_timestamp_helpers(self): + as_datetime = datetime(2019, 4, 8, 10, 30, 59, 375000) + as_timestamp = MilliDatetime.to_timestamp(as_datetime) + self.assertEqual(as_timestamp, 1554719459375) + dt2 = MilliDatetime.from_timestamp(as_timestamp) + self.assertEqual(dt2, as_datetime) + + def test_from_to_string(self): + self.assertEqual( + MilliDatetime.to_string(datetime(2019, 4, 8, 10, 30, 59, 375000)), + "2019-04-08 10:30:59.375000", + ) + self.assertEqual( + MilliDatetime.to_datetime("2019-04-08 10:30:59.375000"), + datetime(2019, 4, 8, 10, 30, 59, 375000), + ) + + +class TestBackend(JiraTransactionComponentCase): + def _test_import_date_computed_field(self, timestamp_field_name, component_usage): + backend = self.backend_record + self.assertFalse(backend[timestamp_field_name]) + # We don't have milliseconds on the jira.backend fields, + # they are shown on the webclient. We lose precision when + # users fill dates, but we mostly want to keep precision + # for dates given by Jira. + test_date = "2019-04-08 10:30:59" + backend.write({timestamp_field_name: test_date}) + jira_ts = self.env["jira.backend.timestamp"].search( + [ + ("backend_id", "=", backend.id), + ("from_date_field", "=", timestamp_field_name), + ("component_usage", "=", component_usage), + ] + ) + # The field on jira.backend is a standard odoo Datetime field so works + # with strings (in 11.0). But the field on jira.backend.timestamp is a + # "custom" MilliDatetime field which works with datetime instances. + self.assertEqual(jira_ts.last_timestamp, fields.Datetime.to_datetime(test_date)) + + def test_import_project_task_from_date(self): + self._test_import_date_computed_field( + "import_project_task_from_date", "timestamp.batch.importer" + ) + + def test_import_analytic_line_from_date(self): + self._test_import_date_computed_field( + "import_analytic_line_from_date", "timestamp.batch.importer" + ) + + def test_delete_analytic_line_from_date(self): + self._test_import_date_computed_field( + "delete_analytic_line_from_date", "timestamp.batch.deleter" + ) + + def test_run_background_from_date(self): + test_date = "2019-04-08 10:30:59" + self.backend_record.write({"import_project_task_from_date": test_date}) + jira_ts = self.env["jira.backend.timestamp"].search( + [ + ("backend_id", "=", self.backend_record.id), + ("from_date_field", "=", "import_project_task_from_date"), + ("component_usage", "=", "timestamp.batch.importer"), + ] + ) + + with self.mock_with_delay() as (delayable_cls, delayable): + self.backend_record._run_background_from_date( + "jira.project.task", + "import_project_task_from_date", + "timestamp.batch.importer", + ) + delayable_cls.assert_called_once() + # arguments passed in 'with_delay()' + delay_args, delay_kwargs = delayable_cls.call_args + self.assertEqual( + (self.env["jira.project.task"],), + delay_args, + ) + + # job method called after 'with_delay()' + delayable.run_batch_timestamp.assert_called_once() + delay_args, __ = delayable.run_batch_timestamp.call_args + + self.assertEqual( + ( + self.backend_record, + jira_ts, + ), + delay_args, + ) diff --git a/connector_jira/tests/test_batch_timestamp_delete.py b/connector_jira/tests/test_batch_timestamp_delete.py new file mode 100644 index 000000000..0efa041de --- /dev/null +++ b/connector_jira/tests/test_batch_timestamp_delete.py @@ -0,0 +1,80 @@ +# Copyright 2019 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from datetime import datetime + +from freezegun import freeze_time + +from .common import JiraTransactionComponentCase, recorder + + +class TestBatchTimestampDelete(JiraTransactionComponentCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._create_issue_type_bindings() + cls.epic_issue_type = cls.env["jira.issue.type"].search([("name", "=", "Epic")]) + cls.project = cls.env["project.project"].create({"name": "Jira Project"}) + + # note: when you are recording tests with VCR, Jira + # will reject any call when you pretend to have a time too + # different from now(). So adjust this date be rougly equal + # to now(). + @freeze_time("2024-03-15 14:52:10.325") + @recorder.use_cassette + def test_delete_batch_timestamp_analytic_line(self): + """Import all deleted worklogs since last timestamp""" + self._create_project_binding( + self.project, issue_types=self.epic_issue_type, external_id="10000" + ) + jira_ts = self.env["jira.backend.timestamp"]._timestamp_for_field( + self.backend_record, + "delete_analytic_line_from_date", + "timestamp.batch.deleter", + ) + since_date = "2019-04-05 00:00:00.000" + jira_ts._update_timestamp(since_date) + + with self.mock_with_delay() as (delayable_cls, delayable): + self.env["jira.account.analytic.line"].run_batch_timestamp( + self.backend_record, + jira_ts, + ) + # Jira WS returns 2 worklog ids to delete here, we expect to have 2 + # jobs delayed + number_of_worklogs = 2 + self.assertEqual(delayable_cls.call_count, number_of_worklogs) + # arguments passed in 'with_delay()' + delay_args, delay_kwargs = delayable_cls.call_args + self.assertEqual( + (self.env["jira.account.analytic.line"],), + delay_args, + ) + + # Job method called after 'with_delay()'. + self.assertEqual(delayable.delete_record.call_count, number_of_worklogs) + delay_args = delayable.delete_record.call_args_list + expected = [ + # backend, issue_id + ( + (self.backend_record, 10103), + {"only_binding": False, "set_inactive": False}, + ), + ( + (self.backend_record, 10104), + {"only_binding": False, "set_inactive": False}, + ), + ] + self.assertEqual( + sorted((args, kwargs) for args, kwargs in delay_args), + sorted(expected), + ) + # the lines would actually be deleted by the 'delete_record' jobs + + # For worklogs, Jira returns the youngest timestamp of the worklogs + # returned by the "deleted since" method, so the next time we look for + # deleted worklogs, we can reuse this timestamp as "since". It has a + # milliseconds precision + self.assertEqual( + jira_ts.last_timestamp, datetime(2019, 4, 8, 13, 51, 37, 945000) + ) diff --git a/connector_jira/tests/test_batch_timestamp_import.py b/connector_jira/tests/test_batch_timestamp_import.py new file mode 100644 index 000000000..241851db5 --- /dev/null +++ b/connector_jira/tests/test_batch_timestamp_import.py @@ -0,0 +1,125 @@ +# Copyright 2019 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from datetime import datetime + +from freezegun import freeze_time + +from .common import JiraTransactionComponentCase, recorder + + +class TestBatchTimestampImport(JiraTransactionComponentCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._create_issue_type_bindings() + cls.epic_issue_type = cls.env["jira.issue.type"].search([("name", "=", "Epic")]) + cls.project = cls.env["project.project"].create({"name": "Jira Project"}) + + # note: when you are recording tests with VCR, Jira + # will reject any call when you pretend to have a time too + # different from now(). So adjust this date be rougly equal + # to now(). + @freeze_time("2019-04-08 12:51:36.595") + @recorder.use_cassette + def test_import_batch_timestamp_tasks(self): + """Import all tasks since last timestamp""" + self._create_project_binding( + self.project, issue_types=self.epic_issue_type, external_id="10000" + ) + jira_ts = self.env["jira.backend.timestamp"]._timestamp_for_field( + self.backend_record, + "import_project_task_from_date", + "timestamp.batch.importer", + ) + since_date = "2019-04-05 00:00:00.000" + jira_ts._update_timestamp(since_date) + with self.mock_with_delay() as (delayable_cls, delayable): + self.env["jira.project.task"].run_batch_timestamp( + self.backend_record, + jira_ts, + ) + # Jira WS returns 4 task ids here, we expect to have 4 + # jobs delayed + number_of_tasks = 4 + self.assertEqual(delayable_cls.call_count, number_of_tasks) + # arguments passed in 'with_delay()' + delay_args, delay_kwargs = delayable_cls.call_args + self.assertEqual( + (self.env["jira.project.task"],), + delay_args, + ) + + # Job method called after 'with_delay()'. + self.assertEqual(delayable.import_record.call_count, number_of_tasks) + delay_args = delayable.import_record.call_args_list + expected = [ + ((self.backend_record, "10103"), {"force": False, "record": None}), + ((self.backend_record, "10102"), {"force": False, "record": None}), + ((self.backend_record, "10101"), {"force": False, "record": None}), + ((self.backend_record, "10100"), {"force": False, "record": None}), + ] + self.assertEqual( + sorted((args, kwargs) for args, kwargs in delay_args), + sorted(expected), + ) + + # For tasks, Jira does not return an "until" time, so we the timestamp + # to start the next import is now (the freezed time for this test) + # minus 5 minutes, the overlap being because the JQL query has a minute + # precision and does not return immediately the modified tasks + self.assertEqual( + jira_ts.last_timestamp, datetime(2019, 4, 8, 12, 46, 36, 595000) + ) + + @freeze_time("2019-04-08 13:22:07.325") + @recorder.use_cassette + def test_import_batch_timestamp_analytic_line(self): + """Import all worklogs since last timestamp""" + self._create_project_binding( + self.project, issue_types=self.epic_issue_type, external_id="10000" + ) + jira_ts = self.env["jira.backend.timestamp"]._timestamp_for_field( + self.backend_record, + "import_analytic_line_from_date", + "timestamp.batch.importer", + ) + since_date = "2019-04-05 00:00:00.000" + jira_ts._update_timestamp(since_date) + with self.mock_with_delay() as (delayable_cls, delayable): + self.env["jira.account.analytic.line"].run_batch_timestamp( + self.backend_record, + jira_ts, + ) + # Jira WS returns 3 worklog ids here, we expect to have 3 + # jobs delayed + number_of_worklogs = 3 + self.assertEqual(delayable_cls.call_count, number_of_worklogs) + # arguments passed in 'with_delay()' + delay_args, delay_kwargs = delayable_cls.call_args + self.assertEqual( + (self.env["jira.account.analytic.line"],), + delay_args, + ) + + # Job method called after 'with_delay()'. + self.assertEqual(delayable.import_record.call_count, number_of_worklogs) + delay_args = delayable.import_record.call_args_list + expected = [ + # backend, issue_id, worklog_id + ((self.backend_record, "10102", "10100"), {"force": False}), + ((self.backend_record, "10100", "10102"), {"force": False}), + ((self.backend_record, "10101", "10101"), {"force": False}), + ] + self.assertEqual( + sorted((args, kwargs) for args, kwargs in delay_args), + sorted(expected), + ) + + # For worklogs, Jira returns the youngest timestamp of + # the worklogs returned by the "updated since" method, so the + # next import, we can reuse this timestamp as "since". + # It has a milliseconds precision + self.assertEqual( + jira_ts.last_timestamp, datetime(2019, 4, 8, 12, 32, 19, 311000) + ) diff --git a/connector_jira/tests/test_delete_analytic_line.py b/connector_jira/tests/test_delete_analytic_line.py new file mode 100644 index 000000000..ec2b3167f --- /dev/null +++ b/connector_jira/tests/test_delete_analytic_line.py @@ -0,0 +1,43 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from .common import JiraTransactionComponentCase, recorder + + +class TestBatchTimestampDelete(JiraTransactionComponentCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._create_issue_type_bindings() + cls.epic_issue_type = cls.env["jira.issue.type"].search([("name", "=", "Epic")]) + cls.project = cls.env["project.project"].create({"name": "Jira Project"}) + + @recorder.use_cassette + def test_delete_analytic_line(self): + """Import all deleted worklogs since last timestamp""" + # Simulate a worklogs we would already have imported and is + # deleted in Jira. First create the binding as it would be + # in Odoo. + line = self.env["account.analytic.line"].create( + { + "project_id": self.project.id, + "amount": 30.0, + "date": "2019-04-08", + "name": "A worklog that will be deleted", + "user_id": self.env.user.id, + } + ) + self._create_project_binding( + self.project, issue_types=self.epic_issue_type, external_id="10000" + ) + binding = self._create_analytic_line_binding( + line, + jira_issue_id="10101", + external_id="10103", + ) + # This is usually delayed as a job from either a controller, + # either the component with usage "timestamp.batch.deleter" + self.env["jira.account.analytic.line"].delete_record( + self.backend_record, "10103", only_binding=False, set_inactive=False + ) + self.assertFalse(binding.exists()) + self.assertFalse(line.exists()) diff --git a/connector_jira/tests/test_import_analytic_line.py b/connector_jira/tests/test_import_analytic_line.py new file mode 100644 index 000000000..fc09a988f --- /dev/null +++ b/connector_jira/tests/test_import_analytic_line.py @@ -0,0 +1,231 @@ +# Copyright 2019 Camptocamp SA +# Copyright 2019 Brainbean Apps (https://brainbeanapps.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from datetime import date, timedelta + +from .common import JiraTransactionComponentCase, recorder + + +class TestImportWorklogBase(JiraTransactionComponentCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._create_issue_type_bindings() + cls.project = cls.env["project.project"].create({"name": "Jira Project"}) + cls.task = cls.env["project.task"].create( + {"name": "My task", "project_id": cls.project.id} + ) + cls.project_binding = cls._create_project_binding( + cls.project, + issue_types=cls.env["jira.issue.type"].search([]), + external_id="10000", + ) + cls.epic_issue_type = cls.env["jira.issue.type"].search([("name", "=", "Epic")]) + # Warning: if you add new tests or change the cassettes + # you might need to change the username + cls._link_user(cls.env.user, "gbaconnier") + cls.env["hr.employee"].create( + {"name": "Test Employee", "user_id": cls.env.user.id} + ) + cls.fallback_project = cls.env["project.project"].create( + {"name": "Test Fallback Project"} + ) + cls.backend_record.write( + {"worklog_fallback_project_id": cls.fallback_project.id} + ) + + def _setup_import_worklog(self, task, jira_issue_id, jira_worklog_id=None): + self._create_task_binding(task, external_id=jira_issue_id) + jira_worklog_id = jira_worklog_id or jira_issue_id + self.env["jira.account.analytic.line"].import_record( + self.backend_record, jira_issue_id, jira_worklog_id + ) + binding = self.env["jira.account.analytic.line"].search( + [ + ("backend_id", "=", self.backend_record.id), + ("external_id", "=", jira_worklog_id), + ] + ) + self.assertEqual(len(binding), 1) + return binding + + +class TestImportAccountAnalyticLine(TestImportWorklogBase): + @recorder.use_cassette + def test_import_worklog(self): + """Import a worklog on a task existing in Odoo on activated project""" + self._test_import_worklog( + expected_project=self.project, expected_task=self.task + ) + + @recorder.use_cassette("test_import_worklog.yaml") + def test_import_worklog_deactivated_project(self): + """ + Import a worklog on a task existing in Odoo on deactivated project + """ + self.project.write({"active": False}) + self._test_import_worklog( + expected_project=self.fallback_project, expected_task=False + ) + + @recorder.use_cassette("test_import_worklog.yaml") + def test_import_worklog_deactivated_task(self): + """ + Import a worklog on a task existing in Odoo on deactivated task + """ + self.task.write({"active": False}) + self._test_import_worklog(expected_project=self.project, expected_task=False) + + def _test_import_worklog(self, expected_project, expected_task): + jira_worklog_id = jira_issue_id = "10000" + binding = self._setup_import_worklog(self.task, jira_issue_id, jira_worklog_id) + self.assertRecordValues( + binding, + [ + { + "account_id": expected_project.analytic_account_id.id, + "backend_id": self.backend_record.id, + "date": date(2019, 4, 4), + "employee_id": self.env.user.employee_ids[0].id, + "external_id": jira_worklog_id, + "jira_epic_issue_key": False, + "jira_issue_id": jira_issue_id, + "jira_issue_key": "TEST-1", + "jira_issue_type_id": self.epic_issue_type.id, + "name": "write tests", + "project_id": expected_project.id, + "tag_ids": [], + "task_id": expected_task.id if expected_task else False, + "unit_amount": 1.0, + "user_id": self.env.user.id, + } + ], + ) + + def test_reimport_worklog(self): + jira_issue_id = "10000" + jira_worklog_id = "10000" + with recorder.use_cassette("test_import_worklog.yaml"): + binding = self._setup_import_worklog( + self.task, + jira_issue_id, + jira_worklog_id, + ) + write_date = binding.write_date - timedelta(seconds=1) + binding.write({"write_date": write_date}) + with recorder.use_cassette("test_import_worklog.yaml"): + binding.force_reimport() + self.assertEqual(binding.write_date, write_date) + + @recorder.use_cassette("test_import_worklog.yaml") + def test_import_worklog_naive(self): + jira_worklog_id = jira_issue_id = "10000" + self.backend_record.worklog_date_timezone_mode = "naive" + binding = self._setup_import_worklog(self.task, jira_issue_id, jira_worklog_id) + self.assertRecordValues( + binding, + [ + { + "account_id": self.project.analytic_account_id.id, + "backend_id": self.backend_record.id, + "date": date(2019, 4, 4), + "employee_id": self.env.user.employee_ids[0].id, + "external_id": jira_worklog_id, + "jira_epic_issue_key": False, + "jira_issue_id": jira_issue_id, + "jira_issue_key": "TEST-1", + "jira_issue_type_id": self.epic_issue_type.id, + "name": "write tests", + "project_id": self.project.id, + "tag_ids": [], + "task_id": self.task.id if self.task else False, + "unit_amount": 1.0, + "user_id": self.env.user.id, + } + ], + ) + + @recorder.use_cassette("test_import_worklog.yaml") + def test_import_worklog_user(self): + jira_worklog_id = jira_issue_id = "10000" + self.backend_record.worklog_date_timezone_mode = "user" + binding = self._setup_import_worklog(self.task, jira_issue_id, jira_worklog_id) + self.assertRecordValues( + binding, + [ + { + "account_id": self.project.analytic_account_id.id, + "backend_id": self.backend_record.id, + "date": date(2019, 4, 3), + "employee_id": self.env.user.employee_ids[0].id, + "external_id": jira_worklog_id, + "jira_epic_issue_key": False, + "jira_issue_id": jira_issue_id, + "jira_issue_key": "TEST-1", + "jira_issue_type_id": self.epic_issue_type.id, + "name": "write tests", + "project_id": self.project.id, + "tag_ids": [], + "task_id": self.task.id if self.task else False, + "unit_amount": 1.0, + "user_id": self.env.user.id, + } + ], + ) + + @recorder.use_cassette("test_import_worklog.yaml") + def test_import_worklog_specific(self): + jira_worklog_id = jira_issue_id = "10000" + self.backend_record.worklog_date_timezone_mode = "specific" + self.backend_record.worklog_date_timezone = "Europe/London" + binding = self._setup_import_worklog(self.task, jira_issue_id, jira_worklog_id) + self.assertRecordValues( + binding, + [ + { + "account_id": self.project.analytic_account_id.id, + "backend_id": self.backend_record.id, + "date": date(2019, 4, 3), + "employee_id": self.env.user.employee_ids[0].id, + "external_id": jira_worklog_id, + "jira_epic_issue_key": False, + "jira_issue_id": jira_issue_id, + "jira_issue_key": "TEST-1", + "jira_issue_type_id": self.epic_issue_type.id, + "name": "write tests", + "project_id": self.project.id, + "tag_ids": [], + "task_id": self.task.id if self.task else False, + "unit_amount": 1.0, + "user_id": self.env.user.id, + } + ], + ) + + def _test_import_worklog_epic_link_on_epic(self, expected_project, expected_task): + jira_worklog_id = jira_issue_id = "10000" + self.backend_record.epic_link_on_epic = True + binding = self._setup_import_worklog(self.task, jira_issue_id, jira_worklog_id) + self.assertRecordValues( + binding, + [ + { + "account_id": expected_project.analytic_account_id.id, + "backend_id": self.backend_record.id, + "date": "2019-04-04", + "employee_id": self.env.user.employee_ids[0].id, + "external_id": jira_worklog_id, + "jira_epic_issue_key": "TEST-1", + "jira_issue_id": jira_issue_id, + "jira_issue_key": "TEST-1", + "jira_issue_type_id": self.epic_issue_type.id, + "name": "write tests", + "project_id": expected_project.id, + "tag_ids": [], + "task_id": expected_task.id if expected_task else False, + "unit_amount": 1.0, + "user_id": self.env.user.id, + } + ], + ) diff --git a/connector_jira/tests/test_import_issue_type.py b/connector_jira/tests/test_import_issue_type.py new file mode 100644 index 000000000..7f021f81d --- /dev/null +++ b/connector_jira/tests/test_import_issue_type.py @@ -0,0 +1,31 @@ +# Copyright 2019 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from .common import JiraTransactionComponentCase, recorder + + +class TestImportIssueType(JiraTransactionComponentCase): + @recorder.use_cassette + def test_import_issue_type_batch(self): + issue_types = self.env["jira.issue.type"].search([]) + self.assertEqual(len(issue_types), 0) + self.env["jira.issue.type"].import_batch( + self.backend_record, + ) + issue_types = self.env["jira.issue.type"].search([]) + self.assertEqual(len(issue_types), 5) + + def test_import_is_issue_type_sync(self): + self._create_issue_type_bindings() + + epic_issue_type = self.env["jira.issue.type"].search([("name", "=", "Epic")]) + task_issue_type = self.env["jira.issue.type"].search([("name", "=", "Task")]) + + project = self.env["project.project"].create({"name": "Jira Project"}) + project_binding = self._create_project_binding( + project, + issue_types=epic_issue_type, + ) + + self.assertTrue(epic_issue_type.is_sync_for_project(project_binding)) + self.assertFalse(task_issue_type.is_sync_for_project(project_binding)) diff --git a/connector_jira/tests/test_import_task.py b/connector_jira/tests/test_import_task.py new file mode 100644 index 000000000..b4bc5ab68 --- /dev/null +++ b/connector_jira/tests/test_import_task.py @@ -0,0 +1,143 @@ +# Copyright 2019 Camptocamp SA +# Copyright 2019 Brainbean Apps (https://brainbeanapps.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import exceptions + +from .common import JiraTransactionComponentCase, recorder + + +class TestImportTask(JiraTransactionComponentCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._create_issue_type_bindings() + cls.epic_issue_type = cls.env["jira.issue.type"].search([("name", "=", "Epic")]) + cls.task_issue_type = cls.env["jira.issue.type"].search([("name", "=", "Task")]) + cls.subtask_issue_type = cls.env["jira.issue.type"].search( + [("name", "=", "Sub-task")] + ) + cls.project = cls.env["project.project"].create({"name": "Jira Project"}) + cls.env["project.task.type"].create( + {"name": "To Do", "sequence": 1, "project_ids": [(4, cls.project.id)]} + ) + + @recorder.use_cassette + def test_import_task_epic(self): + """ + Import Epic task where we sync this type issue on activated project + """ + self._test_import_task_epic(expected_active=True) + + @recorder.use_cassette("test_import_task_epic.yaml") + def test_import_task_epic_deactivated_project(self): + """ + Import Epic task where we sync this type issue on deactivated project + """ + self.project.write({"active": False}) + self._test_import_task_epic(expected_active=False) + + def _test_import_task_epic(self, expected_active): + self._create_project_binding( + self.project, issue_types=self.epic_issue_type, external_id="10000" + ) + jira_issue_id = "10000" + self.env["jira.project.task"].import_record(self.backend_record, jira_issue_id) + binding = ( + self.env["jira.project.task"] + .with_context(active_test=False) + .search( + [ + ("backend_id", "=", self.backend_record.id), + ("external_id", "=", jira_issue_id), + ] + ) + ) + self.assertEqual(len(binding), 1) + + self.assertEqual(binding.jira_key, "TEST-1") + self.assertEqual(binding.jira_issue_type_id, self.epic_issue_type) + self.assertFalse(binding.jira_epic_link_id) + self.assertFalse(binding.jira_parent_id) + + self.assertEqual(binding.odoo_id.active, expected_active) + self.assertEqual(binding.odoo_id.stage_id.name, "To Do") + + with self.assertRaises(exceptions.UserError): + binding.odoo_id.active = not expected_active + + with self.assertRaises(exceptions.UserError): + binding.odoo_id.unlink() + + @recorder.use_cassette + def test_import_task_type_not_synced(self): + """Import ask where we do not sync this type issue: ignored""" + self._create_project_binding(self.project, external_id="10000") + jira_issue_id = "10000" + self.env["jira.project.task"].import_record(self.backend_record, jira_issue_id) + binding = self.env["jira.project.task"].search( + [ + ("backend_id", "=", self.backend_record.id), + ("external_id", "=", jira_issue_id), + ] + ) + self.assertEqual(len(binding), 0) + + @recorder.use_cassette + def test_import_task_parents(self): + """Import Epic/Task/Subtask recursively""" + self._create_project_binding( + self.project, + issue_types=( + self.epic_issue_type + self.task_issue_type + self.subtask_issue_type + ), + external_id="10000", + ) + + projects_by_name = self.env["project.project"].name_search("TEST") + self.assertEqual(len(projects_by_name), 1) + + jira_subtask_issue_id = "10002" + self.env["jira.project.task"].import_record( + self.backend_record, jira_subtask_issue_id + ) + + binding = self.env["jira.project.task"].search( + [ + ("backend_id", "=", self.backend_record.id), + ("external_id", "=", jira_subtask_issue_id), + ] + ) + self.assertEqual(len(binding), 1) + + self.assertEqual(binding.jira_key, "TEST-3") + self.assertEqual(binding.name, "Subtask1") + self.assertEqual(binding.jira_issue_type_id, self.subtask_issue_type) + self.assertTrue(binding.jira_parent_id) + + task_binding = binding.jira_parent_id + self.assertEqual(task_binding.jira_key, "TEST-2") + self.assertEqual(task_binding.name, "Task1") + self.assertEqual(task_binding.jira_issue_type_id, self.task_issue_type) + self.assertTrue(task_binding.jira_epic_link_id) + self.assertAlmostEqual(task_binding.odoo_id.allocated_hours, 4.5) + + epic_binding = task_binding.jira_epic_link_id + self.assertEqual(epic_binding.jira_key, "TEST-1") + self.assertEqual(epic_binding.name, "Epic1") + self.assertEqual(epic_binding.jira_issue_type_id, self.epic_issue_type) + + tasks_by_name = self.env["project.task"].name_search("TEST-3") + self.assertEqual(len(tasks_by_name), 1) + self.assertEqual(tasks_by_name[0][0], binding.odoo_id.id) + + def test_task_restrict_create(self): + self._create_project_binding( + self.project, + issue_types=self.env["jira.issue.type"].search([]), + external_id="10000", + ) + with self.assertRaises(exceptions.UserError): + self.env["project.task"].create( + {"name": "My task", "project_id": self.project.id} + ) diff --git a/connector_jira/views/account_analytic_line.xml b/connector_jira/views/account_analytic_line.xml new file mode 100644 index 000000000..f795595d9 --- /dev/null +++ b/connector_jira/views/account_analytic_line.xml @@ -0,0 +1,82 @@ + + + + account.analytic.line.form + account.analytic.line + + + + + + + + + + + + + account.analytic.line.tree + account.analytic.line + + + + + + + + + + + + + account.analytic.line.search + account.analytic.line + + + + + + + + + + + + + + diff --git a/connector_jira/views/jira_backend.xml b/connector_jira/views/jira_backend.xml new file mode 100644 index 000000000..b06b8327a --- /dev/null +++ b/connector_jira/views/jira_backend.xml @@ -0,0 +1,318 @@ + + + + + + jira.backend.form + jira.backend + +
+
+
+ +