From d29b6b1a2eea6fb9256584839e876851fc55f6b8 Mon Sep 17 00:00:00 2001
From: Alexey Pelykh
Date: Fri, 8 Nov 2019 07:25:15 +0000
Subject: [PATCH] [IMP] connector_jira: worklog/timesheet date timezone
---
connector_jira/__manifest__.py | 2 +-
connector_jira/components/mapper.py | 22 ++---
.../models/account_analytic_line/importer.py | 17 +++-
connector_jira/models/jira_backend/common.py | 28 +++++++
.../cassettes/test_import_worklog.yaml | 6 +-
.../tests/test_import_analytic_line.py | 82 +++++++++++++++++++
connector_jira/views/jira_backend_views.xml | 17 ++++
7 files changed, 157 insertions(+), 17 deletions(-)
diff --git a/connector_jira/__manifest__.py b/connector_jira/__manifest__.py
index deee0b69..aadeecba 100644
--- a/connector_jira/__manifest__.py
+++ b/connector_jira/__manifest__.py
@@ -2,7 +2,7 @@
{
'name': 'JIRA Connector',
- 'version': '12.0.1.2.0',
+ 'version': '12.0.1.3.0',
'author': 'Camptocamp,Odoo Community Association (OCA)',
'license': 'AGPL-3',
'category': 'Connector',
diff --git a/connector_jira/components/mapper.py b/connector_jira/components/mapper.py
index 1b43358d..d1da680c 100644
--- a/connector_jira/components/mapper.py
+++ b/connector_jira/components/mapper.py
@@ -74,33 +74,33 @@ def modifier(self, record, to_attr):
return modifier
-def iso8601_to_local_date(isodate):
- """ Returns the local date from an iso8601 date
+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 local 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.
"""
- local_date = isodate[:10]
- return datetime.strptime(local_date, '%Y-%m-%d').date()
+ naive_date = isodate[:10]
+ return datetime.strptime(naive_date, '%Y-%m-%d').date()
-def iso8601_local_date(field):
+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 local date from an iso8601 datetime.
+ Returns the naive date from an iso8601 datetime.
- Keep only the date, when we want to keep only the local 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.
Usage::
- direct = [(iso8601_local_date('name'), 'name')]
+ direct = [(iso8601_naive_date('name'), 'name')]
:param field: name of the source field in the record
@@ -110,8 +110,8 @@ def modifier(self, record, to_attr):
value = record.get(field)
if not value:
return False
- utc_date = iso8601_to_local_date(value)
- return fields.Date.to_string(utc_date)
+ naive_date = iso8601_to_naive_date(value)
+ return fields.Date.to_string(naive_date)
return modifier
diff --git a/connector_jira/models/account_analytic_line/importer.py b/connector_jira/models/account_analytic_line/importer.py
index 4700d63d..3b37e83c 100644
--- a/connector_jira/models/account_analytic_line/importer.py
+++ b/connector_jira/models/account_analytic_line/importer.py
@@ -3,13 +3,14 @@
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
import logging
+from pytz import timezone, utc
from odoo import _
from odoo.addons.connector.exception import MappingError
from odoo.addons.connector.components.mapper import mapping, only_create
from odoo.addons.component.core import Component
from ...components.mapper import (
- iso8601_local_date, iso8601_to_utc_datetime, whenempty
+ iso8601_to_naive_date, iso8601_to_utc_datetime, whenempty
)
from ...fields import MilliDatetime
@@ -23,7 +24,6 @@ class AnalyticLineMapper(Component):
direct = [
(whenempty('comment', _('missing description')), 'name'),
- (iso8601_local_date('started'), 'date'),
]
@only_create
@@ -49,6 +49,19 @@ def issue(self, record):
refs['jira_epic_issue_key'] = issue['fields'][epic_field_name]
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)
+ return {'date': started.astimezone(tz).date()}
+
@mapping
def duration(self, record):
spent = float(record['timeSpentSeconds'])
diff --git a/connector_jira/models/jira_backend/common.py b/connector_jira/models/jira_backend/common.py
index f51799ed..117d1fd0 100644
--- a/connector_jira/models/jira_backend/common.py
+++ b/connector_jira/models/jira_backend/common.py
@@ -6,6 +6,7 @@
import binascii
import logging
import json
+import pytz
import urllib.parse
from contextlib import contextmanager, closing
@@ -89,6 +90,26 @@ def _default_consumer_key(self):
"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'),
@@ -428,6 +449,13 @@ def onchange_odoo_webhook_base_url(self):
'the Webhooks again.')
return {'warning': {'title': _('Warning'), 'message': msg}}
+ @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':
+ continue
+ jira_backend.worklog_date_timezone = False
+
@api.multi
def delete_webhooks(self):
self.ensure_one()
diff --git a/connector_jira/tests/fixtures/cassettes/test_import_worklog.yaml b/connector_jira/tests/fixtures/cassettes/test_import_worklog.yaml
index 47b9a78b..fdd2459a 100644
--- a/connector_jira/tests/fixtures/cassettes/test_import_worklog.yaml
+++ b/connector_jira/tests/fixtures/cassettes/test_import_worklog.yaml
@@ -311,7 +311,7 @@ interactions:
body: {string: '{"expand":"renderedFields,names,schema,operations,editmeta,changelog,versionedRepresentations","id":"10000","self":"http://jira:8080/rest/api/2/issue/10000","key":"TEST-1","fields":{"issuetype":{"self":"http://jira:8080/rest/api/2/issuetype/10000","id":"10000","description":"Created
by Jira Software - do not edit or delete. Issue type for a big user story
that needs to be broken down.","iconUrl":"http://jira:8080/images/icons/issuetypes/epic.svg","name":"Epic","subtask":false},"timespent":3600,"project":{"self":"http://jira:8080/rest/api/2/project/10000","id":"10000","key":"TEST","name":"TEST","avatarUrls":{"48x48":"http://jira:8080/secure/projectavatar?avatarId=10324","24x24":"http://jira:8080/secure/projectavatar?size=small&avatarId=10324","16x16":"http://jira:8080/secure/projectavatar?size=xsmall&avatarId=10324","32x32":"http://jira:8080/secure/projectavatar?size=medium&avatarId=10324"}},"fixVersions":[],"aggregatetimespent":3600,"resolution":null,"customfield_10104":"ghx-label-1","customfield_10105":"0|hzzzzz:","customfield_10106":null,"resolutiondate":null,"workratio":-1,"lastViewed":"2019-04-08T13:51:13.798+0000","watches":{"self":"http://jira:8080/rest/api/2/issue/TEST-1/watchers","watchCount":1,"isWatching":true},"created":"2019-04-04T09:31:27.779+0000","priority":{"self":"http://jira:8080/rest/api/2/priority/3","iconUrl":"http://jira:8080/images/icons/priorities/medium.svg","name":"Medium","id":"3"},"customfield_10100":null,"customfield_10101":null,"customfield_10102":{"self":"http://jira:8080/rest/api/2/customFieldOption/10000","value":"To
- Do","id":"10000"},"labels":[],"customfield_10103":"Epic1","timeestimate":0,"aggregatetimeoriginalestimate":null,"versions":[],"issuelinks":[],"assignee":null,"updated":"2019-04-04T11:01:47.600+0000","status":{"self":"http://jira:8080/rest/api/2/status/10000","description":"","iconUrl":"http://jira:8080/","name":"To
+ Do","id":"10000"},"labels":[],"customfield_10103":"Epic1","timeestimate":0,"aggregatetimeoriginalestimate":null,"versions":[],"issuelinks":[],"assignee":null,"updated":"2019-04-04T09:01:47.600+0000","status":{"self":"http://jira:8080/rest/api/2/status/10000","description":"","iconUrl":"http://jira:8080/","name":"To
Do","id":"10000","statusCategory":{"self":"http://jira:8080/rest/api/2/statuscategory/2","id":2,"key":"new","colorName":"blue-gray","name":"To
Do"}},"components":[],"timeoriginalestimate":null,"description":null,"timetracking":{"remainingEstimate":"0m","timeSpent":"1h","remainingEstimateSeconds":0,"timeSpentSeconds":3600},"customfield_10203":null,"customfield_10204":null,"customfield_10205":null,"customfield_10206":null,"attachment":[],"customfield_10207":null,"aggregatetimeestimate":0,"customfield_10208":null,"summary":"Epic1","creator":{"self":"http://jira:8080/rest/api/2/user?username=gbaconnier","name":"gbaconnier","key":"gbaconnier","emailAddress":"guewen.baconnier@camptocamp.com","avatarUrls":{"48x48":"https://www.gravatar.com/avatar/ad252192c3f73885676b7d2e850ad13c?d=mm&s=48","24x24":"https://www.gravatar.com/avatar/ad252192c3f73885676b7d2e850ad13c?d=mm&s=24","16x16":"https://www.gravatar.com/avatar/ad252192c3f73885676b7d2e850ad13c?d=mm&s=16","32x32":"https://www.gravatar.com/avatar/ad252192c3f73885676b7d2e850ad13c?d=mm&s=32"},"displayName":"Guewen
Baconnier","active":true,"timeZone":"GMT"},"subtasks":[],"reporter":{"self":"http://jira:8080/rest/api/2/user?username=gbaconnier","name":"gbaconnier","key":"gbaconnier","emailAddress":"guewen.baconnier@camptocamp.com","avatarUrls":{"48x48":"https://www.gravatar.com/avatar/ad252192c3f73885676b7d2e850ad13c?d=mm&s=48","24x24":"https://www.gravatar.com/avatar/ad252192c3f73885676b7d2e850ad13c?d=mm&s=24","16x16":"https://www.gravatar.com/avatar/ad252192c3f73885676b7d2e850ad13c?d=mm&s=16","32x32":"https://www.gravatar.com/avatar/ad252192c3f73885676b7d2e850ad13c?d=mm&s=32"},"displayName":"Guewen
@@ -324,7 +324,7 @@ interactions:
branch=com.atlassian.jira.plugin.devstatus.rest.SummaryItemBean@7af21e5f[overall=com.atlassian.jira.plugin.devstatus.summary.beans.BranchOverallBean@7a591452[count=0,lastUpdated=,lastUpdatedTimestamp=],byInstanceType={}]},errors=[],configErrors=[]],
devSummaryJson={\"cachedValue\":{\"errors\":[],\"configErrors\":[],\"summary\":{\"pullrequest\":{\"overall\":{\"count\":0,\"lastUpdated\":null,\"stateCount\":0,\"state\":\"OPEN\",\"details\":{\"openCount\":0,\"mergedCount\":0,\"declinedCount\":0,\"total\":0},\"open\":true},\"byInstanceType\":{}},\"build\":{\"overall\":{\"count\":0,\"lastUpdated\":null,\"failedBuildCount\":0,\"successfulBuildCount\":0,\"unknownBuildCount\":0},\"byInstanceType\":{}},\"review\":{\"overall\":{\"count\":0,\"lastUpdated\":null,\"stateCount\":0,\"state\":null,\"dueDate\":null,\"overDue\":false,\"completed\":false},\"byInstanceType\":{}},\"deployment-environment\":{\"overall\":{\"count\":0,\"lastUpdated\":null,\"topEnvironments\":[],\"showProjects\":false,\"successfulCount\":0},\"byInstanceType\":{}},\"repository\":{\"overall\":{\"count\":0,\"lastUpdated\":null},\"byInstanceType\":{}},\"branch\":{\"overall\":{\"count\":0,\"lastUpdated\":null},\"byInstanceType\":{}}}},\"isStale\":false}}","aggregateprogress":{"progress":3600,"total":3600,"percent":100},"customfield_10200":null,"customfield_10201":[],"customfield_10202":null,"environment":null,"duedate":null,"progress":{"progress":3600,"total":3600,"percent":100},"comment":{"comments":[],"maxResults":0,"total":0,"startAt":0},"votes":{"self":"http://jira:8080/rest/api/2/issue/TEST-1/votes","votes":0,"hasVoted":false},"worklog":{"startAt":0,"maxResults":20,"total":1,"worklogs":[{"self":"http://jira:8080/rest/api/2/issue/10000/worklog/10000","author":{"self":"http://jira:8080/rest/api/2/user?username=gbaconnier","name":"gbaconnier","key":"gbaconnier","emailAddress":"guewen.baconnier@camptocamp.com","avatarUrls":{"48x48":"https://www.gravatar.com/avatar/ad252192c3f73885676b7d2e850ad13c?d=mm&s=48","24x24":"https://www.gravatar.com/avatar/ad252192c3f73885676b7d2e850ad13c?d=mm&s=24","16x16":"https://www.gravatar.com/avatar/ad252192c3f73885676b7d2e850ad13c?d=mm&s=16","32x32":"https://www.gravatar.com/avatar/ad252192c3f73885676b7d2e850ad13c?d=mm&s=32"},"displayName":"Guewen
Baconnier","active":true,"timeZone":"GMT"},"updateAuthor":{"self":"http://jira:8080/rest/api/2/user?username=gbaconnier","name":"gbaconnier","key":"gbaconnier","emailAddress":"guewen.baconnier@camptocamp.com","avatarUrls":{"48x48":"https://www.gravatar.com/avatar/ad252192c3f73885676b7d2e850ad13c?d=mm&s=48","24x24":"https://www.gravatar.com/avatar/ad252192c3f73885676b7d2e850ad13c?d=mm&s=24","16x16":"https://www.gravatar.com/avatar/ad252192c3f73885676b7d2e850ad13c?d=mm&s=16","32x32":"https://www.gravatar.com/avatar/ad252192c3f73885676b7d2e850ad13c?d=mm&s=32"},"displayName":"Guewen
- Baconnier","active":true,"timeZone":"GMT"},"comment":"write tests","created":"2019-04-04T11:01:47.597+0000","updated":"2019-04-04T11:01:47.597+0000","started":"2019-04-04T11:01:00.000+0000","timeSpent":"1h","timeSpentSeconds":3600,"id":"10000","issueId":"10000"}]}},"renderedFields":{"issuetype":null,"timespent":"1
+ Baconnier","active":true,"timeZone":"GMT"},"comment":"write tests","created":"2019-04-04T04:01:47.597+0800","updated":"2019-04-04T04:01:47.597+0800","started":"2019-04-04T11:01:00.000+0000","timeSpent":"1h","timeSpentSeconds":3600,"id":"10000","issueId":"10000"}]}},"renderedFields":{"issuetype":null,"timespent":"1
hour","project":null,"fixVersions":null,"aggregatetimespent":"1 hour","resolution":null,"customfield_10104":"ghx-label-1","customfield_10105":null,"customfield_10106":null,"resolutiondate":null,"workratio":null,"lastViewed":"4
days ago 1:51 PM","watches":null,"created":"04/Apr/19 9:31 AM","priority":null,"customfield_10100":null,"customfield_10101":null,"customfield_10102":null,"labels":null,"customfield_10103":"Epic1","timeestimate":"0
minutes","aggregatetimeoriginalestimate":null,"versions":null,"issuelinks":null,"assignee":null,"updated":"04/Apr/19
@@ -664,7 +664,7 @@ interactions:
response:
body: {string: '{"self":"http://jira:8080/rest/api/2/issue/10000/worklog/10000","author":{"self":"http://jira:8080/rest/api/2/user?username=gbaconnier","name":"gbaconnier","key":"gbaconnier","emailAddress":"guewen.baconnier@camptocamp.com","avatarUrls":{"48x48":"https://www.gravatar.com/avatar/ad252192c3f73885676b7d2e850ad13c?d=mm&s=48","24x24":"https://www.gravatar.com/avatar/ad252192c3f73885676b7d2e850ad13c?d=mm&s=24","16x16":"https://www.gravatar.com/avatar/ad252192c3f73885676b7d2e850ad13c?d=mm&s=16","32x32":"https://www.gravatar.com/avatar/ad252192c3f73885676b7d2e850ad13c?d=mm&s=32"},"displayName":"Guewen
Baconnier","active":true,"timeZone":"GMT"},"updateAuthor":{"self":"http://jira:8080/rest/api/2/user?username=gbaconnier","name":"gbaconnier","key":"gbaconnier","emailAddress":"guewen.baconnier@camptocamp.com","avatarUrls":{"48x48":"https://www.gravatar.com/avatar/ad252192c3f73885676b7d2e850ad13c?d=mm&s=48","24x24":"https://www.gravatar.com/avatar/ad252192c3f73885676b7d2e850ad13c?d=mm&s=24","16x16":"https://www.gravatar.com/avatar/ad252192c3f73885676b7d2e850ad13c?d=mm&s=16","32x32":"https://www.gravatar.com/avatar/ad252192c3f73885676b7d2e850ad13c?d=mm&s=32"},"displayName":"Guewen
- Baconnier","active":true,"timeZone":"GMT"},"comment":"write tests","created":"2019-04-04T11:01:47.597+0000","updated":"2019-04-04T11:01:47.597+0000","started":"2019-04-04T11:01:00.000+0000","timeSpent":"1h","timeSpentSeconds":3600,"id":"10000","issueId":"10000"}'}
+ Baconnier","active":true,"timeZone":"GMT"},"comment":"write tests","created":"2019-04-04T04:01:47.597+0800","updated":"2019-04-04T04:01:47.597+0800","started":"2019-04-04T04:01:47.597+0800","timeSpent":"1h","timeSpentSeconds":3600,"id":"10000","issueId":"10000"}'}
headers:
Cache-Control: ['no-cache, no-store, no-transform']
Content-Security-Policy: [frame-ancestors 'self']
diff --git a/connector_jira/tests/test_import_analytic_line.py b/connector_jira/tests/test_import_analytic_line.py
index ec7c24a6..71a0ca60 100644
--- a/connector_jira/tests/test_import_analytic_line.py
+++ b/connector_jira/tests/test_import_analytic_line.py
@@ -108,3 +108,85 @@ def _test_import_worklog(self, expected_project, expected_task):
'user_id': self.env.user.id,
}]
)
+
+ @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,
+ }]
+ )
diff --git a/connector_jira/views/jira_backend_views.xml b/connector_jira/views/jira_backend_views.xml
index 615e3582..9805cb18 100644
--- a/connector_jira/views/jira_backend_views.xml
+++ b/connector_jira/views/jira_backend_views.xml
@@ -198,6 +198,23 @@
the field will be empty.
+
+
+
+
+
+
+
+ Configure worklog fields
+
+