diff --git a/rattail_harvest/db/alembic/versions/6bc1cb21d920_add_harvest_user_person.py b/rattail_harvest/db/alembic/versions/6bc1cb21d920_add_harvest_user_person.py
new file mode 100644
index 0000000..31cca85
--- /dev/null
+++ b/rattail_harvest/db/alembic/versions/6bc1cb21d920_add_harvest_user_person.py
@@ -0,0 +1,35 @@
+# -*- coding: utf-8; -*-
+"""add harvest_user.person
+
+Revision ID: 6bc1cb21d920
+Revises: 5505c0e60d28
+Create Date: 2022-01-30 16:49:32.271745
+
+"""
+
+# revision identifiers, used by Alembic.
+revision = '6bc1cb21d920'
+down_revision = '5505c0e60d28'
+branch_labels = None
+depends_on = None
+
+from alembic import op
+import sqlalchemy as sa
+import rattail.db.types
+
+
+
+def upgrade():
+
+ # harvest_user
+ op.add_column('harvest_user', sa.Column('person_uuid', sa.String(length=32), nullable=True))
+ op.create_foreign_key('harvest_user_fk_person', 'harvest_user', 'person', ['person_uuid'], ['uuid'])
+ op.add_column('harvest_user_version', sa.Column('person_uuid', sa.String(length=32), autoincrement=False, nullable=True))
+
+
+def downgrade():
+
+ # harvest_user
+ op.drop_column('harvest_user_version', 'person_uuid')
+ op.drop_constraint('harvest_user_fk_person', 'harvest_user', type_='foreignkey')
+ op.drop_column('harvest_user', 'person_uuid')
diff --git a/rattail_harvest/db/model/harvest.py b/rattail_harvest/db/model/harvest.py
index 4d0e80e..7ac7506 100644
--- a/rattail_harvest/db/model/harvest.py
+++ b/rattail_harvest/db/model/harvest.py
@@ -39,6 +39,7 @@ class HarvestUser(model.Base):
"""
__tablename__ = 'harvest_user'
__table_args__ = (
+ sa.ForeignKeyConstraint(['person_uuid'], ['person.uuid'], name='harvest_user_fk_person'),
sa.UniqueConstraint('id', name='harvest_user_uq_id'),
)
__versioned__ = {}
@@ -90,6 +91,19 @@ class HarvestUser(model.Base):
updated_at = sa.Column(sa.DateTime(), nullable=True)
+ person_uuid = sa.Column(sa.String(length=32), nullable=True)
+ person = orm.relationship(
+ model.Person,
+ doc="""
+ Reference to the person associated with this Harvest user.
+ """,
+ backref=orm.backref(
+ 'harvest_users',
+ doc="""
+ List of all Harvest user accounts for the person.
+ """)
+ )
+
def __str__(self):
return normalize_full_name(self.first_name, self.last_name)
diff --git a/rattail_harvest/harvest/importing/__init__.py b/rattail_harvest/harvest/importing/__init__.py
new file mode 100644
index 0000000..e0b3e09
--- /dev/null
+++ b/rattail_harvest/harvest/importing/__init__.py
@@ -0,0 +1,27 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+# Rattail -- Retail Software Framework
+# Copyright © 2010-2022 Lance Edgar
+#
+# This file is part of Rattail.
+#
+# Rattail is free software: you can redistribute it and/or modify it under the
+# terms of the GNU General Public License as published by the Free Software
+# Foundation, either version 3 of the License, or (at your option) any later
+# version.
+#
+# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+# details.
+#
+# You should have received a copy of the GNU General Public License along with
+# Rattail. If not, see .
+#
+################################################################################
+"""
+Exporting data to Harvest
+"""
+
+from . import model
diff --git a/rattail_harvest/harvest/importing/model.py b/rattail_harvest/harvest/importing/model.py
new file mode 100644
index 0000000..47e85de
--- /dev/null
+++ b/rattail_harvest/harvest/importing/model.py
@@ -0,0 +1,120 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+# Rattail -- Retail Software Framework
+# Copyright © 2010-2022 Lance Edgar
+#
+# This file is part of Rattail.
+#
+# Rattail is free software: you can redistribute it and/or modify it under the
+# terms of the GNU General Public License as published by the Free Software
+# Foundation, either version 3 of the License, or (at your option) any later
+# version.
+#
+# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+# details.
+#
+# You should have received a copy of the GNU General Public License along with
+# Rattail. If not, see .
+#
+################################################################################
+"""
+Harvest model importers
+"""
+
+from rattail import importing
+from rattail_harvest.harvest.webapi import make_harvest_webapi
+
+
+class ToHarvest(importing.Importer):
+
+ def setup(self):
+ super(ToHarvest, self).setup()
+ self.webapi = make_harvest_webapi(self.config)
+
+
+class TimeEntryImporter(ToHarvest):
+ """
+ Harvest time entry data importer.
+ """
+ model_name = 'TimeEntry'
+ supported_fields = [
+ 'user_id',
+ 'project_id',
+ 'task_id',
+ 'spent_date',
+ # 'started_time',
+ # 'ended_time',
+ 'hours',
+ 'notes',
+ ]
+ caches_local_data = True
+
+ def cache_local_data(self, host_data=None):
+ """
+ Fetch existing time entries from Harvest.
+ """
+ cache = {}
+ entries = self.webapi.get_time_entries(**{'from': self.start_date,
+ 'to': self.end_date})
+ for entry in entries:
+ data = self.normalize_local_object(entry)
+ if data:
+ normal = self.normalize_cache_object(entry, data)
+ key = self.get_cache_key(entry, normal)
+ cache[key] = normal
+ return cache
+
+ def normalize_local_object(self, entry):
+ data = {
+ 'project_id': entry['project']['id'],
+ 'task_id': entry['task']['id'],
+ 'spent_date': entry['spent_date'],
+ # 'started_time': entry['started_time'],
+ # 'ended_time': entry['ended_time'],
+ 'hours': entry['hours'],
+ 'notes': entry['notes'],
+ }
+
+ if 'user_id' in self.fields:
+ data['user_id'] = entry['user']['id']
+
+ return data
+
+ def create_object(self, key, host_data):
+ if self.dry_run:
+ # mock out return value
+ result = dict(host_data)
+ if 'user_id' in self.fields:
+ result['user'] = {'id': result['user_id']}
+ result['project'] = {'id': result['project_id']}
+ result['task'] = {'id': result['task_id']}
+ return result
+
+ kwargs = {
+ 'project_id': host_data['project_id'],
+ 'task_id': host_data['task_id'],
+ 'spent_date': host_data['spent_date'],
+ # 'started_time': host_data['started_time'],
+ # 'ended_time': host_data['ended_time'],
+ 'hours': host_data['hours'],
+ 'notes': host_data['notes'],
+ }
+ if 'user_id' in self.fields:
+ kwargs['user_id'] = host_data['user_id']
+ entry = self.webapi.put_time_entry(**kwargs)
+ return entry
+
+ def update_object(self, obj, host_data, local_data=None, all_fields=False):
+ if self.dry_run:
+ return obj
+
+ raise NotImplementedError
+
+ def delete_object(self, obj):
+ if self.dry_run:
+ return True
+
+ raise NotImplementedError
diff --git a/rattail_harvest/harvest/webapi.py b/rattail_harvest/harvest/webapi.py
index 6107b19..a037f1b 100644
--- a/rattail_harvest/harvest/webapi.py
+++ b/rattail_harvest/harvest/webapi.py
@@ -167,3 +167,12 @@ class HarvestWebAPI(object):
raise ValueError("must provide all of: {}".format(', '.join(required)))
response = self.post('/time_entries', params=kwargs)
return response.json()
+
+
+def make_harvest_webapi(config):
+ access_token = config.require('harvest', 'api.access_token')
+ account_id = config.require('harvest', 'api.account_id')
+ user_agent = config.require('harvest', 'api.user_agent')
+ return HarvestWebAPI(access_token=access_token,
+ account_id=account_id,
+ user_agent=user_agent)
diff --git a/rattail_harvest/importing/harvest.py b/rattail_harvest/importing/harvest.py
index cde1e1c..695d04d 100644
--- a/rattail_harvest/importing/harvest.py
+++ b/rattail_harvest/importing/harvest.py
@@ -33,7 +33,7 @@ import sqlalchemy as sa
from rattail import importing
from rattail.util import OrderedDict
from rattail_harvest import importing as rattail_harvest_importing
-from rattail_harvest.harvest.webapi import HarvestWebAPI
+from rattail_harvest.harvest.webapi import make_harvest_webapi
log = logging.getLogger(__name__)
@@ -71,13 +71,7 @@ class FromHarvest(importing.Importer):
def setup(self):
super(FromHarvest, self).setup()
-
- access_token = self.config.require('harvest', 'api.access_token')
- account_id = self.config.require('harvest', 'api.account_id')
- user_agent = self.config.require('harvest', 'api.user_agent')
- self.webapi = HarvestWebAPI(access_token=access_token,
- account_id=account_id,
- user_agent=user_agent)
+ self.webapi = make_harvest_webapi(self.config)
def time_from_harvest(self, value):
# all harvest times appear to come as UTC, so no conversion needed
@@ -105,6 +99,9 @@ class HarvestUserImporter(FromHarvest, rattail_harvest_importing.model.HarvestUs
def supported_fields(self):
fields = list(super(HarvestUserImporter, self).supported_fields)
+ # this is for local tracking only; is not in harvest
+ fields.remove('person_uuid')
+
# this used to be in harvest i thought, but is no longer?
fields.remove('name')