Add Clockify import, 4matting
This commit is contained in:
@ -16,6 +16,8 @@ from textual.logging import TextualHandler
|
|||||||
|
|
||||||
from xdg.BaseDirectory import xdg_config_home
|
from xdg.BaseDirectory import xdg_config_home
|
||||||
|
|
||||||
|
from hamstertools.clockify import export_fact
|
||||||
|
|
||||||
|
|
||||||
from .db import (
|
from .db import (
|
||||||
KimaiTag,
|
KimaiTag,
|
||||||
@ -31,7 +33,7 @@ from .db import (
|
|||||||
HamsterFactTag,
|
HamsterFactTag,
|
||||||
ClockifyProject,
|
ClockifyProject,
|
||||||
HamsterClockifyMapping,
|
HamsterClockifyMapping,
|
||||||
HamsterFactClockifyImport
|
HamsterFactClockifyImport,
|
||||||
)
|
)
|
||||||
from .kimaiapi import KimaiAPI, Timesheet
|
from .kimaiapi import KimaiAPI, Timesheet
|
||||||
from .sync import sync
|
from .sync import sync
|
||||||
@ -66,7 +68,7 @@ def cli(ctx, config, debug):
|
|||||||
requests_log.propagate = True
|
requests_log.propagate = True
|
||||||
|
|
||||||
ctx.ensure_object(dict)
|
ctx.ensure_object(dict)
|
||||||
ctx.obj['config'] = file_config
|
ctx.obj["config"] = file_config
|
||||||
|
|
||||||
|
|
||||||
@cli.group()
|
@cli.group()
|
||||||
@ -337,11 +339,17 @@ def find_duplicates():
|
|||||||
@click.option("--kimai-api-key", envvar="KIMAI_API_KEY")
|
@click.option("--kimai-api-key", envvar="KIMAI_API_KEY")
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def kimai(ctx, kimai_api_url, kimai_username, kimai_api_key):
|
def kimai(ctx, kimai_api_url, kimai_username, kimai_api_key):
|
||||||
file_config = ctx.obj['config']
|
file_config = ctx.obj["config"]
|
||||||
ctx.obj['kimai'] = KimaiAPI(
|
ctx.obj["kimai"] = KimaiAPI(
|
||||||
kimai_username if kimai_username is not None else file_config.get("kimai_username"),
|
kimai_username
|
||||||
kimai_api_key if kimai_api_key is not None else file_config.get("kimai_api_key"),
|
if kimai_username is not None
|
||||||
kimai_api_url if kimai_api_url is not None else file_config.get("kimai_api_url"),
|
else file_config.get("kimai_username"),
|
||||||
|
kimai_api_key
|
||||||
|
if kimai_api_key is not None
|
||||||
|
else file_config.get("kimai_api_key"),
|
||||||
|
kimai_api_url
|
||||||
|
if kimai_api_url is not None
|
||||||
|
else file_config.get("kimai_api_url"),
|
||||||
)
|
)
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@ -513,13 +521,12 @@ def check(username, api_key, just_errors, ignore_activities, mapping_path=None):
|
|||||||
found_activities.append(activity_str)
|
found_activities.append(activity_str)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@kimai.command("import")
|
@kimai.command("import")
|
||||||
@click.argument("search")
|
@click.argument("search")
|
||||||
@click.argument("after")
|
@click.argument("after")
|
||||||
@click.argument("before")
|
@click.argument("before")
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def _import(ctx, search, after, before):
|
def kimai_import(ctx, search, after, before):
|
||||||
api = ctx.obj
|
api = ctx.obj
|
||||||
|
|
||||||
SEARCH = "auto"
|
SEARCH = "auto"
|
||||||
@ -559,7 +566,8 @@ def _import(ctx, search, after, before):
|
|||||||
and not mappings[0].kimai_project.allow_global_activities
|
and not mappings[0].kimai_project.allow_global_activities
|
||||||
):
|
):
|
||||||
click.secho(
|
click.secho(
|
||||||
f"fact {f.id}: project @{mappings[0].kimai_project.id} {mappings[0].kimai_project.name} does not allow global activity {mappings[0].kimai_activity} ({mappings[0].hamster_activity.name})", fg="red",
|
f"fact {f.id}: project @{mappings[0].kimai_project.id} {mappings[0].kimai_project.name} does not allow global activity {mappings[0].kimai_activity} ({mappings[0].hamster_activity.name})",
|
||||||
|
fg="red",
|
||||||
)
|
)
|
||||||
has_errors = True
|
has_errors = True
|
||||||
continue
|
continue
|
||||||
@ -598,11 +606,17 @@ def _import(ctx, search, after, before):
|
|||||||
description=f.description
|
description=f.description
|
||||||
if f.description != ""
|
if f.description != ""
|
||||||
else mapping.kimai_description,
|
else mapping.kimai_description,
|
||||||
tags=",".join([t.tag.name for t in f.tags]) if len(f.tags) > 0 else mapping.kimai_tags
|
tags=",".join([t.tag.name for t in f.tags])
|
||||||
|
if len(f.tags) > 0
|
||||||
|
else mapping.kimai_tags,
|
||||||
)
|
)
|
||||||
r = t.upload().json()
|
r = t.upload().json()
|
||||||
if len(f.tags) > 0 or mapping.kimai_tags:
|
if len(f.tags) > 0 or mapping.kimai_tags:
|
||||||
print(",".join([t.tag.name for t in f.tags]) if len(f.tags)> 0 else mapping.kimai_tags)
|
print(
|
||||||
|
",".join([t.tag.name for t in f.tags])
|
||||||
|
if len(f.tags) > 0
|
||||||
|
else mapping.kimai_tags
|
||||||
|
)
|
||||||
print(r["tags"])
|
print(r["tags"])
|
||||||
if r.get("code", 200) != 200:
|
if r.get("code", 200) != 200:
|
||||||
print(r)
|
print(r)
|
||||||
@ -610,7 +624,7 @@ def _import(ctx, search, after, before):
|
|||||||
|
|
||||||
else:
|
else:
|
||||||
HamsterFactKimaiImport.create(hamster_fact=f, kimai_id=r["id"]).save()
|
HamsterFactKimaiImport.create(hamster_fact=f, kimai_id=r["id"]).save()
|
||||||
print(f'Created Kimai timesheet {r["id"]}')
|
print(f"Created Kimai timesheet {r['id']}")
|
||||||
|
|
||||||
|
|
||||||
@kimai.group("db")
|
@kimai.group("db")
|
||||||
@ -640,9 +654,7 @@ def reset():
|
|||||||
@kimai_db.command("sync")
|
@kimai_db.command("sync")
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def kimai_db_sync(ctx):
|
def kimai_db_sync(ctx):
|
||||||
sync(
|
sync(ctx.obj["kimai"])
|
||||||
ctx.obj['kimai']
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@kimai_db.command("mapping2db")
|
@kimai_db.command("mapping2db")
|
||||||
@ -687,19 +699,23 @@ def kimai_mapping2db(mapping_path=None, global_=False):
|
|||||||
def kimai_app(ctx):
|
def kimai_app(ctx):
|
||||||
from .app import HamsterToolsAppKimai
|
from .app import HamsterToolsAppKimai
|
||||||
|
|
||||||
app = HamsterToolsAppKimai(ctx.obj['kimai'])
|
app = HamsterToolsAppKimai(ctx.obj["kimai"])
|
||||||
app.run()
|
app.run()
|
||||||
|
|
||||||
|
|
||||||
@cli.group()
|
@cli.group()
|
||||||
@click.option("--api-key", envvar="CLOCKIFY_API_KEY", help="Clockify API key")
|
@click.option("--api-key", envvar="CLOCKIFY_API_KEY", help="Clockify API key")
|
||||||
@click.option("--workspace-id", envvar="CLOCKIFY_WORKSPACE_ID", help="Clockify workspace ID")
|
@click.option(
|
||||||
|
"--workspace-id", envvar="CLOCKIFY_WORKSPACE_ID", help="Clockify workspace ID"
|
||||||
|
)
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def clockify(ctx, api_key, workspace_id):
|
def clockify(ctx, api_key, workspace_id):
|
||||||
file_config = ctx.obj['config']
|
file_config = ctx.obj["config"]
|
||||||
ctx.obj['clockify'] = Clockify(
|
ctx.obj["clockify"] = Clockify(
|
||||||
api_key=api_key if api_key is not None else file_config.get("clockify_api_key"),
|
api_key=api_key if api_key is not None else file_config.get("clockify_api_key"),
|
||||||
workspace_id=workspace_id if workspace_id is not None else file_config.get("clockify_workspace_id")
|
workspace_id=workspace_id
|
||||||
|
if workspace_id is not None
|
||||||
|
else file_config.get("clockify_workspace_id"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -712,7 +728,8 @@ def clockify_db():
|
|||||||
@click.pass_context
|
@click.pass_context
|
||||||
def clockify_db_sync(ctx):
|
def clockify_db_sync(ctx):
|
||||||
from .clockify import sync_projects
|
from .clockify import sync_projects
|
||||||
count = sync_projects(ctx.obj['clockify'], db)
|
|
||||||
|
count = sync_projects(ctx.obj["clockify"], db)
|
||||||
click.echo(f"Synced {count} Clockify projects")
|
click.echo(f"Synced {count} Clockify projects")
|
||||||
|
|
||||||
|
|
||||||
@ -726,10 +743,12 @@ def clockify_db_init():
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@clockify_db.command("reset")
|
@clockify_db.command("reset")
|
||||||
def clockify_db_reset():
|
def clockify_db_reset():
|
||||||
HamsterClockifyMapping.delete().execute()
|
HamsterClockifyMapping.delete().execute()
|
||||||
|
|
||||||
|
|
||||||
@clockify_db.command("mapping2db")
|
@clockify_db.command("mapping2db")
|
||||||
@click.argument("mapping-path")
|
@click.argument("mapping-path")
|
||||||
def clockify_mapping2db(mapping_path):
|
def clockify_mapping2db(mapping_path):
|
||||||
@ -746,16 +765,92 @@ def clockify_mapping2db(mapping_path):
|
|||||||
|
|
||||||
HamsterClockifyMapping.create(
|
HamsterClockifyMapping.create(
|
||||||
hamster_activity=hamster_activity,
|
hamster_activity=hamster_activity,
|
||||||
clockify_project=clockify_project,
|
clockify_project=clockify_project.clockify_id,
|
||||||
clockify_description=row[3],
|
clockify_description=row[3],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@clockify.command("import")
|
||||||
|
@click.argument("search")
|
||||||
|
@click.argument("after")
|
||||||
|
@click.argument("before")
|
||||||
|
@click.pass_context
|
||||||
|
def clockify_import(ctx, search, after, before):
|
||||||
|
api = ctx.obj['clockify']
|
||||||
|
|
||||||
|
facts = (
|
||||||
|
HamsterFact.select(HamsterFact, HamsterActivity, HamsterCategory)
|
||||||
|
.join(HamsterActivity, JOIN.LEFT_OUTER)
|
||||||
|
.join(HamsterCategory, JOIN.LEFT_OUTER)
|
||||||
|
.switch(HamsterFact)
|
||||||
|
.join(HamsterFactTag, JOIN.LEFT_OUTER)
|
||||||
|
.where(
|
||||||
|
(HamsterFact.start_time > datetime.strptime(after, "%Y-%m-%d"))
|
||||||
|
& (HamsterFact.start_time < datetime.strptime(before, "%Y-%m-%d"))
|
||||||
|
& HamsterCategory.name.contains(search)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
has_errors = False
|
||||||
|
|
||||||
|
# check data
|
||||||
|
for f in facts:
|
||||||
|
mappings = f.activity.clockify_mappings
|
||||||
|
if len(mappings) == 0:
|
||||||
|
print(
|
||||||
|
f"fact {f.id}: @{f.activity.category.id} {f.activity.category.name} » @{f.activity.id} {f.activity.name} has no mapping"
|
||||||
|
)
|
||||||
|
has_errors = True
|
||||||
|
continue
|
||||||
|
if len(mappings) > 1:
|
||||||
|
print(
|
||||||
|
f"fact {f.id}: activity @{f.activity.id} {f.activity.name} has multiple mappings"
|
||||||
|
)
|
||||||
|
has_errors = True
|
||||||
|
continue
|
||||||
|
if f.clockify_imports.count() > 0:
|
||||||
|
click.secho(
|
||||||
|
f"fact {f.id}: activity @{f.activity.id} {f.activity.name} was already imported {f.clockify_imports.count()} time(s)",
|
||||||
|
fg="yellow",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if has_errors:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# upload data
|
||||||
|
for f in facts:
|
||||||
|
try:
|
||||||
|
mapping = f.activity.clockify_mappings[0]
|
||||||
|
except IndexError:
|
||||||
|
print(
|
||||||
|
f"no mapping, skipping {f.id} ({f.activity.category.name} » {f.activity.name})"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if f.clockify_imports.count() > 0:
|
||||||
|
print(
|
||||||
|
f"already imported, skipping {f.id} ({f.activity.category.name} » {f.activity.name})"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
t = export_fact(
|
||||||
|
api,
|
||||||
|
mapping.clockify_project_id,
|
||||||
|
f.start_time,
|
||||||
|
f.end_time,
|
||||||
|
f.description if f.description is not None else mapping.clockify_description,
|
||||||
|
)
|
||||||
|
HamsterFactClockifyImport.create(hamster_fact=f, clockify_time_entry_id=t).save()
|
||||||
|
print(f"Created Clockify timesheet {t}")
|
||||||
|
|
||||||
|
|
||||||
@clockify.command("app")
|
@clockify.command("app")
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def clockify_app(ctx):
|
def clockify_app(ctx):
|
||||||
from .app import HamsterToolsAppClockify
|
from .app import HamsterToolsAppClockify
|
||||||
|
|
||||||
app = HamsterToolsAppClockify(ctx.obj['clockify'])
|
app = HamsterToolsAppClockify(ctx.obj["clockify"])
|
||||||
app.run()
|
app.run()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -50,15 +50,13 @@ def sync_projects(api, db):
|
|||||||
return len(projects)
|
return len(projects)
|
||||||
|
|
||||||
|
|
||||||
def export_fact(api, fact, project_id):
|
def export_fact(api, project_id, start, end, description=None):
|
||||||
"""Export a Hamster fact to Clockify as a time entry"""
|
"""Export a Hamster fact to Clockify as a time entry"""
|
||||||
start = fact.start_time.isoformat()
|
time_entry = api.time_entries.create(
|
||||||
end = fact.end_time.isoformat() if fact.end_time else datetime.now().isoformat()
|
|
||||||
|
|
||||||
time_entry = api.create_time_entry(
|
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
start=start,
|
start=start,
|
||||||
end=end,
|
end=end,
|
||||||
description=fact.description
|
description=description,
|
||||||
|
billable=True
|
||||||
)
|
)
|
||||||
return time_entry.id
|
return time_entry['id']
|
||||||
|
|||||||
@ -101,6 +101,7 @@ class KimaiTag(Model):
|
|||||||
|
|
||||||
|
|
||||||
class HamsterActivityKimaiMapping(Model):
|
class HamsterActivityKimaiMapping(Model):
|
||||||
|
# TODO: Rename these backrefs to kimai_mappings
|
||||||
hamster_activity = ForeignKeyField(HamsterActivity, backref="mappings")
|
hamster_activity = ForeignKeyField(HamsterActivity, backref="mappings")
|
||||||
kimai_customer = ForeignKeyField(KimaiCustomer, backref="mappings")
|
kimai_customer = ForeignKeyField(KimaiCustomer, backref="mappings")
|
||||||
kimai_project = ForeignKeyField(KimaiProject, backref="mappings")
|
kimai_project = ForeignKeyField(KimaiProject, backref="mappings")
|
||||||
|
|||||||
Reference in New Issue
Block a user