Compare commits

..

No commits in common. "master" and "v0.1.1" have entirely different histories.

26 changed files with 245 additions and 1248 deletions

3
.gitignore vendored
View file

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

View file

@ -5,36 +5,6 @@ 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/) 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). 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 ## [0.1.1] - 2022-01-29
### Added ### Added
- Initial version. - Initial version.

View file

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

View file

@ -1,11 +0,0 @@
# 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.

14
README.rst Normal file
View file

@ -0,0 +1,14 @@
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/

View file

@ -1,53 +0,0 @@
[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,6 +1,3 @@
# -*- coding: utf-8; -*- # -*- coding: utf-8; -*-
from importlib.metadata import version __version__ = '0.1.1'
__version__ = version('rattail-harvest')

View file

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

View file

@ -1,42 +0,0 @@
# -*- 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

@ -1,179 +0,0 @@
# -*- 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

@ -1,33 +0,0 @@
# -*- 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

@ -1,35 +0,0 @@
# -*- 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

@ -1,105 +0,0 @@
# -*- 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

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

View file

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

View file

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

View file

@ -1,27 +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/>.
#
################################################################################
"""
Exporting data to Harvest
"""
from . import model

View file

@ -1,169 +0,0 @@
# -*- 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,12 +58,6 @@ class HarvestWebAPI(object):
elif request_method == 'POST': elif request_method == 'POST':
response = requests.post('{}/{}'.format(self.base_url, api_method), response = requests.post('{}/{}'.format(self.base_url, api_method),
headers=headers, params=params) 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: else:
raise NotImplementedError("unknown request method: {}".format( raise NotImplementedError("unknown request method: {}".format(
request_method)) request_method))
@ -82,18 +76,6 @@ class HarvestWebAPI(object):
""" """
return self._request('POST', api_method, params=params) 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): def get_company(self):
""" """
Retrieves the company for the currently authenticated user. Retrieves the company for the currently authenticated user.
@ -131,17 +113,7 @@ class HarvestWebAPI(object):
https://help.getharvest.com/api-v2/projects-api/projects/projects/#list-all-projects https://help.getharvest.com/api-v2/projects-api/projects/projects/#list-all-projects
""" """
response = self.get('/projects', params=kwargs) response = self.get('/projects', params=kwargs)
data = response.json() return 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): def get_tasks(self, **kwargs):
""" """
@ -179,15 +151,10 @@ class HarvestWebAPI(object):
https://help.getharvest.com/api-v2/timesheets-api/timesheets/time-entries/#retrieve-a-time-entry https://help.getharvest.com/api-v2/timesheets-api/timesheets/time-entries/#retrieve-a-time-entry
""" """
try: response = self.get('/time_entries/{}'.format(time_entry_id))
response = self.get('/time_entries/{}'.format(time_entry_id)) return response.json()
except requests.exceptions.HTTPError as error:
if error.response.status_code != 404:
raise
else:
return response.json()
def create_time_entry(self, **kwargs): def put_time_entry(self, **kwargs):
""" """
Create a new time entry. All kwargs are passed on as POST parameters. Create a new time entry. All kwargs are passed on as POST parameters.
@ -200,41 +167,3 @@ class HarvestWebAPI(object):
raise ValueError("must provide all of: {}".format(', '.join(required))) raise ValueError("must provide all of: {}".format(', '.join(required)))
response = self.post('/time_entries', params=kwargs) response = self.post('/time_entries', params=kwargs)
return response.json() 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 # Rattail -- Retail Software Framework
# Copyright © 2010-2023 Lance Edgar # Copyright © 2010-2022 Lance Edgar
# #
# This file is part of Rattail. # This file is part of Rattail.
# #
@ -27,13 +27,11 @@ Harvest -> Rattail "cache" data import
import datetime import datetime
import decimal import decimal
import logging import logging
from collections import OrderedDict
import sqlalchemy as sa
from rattail import importing from rattail import importing
from rattail.util import OrderedDict
from rattail_harvest import importing as rattail_harvest_importing from rattail_harvest import importing as rattail_harvest_importing
from rattail_harvest.harvest.webapi import make_harvest_webapi from rattail_harvest.harvest.webapi import HarvestWebAPI
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -49,11 +47,11 @@ class FromHarvestToRattail(importing.ToRattailHandler):
def get_importers(self): def get_importers(self):
importers = OrderedDict() importers = OrderedDict()
importers['HarvestCacheUser'] = HarvestCacheUserImporter importers['HarvestUser'] = HarvestUserImporter
importers['HarvestCacheClient'] = HarvestCacheClientImporter importers['HarvestClient'] = HarvestClientImporter
importers['HarvestCacheProject'] = HarvestCacheProjectImporter importers['HarvestProject'] = HarvestProjectImporter
importers['HarvestCacheTask'] = HarvestCacheTaskImporter importers['HarvestTask'] = HarvestTaskImporter
importers['HarvestCacheTimeEntry'] = HarvestCacheTimeEntryImporter importers['HarvestTimeEntry'] = HarvestTimeEntryImporter
return importers return importers
@ -71,7 +69,13 @@ class FromHarvest(importing.Importer):
def setup(self): def setup(self):
super(FromHarvest, self).setup() super(FromHarvest, self).setup()
self.webapi = make_harvest_webapi(self.config)
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)
def time_from_harvest(self, value): def time_from_harvest(self, value):
# all harvest times appear to come as UTC, so no conversion needed # all harvest times appear to come as UTC, so no conversion needed
@ -90,17 +94,14 @@ class FromHarvest(importing.Importer):
return data return data
class HarvestCacheUserImporter(FromHarvest, rattail_harvest_importing.model.HarvestCacheUserImporter): class HarvestUserImporter(FromHarvest, rattail_harvest_importing.model.HarvestUserImporter):
""" """
Import user data from Harvest Import user data from Harvest
""" """
@property @property
def supported_fields(self): def supported_fields(self):
fields = list(super().supported_fields) 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? # this used to be in harvest i thought, but is no longer?
fields.remove('name') fields.remove('name')
@ -110,28 +111,8 @@ class HarvestCacheUserImporter(FromHarvest, rattail_harvest_importing.model.Harv
def get_host_objects(self): def get_host_objects(self):
return self.webapi.get_users()['users'] return self.webapi.get_users()['users']
def normalize_host_object(self, user):
data = super().normalize_host_object(user)
if data:
# TODO: for some reason the API used to include the these class HarvestClientImporter(FromHarvest, rattail_harvest_importing.model.HarvestClientImporter):
# 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 Import client data from Harvest
""" """
@ -140,32 +121,16 @@ class HarvestCacheClientImporter(FromHarvest, rattail_harvest_importing.model.Ha
return self.webapi.get_clients()['clients'] return self.webapi.get_clients()['clients']
class HarvestCacheProjectImporter(FromHarvest, rattail_harvest_importing.model.HarvestCacheProjectImporter): class HarvestProjectImporter(FromHarvest, rattail_harvest_importing.model.HarvestProjectImporter):
""" """
Import project data from Harvest 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): def get_host_objects(self):
return self.webapi.get_projects() return self.webapi.get_projects()['projects']
def normalize_host_object(self, project): def normalize_host_object(self, project):
data = super().normalize_host_object(project) data = super(HarvestProjectImporter, self).normalize_host_object(project)
if not data: if not data:
return return
@ -203,15 +168,8 @@ class HarvestCacheProjectImporter(FromHarvest, rattail_harvest_importing.model.H
return data return data
def can_delete_object(self, project, data):
return not project.deleted
def delete_object(self, project): class HarvestTaskImporter(FromHarvest, rattail_harvest_importing.model.HarvestTaskImporter):
project.deleted = True
return True
class HarvestCacheTaskImporter(FromHarvest, rattail_harvest_importing.model.HarvestCacheTaskImporter):
""" """
Import task data from Harvest Import task data from Harvest
""" """
@ -220,49 +178,40 @@ class HarvestCacheTaskImporter(FromHarvest, rattail_harvest_importing.model.Harv
return self.webapi.get_tasks()['tasks'] return self.webapi.get_tasks()['tasks']
class HarvestCacheTimeEntryImporter(FromHarvest, rattail_harvest_importing.model.HarvestCacheTimeEntryImporter): class HarvestTimeEntryImporter(FromHarvest, rattail_harvest_importing.model.HarvestTimeEntryImporter):
""" """
Import time entry data from Harvest Import time entry data from Harvest
""" """
def get_host_objects(self): def setup(self):
kw = {} super(HarvestTimeEntryImporter, self).setup()
if self.start_date: model = self.model
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): self.harvest_projects_by_id = self.app.cache_model(self.session,
assert len(self.key) == 1 and self.key[0] == 'id' model.HarvestProject,
entry_id = key[0] key='id')
return self.webapi.get_time_entry(entry_id)
def get_host_objects(self):
return self.webapi.get_time_entries(**{'from': self.start_date,
'to': self.end_date})
def normalize_host_object(self, entry): def normalize_host_object(self, entry):
data = super().normalize_host_object(entry) data = super(HarvestTimeEntryImporter, self).normalize_host_object(entry)
if not data: if not data:
return return
if entry['is_running']:
log.debug("Harvest time entry is still running: %s", entry)
return
data['user_id'] = entry['user']['id'] data['user_id'] = entry['user']['id']
data['client_id'] = entry['client']['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['task_id'] = entry['task']['id']
data['invoice_id'] = entry['invoice']['id'] if entry['invoice'] else None 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
spent_date = data['spent_date'] spent_date = data['spent_date']
if spent_date: if spent_date:

View file

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# Rattail -- Retail Software Framework # Rattail -- Retail Software Framework
# Copyright © 2010-2023 Lance Edgar # Copyright © 2010-2022 Lance Edgar
# #
# This file is part of Rattail. # This file is part of Rattail.
# #
@ -24,86 +24,30 @@
rattail-harvest model importers rattail-harvest model importers
""" """
import logging
from rattail.importing.model import ToRattail from rattail.importing.model import ToRattail
from rattail_harvest.db import model from rattail_harvest.db import model
log = logging.getLogger(__name__)
############################## ##############################
# harvest cache models # harvest cache models
############################## ##############################
class HarvestCacheUserImporter(ToRattail): class HarvestUserImporter(ToRattail):
model_class = model.HarvestCacheUser model_class = model.HarvestUser
class HarvestCacheClientImporter(ToRattail): class HarvestClientImporter(ToRattail):
model_class = model.HarvestCacheClient model_class = model.HarvestClient
class HarvestCacheProjectImporter(ToRattail): class HarvestProjectImporter(ToRattail):
model_class = model.HarvestCacheProject model_class = model.HarvestProject
class HarvestCacheTaskImporter(ToRattail): class HarvestTaskImporter(ToRattail):
model_class = model.HarvestCacheTask model_class = model.HarvestTask
class HarvestCacheTimeEntryImporter(ToRattail): class HarvestTimeEntryImporter(ToRattail):
model_class = model.HarvestCacheTimeEntry model_class = model.HarvestTimeEntry
# 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): def cache_query(self):
query = super().cache_query() query = super(HarvestTimeEntryImporter, self).cache_query()
return query.filter(self.model_class.spent_date >= self.start_date)\
if self.start_date: .filter(self.model_class.spent_date <= self.end_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 # Rattail -- Retail Software Framework
# Copyright © 2010-2023 Lance Edgar # Copyright © 2010-2022 Lance Edgar
# #
# This file is part of Rattail. # This file is part of Rattail.
# #
@ -34,11 +34,11 @@ class FromRattailToRattailHarvestMixin(object):
""" """
def add_harvest_importers(self, importers): def add_harvest_importers(self, importers):
importers['HarvestCacheUser'] = HarvestCacheUserImporter importers['HarvestUser'] = HarvestUserImporter
importers['HarvestCacheClient'] = HarvestCacheClientImporter importers['HarvestClient'] = HarvestClientImporter
importers['HarvestCacheProject'] = HarvestCacheProjectImporter importers['HarvestProject'] = HarvestProjectImporter
importers['HarvestCacheTask'] = HarvestCacheTaskImporter importers['HarvestTask'] = HarvestTaskImporter
importers['HarvestCacheTimeEntry'] = HarvestCacheTimeEntryImporter importers['HarvestTimeEntry'] = HarvestTimeEntryImporter
return importers return importers
@ -46,26 +46,17 @@ class FromRattailToRattailHarvestMixin(object):
# harvest cache models # harvest cache models
############################## ##############################
class HarvestCacheUserImporter(base.FromRattail, rattail_harvest_importing.model.HarvestCacheUserImporter): class HarvestUserImporter(base.FromRattail, rattail_harvest_importing.model.HarvestUserImporter):
pass pass
class HarvestCacheClientImporter(base.FromRattail, rattail_harvest_importing.model.HarvestCacheClientImporter): class HarvestClientImporter(base.FromRattail, rattail_harvest_importing.model.HarvestClientImporter):
pass pass
class HarvestCacheProjectImporter(base.FromRattail, rattail_harvest_importing.model.HarvestCacheProjectImporter): class HarvestProjectImporter(base.FromRattail, rattail_harvest_importing.model.HarvestProjectImporter):
pass pass
class HarvestCacheTaskImporter(base.FromRattail, rattail_harvest_importing.model.HarvestCacheTaskImporter): class HarvestTaskImporter(base.FromRattail, rattail_harvest_importing.model.HarvestTaskImporter):
pass pass
class HarvestCacheTimeEntryImporter(base.FromRattail, rattail_harvest_importing.model.HarvestCacheTimeEntryImporter): class HarvestTimeEntryImporter(base.FromRattail, rattail_harvest_importing.model.HarvestTimeEntryImporter):
pass
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

@ -1,73 +0,0 @@
# -*- 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 Normal file
View file

@ -0,0 +1,106 @@
# -*- 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 # Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar # Copyright © 2010-2022 Lance Edgar
# #
# This file is part of Rattail. # This file is part of Rattail.
# #
@ -25,36 +25,24 @@ Tasks for rattail-harvest
""" """
import os import os
import re
import shutil import shutil
from invoke import task from invoke import task
here = os.path.abspath(os.path.dirname(__file__)) here = os.path.abspath(os.path.dirname(__file__))
__version__ = None exec(open(os.path.join(here, 'rattail_harvest', '_version.py')).read())
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 @task
def release(c): def release(ctx):
""" """
Release a new version of rattail-harvest Release a new version of rattail-harvest
""" """
# rebuild local tar.gz file for distribution # rebuild local tar.gz file for distribution
if os.path.exists('rattail_harvest.egg-info'): shutil.rmtree('rattail_harvest.egg-info')
shutil.rmtree('rattail_harvest.egg-info') ctx.run('python setup.py sdist --formats=gztar')
c.run('python -m build --sdist')
# upload to public PyPI # upload to public PyPI
filename = f'rattail_harvest-{__version__}.tar.gz' filename = 'rattail-harvest-{}.tar.gz'.format(__version__)
c.run(f'twine upload dist/{filename}') ctx.run('twine upload dist/{}'.format(filename))