Compare commits

..

4 Commits

Author SHA1 Message Date
3wc
f7edf18391 Fuckkk yeah, working mapping-adding 2023-10-29 21:49:26 +00:00
3wc
6b8b4c380e black reformat 2023-10-29 13:40:03 +00:00
3wc
ccbbc80116 Fix mapping import, show mapping count on list 2023-10-29 13:38:32 +00:00
3wc
f8f83ce4d4 Finish converting CLI commands to use peewee 2023-10-29 09:28:25 +00:00
4 changed files with 706 additions and 433 deletions

View File

@ -9,11 +9,20 @@ import click
import requests import requests
from peewee import fn, JOIN from peewee import fn, JOIN
from .db import db, HamsterCategory, HamsterActivity, HamsterFact, KimaiCustomer, KimaiProject, KimaiActivity, HamsterKimaiMapping from .db import (
db,
HamsterCategory,
HamsterActivity,
HamsterFact,
KimaiCustomer,
KimaiProject,
KimaiActivity,
HamsterKimaiMapping,
)
# HAMSTER_DIR = Path.home() / '.local/share/hamster' HAMSTER_DIR = Path.home() / ".local/share/hamster"
# HAMSTER_FILE = HAMSTER_DIR / 'hamster.db' # HAMSTER_FILE = HAMSTER_DIR / 'hamster.db'
HAMSTER_FILE = 'hamster-testing.db' HAMSTER_FILE = "hamster-testing.db"
db.init(HAMSTER_FILE) db.init(HAMSTER_FILE)
@ -28,103 +37,100 @@ def categories():
pass pass
@categories.command('list') @categories.command("list")
@click.option('--search', help='Search string') @click.option("--search", help="Search string")
def list_categories(search): def list_categories(search):
""" List / search categories """ """List / search categories"""
categories = HamsterCategory.select() categories = HamsterCategory.select()
if search is not None: if search is not None:
categories = categories.where(HamsterCategory.name.contains(search)) categories = categories.where(HamsterCategory.name.contains(search))
for c in categories: for c in categories:
click.echo(f'@{c.id}: {c.name}') click.echo(f"@{c.id}: {c.name}")
@categories.command('delete') @categories.command("delete")
@click.argument('ids', nargs=-1) @click.argument("ids", nargs=-1)
def delete_categories(ids): def delete_categories(ids):
""" Delete categories specified by IDS """ """Delete categories specified by IDS"""
click.secho('Deleting:', fg='red') click.secho("Deleting:", fg="red")
categories = HamsterCategory.select( categories = (
HamsterCategory, HamsterCategory.select(
fn.Count(HamsterActivity.id).alias("activities_count") HamsterCategory, fn.Count(HamsterActivity.id).alias("activities_count")
).join(HamsterActivity, JOIN.LEFT_OUTER).group_by(HamsterActivity).where(HamsterCategory.id.in_(ids)) )
.join(HamsterActivity, JOIN.LEFT_OUTER)
.group_by(HamsterCategory)
.where(HamsterCategory.id.in_(ids))
)
for c in categories: for c in categories:
click.echo(f'@{c.id}: {c.name} ({c.activities_count} activities)') click.echo(f"@{c.id}: {c.name} ({c.activities_count} activities)")
click.confirm('Do you want to continue?', abort=True) click.confirm("Do you want to continue?", abort=True)
count = HamsterCategory.delete().where(HamsterCategory.id.in_(ids)).execute() count = HamsterCategory.delete().where(HamsterCategory.id.in_(ids)).execute()
click.secho('Deleted {0} categories'.format(count), fg='green') click.secho("Deleted {0} categories".format(count), fg="green")
@categories.command('rename') @categories.command("rename")
@click.argument('id_', metavar='ID') @click.argument("id_", metavar="ID")
@click.argument('name') @click.argument("name")
def rename_category(id_, name): def rename_category(id_, name):
""" Rename a category """ """Rename a category"""
r = get_categories((id_,))[0] category = HamsterCategory.get(id=id_)
click.echo('Renaming @{0[0]}: {0[1]} to "{1}"'.format(r, name)) click.echo(f'Renaming @{category.id}: {category.name} to "{name}"')
sql = 'UPDATE categories SET name = ? WHERE id = ?' category.name = name
category.save()
c.execute(sql, (name, r[0]))
conn.commit()
@categories.command('activities') @categories.command("activities")
@click.argument('ids', nargs=-1) @click.argument("ids", nargs=-1)
def list_category_activities(ids): def list_category_activities(ids):
""" Show activities for categories specified by IDS """ """Show activities for categories specified by ids"""
sql = '''
SELECT activities = (
activities.id, activities.name, categories.name HamsterActivity.select(HamsterActivity, HamsterCategory.name)
FROM .join(HamsterCategory, JOIN.LEFT_OUTER)
activities .where(HamsterCategory.id.in_(ids))
LEFT JOIN
categories
ON
activities.category_id = categories.id
WHERE
categories.id IN ({seq})
'''.format(
seq=','.join(['?'] * len(ids))
) )
results = c.execute(sql, ids) for a in activities:
click.echo(f"@{a.id}: {a.category.name} » {a.name}")
for r in results:
click.echo('@{0[0]}: {0[2]} » {0[1]}'.format(r))
@categories.command('tidy') @categories.command("tidy")
def tidy_categories(): def tidy_categories():
""" Remove categories with no activities """ """Remove categories with no activities"""
sql = 'SELECT categories.id, categories.name FROM categories LEFT JOIN activities ON categories.id = activities.category_id WHERE activities.id IS NULL' subquery = (
categories = c.execute(sql).fetchall() HamsterCategory.select(
HamsterCategory, fn.COUNT(HamsterActivity.id).alias("activities_count")
)
.join(HamsterActivity, JOIN.LEFT_OUTER)
.group_by(HamsterCategory)
.alias("subquery")
)
click.echo('Found {0} empty categories:'.format(len(categories))) categories = (
HamsterCategory.select()
.join(subquery, on=(HamsterCategory.id == subquery.c.id))
.where(subquery.c.activities_count == 0)
)
click.echo("Found {0} empty categories:".format(categories.count()))
for cat in categories: for cat in categories:
click.echo('@{0[0]}: {0[1]}'.format(cat)) click.echo(f"@{cat.id}: {cat.name}")
click.confirm('Do you want to continue?', abort=True) click.confirm("Do you want to continue?", abort=True)
sql = 'DELETE FROM categories ' [cat.delete_instance() for cat in categories]
sql = sql + 'WHERE id IN ({seq})'.format(
seq=','.join(['?'] * len(categories))
)
c.execute(sql, [cat[0] for cat in categories])
conn.commit()
@cli.group() @cli.group()
@ -132,199 +138,160 @@ def activities():
pass pass
@activities.command('list') @activities.command("list")
@click.option('--search', help='Search string') @click.option("--search", help="Search string")
@click.option('--csv/--no-csv', 'csv_output', default=False, help='CSV output') @click.option("--csv/--no-csv", "csv_output", default=False, help="CSV output")
def list_activities(search, csv_output): def list_activities(search, csv_output):
""" List / search activities """ """List / search activities"""
results = get_activities(search=search) activities = (
HamsterActivity.select(HamsterActivity, HamsterCategory)
.join(HamsterCategory, JOIN.LEFT_OUTER)
.order_by(HamsterCategory.name, HamsterActivity.name)
)
results.sort(key=lambda t: (t[2], t[1])) if search is not None:
activities = activities.where(HamsterActivity.name.contains(search))
if csv_output: if csv_output:
csv_writer = csv.writer(sys.stdout) csv_writer = csv.writer(sys.stdout)
for r in results: for a in activities:
category_name = a.category.name if a.category_id != -1 else ""
if csv_output: if csv_output:
csv_writer.writerow([r[3], r[2], r[0], r[1]]) csv_writer.writerow([a.category_id, category_name, a.id, a.name])
else: else:
click.echo('@{0[3]}: {0[2]} » {0[0]}: {0[1]}'.format(r)) click.echo(f"@{a.category_id}: {category_name} » {a.id}: {a.name}")
@activities.command('delete') @activities.command("delete")
@click.argument('ids', nargs=-1) @click.argument("ids", nargs=-1)
def delete_activities(ids): def delete_activities(ids):
""" Delete activities specified by IDS """ """Delete activities specified by IDS"""
results = get_activities(ids) activities = (
HamsterActivity.select(
click.secho('Deleting:', fg='red') HamsterActivity,
HamsterCategory.name,
for r in results: fn.Count(HamsterFact.id).alias("facts_count"),
sql = "SELECT COUNT(id) FROM facts WHERE activity_id = ?" )
count = c.execute(sql, (r[0],)).fetchone()[0] .join(HamsterCategory, JOIN.LEFT_OUTER)
click.echo('@{0[0]}: {0[2]} » {0[1]} ({1} facts)'.format(r, count)) .switch(HamsterActivity)
.join(HamsterFact, JOIN.LEFT_OUTER)
click.confirm('Do you want to continue?', abort=True) .group_by(HamsterActivity)
.where(HamsterActivity.id.in_(ids))
sql = 'DELETE FROM activities '
sql = sql + 'WHERE id IN ({seq})'.format(
seq=','.join(['?'] * len(ids))
) )
c.execute(sql, ids) click.secho("Deleting:", fg="red")
conn.commit()
click.secho('Deleted {0} activities'.format(len(ids)), fg='green') for a in activities:
category_name = a.category.name if a.category_id != -1 else ""
click.echo(f"@{a.id}: {category_name} » {a.name} ({a.facts_count} facts)")
click.confirm("Do you want to continue?", abort=True)
[a.delete_instance() for a in activities]
click.secho("Deleted {0} activities".format(len(ids)), fg="green")
@activities.command() @activities.command()
@click.argument('category_id') @click.argument("category_id")
@click.argument('ids', nargs=-1) @click.argument("ids", nargs=-1)
def move(category_id, ids): def move(category_id, ids):
""" Move activities to another category """ """Move activities to another category"""
category = get_categories((category_id,))[0] category = HamsterCategory.get(id=category_id)
results = get_activities(ids) activities = HamsterActivity.select().where(HamsterActivity.id.in_(ids))
click.secho('Moving to "@{0[0]}: {0[1]}":'.format(category), fg='green') click.secho(f'Moving to "@{category.id}: {category.name}":', fg="green")
for r in results: for a in activities:
click.secho('@{0[3]}: {0[2]} » @{0[0]}: {0[1]}'.format(r), fg='blue') category_name = a.category.name if a.category_id != -1 else ""
click.secho(f"@{a.category_id}: {category_name} » @{a.id}: {a.name}", fg="blue")
click.confirm('Do you want to continue?', abort=True) click.confirm("Do you want to continue?", abort=True)
sql = ''' for a in activities:
UPDATE a.category = category
activities a.save()
SET
category_id = ?
'''
sql = sql + 'WHERE id IN ({seq})'.format( click.secho("Moved {0} activities".format(len(ids)), fg="green")
seq=','.join(['?'] * len(ids))
)
c.execute(sql, (category[0], *ids))
conn.commit()
click.secho('Moved {0} activities'.format(len(ids)), fg='green')
@activities.command() @activities.command()
@click.argument('ids', nargs=-1) @click.argument("ids", nargs=-1)
def list_facts(ids): def list_facts(ids):
""" Show facts for activities """ """Show facts for activities"""
activities = HamsterActivity.select().where(HamsterActivity.id.in_(ids))
results = get_activities(ids) for a in activities:
click.secho(f"@{a.id}: {a.name}", fg="green")
for r in results: for f in a.facts:
click.secho( click.secho(f"@{f.id}, {f.start_time}", fg="blue")
'@{0[0]}: {0[1]}'.format(r), fg='green'
)
sql = '''
SELECT
start_time,
activities.name
FROM
facts
LEFT JOIN
activities
ON
facts.activity_id = activities.id
WHERE
activities.id = ?
'''
results = c.execute(sql, (r[0],))
for r in results:
click.secho('@{0[0]}, {0[1]}'.format(r), fg='blue')
@activities.command() @activities.command()
@click.argument('from_id') @click.argument("from_id")
@click.argument('to_id') @click.argument("to_id")
def move_facts(from_id, to_id): def move_facts(from_id, to_id):
""" Move facts from one activity to another """ """Move facts from one activity to another"""
from_activity = get_activities((from_id,))[0] from_activity = HamsterActivity.get(id=from_id)
to_activity = get_activities((to_id,))[0] to_activity = HamsterActivity.get(id=to_id)
from_category_name = (
from_activity.category.name if from_activity.category_id != -1 else ""
)
to_category_name = (
to_activity.category.name if to_activity.category_id != -1 else ""
)
click.secho( click.secho(
'Moving facts from "@{0[2]} » @{0[0]}: {0[1]}" to "@{1[2]} » @{1[0]}: {1[1]}"'.format( f'Moving facts from "{from_category_name} » @{from_activity.id}: {from_activity.name}" to "@{to_category_name} » @{to_activity.id}: {to_activity.name}"',
from_activity, to_activity fg="green",
), fg='green'
) )
sql = ''' for f in from_activity.facts:
SELECT click.secho(f"@{f.id}, {f.start_time}", fg="blue")
start_time,
activities.name
FROM
facts
LEFT JOIN
activities
ON
facts.activity_id = activities.id
WHERE
activities.id = ?
'''
results = c.execute(sql, (from_id,)) click.confirm("Do you want to continue?", abort=True)
for r in results: count = (
click.secho('@{0[0]}, {0[1]}'.format(r), fg='blue') HamsterFact.update(activity_id=to_activity.id)
.where(HamsterFact.activity == from_activity)
click.confirm('Do you want to continue?', abort=True) .execute()
c.execute(
'UPDATE facts SET activity_id = ? WHERE activity_id = ?',
(to_id, from_id)
) )
conn.commit() click.secho("Moved {0} facts".format(count), fg="green")
click.secho('Moved {0} facts'.format(results.rowcount), fg='green')
click.confirm( click.confirm(
'Would you like to delete @{0[2]} » @{0[0]}: {0[1]}?'.format( f'Would you like to delete "{from_category_name} » @{from_activity.id}: {from_activity.name}?',
from_activity), abort=True,
abort=True
) )
delete_activities((from_id,)) from_activity.delete_instance()
@activities.command() @activities.command()
def find_duplicates(): def find_duplicates():
""" Show activities which are not unique in their categories """ """Show activities which are not unique in their categories"""
sql = ''' non_unique_activities = (
SELECT HamsterActivity.select(
categories.id, HamsterActivity,
categories.name, HamsterCategory.id,
activities.id, fn.COALESCE(HamsterCategory.name, "None").alias("category_name"),
activities.name, )
COUNT(activities.id) c .join(HamsterCategory, JOIN.LEFT_OUTER)
FROM .group_by(HamsterActivity.category_id, HamsterActivity.name)
activities .having(fn.COUNT(HamsterActivity.id) > 1)
LEFT JOIN )
categories
ON
activities.category_id = categories.id
GROUP BY
activities.name,
activities.category_id
HAVING c > 1
'''
results = c.execute(sql) for activity in non_unique_activities:
for r in results:
click.secho( click.secho(
'@{0[0]}: {0[1]} » @{0[2]}: {0[3]} ({0[4]})'.format(r), fg='blue') f"@{activity.category_id}: {activity.category_name} » @{activity.id}: {activity.name}",
fg="blue",
)
@cli.group() @cli.group()
@ -336,46 +303,51 @@ def _get_kimai_mapping_file(path, category_search=None):
try: try:
return open(path) return open(path)
except FileNotFoundError: except FileNotFoundError:
click.confirm( click.confirm("Mapping file {} not found, create it?:".format(path), abort=True)
'Mapping file {} not found, create it?:'.format(path), mapping_file = open(path, "w")
abort=True
)
mapping_file = open(path, 'w')
mapping_writer = csv.writer(mapping_file) mapping_writer = csv.writer(mapping_file)
mapping_writer.writerow([ mapping_writer.writerow(
'FROM category', [
'FROM activity', "FROM category",
'TO Customer', "FROM activity",
'TO Project', "TO Customer",
'TO Activity', "TO Project",
'TO Tag', "TO Activity",
'TO Note' "TO Tag",
]) "TO Note",
]
)
results = get_activities(category_search=category_search) activities = HamsterActivity.select(HamsterActivity, HamsterCategory).join(
HamsterCategory, JOIN.LEFT_OUTER
)
for r in results: for a in activities:
mapping_writer.writerow([ mapping_writer.writerow(
r[2], r[1] [a.category.name if a.category_id != -1 else "", a.name]
]) )
mapping_file.close() mapping_file.close()
return open(path) return open(path)
@kimai.command() @kimai.command()
@click.option('--mapping-path', help='Mapping file (default ~/.local/share/hamster/mapping.kimai.csv)', multiple=True) @click.option(
@click.argument('username') "--mapping-path",
@click.argument('api_key') help="Mapping file (default ~/.local/share/hamster/mapping.kimai.csv)",
@click.option('--just-errors', 'just_errors', is_flag=True, help='Only display errors') multiple=True,
@click.option('--ignore-activities', is_flag=True, help='Ignore missing activities') )
@click.argument("username")
@click.argument("api_key")
@click.option("--just-errors", "just_errors", is_flag=True, help="Only display errors")
@click.option("--ignore-activities", is_flag=True, help="Ignore missing activities")
def sync(username, api_key, just_errors, ignore_activities, mapping_path=None): def sync(username, api_key, just_errors, ignore_activities, mapping_path=None):
""" """
Download customer / project / activity data from Kimai Download customer / project / activity data from Kimai
""" """
kimai_api_url = 'https://kimai.autonomic.zone/api' kimai_api_url = "https://kimai.autonomic.zone/api"
if type(mapping_path) == tuple: if type(mapping_path) == tuple:
mapping_files = [] mapping_files = []
@ -386,30 +358,27 @@ def sync(username, api_key, just_errors, ignore_activities, mapping_path=None):
mapping_reader = csv.reader(chain(*mapping_files)) mapping_reader = csv.reader(chain(*mapping_files))
else: else:
if mapping_path is None: if mapping_path is None:
mapping_path = HAMSTER_DIR / 'mapping.kimai.csv' mapping_path = HAMSTER_DIR / "mapping.kimai.csv"
mapping_file = _get_kimai_mapping_file(mapping_path) mapping_file = _get_kimai_mapping_file(mapping_path)
mapping_reader = csv.reader(mapping_file) mapping_reader = csv.reader(mapping_file)
next(mapping_reader) next(mapping_reader)
mapping_data = [ mapping_data = [[row[2], row[3], row[4]] for row in mapping_reader]
[row[2], row[3], row[4]]
for row in mapping_reader
]
mapping_file.close() mapping_file.close()
auth_headers = { auth_headers = {"X-AUTH-USER": username, "X-AUTH-TOKEN": api_key}
'X-AUTH-USER': username,
'X-AUTH-TOKEN': api_key
}
customers = requests.get( customers = requests.get(
f'{kimai_api_url}/customers?visible=3', headers=auth_headers).json() f"{kimai_api_url}/customers?visible=3", headers=auth_headers
).json()
projects = requests.get( projects = requests.get(
f'{kimai_api_url}/projects?visible=3', headers=auth_headers).json() f"{kimai_api_url}/projects?visible=3", headers=auth_headers
).json()
activities = requests.get( activities = requests.get(
f'{kimai_api_url}/activities?visible=3', headers=auth_headers).json() f"{kimai_api_url}/activities?visible=3", headers=auth_headers
).json()
found_customers = [] found_customers = []
found_projects = [] found_projects = []
@ -418,93 +387,115 @@ def sync(username, api_key, just_errors, ignore_activities, mapping_path=None):
for row in mapping_data: for row in mapping_data:
# Check if each mapping still exists in Kimai # Check if each mapping still exists in Kimai
matching_customers = list( matching_customers = list(filter(lambda x: x["name"] == row[0], customers))
filter(lambda x: x['name'] == row[0], customers))
if row[0] in found_customers: if row[0] in found_customers:
just_errors or click.secho( just_errors or click.secho(
"Skipping existing customer '{0}'".format(row[0]), fg='green') "Skipping existing customer '{0}'".format(row[0]), fg="green"
)
else: else:
if len(matching_customers) > 1: if len(matching_customers) > 1:
click.secho( click.secho(
"More than one match for customer '{0}'".format(row[0]), fg='red') "More than one match for customer '{0}'".format(row[0]), fg="red"
)
continue continue
elif len(matching_customers) < 1: elif len(matching_customers) < 1:
click.secho("Missing customer '{0}'".format( click.secho("Missing customer '{0}'".format(row[0]), fg="yellow")
row[0]), fg='yellow')
continue continue
else: else:
just_errors or click.secho( just_errors or click.secho(
"Found customer '{0}'".format(row[0]), fg='green') "Found customer '{0}'".format(row[0]), fg="green"
)
found_customers.append(row[0]) found_customers.append(row[0])
project_str = ':'.join(row[0:2]) project_str = ":".join(row[0:2])
matching_projects = list(filter( matching_projects = list(
lambda x: x['name'] == row[1] and filter(
x['customer'] == matching_customers[0]['id'], lambda x: x["name"] == row[1]
projects) and x["customer"] == matching_customers[0]["id"],
projects,
)
) )
if project_str in found_projects: if project_str in found_projects:
just_errors or click.secho( just_errors or click.secho(
"Skipping existing project '{0}'".format(project_str), fg='green') "Skipping existing project '{0}'".format(project_str), fg="green"
)
else: else:
if len(matching_projects) > 1: if len(matching_projects) > 1:
click.secho("More than one match for project '{0}'".format( click.secho(
project_str), fg='red') "More than one match for project '{0}'".format(project_str),
fg="red",
)
continue continue
elif len(matching_projects) < 1: elif len(matching_projects) < 1:
click.secho("Missing project '{0}'".format( click.secho("Missing project '{0}'".format(project_str), fg="yellow")
project_str), fg='yellow')
continue continue
else: else:
just_errors or click.secho( just_errors or click.secho(
"Found project '{0}'".format(project_str), fg='green') "Found project '{0}'".format(project_str), fg="green"
)
found_projects.append(project_str) found_projects.append(project_str)
if ignore_activities: if ignore_activities:
continue continue
activity_str = ':'.join(row) activity_str = ":".join(row)
if activity_str in found_activities: if activity_str in found_activities:
just_errors or click.secho( just_errors or click.secho(
"Skipping existing activity '{0}'".format(activity_str), fg='green') "Skipping existing activity '{0}'".format(activity_str), fg="green"
)
else: else:
matching_activities = list(filter( matching_activities = list(
lambda x: x['name'] == row[2] filter(
and x['project'] == matching_projects[0]['id'], lambda x: x["name"] == row[2]
activities and x["project"] == matching_projects[0]["id"],
)) activities,
)
)
if len(matching_activities) > 1: if len(matching_activities) > 1:
click.secho("More than one match for activity '{0}'".format( click.secho(
activity_str), fg='red') "More than one match for activity '{0}'".format(activity_str),
fg="red",
)
elif len(matching_activities) < 1: elif len(matching_activities) < 1:
click.secho("Missing activity '{0}'".format( click.secho("Missing activity '{0}'".format(activity_str), fg="yellow")
activity_str), fg='yellow')
else: else:
just_errors or click.secho( just_errors or click.secho(
"Found activity '{0}'".format(activity_str), fg='green') "Found activity '{0}'".format(activity_str), fg="green"
)
found_activities.append(activity_str) found_activities.append(activity_str)
@kimai.command('import') @kimai.command("import")
@click.option('--mapping-path', help='Mapping file (default ~/.local/share/hamster/mapping.kimai.csv)', multiple=True) @click.option(
@click.option('--output', help='Output file (default kimai.csv)') "--mapping-path",
@click.option('--category-search', help='Category search string') help="Mapping file (default ~/.local/share/hamster/mapping.kimai.csv)",
@click.option('--after', help='Only show time entries after this date') multiple=True,
@click.option('--show-missing', help='Just report on the missing entries', is_flag=True) )
@click.argument('username') @click.option("--output", help="Output file (default kimai.csv)")
def _import(username, mapping_path=None, output=None, category_search=None, after=None, show_missing=False): @click.option("--category-search", help="Category search string")
@click.option("--after", help="Only show time entries after this date")
@click.option("--show-missing", help="Just report on the missing entries", is_flag=True)
@click.argument("username")
def _import(
username,
mapping_path=None,
output=None,
category_search=None,
after=None,
show_missing=False,
):
""" """
Export time tracking data in Kimai format Export time tracking data in Kimai format
""" """
if mapping_path is None: if mapping_path is None:
mapping_path = HAMSTER_DIR / 'mapping.kimai.csv' mapping_path = HAMSTER_DIR / "mapping.kimai.csv"
if output is None: if output is None:
timestamp = datetime.now().strftime('%F') timestamp = datetime.now().strftime("%F")
output = f'kimai_{timestamp}.csv' output = f"kimai_{timestamp}.csv"
if type(mapping_path) == tuple: if type(mapping_path) == tuple:
mapping_files = [] mapping_files = []
@ -519,7 +510,7 @@ def _import(username, mapping_path=None, output=None, category_search=None, afte
mapping_reader = csv.reader(mapping_file) mapping_reader = csv.reader(mapping_file)
mapping = { mapping = {
'{0}:{1}'.format(row[0], row[1]): [row[2], row[3], row[4], row[5]] "{0}:{1}".format(row[0], row[1]): [row[2], row[3], row[4], row[5]]
for row in mapping_reader for row in mapping_reader
} }
@ -529,12 +520,18 @@ def _import(username, mapping_path=None, output=None, category_search=None, afte
else: else:
mapping_file.close() mapping_file.close()
output_file = open(output, 'w') output_file = open(output, "w")
output_writer = csv.writer(output_file) output_writer = csv.writer(output_file)
args = [] args = []
sql = ''' facts = (
HamsterFact.select(HamsterFact, HamsterActivity, HamsterCategory)
.join(HamsterActivity)
.join(HamsterCategory, JOIN.LEFT_OUTER)
)
sql = """
SELECT SELECT
facts.id, facts.start_time, facts.end_time, facts.description, facts.id, facts.start_time, facts.end_time, facts.description,
activities.id, activities.name, activities.id, activities.name,
@ -544,26 +541,17 @@ def _import(username, mapping_path=None, output=None, category_search=None, afte
LEFT JOIN LEFT JOIN
activities ON facts.activity_id = activities.id activities ON facts.activity_id = activities.id
LEFT JOIN LEFT JOIN
categories ON activities.category_id = categories.id ''' categories ON activities.category_id = categories.id """
if category_search is not None: if category_search is not None:
sql = sql + " WHERE categories.name LIKE ?" facts = facts.where(HamsterCategory.name.contains(category_search))
category_search = '%{0}%'.format(category_search)
args.append(category_search)
if after is not None: if after is not None:
if category_search is not None: facts = facts.where(HamsterFact.start_time >= after)
sql = sql + ' AND '
else:
sql = sql + ' WHERE '
sql = sql + f"DATE(facts.start_time) > DATE(?)"
args.append(after)
results = c.execute(sql, args)
results = c.fetchall()
if not show_missing: if not show_missing:
output_writer.writerow([ output_writer.writerow(
[
"Date", "Date",
"From", "From",
"To", "To",
@ -578,91 +566,103 @@ def _import(username, mapping_path=None, output=None, category_search=None, afte
"Tags", "Tags",
"HourlyRate", "HourlyRate",
"FixedRate", "FixedRate",
"InternalRate" "InternalRate",
]) ]
)
for fact in results: for fact in facts:
k = '{0}:{1}'.format(fact[6], fact[5]) k = f"{fact.activity.category.name}:{fact.activity.name}"
try: try:
mapping_ = mapping[k] mapping_ = mapping[k]
except KeyError: except KeyError:
if show_missing: if show_missing:
output_writer.writerow([fact[6], fact[5]]) output_writer.writerow(
click.secho( [fact.activity.category.name, fact.activity.name]
"Can't find mapping for '{0}', skipping".format(k), fg='yellow') )
click.secho("Can't find mapping for '{0}', skipping".format(k), fg="yellow")
continue continue
if show_missing: if show_missing:
continue continue
if fact[1] is None or fact[2] is None: if fact.start_time is None or fact.end_time is None:
click.secho("Missing duration data '{0}-{1}', skipping".format( click.secho(
fact[1], f"Missing duration data '{fact.start_time}-{fact.end_time}', skipping",
fact[2] fg="yellow",
), fg='yellow') )
continue continue
if len(mapping_) < 5: if len(mapping_) < 5:
mapping_.append(None) mapping_.append(None)
date_start, date_end = ( date_start, date_end = (
datetime.strptime(fact[2].split('.')[0], '%Y-%m-%d %H:%M:%S'), datetime.strptime(fact.start_time.split(".")[0], "%Y-%m-%d %H:%M:%S"),
datetime.strptime(fact[1].split('.')[0], '%Y-%m-%d %H:%M:%S') datetime.strptime(fact.end_time.split(".")[0], "%Y-%m-%d %H:%M:%S"),
) )
duration = ( duration = (date_start - date_end).seconds / 3600
date_start - date_end
).seconds / 3600
output_writer.writerow([ output_writer.writerow(
date_start.strftime('%Y-%m-%d'), [
date_start.strftime('%H:%M'), date_start.strftime("%Y-%m-%d"),
'', # To (time) date_start.strftime("%H:%M"),
"", # To (time)
duration, duration,
'', # Rate "", # Rate
username, username,
mapping_[0], mapping_[0],
mapping_[1], mapping_[1],
mapping_[2], mapping_[2],
fact[3] or mapping_[4] or '', fact.description or mapping_[4] or "",
'0', # Exported "0", # Exported
mapping_[3], mapping_[3],
'', # Hourly rate "", # Hourly rate
'', # Fixed rate "", # Fixed rate
]) ]
)
output_file.close() output_file.close()
@kimai.command() @kimai.group("db")
def dbinit(): def db_():
db.create_tables([KimaiCustomer, KimaiProject, KimaiActivity, pass
HamsterKimaiMapping])
@kimai.command() @db_.command()
def dbreset(): def init():
db.create_tables([KimaiCustomer, KimaiProject, KimaiActivity, HamsterKimaiMapping])
@db_.command()
def reset():
HamsterKimaiMapping.delete().execute() HamsterKimaiMapping.delete().execute()
@kimai.command() @db_.command()
@click.option('-g', '--global', 'global_', help='Does this file contain mappings to global activties', is_flag=True) @click.option(
@click.option('--mapping-path', help='Mapping file') "-g",
def csv2db(mapping_path=None, global_=False): "--global",
"global_",
help="Does this file contain mappings to global activties",
is_flag=True,
)
@click.option("--mapping-path", help="Mapping file")
def mapping2db(mapping_path=None, global_=False):
mapping_file = _get_kimai_mapping_file(mapping_path, None) mapping_file = _get_kimai_mapping_file(mapping_path, None)
next(mapping_file) next(mapping_file)
mapping_reader = csv.reader(mapping_file) mapping_reader = csv.reader(mapping_file)
for row in mapping_reader: for row in mapping_reader:
hamster_category = HamsterCategory.get(name=row[0]) hamster_category = HamsterCategory.get(name=row[0])
hamster_activity = HamsterActivity.get(name=row[1]) hamster_activity = HamsterActivity.get(
kimai_customer = KimaiCustomer.get(name=row[2]) name=row[1], category_id=hamster_category.id
kimai_project = KimaiProject.get(name=row[3],
customer_id=kimai_customer.id)
try:
kimai_activity = KimaiActivity.get(
name=row[4],
project_id=kimai_project.id
) )
kimai_customer = KimaiCustomer.get(name=row[2])
kimai_project = KimaiProject.get(name=row[3], customer_id=kimai_customer.id)
try:
kimai_activity = KimaiActivity.get(name=row[4], project_id=kimai_project.id)
except KimaiActivity.DoesNotExist: except KimaiActivity.DoesNotExist:
kimai_activity = KimaiActivity.get( kimai_activity = KimaiActivity.get(
name=row[4], name=row[4],
@ -674,19 +674,21 @@ def csv2db(mapping_path=None, global_=False):
kimai_project=kimai_project, kimai_project=kimai_project,
kimai_activity=kimai_activity, kimai_activity=kimai_activity,
kimai_description=row[6], kimai_description=row[6],
kimai_tags=row[5] kimai_tags=row[5],
) )
@cli.command() @cli.command()
def app(): def app():
from .app import HamsterToolsApp from .app import HamsterToolsApp
app = HamsterToolsApp() app = HamsterToolsApp()
app.run() app.run()
@cli.command() @cli.command()
def hamster(): def hamster():
click.echo('🐹') click.echo("🐹")
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -1,9 +1,15 @@
from textual import on
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.binding import Binding from textual.binding import Binding
from textual.widgets import Header, Footer, DataTable, Input from textual.containers import Grid
from textual.events import DescendantBlur
from textual.widgets import Header, Footer, DataTable, Input, Button, Label, Checkbox
from textual.containers import Horizontal, Vertical from textual.containers import Horizontal, Vertical
from textual.coordinate import Coordinate from textual.coordinate import Coordinate
from textual.screen import Screen from textual.reactive import reactive
from textual.screen import Screen, ModalScreen
from textual_autocomplete import AutoComplete, Dropdown, DropdownItem
from peewee import fn, JOIN from peewee import fn, JOIN
@ -15,6 +21,7 @@ from .db import (
KimaiProject, KimaiProject,
KimaiCustomer, KimaiCustomer,
KimaiActivity, KimaiActivity,
HamsterKimaiMapping,
) )
from .kimai import ( from .kimai import (
KimaiAPI, KimaiAPI,
@ -65,28 +72,220 @@ class ListScreen(Screen):
self._refresh(event.value) self._refresh(event.value)
class ActivitiesScreen(ListScreen): class ActivityEditScreen(ModalScreen):
BINDINGS = [
("escape", "cancel", "Cancel")
]
def compose(self) -> ComposeResult:
yield Grid(
Label("Are you sure you want to quit?", id="question"),
Button("Quit", variant="error", id="quit"),
Button("Cancel", variant="primary", id="cancel"),
id="dialog",
)
def action_cancel(self):
self.dismiss(None)
class ActivityMappingScreen(ModalScreen):
BINDINGS = [
("ctrl+g", "global", "Toggle global"),
("ctrl+s", "save", "Save"),
("escape", "cancel", "Cancel")
]
customer_id = None
project_id = None
activity_id = None
def __init__(self, category, activity):
self.category = category
self.activity = activity
super().__init__()
@staticmethod
def _filter_dropdowns(options, value):
matches = [c for c in options if value.lower() in c.main.plain.lower()]
return sorted(matches, key=lambda v: v.main.plain.startswith(value.lower()))
def _get_customers(self, input_state):
customers = [
DropdownItem(c.name, str(c.id))
for c in KimaiCustomer.select()
]
return ActivityMappingScreen._filter_dropdowns(customers,
input_state.value)
def _get_projects(self, input_state):
projects = [
DropdownItem(p.name, str(p.id))
for p in KimaiProject.select().where(
KimaiProject.customer_id == self.customer_id
)
]
return ActivityMappingScreen._filter_dropdowns(projects,
input_state.value)
def _get_activities(self, input_state):
activities = KimaiActivity.select()
if self.query_one('#global').value:
activities = activities.where(
KimaiActivity.project_id.is_null(),
)
else:
activities = activities.where(
KimaiActivity.project_id == self.project_id
)
return ActivityMappingScreen._filter_dropdowns([
DropdownItem(a.name, str(a.id))
for a in activities], input_state.value)
def compose(self) -> ComposeResult:
yield Vertical(
Horizontal(
Label(f"Mapping for {self.activity}@{self.category}"),
),
Horizontal(
Label("Customer"),
AutoComplete(
Input(placeholder="Type to search...", id="customer"),
Dropdown(items=self._get_customers),
)
),
Horizontal(
Label("Project"),
AutoComplete(
Input(placeholder="Type to search...", id='project'),
Dropdown(items=self._get_projects),
)
),
Horizontal(
Label("Activity"),
AutoComplete(
Input(placeholder="Type to search...", id='activity'),
Dropdown(items=self._get_activities),
)
),
Horizontal(
Label("Description"),
Input(id='description'),
),
Horizontal(
Label("Tags"),
Input(id='tags'),
),
Horizontal(Checkbox("Global", id='global')),
)
@on(Input.Submitted, '#customer')
def customer_submitted(self, event):
if event.control.parent.dropdown.selected_item is not None:
self.customer_id = str(event.control.parent.dropdown.selected_item.left_meta)
self.query_one('#project').focus()
@on(DescendantBlur, '#customer')
def customer_blur(self, event):
if event.control.parent.dropdown.selected_item is not None:
self.customer_id = str(event.control.parent.dropdown.selected_item.left_meta)
@on(Input.Submitted, '#project')
def project_submitted(self, event):
if event.control.parent.dropdown.selected_item is not None:
self.project_id = str(event.control.parent.dropdown.selected_item.left_meta)
self.query_one('#activity').focus()
@on(DescendantBlur, '#project')
def project_blur(self, event):
if event.control.parent.dropdown.selected_item is not None:
self.project_id = str(event.control.parent.dropdown.selected_item.left_meta)
@on(Input.Submitted, '#activity')
def activity_submitted(self, event):
if event.control.parent.dropdown.selected_item is not None:
self.activity_id = str(event.control.parent.dropdown.selected_item.left_meta)
self.query_one('#activity').focus()
@on(DescendantBlur, '#activity')
def activity_blur(self, event):
if event.control.parent.dropdown.selected_item is not None:
self.activity_id = str(event.control.parent.dropdown.selected_item.left_meta)
def action_global(self):
self.query_one('#global').value = not self.query_one('#global').value
def action_save(self):
self.dismiss({
'kimai_customer_id': self.customer_id,
'kimai_project_id': self.project_id,
'kimai_activity_id': self.activity_id,
'kimai_description': self.query_one('#description').value,
'kimai_tags': self.query_one('#tags').value,
'global': self.query_one('#global').value,
})
def action_cancel(self):
self.dismiss(None)
class ActivityListScreen(ListScreen):
BINDINGS = [ BINDINGS = [
("s", "sort", "Sort"), ("s", "sort", "Sort"),
("r", "refresh", "Refresh"), ("r", "refresh", "Refresh"),
("/", "filter", "Search"), ("/", "filter", "Search"),
("d", "delete", "Delete activity"), ("d", "delete", "Delete"),
("f", "move_facts", "Move facts"), ("f", "move_facts", "Move facts"),
("e", "edit", "Edit"),
("m", "mapping", "Mapping"),
Binding(key="escape", action="cancelfilter", show=False), Binding(key="escape", action="cancelfilter", show=False),
] ]
def _refresh(self, filter_query=None): def _refresh(self, filter_query=None):
self.table.clear() self.table.clear()
facts_count_query = (
HamsterFact.select(
HamsterFact.activity_id, fn.COUNT(HamsterFact.id).alias("facts_count")
)
.group_by(HamsterFact.activity_id)
.alias("facts_count_query")
)
mappings_count_query = (
HamsterKimaiMapping.select(
HamsterKimaiMapping.hamster_activity_id,
fn.COUNT(HamsterKimaiMapping.id).alias("mappings_count"),
)
.group_by(HamsterKimaiMapping.hamster_activity_id)
.alias("mappings_count_query")
)
activities = ( activities = (
HamsterActivity.select( HamsterActivity.select(
HamsterActivity, HamsterActivity,
HamsterCategory, HamsterCategory.id,
fn.Count(HamsterFact.id).alias("facts_count") fn.COALESCE(HamsterCategory.name, "None").alias("category_name"),
fn.COALESCE(facts_count_query.c.facts_count, 0).alias("facts_count"),
fn.COALESCE(mappings_count_query.c.mappings_count, 0).alias(
"mappings_count"
),
) )
.join(HamsterCategory, JOIN.LEFT_OUTER) .join(HamsterCategory, JOIN.LEFT_OUTER)
.switch(HamsterActivity) .switch(HamsterActivity)
.join(HamsterFact, JOIN.LEFT_OUTER) .join(
facts_count_query,
JOIN.LEFT_OUTER,
on=(HamsterActivity.id == facts_count_query.c.activity_id),
)
.switch(HamsterActivity)
.join(
mappings_count_query,
JOIN.LEFT_OUTER,
on=(HamsterActivity.id == mappings_count_query.c.hamster_activity_id),
)
.group_by(HamsterActivity) .group_by(HamsterActivity)
) )
@ -100,10 +299,11 @@ class ActivitiesScreen(ListScreen):
[ [
[ [
activity.category_id, activity.category_id,
(activity.category.name if (activity.category_id != -1) else ""), activity.category_name,
activity.id, activity.id,
activity.name, activity.name,
activity.facts_count, activity.facts_count,
activity.mappings_count,
] ]
for activity in activities for activity in activities
] ]
@ -115,7 +315,7 @@ class ActivitiesScreen(ListScreen):
self.table = self.query_one(DataTable) self.table = self.query_one(DataTable)
self.table.cursor_type = "row" self.table.cursor_type = "row"
self.columns = self.table.add_columns( self.columns = self.table.add_columns(
"category id", "category", "activity id", "activity", "entries" "category id", "category", "activity id", "activity", "entries", "mappings"
) )
self.sort = (self.columns[1], self.columns[3]) self.sort = (self.columns[1], self.columns[3])
self._refresh() self._refresh()
@ -151,15 +351,44 @@ class ActivitiesScreen(ListScreen):
move_to_activity = HamsterActivity.get( move_to_activity = HamsterActivity.get(
self.table.get_cell_at(Coordinate(event.cursor_row, 2)) self.table.get_cell_at(Coordinate(event.cursor_row, 2))
) )
HamsterFact.update({HamsterFact.activity: HamsterFact.update({HamsterFact.activity: move_to_activity}).where(
move_to_activity}).where(HamsterFact.activity == HamsterFact.activity == self.move_from_activity
self.move_from_activity).execute() ).execute()
filter_input = self.query_one("#filter") filter_input = self.query_one("#filter")
self._refresh(filter_input.value) self._refresh(filter_input.value)
del self.move_from_activity del self.move_from_activity
def action_edit(self):
def handle_edit(properties):
print(properties)
class CategoriesScreen(ListScreen): self.app.push_screen(ActivityEditScreen(), handle_edit)
def action_mapping(self):
selected_activity = HamsterActivity.select(
HamsterActivity,
fn.COALESCE(HamsterCategory.name, "None").alias("category_name"),
).join(HamsterCategory, JOIN.LEFT_OUTER).where(
HamsterActivity.id == self.table.get_cell_at(
Coordinate(self.table.cursor_coordinate.row, 2),
)
).get()
def handle_mapping(mapping):
if mapping is None:
return
m = HamsterKimaiMapping.create(hamster_activity=selected_activity, **mapping)
m.save()
filter_input = self.query_one("#filter")
self._refresh(filter_input.value)
self.app.push_screen(ActivityMappingScreen(
category=selected_activity.category_name,
activity=selected_activity.name
), handle_mapping)
class CategoryListScreen(ListScreen):
BINDINGS = [ BINDINGS = [
("s", "sort", "Sort"), ("s", "sort", "Sort"),
("r", "refresh", "Refresh"), ("r", "refresh", "Refresh"),
@ -173,17 +402,14 @@ class CategoriesScreen(ListScreen):
categories = ( categories = (
HamsterCategory.select( HamsterCategory.select(
HamsterCategory, HamsterCategory, fn.Count(HamsterActivity.id).alias("activities_count")
fn.Count(HamsterActivity.id).alias("activities_count")
) )
.join(HamsterActivity, JOIN.LEFT_OUTER) .join(HamsterActivity, JOIN.LEFT_OUTER)
.group_by(HamsterCategory) .group_by(HamsterCategory)
) )
if filter_query: if filter_query:
categories = categories.where( categories = categories.where(HamsterCategory.name.contains(filter_query))
HamsterCategory.name.contains(filter_query)
)
self.table.add_rows( self.table.add_rows(
[ [
@ -219,7 +445,7 @@ class CategoriesScreen(ListScreen):
self.table.remove_row(row_key) self.table.remove_row(row_key)
class KimaiScreen(ListScreen): class KimaiProjectListScreen(ListScreen):
BINDINGS = [ BINDINGS = [
("s", "sort", "Sort"), ("s", "sort", "Sort"),
("r", "refresh", "Refresh"), ("r", "refresh", "Refresh"),
@ -235,7 +461,7 @@ class KimaiScreen(ListScreen):
KimaiProject.select( KimaiProject.select(
KimaiProject, KimaiProject,
KimaiCustomer, KimaiCustomer,
fn.Count(KimaiActivity.id).alias("activities_count") fn.Count(KimaiActivity.id).alias("activities_count"),
) )
.join(KimaiCustomer, JOIN.LEFT_OUTER) .join(KimaiCustomer, JOIN.LEFT_OUTER)
.switch(KimaiProject) .switch(KimaiProject)
@ -257,7 +483,7 @@ class KimaiScreen(ListScreen):
project.customer.name, project.customer.name,
project.id, project.id,
project.name, project.name,
project.activities_count project.activities_count,
] ]
for project in projects for project in projects
] ]
@ -274,26 +500,37 @@ class KimaiScreen(ListScreen):
customers = KimaiAPICustomer.list(api) customers = KimaiAPICustomer.list(api)
with db.atomic(): with db.atomic():
KimaiCustomer.insert_many([{ KimaiCustomer.insert_many(
'id': customer.id, [{"id": customer.id, "name": customer.name} for customer in customers]
'name': customer.name ).execute()
} for customer in customers]).execute()
projects = KimaiAPIProject.list(api) projects = KimaiAPIProject.list(api)
with db.atomic(): with db.atomic():
KimaiProject.insert_many([{ KimaiProject.insert_many(
'id': project.id, [
'name': project.name, {
'customer_id': project.customer.id "id": project.id,
} for project in projects]).execute() "name": project.name,
"customer_id": project.customer.id,
}
for project in projects
]
).execute()
activities = KimaiAPIActivity.list(api) activities = KimaiAPIActivity.list(api)
with db.atomic(): with db.atomic():
KimaiActivity.insert_many([{ KimaiActivity.insert_many(
'id': activity.id, [
'name': activity.name, {
'project_id': (activity.project and activity.project.id or None) "id": activity.id,
} for activity in activities]).execute() "name": activity.name,
"project_id": (
activity.project and activity.project.id or None
),
}
for activity in activities
]
).execute()
self._refresh() self._refresh()
@ -321,9 +558,9 @@ class HamsterToolsApp(App):
db.init("hamster-testing.db") db.init("hamster-testing.db")
self.MODES = { self.MODES = {
"categories": CategoriesScreen(), "categories": CategoryListScreen(),
"activities": ActivitiesScreen(), "activities": ActivityListScreen(),
"kimai": KimaiScreen(), "kimai": KimaiProjectListScreen(),
} }
super().__init__() super().__init__()

View File

@ -13,3 +13,34 @@ DataTable:focus .datatable--cursor {
#filter { #filter {
display: none; display: none;
} }
ActivityEditScreen, ActivityMappingScreen {
align: center middle;
}
ActivityMappingScreen > Vertical {
padding: 0 1;
width: auto;
height: 30;
border: thick $background 80%;
background: $surface;
}
ActivityMappingScreen Horizontal {
align: left middle;
width: auto;
}
ActivityMappingScreen Label {
padding: 0 1;
width: auto;
border: blank;
}
ActivityMappingScreen AutoComplete {
width: 80;
}
#description, #tags {
width: 30;
}

View File

@ -1,9 +1,9 @@
import logging import logging
from peewee import SqliteDatabase, Model, CharField, ForeignKeyField from peewee import SqliteDatabase, Model, CharField, ForeignKeyField, DateTimeField
from textual.logging import TextualHandler from textual.logging import TextualHandler
logger = logging.getLogger('peewee') logger = logging.getLogger("peewee")
logger.addHandler(TextualHandler()) logger.addHandler(TextualHandler())
logger.setLevel(logging.DEBUG) logger.setLevel(logging.DEBUG)
@ -15,24 +15,27 @@ class HamsterCategory(Model):
class Meta: class Meta:
database = db database = db
table_name = 'categories' table_name = "categories"
class HamsterActivity(Model): class HamsterActivity(Model):
name = CharField() name = CharField()
category = ForeignKeyField(HamsterCategory, backref='activities') category = ForeignKeyField(HamsterCategory, backref="activities")
class Meta: class Meta:
database = db database = db
table_name = 'activities' table_name = "activities"
class HamsterFact(Model): class HamsterFact(Model):
activity = ForeignKeyField(HamsterActivity, backref='facts') activity = ForeignKeyField(HamsterActivity, backref="facts")
start_time = DateTimeField()
end_time = DateTimeField(null=True)
description = CharField()
class Meta: class Meta:
database = db database = db
table_name = 'facts' table_name = "facts"
class KimaiCustomer(Model): class KimaiCustomer(Model):
@ -40,35 +43,35 @@ class KimaiCustomer(Model):
class Meta: class Meta:
database = db database = db
table_name = 'kimai_customers' table_name = "kimai_customers"
class KimaiProject(Model): class KimaiProject(Model):
name = CharField() name = CharField()
customer = ForeignKeyField(KimaiCustomer, backref='projects') customer = ForeignKeyField(KimaiCustomer, backref="projects")
class Meta: class Meta:
database = db database = db
table_name = 'kimai_projects' table_name = "kimai_projects"
class KimaiActivity(Model): class KimaiActivity(Model):
name = CharField() name = CharField()
project = ForeignKeyField(KimaiProject, backref='activities', null=True) project = ForeignKeyField(KimaiProject, backref="activities", null=True)
class Meta: class Meta:
database = db database = db
table_name = 'kimai_activities' table_name = "kimai_activities"
class HamsterKimaiMapping(Model): class HamsterKimaiMapping(Model):
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")
kimai_activity = ForeignKeyField(KimaiActivity, backref='mappings') kimai_activity = ForeignKeyField(KimaiActivity, backref="mappings")
kimai_description = CharField() kimai_description = CharField()
kimai_tags = CharField() kimai_tags = CharField()
class Meta: class Meta:
database = db database = db
table_name = 'hamster_kimai_mappings' table_name = "hamster_kimai_mappings"