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 +

+