diff --git a/hamstertools/__init__.py b/hamstertools/__init__.py index d145cd7..1d610c1 100755 --- a/hamstertools/__init__.py +++ b/hamstertools/__init__.py @@ -16,6 +16,8 @@ from textual.logging import TextualHandler from xdg.BaseDirectory import xdg_config_home +from hamstertools.clockify import export_fact + from .db import ( KimaiTag, @@ -31,7 +33,7 @@ from .db import ( HamsterFactTag, ClockifyProject, HamsterClockifyMapping, - HamsterFactClockifyImport + HamsterFactClockifyImport, ) from .kimaiapi import KimaiAPI, Timesheet from .sync import sync @@ -66,7 +68,7 @@ def cli(ctx, config, debug): requests_log.propagate = True ctx.ensure_object(dict) - ctx.obj['config'] = file_config + ctx.obj["config"] = file_config @cli.group() @@ -337,11 +339,17 @@ def find_duplicates(): @click.option("--kimai-api-key", envvar="KIMAI_API_KEY") @click.pass_context def kimai(ctx, kimai_api_url, kimai_username, kimai_api_key): - file_config = ctx.obj['config'] - ctx.obj['kimai'] = KimaiAPI( - kimai_username if kimai_username is not None 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"), + file_config = ctx.obj["config"] + ctx.obj["kimai"] = KimaiAPI( + kimai_username + if kimai_username is not None + 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 @@ -513,13 +521,12 @@ def check(username, api_key, just_errors, ignore_activities, mapping_path=None): found_activities.append(activity_str) - @kimai.command("import") @click.argument("search") @click.argument("after") @click.argument("before") @click.pass_context -def _import(ctx, search, after, before): +def kimai_import(ctx, search, after, before): api = ctx.obj SEARCH = "auto" @@ -559,7 +566,8 @@ def _import(ctx, search, after, before): and not mappings[0].kimai_project.allow_global_activities ): 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 continue @@ -598,11 +606,17 @@ def _import(ctx, search, after, before): description=f.description if f.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() 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"]) if r.get("code", 200) != 200: print(r) @@ -610,7 +624,7 @@ def _import(ctx, search, after, before): else: 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") @@ -640,9 +654,7 @@ def reset(): @kimai_db.command("sync") @click.pass_context def kimai_db_sync(ctx): - sync( - ctx.obj['kimai'] - ) + sync(ctx.obj["kimai"]) @kimai_db.command("mapping2db") @@ -687,19 +699,23 @@ def kimai_mapping2db(mapping_path=None, global_=False): def kimai_app(ctx): from .app import HamsterToolsAppKimai - app = HamsterToolsAppKimai(ctx.obj['kimai']) + app = HamsterToolsAppKimai(ctx.obj["kimai"]) app.run() @cli.group() @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 def clockify(ctx, api_key, workspace_id): - file_config = ctx.obj['config'] - ctx.obj['clockify'] = Clockify( - 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") + file_config = ctx.obj["config"] + ctx.obj["clockify"] = Clockify( + 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"), ) @@ -712,7 +728,8 @@ def clockify_db(): @click.pass_context def clockify_db_sync(ctx): 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") @@ -726,10 +743,12 @@ def clockify_db_init(): ] ) + @clockify_db.command("reset") def clockify_db_reset(): HamsterClockifyMapping.delete().execute() + @clockify_db.command("mapping2db") @click.argument("mapping-path") def clockify_mapping2db(mapping_path): @@ -746,16 +765,92 @@ def clockify_mapping2db(mapping_path): HamsterClockifyMapping.create( hamster_activity=hamster_activity, - clockify_project=clockify_project, + clockify_project=clockify_project.clockify_id, 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") @click.pass_context def clockify_app(ctx): from .app import HamsterToolsAppClockify - app = HamsterToolsAppClockify(ctx.obj['clockify']) + app = HamsterToolsAppClockify(ctx.obj["clockify"]) app.run() diff --git a/hamstertools/clockify.py b/hamstertools/clockify.py index cf69936..7e184ab 100644 --- a/hamstertools/clockify.py +++ b/hamstertools/clockify.py @@ -50,15 +50,13 @@ def sync_projects(api, db): 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""" - start = fact.start_time.isoformat() - end = fact.end_time.isoformat() if fact.end_time else datetime.now().isoformat() - - time_entry = api.create_time_entry( + time_entry = api.time_entries.create( project_id=project_id, start=start, end=end, - description=fact.description + description=description, + billable=True ) - return time_entry.id + return time_entry['id'] diff --git a/hamstertools/db.py b/hamstertools/db.py index 4658830..d0b7069 100644 --- a/hamstertools/db.py +++ b/hamstertools/db.py @@ -101,6 +101,7 @@ class KimaiTag(Model): class HamsterActivityKimaiMapping(Model): + # TODO: Rename these backrefs to kimai_mappings hamster_activity = ForeignKeyField(HamsterActivity, backref="mappings") kimai_customer = ForeignKeyField(KimaiCustomer, backref="mappings") kimai_project = ForeignKeyField(KimaiProject, backref="mappings")