Compare commits

...

42 commits

Author SHA1 Message Date
Lance Edgar e0b8797046 docs: update project links, kallithea -> forgejo 2024-09-14 13:20:04 -05:00
Lance Edgar 69ce07a778 docs: use markdown for readme file 2024-09-13 18:38:44 -05:00
Lance Edgar df324159ba bump: version 0.3.1 → 0.3.2 2024-08-18 20:09:43 -05:00
Lance Edgar 9687c85529 fix: avoid deprecated base class for config extension 2024-08-16 13:32:56 -05:00
Lance Edgar 0521f5e38c bump: version 0.3.0 → 0.3.1 2024-07-01 14:00:10 -05:00
Lance Edgar ff202415e3 fix: remove legacy command definitions 2024-07-01 12:32:20 -05:00
Lance Edgar d419bc1854 bump: version 0.2.1 → 0.3.0 2024-06-10 19:31:34 -05:00
Lance Edgar ffe33b20bc feat: switch from setup.cfg to pyproject.toml + hatchling 2024-06-10 19:31:21 -05:00
Lance Edgar 70f496893b Update changelog 2024-06-06 18:53:43 -05:00
Lance Edgar 5ae7db78a5 Add alembic scripts to project manifest 2024-06-06 18:53:15 -05:00
Lance Edgar 1cfd30a211 Fix default dist filename for release task
not sure why this fix was needed, did setuptools behavior change?
2024-06-06 18:17:31 -05:00
Lance Edgar 02298a7a29 Update changelog 2024-06-06 18:16:17 -05:00
Lance Edgar 8784579e27 Add typer equivalents for rattail commands 2024-05-16 19:22:38 -05:00
Lance Edgar d7e840138b Update subcommand entry point group names, per wuttjamaican 2023-11-23 06:50:29 -06:00
Lance Edgar da408a66bb Update changelog 2023-11-18 23:25:16 -06:00
Lance Edgar 9bf9a17bf6 Fix version table index names 2023-10-23 17:37:46 -05:00
Lance Edgar bdb8b22ef4 Add some support for datasync, and deleting times from Harvest 2023-10-08 20:38:25 -05:00
Lance Edgar fe0daf00bc Add logic to fetch single Harvest time entry via API
for one-off imports
2023-10-05 09:50:12 -05:00
Lance Edgar aa87ce57be Rename all tables/models for Harvest "cache"
make this more explicit, for better naming convention
2023-10-04 15:54:52 -05:00
Lance Edgar 509405cb34 Let Harvest API -> Harvest cache importer fetch single host object
for one-off imports via web app
2023-10-04 13:08:29 -05:00
Lance Edgar e58d843ee4 Fix (more) start/end date defaults for importers, per upstream changes 2023-09-25 13:26:58 -05:00
Lance Edgar 2f21e574ae Fix start/end date defaults for importers, per upstream changes 2023-09-24 09:23:31 -05:00
Lance Edgar 2fa7ef5e71 Grow all ID fields for Harvest cache tables
turns out Integer is not big enough, need BigInteger
2023-08-08 11:05:15 -05:00
Lance Edgar d508ca225b Replace setup.py contents with setup.cfg 2023-05-16 13:09:08 -05:00
Lance Edgar 44574d9ea6 Avoid deprecated import for OrderedDict 2023-05-05 01:55:53 -05:00
Lance Edgar 0164336784 Fix date filter for rattail <-> rattail sync of HarvestTimeEntry 2023-01-17 16:10:48 -06:00
Lance Edgar e239ea70e4 Stop importing certain fields for HarvestUser cache table
since the Harvest API is suddenly no longer providing the values
2022-11-14 17:43:26 -06:00
Lance Edgar 782cb1fcec Fix API call to return all Harvest Projects 2022-07-01 12:46:06 -05:00
Lance Edgar 03066f1135 Add basic support for updating a Harvest Time Entry via API 2022-03-06 11:09:59 -06:00
Lance Edgar d84cc7a9d9 Add config extension to define importers 2022-02-28 22:05:34 -06:00
Lance Edgar 0ee4d52145 Lower log level when harvest time entry still running during import
that is not exactly an unexpected situation, no need to raise
awareness to it probably
2022-02-22 12:02:58 -06:00
Lance Edgar c6332be453 Add stop_time_entry() API method 2022-02-20 21:15:48 -06:00
Lance Edgar d8e9714771 Avoid entries w/ timer still running when exporting to Harvest
i.e. ignore entries on Harvest, which have timer running
2022-02-17 07:39:29 -06:00
Lance Edgar e79686b5b7 Avoid importing Harvest times for which timer is still running 2022-02-17 06:57:36 -06:00
Lance Edgar ac653d25f1 Add convenience function to get configured Harvest URL 2022-02-11 19:16:03 -06:00
Lance Edgar caa1ef93b7 Add version importers for Harvest cache models 2022-02-09 16:11:32 -06:00
Lance Edgar 63d238d307 Coalesce HarvestUser telephone to null 2022-01-31 10:37:33 -06:00
Lance Edgar 14f9dfdaa9 Add client_id field for TimeEntry export to Harvest 2022-01-30 20:28:24 -06:00
Lance Edgar 1f54ddc9e4 Add 'id' key field for exporting TimeEntry to Harvest 2022-01-30 19:36:04 -06:00
Lance Edgar ec78f8c9c4 Add HarvestUser.person association
importer does not set this; you must do so manually
2022-01-30 17:40:19 -06:00
Lance Edgar 3883a8551f Add HarvestProject.deleted flag to track deletions in Harvest
set this flag instead of deleting project, so we do not lose other
info about it.  can delete manually if truly unwanted
2022-01-30 12:14:42 -06:00
Lance Edgar 259d3b0f33 Allow import of harvest time entries w/ missing project 2022-01-29 19:33:03 -06:00
26 changed files with 1248 additions and 245 deletions

3
.gitignore vendored
View file

@ -1 +1,4 @@
*~
*.pyc
dist/
rattail_harvest.egg-info/

View file

@ -5,6 +5,36 @@ All notable changes to rattail-harvest will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
## v0.3.2 (2024-08-18)
### Fix
- avoid deprecated base class for config extension
## v0.3.1 (2024-07-01)
### Fix
- remove legacy command definitions
## v0.3.0 (2024-06-10)
### Feat
- switch from setup.cfg to pyproject.toml + hatchling
## [0.2.1] - 2024-06-06
### Changed
- Add alembic scripts to project manifest.
## [0.2.0] - 2024-06-06
### Changed
- Add typer equivalents for `rattail` commands.
## [0.1.2] - 2023-11-18
### Changed
- Catch-up release, with various schema changes etc.
## [0.1.1] - 2022-01-29
### Added
- Initial version.

View file

@ -1,3 +1,5 @@
include *.md
include *.rst
recursive-include rattail_harvest/db/alembic *.mako
recursive-include rattail_harvest/db/alembic *.py

11
README.md Normal file
View file

@ -0,0 +1,11 @@
# rattail-harvest
Rattail is a retail software framework, released under the GNU General
Public License.
This package contains software interfaces for
[Harvest](https://www.getharvest.com/).
Please see the [Rattail Project](https://rattailproject.org/) for more
information.

View file

@ -1,14 +0,0 @@
rattail-harvest
===============
Rattail is a retail software framework, released under the GNU General
Public License.
This package contains software interfaces for `Harvest`_.
.. _`Harvest`: https://www.getharvest.com/
Please see the `Rattail Project`_ for more information.
.. _`Rattail Project`: https://rattailproject.org/

53
pyproject.toml Normal file
View file

@ -0,0 +1,53 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "rattail-harvest"
version = "0.3.2"
description = "Rattail integration package for Harvest"
readme = "README.md"
authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]
license = {text = "GNU GPL v3+"}
classifiers = [
"Development Status :: 3 - Alpha",
"Environment :: Console",
"Intended Audience :: Developers",
"License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
"Natural Language :: English",
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Topic :: Office/Business",
"Topic :: Software Development :: Libraries :: Python Modules",
]
dependencies = [
"invoke",
"rattail[db]",
]
[project.urls]
Homepage = "https://rattailproject.org"
Repository = "https://forgejo.wuttaproject.org/rattail/rattail-harvest"
Changelog = "https://forgejo.wuttaproject.org/rattail/rattail-harvest/src/branch/master/CHANGELOG.md"
[project.entry-points."rattail.typer_imports"]
rattail_harvest = "rattail_harvest.commands"
[project.entry-points."rattail.config.extensions"]
rattail_harvest = "rattail_harvest.config:RattailHarvestExtension"
[project.entry-points."rattail.importing"]
"to_rattail.from_harvest.import" = "rattail_harvest.importing.harvest:FromHarvestToRattail"
[tool.commitizen]
version_provider = "pep621"
tag_format = "v$version"
update_changelog_on_bump = true

View file

@ -1,3 +1,6 @@
# -*- coding: utf-8; -*-
__version__ = '0.1.1'
from importlib.metadata import version
__version__ = version('rattail-harvest')

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2022 Lance Edgar
# Copyright © 2010-2024 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,13 +24,25 @@
rattail-harvest commands
"""
from rattail import commands
import typer
from rattail.commands import rattail_typer
from rattail.commands.typer import importer_command, typer_get_runas_user
from rattail.commands.importing import ImportCommandHandler
class ImportHarvest(commands.ImportSubcommand):
@rattail_typer.command()
@importer_command
def import_harvest(
ctx: typer.Context,
**kwargs
):
"""
Import data to Rattail, from Harvest API
"""
name = 'import-harvest'
description = __doc__.strip()
handler_key = 'to_rattail.from_harvest.import'
config = ctx.parent.rattail_config
progress = ctx.parent.rattail_progress
handler = ImportCommandHandler(
config, import_handler_key='to_rattail.from_harvest.import')
kwargs['user'] = typer_get_runas_user(ctx)
handler.run(kwargs, progress=progress)

42
rattail_harvest/config.py Normal file
View file

@ -0,0 +1,42 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Config Extension
"""
from wuttjamaican.conf import WuttaConfigExtension
class RattailHarvestExtension(WuttaConfigExtension):
"""
Config extension for rattail-harvest.
"""
key = 'rattail_harvest'
def configure(self, config):
# rattail import-harvest
config.setdefault('rattail.importing', 'to_rattail.from_harvest.import.default_handler',
'rattail_harvest.importing.harvest:FromHarvestToRattail')
config.setdefault('rattail.importing', 'to_rattail.from_harvest.import.default_cmd',
'rattail import-harvest')

View file

@ -0,0 +1,179 @@
# -*- coding: utf-8; -*-
"""rename cache tables
Revision ID: 53c066772ad5
Revises: f2a1650e7fbc
Create Date: 2023-10-04 15:19:03.857323
"""
# revision identifiers, used by Alembic.
revision = '53c066772ad5'
down_revision = 'f2a1650e7fbc'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
import rattail.db.types
def upgrade():
##############################
# drop all constraints
##############################
# harvest_time_entry
op.drop_constraint('harvest_time_entry_fk_user', 'harvest_time_entry', type_='foreignkey')
op.drop_constraint('harvest_time_entry_fk_client', 'harvest_time_entry', type_='foreignkey')
op.drop_constraint('harvest_time_entry_fk_project', 'harvest_time_entry', type_='foreignkey')
op.drop_constraint('harvest_time_entry_fk_task', 'harvest_time_entry', type_='foreignkey')
op.drop_constraint('harvest_time_entry_uq_id', 'harvest_time_entry', type_='unique')
# harvest_task
op.drop_constraint('harvest_task_uq_id', 'harvest_task', type_='unique')
# harvest_project
op.drop_constraint('harvest_project_fk_client', 'harvest_project', type_='foreignkey')
op.drop_constraint('harvest_project_uq_id', 'harvest_project', type_='unique')
# harvest_client
op.drop_constraint('harvest_client_uq_id', 'harvest_client', type_='unique')
# harvest_user
op.drop_constraint('harvest_user_fk_person', 'harvest_user', type_='foreignkey')
op.drop_constraint('harvest_user_uq_id', 'harvest_user', type_='unique')
##############################
# rename all tables
##############################
op.rename_table('harvest_user', 'harvest_cache_user')
op.rename_table('harvest_user_version', 'harvest_cache_user_version')
op.rename_table('harvest_client', 'harvest_cache_client')
op.rename_table('harvest_client_version', 'harvest_cache_client_version')
op.rename_table('harvest_project', 'harvest_cache_project')
op.rename_table('harvest_project_version', 'harvest_cache_project_version')
op.rename_table('harvest_task', 'harvest_cache_task')
op.rename_table('harvest_task_version', 'harvest_cache_task_version')
op.rename_table('harvest_time_entry', 'harvest_cache_time_entry')
op.rename_table('harvest_time_entry_version', 'harvest_cache_time_entry_version')
##############################
# re-create all constraints
##############################
# harvest_cache_user
op.create_foreign_key('harvest_cache_user_fk_person',
'harvest_cache_user', 'person',
['person_uuid'], ['uuid'])
op.create_unique_constraint('harvest_cache_user_uq_id', 'harvest_cache_user', ['id'])
# harvest_cache_client
op.create_unique_constraint('harvest_cache_client_uq_id', 'harvest_cache_client', ['id'])
# harvest_cache_project
op.create_foreign_key('harvest_cache_project_fk_client',
'harvest_cache_project', 'harvest_cache_client',
['client_id'], ['id'])
op.create_unique_constraint('harvest_cache_project_uq_id', 'harvest_cache_project', ['id'])
# harvest_cache_task
op.create_unique_constraint('harvest_cache_task_uq_id', 'harvest_cache_task', ['id'])
# harvest_cache_time_entry
op.create_foreign_key('harvest_cache_time_entry_fk_user',
'harvest_cache_time_entry', 'harvest_cache_user',
['user_id'], ['id'])
op.create_foreign_key('harvest_cache_time_entry_fk_client',
'harvest_cache_time_entry', 'harvest_cache_client',
['client_id'], ['id'])
op.create_foreign_key('harvest_cache_time_entry_fk_project',
'harvest_cache_time_entry', 'harvest_cache_project',
['project_id'], ['id'])
op.create_foreign_key('harvest_cache_time_entry_fk_task',
'harvest_cache_time_entry', 'harvest_cache_task',
['task_id'], ['id'])
op.create_unique_constraint('harvest_cache_time_entry_uq_id', 'harvest_cache_time_entry', ['id'])
def downgrade():
##############################
# drop all constraints
##############################
# harvest_cache_time_entry
op.drop_constraint('harvest_cache_time_entry_fk_user', 'harvest_cache_time_entry', type_='foreignkey')
op.drop_constraint('harvest_cache_time_entry_fk_client', 'harvest_cache_time_entry', type_='foreignkey')
op.drop_constraint('harvest_cache_time_entry_fk_project', 'harvest_cache_time_entry', type_='foreignkey')
op.drop_constraint('harvest_cache_time_entry_fk_task', 'harvest_cache_time_entry', type_='foreignkey')
op.drop_constraint('harvest_cache_time_entry_uq_id', 'harvest_cache_time_entry', type_='unique')
# harvest_cache_task
op.drop_constraint('harvest_cache_task_uq_id', 'harvest_cache_task', type_='unique')
# harvest_cache_project
op.drop_constraint('harvest_cache_project_fk_client', 'harvest_cache_project', type_='foreignkey')
op.drop_constraint('harvest_cache_project_uq_id', 'harvest_cache_project', type_='unique')
# harvest_cache_client
op.drop_constraint('harvest_cache_client_uq_id', 'harvest_cache_client', type_='unique')
# harvest_cache_user
op.drop_constraint('harvest_cache_user_fk_person', 'harvest_cache_user', type_='foreignkey')
op.drop_constraint('harvest_cache_user_uq_id', 'harvest_cache_user', type_='unique')
##############################
# rename all tables
##############################
op.rename_table('harvest_cache_user', 'harvest_user')
op.rename_table('harvest_cache_user_version', 'harvest_user_version')
op.rename_table('harvest_cache_client', 'harvest_client')
op.rename_table('harvest_cache_client_version', 'harvest_client_version')
op.rename_table('harvest_cache_project', 'harvest_project')
op.rename_table('harvest_cache_project_version', 'harvest_project_version')
op.rename_table('harvest_cache_task', 'harvest_task')
op.rename_table('harvest_cache_task_version', 'harvest_task_version')
op.rename_table('harvest_cache_time_entry', 'harvest_time_entry')
op.rename_table('harvest_cache_time_entry_version', 'harvest_time_entry_version')
##############################
# re-create all constraints
##############################
# harvest_user
op.create_foreign_key('harvest_user_fk_person',
'harvest_user', 'person',
['person_uuid'], ['uuid'])
op.create_unique_constraint('harvest_user_uq_id', 'harvest_user', ['id'])
# harvest_client
op.create_unique_constraint('harvest_client_uq_id', 'harvest_client', ['id'])
# harvest_project
op.create_foreign_key('harvest_project_fk_client',
'harvest_project', 'harvest_client',
['client_id'], ['id'])
op.create_unique_constraint('harvest_project_uq_id', 'harvest_project', ['id'])
# harvest_cache_task
op.create_unique_constraint('harvest_task_uq_id', 'harvest_task', ['id'])
# harvest_time_entry
op.create_foreign_key('harvest_time_entry_fk_user',
'harvest_time_entry', 'harvest_user',
['user_id'], ['id'])
op.create_foreign_key('harvest_time_entry_fk_client',
'harvest_time_entry', 'harvest_client',
['client_id'], ['id'])
op.create_foreign_key('harvest_time_entry_fk_project',
'harvest_time_entry', 'harvest_project',
['project_id'], ['id'])
op.create_foreign_key('harvest_time_entry_fk_task',
'harvest_time_entry', 'harvest_task',
['task_id'], ['id'])
op.create_unique_constraint('harvest_time_entry_uq_id', 'harvest_time_entry', ['id'])

View file

@ -0,0 +1,33 @@
# -*- coding: utf-8; -*-
"""add project.deleted
Revision ID: 5505c0e60d28
Revises: d59ce24c2f9f
Create Date: 2022-01-30 12:08:04.338229
"""
# revision identifiers, used by Alembic.
revision = '5505c0e60d28'
down_revision = 'd59ce24c2f9f'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
import rattail.db.types
def upgrade():
# harvest_project
op.add_column('harvest_project', sa.Column('deleted', sa.Boolean(), nullable=True))
op.add_column('harvest_project_version', sa.Column('deleted', sa.Boolean(), autoincrement=False, nullable=True))
def downgrade():
# harvest_project
op.drop_column('harvest_project_version', 'deleted')
op.drop_column('harvest_project', 'deleted')

View file

@ -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')

View file

@ -0,0 +1,105 @@
# -*- coding: utf-8; -*-
"""fix indeces
Revision ID: a1cf300fb371
Revises: 53c066772ad5
Create Date: 2023-10-23 17:35:15.527740
"""
# revision identifiers, used by Alembic.
revision = 'a1cf300fb371'
down_revision = '53c066772ad5'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
import rattail.db.types
def upgrade():
# harvest_cache_user
op.drop_index('ix_harvest_user_version_end_transaction_id', table_name='harvest_cache_user_version')
op.drop_index('ix_harvest_user_version_operation_type', table_name='harvest_cache_user_version')
op.drop_index('ix_harvest_user_version_transaction_id', table_name='harvest_cache_user_version')
op.create_index(op.f('ix_harvest_cache_user_version_end_transaction_id'), 'harvest_cache_user_version', ['end_transaction_id'], unique=False)
op.create_index(op.f('ix_harvest_cache_user_version_operation_type'), 'harvest_cache_user_version', ['operation_type'], unique=False)
op.create_index(op.f('ix_harvest_cache_user_version_transaction_id'), 'harvest_cache_user_version', ['transaction_id'], unique=False)
# harvest_cache_client
op.drop_index('ix_harvest_client_version_end_transaction_id', table_name='harvest_cache_client_version')
op.drop_index('ix_harvest_client_version_operation_type', table_name='harvest_cache_client_version')
op.drop_index('ix_harvest_client_version_transaction_id', table_name='harvest_cache_client_version')
op.create_index(op.f('ix_harvest_cache_client_version_end_transaction_id'), 'harvest_cache_client_version', ['end_transaction_id'], unique=False)
op.create_index(op.f('ix_harvest_cache_client_version_operation_type'), 'harvest_cache_client_version', ['operation_type'], unique=False)
op.create_index(op.f('ix_harvest_cache_client_version_transaction_id'), 'harvest_cache_client_version', ['transaction_id'], unique=False)
# harvest_cache_project
op.drop_index('ix_harvest_project_version_end_transaction_id', table_name='harvest_cache_project_version')
op.drop_index('ix_harvest_project_version_operation_type', table_name='harvest_cache_project_version')
op.drop_index('ix_harvest_project_version_transaction_id', table_name='harvest_cache_project_version')
op.create_index(op.f('ix_harvest_cache_project_version_end_transaction_id'), 'harvest_cache_project_version', ['end_transaction_id'], unique=False)
op.create_index(op.f('ix_harvest_cache_project_version_operation_type'), 'harvest_cache_project_version', ['operation_type'], unique=False)
op.create_index(op.f('ix_harvest_cache_project_version_transaction_id'), 'harvest_cache_project_version', ['transaction_id'], unique=False)
# harvest_cache_task
op.drop_index('ix_harvest_task_version_end_transaction_id', table_name='harvest_cache_task_version')
op.drop_index('ix_harvest_task_version_operation_type', table_name='harvest_cache_task_version')
op.drop_index('ix_harvest_task_version_transaction_id', table_name='harvest_cache_task_version')
op.create_index(op.f('ix_harvest_cache_task_version_end_transaction_id'), 'harvest_cache_task_version', ['end_transaction_id'], unique=False)
op.create_index(op.f('ix_harvest_cache_task_version_operation_type'), 'harvest_cache_task_version', ['operation_type'], unique=False)
op.create_index(op.f('ix_harvest_cache_task_version_transaction_id'), 'harvest_cache_task_version', ['transaction_id'], unique=False)
# harvest_cache_time_entry
op.drop_index('ix_harvest_time_entry_version_end_transaction_id', table_name='harvest_cache_time_entry_version')
op.drop_index('ix_harvest_time_entry_version_operation_type', table_name='harvest_cache_time_entry_version')
op.drop_index('ix_harvest_time_entry_version_transaction_id', table_name='harvest_cache_time_entry_version')
op.create_index(op.f('ix_harvest_cache_time_entry_version_end_transaction_id'), 'harvest_cache_time_entry_version', ['end_transaction_id'], unique=False)
op.create_index(op.f('ix_harvest_cache_time_entry_version_operation_type'), 'harvest_cache_time_entry_version', ['operation_type'], unique=False)
op.create_index(op.f('ix_harvest_cache_time_entry_version_transaction_id'), 'harvest_cache_time_entry_version', ['transaction_id'], unique=False)
def downgrade():
# harvest_cache_time_entry
op.drop_index(op.f('ix_harvest_cache_time_entry_version_transaction_id'), table_name='harvest_cache_time_entry_version')
op.drop_index(op.f('ix_harvest_cache_time_entry_version_operation_type'), table_name='harvest_cache_time_entry_version')
op.drop_index(op.f('ix_harvest_cache_time_entry_version_end_transaction_id'), table_name='harvest_cache_time_entry_version')
op.create_index('ix_harvest_time_entry_version_transaction_id', 'harvest_cache_time_entry_version', ['transaction_id'], unique=False)
op.create_index('ix_harvest_time_entry_version_operation_type', 'harvest_cache_time_entry_version', ['operation_type'], unique=False)
op.create_index('ix_harvest_time_entry_version_end_transaction_id', 'harvest_cache_time_entry_version', ['end_transaction_id'], unique=False)
# harvest_cache_task
op.drop_index(op.f('ix_harvest_cache_task_version_transaction_id'), table_name='harvest_cache_task_version')
op.drop_index(op.f('ix_harvest_cache_task_version_operation_type'), table_name='harvest_cache_task_version')
op.drop_index(op.f('ix_harvest_cache_task_version_end_transaction_id'), table_name='harvest_cache_task_version')
op.create_index('ix_harvest_task_version_transaction_id', 'harvest_cache_task_version', ['transaction_id'], unique=False)
op.create_index('ix_harvest_task_version_operation_type', 'harvest_cache_task_version', ['operation_type'], unique=False)
op.create_index('ix_harvest_task_version_end_transaction_id', 'harvest_cache_task_version', ['end_transaction_id'], unique=False)
# harvest_cache_project
op.drop_index(op.f('ix_harvest_cache_project_version_transaction_id'), table_name='harvest_cache_project_version')
op.drop_index(op.f('ix_harvest_cache_project_version_operation_type'), table_name='harvest_cache_project_version')
op.drop_index(op.f('ix_harvest_cache_project_version_end_transaction_id'), table_name='harvest_cache_project_version')
op.create_index('ix_harvest_project_version_transaction_id', 'harvest_cache_project_version', ['transaction_id'], unique=False)
op.create_index('ix_harvest_project_version_operation_type', 'harvest_cache_project_version', ['operation_type'], unique=False)
op.create_index('ix_harvest_project_version_end_transaction_id', 'harvest_cache_project_version', ['end_transaction_id'], unique=False)
# harvest_cache_client
op.drop_index(op.f('ix_harvest_cache_client_version_transaction_id'), table_name='harvest_cache_client_version')
op.drop_index(op.f('ix_harvest_cache_client_version_operation_type'), table_name='harvest_cache_client_version')
op.drop_index(op.f('ix_harvest_cache_client_version_end_transaction_id'), table_name='harvest_cache_client_version')
op.create_index('ix_harvest_client_version_transaction_id', 'harvest_cache_client_version', ['transaction_id'], unique=False)
op.create_index('ix_harvest_client_version_operation_type', 'harvest_cache_client_version', ['operation_type'], unique=False)
op.create_index('ix_harvest_client_version_end_transaction_id', 'harvest_cache_client_version', ['end_transaction_id'], unique=False)
# harvest_cache_user
op.drop_index(op.f('ix_harvest_cache_user_version_transaction_id'), table_name='harvest_cache_user_version')
op.drop_index(op.f('ix_harvest_cache_user_version_operation_type'), table_name='harvest_cache_user_version')
op.drop_index(op.f('ix_harvest_cache_user_version_end_transaction_id'), table_name='harvest_cache_user_version')
op.create_index('ix_harvest_user_version_transaction_id', 'harvest_cache_user_version', ['transaction_id'], unique=False)
op.create_index('ix_harvest_user_version_operation_type', 'harvest_cache_user_version', ['operation_type'], unique=False)
op.create_index('ix_harvest_user_version_end_transaction_id', 'harvest_cache_user_version', ['end_transaction_id'], unique=False)

View file

@ -0,0 +1,89 @@
# -*- coding: utf-8; -*-
"""grow id fields
Revision ID: f2a1650e7fbc
Revises: 6bc1cb21d920
Create Date: 2023-08-08 10:53:56.013211
"""
# revision identifiers, used by Alembic.
revision = 'f2a1650e7fbc'
down_revision = '6bc1cb21d920'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
import rattail.db.types
from sqlalchemy.dialects import postgresql
def upgrade():
# harvest_user
op.alter_column('harvest_user', 'id', type_=sa.BigInteger())
op.alter_column('harvest_user_version', 'id', type_=sa.BigInteger())
# harvest_client
op.alter_column('harvest_client', 'id', type_=sa.BigInteger())
op.alter_column('harvest_client_version', 'id', type_=sa.BigInteger())
# harvest_project
op.alter_column('harvest_project', 'id', type_=sa.BigInteger())
op.alter_column('harvest_project', 'client_id', type_=sa.BigInteger())
op.alter_column('harvest_project_version', 'id', type_=sa.BigInteger())
op.alter_column('harvest_project_version', 'client_id', type_=sa.BigInteger())
# harvest_task
op.alter_column('harvest_task', 'id', type_=sa.BigInteger())
op.alter_column('harvest_task_version', 'id', type_=sa.BigInteger())
# harvest_time_entry
op.alter_column('harvest_time_entry', 'id', type_=sa.BigInteger())
op.alter_column('harvest_time_entry', 'user_id', type_=sa.BigInteger())
op.alter_column('harvest_time_entry', 'client_id', type_=sa.BigInteger())
op.alter_column('harvest_time_entry', 'project_id', type_=sa.BigInteger())
op.alter_column('harvest_time_entry', 'task_id', type_=sa.BigInteger())
op.alter_column('harvest_time_entry', 'invoice_id', type_=sa.BigInteger())
op.alter_column('harvest_time_entry_version', 'id', type_=sa.BigInteger())
op.alter_column('harvest_time_entry_version', 'user_id', type_=sa.BigInteger())
op.alter_column('harvest_time_entry_version', 'client_id', type_=sa.BigInteger())
op.alter_column('harvest_time_entry_version', 'project_id', type_=sa.BigInteger())
op.alter_column('harvest_time_entry_version', 'task_id', type_=sa.BigInteger())
op.alter_column('harvest_time_entry_version', 'invoice_id', type_=sa.BigInteger())
def downgrade():
# harvest_time_entry
op.alter_column('harvest_time_entry_version', 'id', type_=sa.Integer())
op.alter_column('harvest_time_entry_version', 'user_id', type_=sa.Integer())
op.alter_column('harvest_time_entry_version', 'client_id', type_=sa.Integer())
op.alter_column('harvest_time_entry_version', 'project_id', type_=sa.Integer())
op.alter_column('harvest_time_entry_version', 'task_id', type_=sa.Integer())
op.alter_column('harvest_time_entry_version', 'invoice_id', type_=sa.Integer())
op.alter_column('harvest_time_entry', 'id', type_=sa.Integer())
op.alter_column('harvest_time_entry', 'user_id', type_=sa.Integer())
op.alter_column('harvest_time_entry', 'client_id', type_=sa.Integer())
op.alter_column('harvest_time_entry', 'project_id', type_=sa.Integer())
op.alter_column('harvest_time_entry', 'task_id', type_=sa.Integer())
op.alter_column('harvest_time_entry', 'invoice_id', type_=sa.Integer())
# harvest_task
op.alter_column('harvest_task_version', 'id', type_=sa.Integer())
op.alter_column('harvest_task', 'id', type_=sa.Integer())
# harvest_project
op.alter_column('harvest_project_version', 'id', type_=sa.Integer())
op.alter_column('harvest_project_version', 'client_id', type_=sa.Integer())
op.alter_column('harvest_project', 'id', type_=sa.Integer())
op.alter_column('harvest_project', 'client_id', type_=sa.Integer())
# harvest_client
op.alter_column('harvest_client_version', 'id', type_=sa.Integer())
op.alter_column('harvest_client', 'id', type_=sa.Integer())
# harvest_user
op.alter_column('harvest_user_version', 'id', type_=sa.Integer())
op.alter_column('harvest_user', 'id', type_=sa.Integer())

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2022 Lance Edgar
# Copyright © 2010-2023 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,5 +24,6 @@
Harvest integration data models
"""
from .harvest import (HarvestUser, HarvestClient, HarvestProject,
HarvestTask, HarvestTimeEntry)
from .harvest import (HarvestCacheUser, HarvestCacheClient,
HarvestCacheProject, HarvestCacheTask,
HarvestCacheTimeEntry)

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2022 Lance Edgar
# Copyright © 2010-2023 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,6 +24,8 @@
Harvest "cache" data models
"""
import warnings
import sqlalchemy as sa
from sqlalchemy import orm
@ -31,21 +33,23 @@ from rattail.db import model
from rattail.db.util import normalize_full_name
class HarvestUser(model.Base):
class HarvestCacheUser(model.Base):
"""
Represents a user record in Harvest.
https://help.getharvest.com/api-v2/users-api/users/users/#the-user-object
"""
__tablename__ = 'harvest_user'
__tablename__ = 'harvest_cache_user'
__table_args__ = (
sa.UniqueConstraint('id', name='harvest_user_uq_id'),
sa.ForeignKeyConstraint(['person_uuid'], ['person.uuid'],
name='harvest_cache_user_fk_person'),
sa.UniqueConstraint('id', name='harvest_cache_user_uq_id'),
)
__versioned__ = {}
uuid = model.uuid_column()
id = sa.Column(sa.Integer(), nullable=False)
id = sa.Column(sa.BigInteger(), nullable=False)
first_name = sa.Column(sa.String(length=255), nullable=True)
@ -90,25 +94,38 @@ 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)
class HarvestClient(model.Base):
class HarvestCacheClient(model.Base):
"""
Represents a client record in Harvest.
https://help.getharvest.com/api-v2/clients-api/clients/clients/#the-client-object
"""
__tablename__ = 'harvest_client'
__tablename__ = 'harvest_cache_client'
__table_args__ = (
sa.UniqueConstraint('id', name='harvest_client_uq_id'),
sa.UniqueConstraint('id', name='harvest_cache_client_uq_id'),
)
__versioned__ = {}
uuid = model.uuid_column()
id = sa.Column(sa.Integer(), nullable=False)
id = sa.Column(sa.BigInteger(), nullable=False)
name = sa.Column(sa.String(length=255), nullable=True)
@ -126,25 +143,26 @@ class HarvestClient(model.Base):
return self.name or ''
class HarvestProject(model.Base):
class HarvestCacheProject(model.Base):
"""
Represents a project record in Harvest.
https://help.getharvest.com/api-v2/projects-api/projects/projects/#the-project-object
"""
__tablename__ = 'harvest_project'
__tablename__ = 'harvest_cache_project'
__table_args__ = (
sa.UniqueConstraint('id', name='harvest_project_uq_id'),
sa.ForeignKeyConstraint(['client_id'], ['harvest_client.id'], name='harvest_project_fk_client'),
sa.UniqueConstraint('id', name='harvest_cache_project_uq_id'),
sa.ForeignKeyConstraint(['client_id'], ['harvest_cache_client.id'],
name='harvest_cache_project_fk_client'),
)
__versioned__ = {'exclude': ['over_budget_notification_date']}
uuid = model.uuid_column()
id = sa.Column(sa.Integer(), nullable=False)
id = sa.Column(sa.BigInteger(), nullable=False)
client_id = sa.Column(sa.Integer(), nullable=True) # TODO: should not allow null?
client = orm.relationship(HarvestClient, backref=orm.backref('projects'))
client_id = sa.Column(sa.BigInteger(), nullable=True) # TODO: should not allow null?
client = orm.relationship(HarvestCacheClient, backref=orm.backref('projects'))
name = sa.Column(sa.String(length=255), nullable=True)
@ -190,25 +208,29 @@ class HarvestProject(model.Base):
updated_at = sa.Column(sa.DateTime(), nullable=True)
deleted = sa.Column(sa.Boolean(), nullable=True, doc="""
Flag indicating the record has been deleted in Harvest.
""")
def __str__(self):
return self.name or ''
class HarvestTask(model.Base):
class HarvestCacheTask(model.Base):
"""
Represents a task record in Harvest.
https://help.getharvest.com/api-v2/tasks-api/tasks/tasks/#the-task-object
"""
__tablename__ = 'harvest_task'
__tablename__ = 'harvest_cache_task'
__table_args__ = (
sa.UniqueConstraint('id', name='harvest_task_uq_id'),
sa.UniqueConstraint('id', name='harvest_cache_task_uq_id'),
)
__versioned__ = {}
uuid = model.uuid_column()
id = sa.Column(sa.Integer(), nullable=False)
id = sa.Column(sa.BigInteger(), nullable=False)
name = sa.Column(sa.String(length=255), nullable=True)
@ -228,42 +250,46 @@ class HarvestTask(model.Base):
return self.name or ''
class HarvestTimeEntry(model.Base):
class HarvestCacheTimeEntry(model.Base):
"""
Represents a time entry record in Harvest.
https://help.getharvest.com/api-v2/timesheets-api/timesheets/time-entries/#the-time-entry-object
"""
__tablename__ = 'harvest_time_entry'
__tablename__ = 'harvest_cache_time_entry'
__table_args__ = (
sa.UniqueConstraint('id', name='harvest_time_entry_uq_id'),
sa.ForeignKeyConstraint(['user_id'], ['harvest_user.id'], name='harvest_time_entry_fk_user'),
sa.ForeignKeyConstraint(['client_id'], ['harvest_client.id'], name='harvest_time_entry_fk_client'),
sa.ForeignKeyConstraint(['project_id'], ['harvest_project.id'], name='harvest_time_entry_fk_project'),
sa.ForeignKeyConstraint(['task_id'], ['harvest_task.id'], name='harvest_time_entry_fk_task'),
sa.UniqueConstraint('id', name='harvest_cache_time_entry_uq_id'),
sa.ForeignKeyConstraint(['user_id'], ['harvest_cache_user.id'],
name='harvest_cache_time_entry_fk_user'),
sa.ForeignKeyConstraint(['client_id'], ['harvest_cache_client.id'],
name='harvest_cache_time_entry_fk_client'),
sa.ForeignKeyConstraint(['project_id'], ['harvest_cache_project.id'],
name='harvest_cache_time_entry_fk_project'),
sa.ForeignKeyConstraint(['task_id'], ['harvest_cache_task.id'],
name='harvest_cache_time_entry_fk_task'),
)
__versioned__ = {}
model_title_plural = "Harvest Time Entries"
uuid = model.uuid_column()
id = sa.Column(sa.Integer(), nullable=False)
id = sa.Column(sa.BigInteger(), nullable=False)
spent_date = sa.Column(sa.Date(), nullable=True)
user_id = sa.Column(sa.Integer(), nullable=True)
user = orm.relationship(HarvestUser, backref=orm.backref('time_entries'))
user_id = sa.Column(sa.BigInteger(), nullable=True)
user = orm.relationship(HarvestCacheUser, backref=orm.backref('time_entries'))
client_id = sa.Column(sa.Integer(), nullable=True)
client = orm.relationship(HarvestClient, backref=orm.backref('time_entries'))
client_id = sa.Column(sa.BigInteger(), nullable=True)
client = orm.relationship(HarvestCacheClient, backref=orm.backref('time_entries'))
project_id = sa.Column(sa.Integer(), nullable=True)
project = orm.relationship(HarvestProject, backref=orm.backref('time_entries'))
project_id = sa.Column(sa.BigInteger(), nullable=True)
project = orm.relationship(HarvestCacheProject, backref=orm.backref('time_entries'))
task_id = sa.Column(sa.Integer(), nullable=True)
task = orm.relationship(HarvestTask, backref=orm.backref('time_entries'))
task_id = sa.Column(sa.BigInteger(), nullable=True)
task = orm.relationship(HarvestCacheTask, backref=orm.backref('time_entries'))
invoice_id = sa.Column(sa.Integer(), nullable=True)
invoice_id = sa.Column(sa.BigInteger(), nullable=True)
hours = sa.Column(sa.Numeric(precision=6, scale=2), nullable=True)

View file

@ -0,0 +1,31 @@
# -*- 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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Harvest config
"""
def get_harvest_url(config):
url = config.get('harvest', 'url')
if url:
return url.rstrip('/')

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Exporting data to Harvest
"""
from . import model

View file

@ -0,0 +1,169 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2023 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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Harvest model importers
"""
from rattail import importing
from rattail_harvest.harvest.webapi import make_harvest_webapi
class ToHarvest(importing.Importer):
def setup(self):
super().setup()
self.setup_webapi()
def datasync_setup(self):
super().datasync_setup()
self.setup_webapi()
def setup_webapi(self):
self.webapi = make_harvest_webapi(self.config)
class TimeEntryImporter(ToHarvest):
"""
Harvest time entry data importer.
"""
model_name = 'TimeEntry'
key = 'id'
supported_fields = [
'id',
'user_id',
'client_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 = {}
# TODO: we try to avoid entries w/ timer still running here,
# but for some reason they still come back, so double-check
kw = {'is_running': False}
if self.start_date:
kw['from'] = self.start_date
if self.end_date:
kw['to'] = self.end_date
entries = self.webapi.get_time_entries(**kw)
for entry in entries:
# double-check here
if not entry['is_running']:
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 get_single_local_object(self, key):
assert len(self.key) == 1 and self.key[0] == 'id'
entry_id = key[0]
if entry_id > 0:
return self.webapi.get_time_entry(entry_id)
def normalize_local_object(self, entry):
data = {
'id': entry['id'],
'client_id': entry['client']['id'],
'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 get_next_harvest_id(self):
if hasattr(self, 'next_harvest_id'):
next_id = self.next_harvest_id
else:
next_id = 1
self.next_harvest_id = next_id + 1
return -next_id
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']}
if 'client_id' in self.fields:
result['client'] = {'id': result['client_id']}
result['project'] = {'id': result['project_id']}
result['task'] = {'id': result['task_id']}
return result
kwargs = {
'client_id': host_data['client_id'],
'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, entry, host_data, local_data=None, all_fields=False):
if self.dry_run:
return entry
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'],
}
return self.webapi.update_time_entry(entry['id'], **kwargs)
def delete_object(self, entry):
if self.dry_run:
return True
self.webapi.delete_time_entry(entry['id'])
return True

View file

@ -58,6 +58,12 @@ class HarvestWebAPI(object):
elif request_method == 'POST':
response = requests.post('{}/{}'.format(self.base_url, api_method),
headers=headers, params=params)
elif request_method == 'PATCH':
response = requests.patch('{}/{}'.format(self.base_url, api_method),
headers=headers, params=params)
elif request_method == 'DELETE':
response = requests.delete('{}/{}'.format(self.base_url, api_method),
headers=headers, params=params)
else:
raise NotImplementedError("unknown request method: {}".format(
request_method))
@ -76,6 +82,18 @@ class HarvestWebAPI(object):
"""
return self._request('POST', api_method, params=params)
def patch(self, api_method, params=None):
"""
Perform a PATCH request for the given API method, and return the response.
"""
return self._request('PATCH', api_method, params=params)
def delete(self, api_method, params=None):
"""
Perform a DELETE request for the given API method, and return the response.
"""
return self._request('DELETE', api_method, params=params)
def get_company(self):
"""
Retrieves the company for the currently authenticated user.
@ -113,7 +131,17 @@ class HarvestWebAPI(object):
https://help.getharvest.com/api-v2/projects-api/projects/projects/#list-all-projects
"""
response = self.get('/projects', params=kwargs)
return response.json()
data = response.json()
projects = data['projects']
while data['next_page']:
kw = dict(kwargs)
kw['page'] = data['next_page']
response = self.get('/projects', params=kw)
data = response.json()
projects.extend(data['projects'])
return projects
def get_tasks(self, **kwargs):
"""
@ -151,10 +179,15 @@ class HarvestWebAPI(object):
https://help.getharvest.com/api-v2/timesheets-api/timesheets/time-entries/#retrieve-a-time-entry
"""
response = self.get('/time_entries/{}'.format(time_entry_id))
return response.json()
try:
response = self.get('/time_entries/{}'.format(time_entry_id))
except requests.exceptions.HTTPError as error:
if error.response.status_code != 404:
raise
else:
return response.json()
def put_time_entry(self, **kwargs):
def create_time_entry(self, **kwargs):
"""
Create a new time entry. All kwargs are passed on as POST parameters.
@ -167,3 +200,41 @@ class HarvestWebAPI(object):
raise ValueError("must provide all of: {}".format(', '.join(required)))
response = self.post('/time_entries', params=kwargs)
return response.json()
# TODO: deprecate / remove this
put_time_entry = create_time_entry
def stop_time_entry(self, time_entry_id):
"""
Stop a running time entry.
https://help.getharvest.com/api-v2/timesheets-api/timesheets/time-entries/#stop-a-running-time-entry
"""
response = self.patch('/time_entries/{}/stop'.format(time_entry_id))
return response.json()
def update_time_entry(self, time_entry_id, **kwargs):
"""
Update a time entry.
https://help.getharvest.com/api-v2/timesheets-api/timesheets/time-entries/#update-a-time-entry
"""
response = self.patch('/time_entries/{}'.format(time_entry_id), params=kwargs)
return response.json()
def delete_time_entry(self, time_entry_id, **kwargs):
"""
Delete a time entry.
https://help.getharvest.com/api-v2/timesheets-api/timesheets/time-entries/#delete-a-time-entry
"""
self.delete('/time_entries/{}'.format(time_entry_id), params=kwargs)
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)

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2022 Lance Edgar
# Copyright © 2010-2023 Lance Edgar
#
# This file is part of Rattail.
#
@ -27,11 +27,13 @@ Harvest -> Rattail "cache" data import
import datetime
import decimal
import logging
from collections import OrderedDict
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__)
@ -47,11 +49,11 @@ class FromHarvestToRattail(importing.ToRattailHandler):
def get_importers(self):
importers = OrderedDict()
importers['HarvestUser'] = HarvestUserImporter
importers['HarvestClient'] = HarvestClientImporter
importers['HarvestProject'] = HarvestProjectImporter
importers['HarvestTask'] = HarvestTaskImporter
importers['HarvestTimeEntry'] = HarvestTimeEntryImporter
importers['HarvestCacheUser'] = HarvestCacheUserImporter
importers['HarvestCacheClient'] = HarvestCacheClientImporter
importers['HarvestCacheProject'] = HarvestCacheProjectImporter
importers['HarvestCacheTask'] = HarvestCacheTaskImporter
importers['HarvestCacheTimeEntry'] = HarvestCacheTimeEntryImporter
return importers
@ -69,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
@ -94,14 +90,17 @@ class FromHarvest(importing.Importer):
return data
class HarvestUserImporter(FromHarvest, rattail_harvest_importing.model.HarvestUserImporter):
class HarvestCacheUserImporter(FromHarvest, rattail_harvest_importing.model.HarvestCacheUserImporter):
"""
Import user data from Harvest
"""
@property
def supported_fields(self):
fields = list(super(HarvestUserImporter, self).supported_fields)
fields = list(super().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')
@ -111,8 +110,28 @@ class HarvestUserImporter(FromHarvest, rattail_harvest_importing.model.HarvestUs
def get_host_objects(self):
return self.webapi.get_users()['users']
def normalize_host_object(self, user):
data = super().normalize_host_object(user)
if data:
class HarvestClientImporter(FromHarvest, rattail_harvest_importing.model.HarvestClientImporter):
# TODO: for some reason the API used to include the these
# fields, but no longer does as of 2022-11-11, so null is
# kinda the only thing that makes sense now. if possible,
# should figure out "what changed" at Harvest, but maybe
# these fields should just be removed from our cache
# schema?
data.setdefault('is_admin', None)
data.setdefault('is_project_manager', None)
data.setdefault('can_see_rates', None)
data.setdefault('can_create_invoices', None)
if data['telephone'] == '':
data['telephone'] = None
return data
class HarvestCacheClientImporter(FromHarvest, rattail_harvest_importing.model.HarvestCacheClientImporter):
"""
Import client data from Harvest
"""
@ -121,16 +140,32 @@ class HarvestClientImporter(FromHarvest, rattail_harvest_importing.model.Harvest
return self.webapi.get_clients()['clients']
class HarvestProjectImporter(FromHarvest, rattail_harvest_importing.model.HarvestProjectImporter):
class HarvestCacheProjectImporter(FromHarvest, rattail_harvest_importing.model.HarvestCacheProjectImporter):
"""
Import project data from Harvest
"""
@property
def supported_fields(self):
fields = list(super().supported_fields)
# this is for local tracking only; is not in harvest
fields.remove('deleted')
return fields
def cache_query(self):
model = self.model
return self.session.query(model.HarvestCacheProject)\
.filter(sa.or_(
model.HarvestCacheProject.deleted == False,
model.HarvestCacheProject.deleted == None))
def get_host_objects(self):
return self.webapi.get_projects()['projects']
return self.webapi.get_projects()
def normalize_host_object(self, project):
data = super(HarvestProjectImporter, self).normalize_host_object(project)
data = super().normalize_host_object(project)
if not data:
return
@ -168,8 +203,15 @@ class HarvestProjectImporter(FromHarvest, rattail_harvest_importing.model.Harves
return data
def can_delete_object(self, project, data):
return not project.deleted
class HarvestTaskImporter(FromHarvest, rattail_harvest_importing.model.HarvestTaskImporter):
def delete_object(self, project):
project.deleted = True
return True
class HarvestCacheTaskImporter(FromHarvest, rattail_harvest_importing.model.HarvestCacheTaskImporter):
"""
Import task data from Harvest
"""
@ -178,40 +220,49 @@ class HarvestTaskImporter(FromHarvest, rattail_harvest_importing.model.HarvestTa
return self.webapi.get_tasks()['tasks']
class HarvestTimeEntryImporter(FromHarvest, rattail_harvest_importing.model.HarvestTimeEntryImporter):
class HarvestCacheTimeEntryImporter(FromHarvest, rattail_harvest_importing.model.HarvestCacheTimeEntryImporter):
"""
Import time entry data from Harvest
"""
def setup(self):
super(HarvestTimeEntryImporter, self).setup()
model = self.model
self.harvest_projects_by_id = self.app.cache_model(self.session,
model.HarvestProject,
key='id')
def get_host_objects(self):
return self.webapi.get_time_entries(**{'from': self.start_date,
'to': self.end_date})
kw = {}
if self.start_date:
kw['from'] = self.start_date
if self.end_date:
kw['to'] = self.end_date
return self.webapi.get_time_entries(**kw)
def get_single_host_object(self, key):
assert len(self.key) == 1 and self.key[0] == 'id'
entry_id = key[0]
return self.webapi.get_time_entry(entry_id)
def normalize_host_object(self, entry):
data = super(HarvestTimeEntryImporter, self).normalize_host_object(entry)
data = super().normalize_host_object(entry)
if not data:
return
if entry['is_running']:
log.debug("Harvest time entry is still running: %s", entry)
return
data['user_id'] = entry['user']['id']
data['client_id'] = entry['client']['id']
data['project_id'] = entry['project']['id']
if data['project_id'] not in self.harvest_projects_by_id:
log.warning("time entry references non-existent project id %s: %s",
data['project_id'], entry)
data['project_id'] = None
data['task_id'] = entry['task']['id']
data['invoice_id'] = entry['invoice']['id'] if entry['invoice'] else None
# project_id
if 'project_id' in self.fields:
data['project_id'] = entry['project']['id']
project = self.get_harvest_project(data['project_id'])
if not project:
logger = log.warning if self.warn_for_unknown_project else log.debug
logger("time entry references non-existent project id %s: %s",
data['project_id'], entry)
if not self.auto_create_unknown_project:
data['project_id'] = None
# spent_date
spent_date = data['spent_date']
if spent_date:

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2022 Lance Edgar
# Copyright © 2010-2023 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,30 +24,86 @@
rattail-harvest model importers
"""
import logging
from rattail.importing.model import ToRattail
from rattail_harvest.db import model
log = logging.getLogger(__name__)
##############################
# harvest cache models
##############################
class HarvestUserImporter(ToRattail):
model_class = model.HarvestUser
class HarvestCacheUserImporter(ToRattail):
model_class = model.HarvestCacheUser
class HarvestClientImporter(ToRattail):
model_class = model.HarvestClient
class HarvestCacheClientImporter(ToRattail):
model_class = model.HarvestCacheClient
class HarvestProjectImporter(ToRattail):
model_class = model.HarvestProject
class HarvestCacheProjectImporter(ToRattail):
model_class = model.HarvestCacheProject
class HarvestTaskImporter(ToRattail):
model_class = model.HarvestTask
class HarvestCacheTaskImporter(ToRattail):
model_class = model.HarvestCacheTask
class HarvestTimeEntryImporter(ToRattail):
model_class = model.HarvestTimeEntry
class HarvestCacheTimeEntryImporter(ToRattail):
model_class = model.HarvestCacheTimeEntry
# flags to auto-create records for "unknown" references
auto_create_unknown_project = True
# flags to log warning vs. debug for "unknown" references
warn_for_unknown_project = True
def setup(self):
super().setup()
model = self.model
if 'project_id' in self.fields:
self.harvest_projects_by_id = self.app.cache_model(
self.session, model.HarvestCacheProject, key='id')
def cache_query(self):
query = super(HarvestTimeEntryImporter, self).cache_query()
return query.filter(self.model_class.spent_date >= self.start_date)\
.filter(self.model_class.spent_date <= self.end_date)
query = super().cache_query()
if self.start_date:
query = query.filter(self.model_class.spent_date >= self.start_date)
if self.end_date:
query = query.filter(self.model_class.spent_date <= self.end_date)
return query
def get_harvest_project(self, project_id):
if hasattr(self, 'harvest_projects_by_id'):
return self.harvest_projects_by_id.get(project_id)
model = self.model
return self.session.query(model.HarvestCacheProject)\
.filter(model.HarvestCacheProject.id == project_id)\
.first()
def update_object(self, entry, data, local_data=None):
entry = super().update_object(entry, data, local_data)
model = self.model
if 'project_id' in self.fields:
project_id = data['project_id']
project = self.get_harvest_project(project_id)
if not project:
logger = log.warning if self.warn_for_unknown_project else log.debug
logger("unknown project id %s for time entry id %s: %s",
project_id, entry.id, entry)
if self.auto_create_unknown_project:
project = model.HarvestCacheProject()
project.id = project_id
project.name = "(unknown)"
self.session.add(project)
if hasattr(self, 'harvest_projects_by_id'):
self.harvest_projects_by_id[project_id] = project
elif entry.project_id:
entry.project_id = None
return entry

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2022 Lance Edgar
# Copyright © 2010-2023 Lance Edgar
#
# This file is part of Rattail.
#
@ -34,11 +34,11 @@ class FromRattailToRattailHarvestMixin(object):
"""
def add_harvest_importers(self, importers):
importers['HarvestUser'] = HarvestUserImporter
importers['HarvestClient'] = HarvestClientImporter
importers['HarvestProject'] = HarvestProjectImporter
importers['HarvestTask'] = HarvestTaskImporter
importers['HarvestTimeEntry'] = HarvestTimeEntryImporter
importers['HarvestCacheUser'] = HarvestCacheUserImporter
importers['HarvestCacheClient'] = HarvestCacheClientImporter
importers['HarvestCacheProject'] = HarvestCacheProjectImporter
importers['HarvestCacheTask'] = HarvestCacheTaskImporter
importers['HarvestCacheTimeEntry'] = HarvestCacheTimeEntryImporter
return importers
@ -46,17 +46,26 @@ class FromRattailToRattailHarvestMixin(object):
# harvest cache models
##############################
class HarvestUserImporter(base.FromRattail, rattail_harvest_importing.model.HarvestUserImporter):
class HarvestCacheUserImporter(base.FromRattail, rattail_harvest_importing.model.HarvestCacheUserImporter):
pass
class HarvestClientImporter(base.FromRattail, rattail_harvest_importing.model.HarvestClientImporter):
class HarvestCacheClientImporter(base.FromRattail, rattail_harvest_importing.model.HarvestCacheClientImporter):
pass
class HarvestProjectImporter(base.FromRattail, rattail_harvest_importing.model.HarvestProjectImporter):
class HarvestCacheProjectImporter(base.FromRattail, rattail_harvest_importing.model.HarvestCacheProjectImporter):
pass
class HarvestTaskImporter(base.FromRattail, rattail_harvest_importing.model.HarvestTaskImporter):
class HarvestCacheTaskImporter(base.FromRattail, rattail_harvest_importing.model.HarvestCacheTaskImporter):
pass
class HarvestTimeEntryImporter(base.FromRattail, rattail_harvest_importing.model.HarvestTimeEntryImporter):
pass
class HarvestCacheTimeEntryImporter(base.FromRattail, rattail_harvest_importing.model.HarvestCacheTimeEntryImporter):
def query(self):
query = super().query()
if self.start_date:
query = query.filter(self.model_class.spent_date >= self.start_date)
if self.end_date:
query = query.filter(self.model_class.spent_date <= self.end_date)
return query

View file

@ -0,0 +1,73 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2023 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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Rattail -> Rattail "versions" data import
"""
from rattail.importing import versions as base
class HarvestVersionMixin(object):
def add_harvest_importers(self, importers):
importers['HarvestCacheUser'] = HarvestCacheUserImporter
importers['HarvestCacheClient'] = HarvestCacheClientImporter
importers['HarvestCacheProject'] = HarvestCacheProjectImporter
importers['HarvestCacheTask'] = HarvestCacheTaskImporter
importers['HarvestCacheTimeEntry'] = HarvestCacheTimeEntryImporter
return importers
class HarvestCacheUserImporter(base.VersionImporter):
@property
def host_model_class(self):
return self.model.HarvestCacheUser
class HarvestCacheClientImporter(base.VersionImporter):
@property
def host_model_class(self):
return self.model.HarvestCacheClient
class HarvestCacheProjectImporter(base.VersionImporter):
@property
def host_model_class(self):
return self.model.HarvestCacheProject
class HarvestCacheTaskImporter(base.VersionImporter):
@property
def host_model_class(self):
return self.model.HarvestCacheTask
class HarvestCacheTimeEntryImporter(base.VersionImporter):
@property
def host_model_class(self):
return self.model.HarvestCacheTimeEntry

106
setup.py
View file

@ -1,106 +0,0 @@
# -*- 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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
rattail-harvest setup script
"""
import os
from setuptools import setup, find_packages
here = os.path.abspath(os.path.dirname(__file__))
exec(open(os.path.join(here, 'rattail_harvest', '_version.py')).read())
README = open(os.path.join(here, 'README.rst')).read()
requires = [
#
# Version numbers within comments below have specific meanings.
# Basically the 'low' value is a "soft low," and 'high' a "soft high."
# In other words:
#
# If either a 'low' or 'high' value exists, the primary point to be
# made about the value is that it represents the most current (stable)
# version available for the package (assuming typical public access
# methods) whenever this project was started and/or documented.
# Therefore:
#
# If a 'low' version is present, you should know that attempts to use
# versions of the package significantly older than the 'low' version
# may not yield happy results. (A "hard" high limit may or may not be
# indicated by a true version requirement.)
#
# Similarly, if a 'high' version is present, and especially if this
# project has laid dormant for a while, you may need to refactor a bit
# when attempting to support a more recent version of the package. (A
# "hard" low limit should be indicated by a true version requirement
# when a 'high' version is present.)
#
# In any case, developers and other users are encouraged to play
# outside the lines with regard to these soft limits. If bugs are
# encountered then they should be filed as such.
#
# package # low high
'invoke', # 1.5.0
'rattail[db]', # 0.9.246
]
setup(
name = "rattail-harvest",
version = __version__,
author = "Lance Edgar",
author_email = "lance@edbob.org",
url = "https://rattailproject.org/",
description = "Rattail integration package for Harvest",
long_description = README,
classifiers = [
'Development Status :: 3 - Alpha',
'Environment :: Console',
'Intended Audience :: Developers',
'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)',
'Natural Language :: English',
'Operating System :: OS Independent',
'Programming Language :: Python',
'Programming Language :: Python :: 3',
'Topic :: Office/Business',
'Topic :: Software Development :: Libraries :: Python Modules',
],
install_requires = requires,
packages = find_packages(),
include_package_data = True,
entry_points = {
'rattail.commands': [
'import-harvest = rattail_harvest.commands:ImportHarvest',
],
'rattail.importing': [
'to_rattail.from_harvest.import = rattail_harvest.importing.harvest:FromHarvestToRattail',
],
},
)

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2022 Lance Edgar
# Copyright © 2010-2024 Lance Edgar
#
# This file is part of Rattail.
#
@ -25,24 +25,36 @@ Tasks for rattail-harvest
"""
import os
import re
import shutil
from invoke import task
here = os.path.abspath(os.path.dirname(__file__))
exec(open(os.path.join(here, 'rattail_harvest', '_version.py')).read())
__version__ = None
pattern = re.compile(r'^version = "(\d+\.\d+\.\d+)"$')
with open(os.path.join(here, 'pyproject.toml'), 'rt') as f:
for line in f:
line = line.rstrip('\n')
match = pattern.match(line)
if match:
__version__ = match.group(1)
break
if not __version__:
raise RuntimeError("could not parse version!")
@task
def release(ctx):
def release(c):
"""
Release a new version of rattail-harvest
"""
# rebuild local tar.gz file for distribution
shutil.rmtree('rattail_harvest.egg-info')
ctx.run('python setup.py sdist --formats=gztar')
if os.path.exists('rattail_harvest.egg-info'):
shutil.rmtree('rattail_harvest.egg-info')
c.run('python -m build --sdist')
# upload to public PyPI
filename = 'rattail-harvest-{}.tar.gz'.format(__version__)
ctx.run('twine upload dist/{}'.format(filename))
filename = f'rattail_harvest-{__version__}.tar.gz'
c.run(f'twine upload dist/{filename}')