Add Clockify import, 4matting

This commit is contained in:
3wc
2025-08-07 15:05:07 +01:00
parent 68998be917
commit a3ccc42a6e
3 changed files with 126 additions and 32 deletions

View File

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

View File

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

View File

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