From b5e486020e2d233f65901c8a7217bad48d378fcc Mon Sep 17 00:00:00 2001 From: 3wc <3wc@doesthisthing.work> Date: Fri, 27 Oct 2023 01:13:08 +0100 Subject: [PATCH 01/46] Basic TUI using textual --- hamstertools/__init__.py | 7 +++++ hamstertools/app.py | 59 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 hamstertools/app.py diff --git a/hamstertools/__init__.py b/hamstertools/__init__.py index f65890f..666b530 100755 --- a/hamstertools/__init__.py +++ b/hamstertools/__init__.py @@ -700,6 +700,13 @@ def _import(username, mapping_path=None, output=None, category_search=None, afte output_file.close() +@cli.command() +def app(): + from .app import HamsterToolsApp + app = HamsterToolsApp(db_cursor=c) + app.run() + + @cli.command() def hamster(): click.echo('๐Ÿน') diff --git a/hamstertools/app.py b/hamstertools/app.py new file mode 100644 index 0000000..e8d6d2d --- /dev/null +++ b/hamstertools/app.py @@ -0,0 +1,59 @@ +from textual.app import App, ComposeResult +from textual.widgets import Header, Footer, DataTable + + +ROWS = [ + ("lane", "swimmer", "country", "time"), + (4, "Joseph Schooling", "Singapore", 50.39), + (2, "Michael Phelps", "United States", 51.14), + (5, "Chad le Clos", "South Africa", 51.14), + (6, "Lรกszlรณ Cseh", "Hungary", 51.14), + (3, "Li Zhuhao", "China", 51.26), + (8, "Mehdy Metella", "France", 51.58), + (7, "Tom Shields", "United States", 51.73), + (1, "Aleksandr Sadovnikov", "Russia", 51.84), + (10, "Darren Burns", "Scotland", 51.84), +] + +class HamsterToolsApp(App): + """A Textual app to manage stopwatches.""" + + BINDINGS = [ + ("q", "quit", "Quit"), + ("m", "mark", "Mark"), + ] + + def __init__(self, db_cursor): + self.db_cursor = db_cursor + super().__init__() + + def compose(self) -> ComposeResult: + """Create child widgets for the app.""" + yield Header() + yield DataTable() + yield Footer() + + def on_mount(self) -> None: + table = self.query_one(DataTable) + + sql = ''' + SELECT + categories.id, categories.name, activities.id, activities.name + FROM + activities + LEFT JOIN + categories + ON + activities.category_id = categories.id ''' + + results = self.db_cursor.execute(sql) + results = [[cell or "" for cell in row] for row in self.db_cursor.fetchall()] + + columns = table.add_columns("category id","category","activity ID","activity") + + table.add_rows(results) + table.sort(columns[1], columns[3]) + table.cursor_type = "row" + + def action_quit(self) -> None: + self.exit() From a0cdf945bf78cbc62e96b702dddd09683543b6a8 Mon Sep 17 00:00:00 2001 From: 3wc <3wc@doesthisthing.work> Date: Fri, 27 Oct 2023 02:01:21 +0100 Subject: [PATCH 02/46] =?UTF-8?q?Switch=20to=20testing=20db,=20working=20d?= =?UTF-8?q?eletion=20=F0=9F=8E=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hamstertools/__init__.py | 7 +-- hamstertools/app.py | 108 ++++++++++++++++++++++++++------------- 2 files changed, 77 insertions(+), 38 deletions(-) diff --git a/hamstertools/__init__.py b/hamstertools/__init__.py index 666b530..be12c92 100755 --- a/hamstertools/__init__.py +++ b/hamstertools/__init__.py @@ -9,8 +9,9 @@ import click import requests import sqlite3 -HAMSTER_DIR = Path.home() / '.local/share/hamster' -HAMSTER_FILE = HAMSTER_DIR / 'hamster.db' +# HAMSTER_DIR = Path.home() / '.local/share/hamster' +# HAMSTER_FILE = HAMSTER_DIR / 'hamster.db' +HAMSTER_FILE = 'hamster-testing.db' conn = sqlite3.connect(HAMSTER_FILE) c = conn.cursor() @@ -703,7 +704,7 @@ def _import(username, mapping_path=None, output=None, category_search=None, afte @cli.command() def app(): from .app import HamsterToolsApp - app = HamsterToolsApp(db_cursor=c) + app = HamsterToolsApp(db_cursor=c, db_connection=conn) app.run() diff --git a/hamstertools/app.py b/hamstertools/app.py index e8d6d2d..3836cc7 100644 --- a/hamstertools/app.py +++ b/hamstertools/app.py @@ -1,32 +1,58 @@ from textual.app import App, ComposeResult from textual.widgets import Header, Footer, DataTable +from textual.coordinate import Coordinate -ROWS = [ - ("lane", "swimmer", "country", "time"), - (4, "Joseph Schooling", "Singapore", 50.39), - (2, "Michael Phelps", "United States", 51.14), - (5, "Chad le Clos", "South Africa", 51.14), - (6, "Lรกszlรณ Cseh", "Hungary", 51.14), - (3, "Li Zhuhao", "China", 51.26), - (8, "Mehdy Metella", "France", 51.58), - (7, "Tom Shields", "United States", 51.73), - (1, "Aleksandr Sadovnikov", "Russia", 51.84), - (10, "Darren Burns", "Scotland", 51.84), -] - class HamsterToolsApp(App): """A Textual app to manage stopwatches.""" BINDINGS = [ ("q", "quit", "Quit"), - ("m", "mark", "Mark"), + ("s", "sort", "Sort"), + ("r", "refresh", "Refresh"), + ("d", "delete_activity", "Delete activity"), ] - def __init__(self, db_cursor): + def __init__(self, db_cursor, db_connection): self.db_cursor = db_cursor + self.db_connection = db_connection super().__init__() + def _refresh(self): + self.table.clear() + + sql = ''' + SELECT + categories.id AS category_id, + COALESCE(categories.name, '') AS category_name, + activities.id AS activity_id, + activities.name AS activity_name, + COALESCE(facts_count, 0) AS total_facts + FROM + activities + LEFT JOIN + categories + ON + activities.category_id = categories.id + LEFT JOIN ( + SELECT + activity_id, + COUNT(*) AS facts_count + FROM + facts + GROUP BY + activity_id + ) AS facts_count_subquery + ON + activities.id = facts_count_subquery.activity_id; + ''' + + results = self.db_cursor.execute(sql) + # results = [[cell or "" for cell in row] for row in self.db_cursor.fetchall()] + + self.table.add_rows(results) + self.table.sort(self.columns[1], self.columns[3]) + def compose(self) -> ComposeResult: """Create child widgets for the app.""" yield Header() @@ -34,26 +60,38 @@ class HamsterToolsApp(App): yield Footer() def on_mount(self) -> None: - table = self.query_one(DataTable) - - sql = ''' - SELECT - categories.id, categories.name, activities.id, activities.name - FROM - activities - LEFT JOIN - categories - ON - activities.category_id = categories.id ''' - - results = self.db_cursor.execute(sql) - results = [[cell or "" for cell in row] for row in self.db_cursor.fetchall()] - - columns = table.add_columns("category id","category","activity ID","activity") - - table.add_rows(results) - table.sort(columns[1], columns[3]) - table.cursor_type = "row" + self.table = self.query_one(DataTable) + self.table.cursor_type = "row" + self.columns = self.table.add_columns("category id","category","activity ID","activity","entries") + self._refresh() def action_quit(self) -> None: self.exit() + + def action_refresh(self) -> None: + self._refresh() + + def action_sort(self) -> None: + self.table.cursor_type = "column" + + def action_delete_activity(self) -> None: + # Get the keys for the row and column under the cursor. + row_key, _ = self.table.coordinate_to_cell_key(self.table.cursor_coordinate) + + activity_id = self.table.get_cell_at( + Coordinate(self.table.cursor_coordinate.row, 2), + ) + + sql = 'DELETE FROM activities WHERE id = ?' + print(Coordinate(2, self.table.cursor_coordinate.row),) + print(activity_id) + + self.db_cursor.execute(sql, (activity_id,)) + self.db_connection.commit() + + # Supply the row key to `remove_row` to delete the row. + self.table.remove_row(row_key) + + def on_data_table_column_selected(self, event): + event.data_table.sort(event.column_key) + event.data_table.cursor_type = "row" From dc30727c62185f87bbdea0b51c2798b3badfa089 Mon Sep 17 00:00:00 2001 From: 3wc <3wc@doesthisthing.work> Date: Fri, 27 Oct 2023 02:26:15 +0100 Subject: [PATCH 03/46] Working categories page --- hamstertools/app.py | 175 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 145 insertions(+), 30 deletions(-) diff --git a/hamstertools/app.py b/hamstertools/app.py index 3836cc7..d595795 100644 --- a/hamstertools/app.py +++ b/hamstertools/app.py @@ -1,16 +1,15 @@ from textual.app import App, ComposeResult -from textual.widgets import Header, Footer, DataTable +from textual.widgets import Header, Footer, DataTable, Placeholder from textual.coordinate import Coordinate +from textual.screen import Screen -class HamsterToolsApp(App): - """A Textual app to manage stopwatches.""" - +class ActivitiesScreen(Screen): BINDINGS = [ ("q", "quit", "Quit"), ("s", "sort", "Sort"), ("r", "refresh", "Refresh"), - ("d", "delete_activity", "Delete activity"), + ("d", "delete", "Delete activity"), ] def __init__(self, db_cursor, db_connection): @@ -22,28 +21,28 @@ class HamsterToolsApp(App): self.table.clear() sql = ''' - SELECT - categories.id AS category_id, - COALESCE(categories.name, '') AS category_name, - activities.id AS activity_id, - activities.name AS activity_name, - COALESCE(facts_count, 0) AS total_facts - FROM + select + categories.id as category_id, + coalesce(categories.name, '') as category_name, + activities.id as activity_id, + activities.name as activity_name, + coalesce(facts_count, 0) as total_facts + from activities - LEFT JOIN + left join categories - ON + on activities.category_id = categories.id - LEFT JOIN ( - SELECT + left join ( + select activity_id, - COUNT(*) AS facts_count - FROM + count(*) as facts_count + from facts - GROUP BY + group by activity_id - ) AS facts_count_subquery - ON + ) as facts_count_subquery + on activities.id = facts_count_subquery.activity_id; ''' @@ -54,7 +53,7 @@ class HamsterToolsApp(App): self.table.sort(self.columns[1], self.columns[3]) def compose(self) -> ComposeResult: - """Create child widgets for the app.""" + """create child widgets for the app.""" yield Header() yield DataTable() yield Footer() @@ -62,36 +61,152 @@ class HamsterToolsApp(App): def on_mount(self) -> None: self.table = self.query_one(DataTable) self.table.cursor_type = "row" - self.columns = self.table.add_columns("category id","category","activity ID","activity","entries") + self.columns = self.table.add_columns("category id","category","activity id","activity","entries") self._refresh() - def action_quit(self) -> None: - self.exit() - def action_refresh(self) -> None: self._refresh() def action_sort(self) -> None: self.table.cursor_type = "column" - def action_delete_activity(self) -> None: - # Get the keys for the row and column under the cursor. + def action_delete(self) -> None: + # get the keys for the row and column under the cursor. row_key, _ = self.table.coordinate_to_cell_key(self.table.cursor_coordinate) activity_id = self.table.get_cell_at( Coordinate(self.table.cursor_coordinate.row, 2), ) - sql = 'DELETE FROM activities WHERE id = ?' + sql = 'delete from activities where id = ?' print(Coordinate(2, self.table.cursor_coordinate.row),) print(activity_id) self.db_cursor.execute(sql, (activity_id,)) self.db_connection.commit() - # Supply the row key to `remove_row` to delete the row. + # supply the row key to `remove_row` to delete the row. self.table.remove_row(row_key) def on_data_table_column_selected(self, event): event.data_table.sort(event.column_key) event.data_table.cursor_type = "row" + + +class CategoriesScreen(Screen): + BINDINGS = [ + ("s", "sort", "Sort"), + ("r", "refresh", "Refresh"), + ("d", "delete", "Delete category"), + ] + + def __init__(self, db_cursor, db_connection): + self.db_cursor = db_cursor + self.db_connection = db_connection + super().__init__() + + def _refresh(self): + self.table.clear() + + sql = ''' + select + categories.id as category_id, + coalesce(categories.name, '') as category_name, + coalesce(activities_count, 0) as total_activities + from + categories + left join ( + select + category_id, + count(*) as activities_count + from + activities + group by + category_id + ) as activities_count_subquery + on + categories.id = activities_count_subquery.category_id; + ''' + + results = self.db_cursor.execute(sql) + # results = [[cell or "" for cell in row] for row in self.db_cursor.fetchall()] + + self.table.add_rows(results) + self.table.sort(self.columns[1]) + + def compose(self) -> ComposeResult: + """create child widgets for the app.""" + yield Header() + yield DataTable() + yield Footer() + + def on_mount(self) -> None: + self.table = self.query_one(DataTable) + self.table.cursor_type = "row" + self.columns = self.table.add_columns("category id","category","activities") + self._refresh() + + def action_refresh(self) -> None: + self._refresh() + + def action_sort(self) -> None: + self.table.cursor_type = "column" + + def action_delete(self) -> None: + # get the keys for the row and column under the cursor. + row_key, _ = self.table.coordinate_to_cell_key(self.table.cursor_coordinate) + + category_id = self.table.get_cell_at( + Coordinate(self.table.cursor_coordinate.row, 0), + ) + + sql = 'delete from categories where id = ?' + print(Coordinate(2, self.table.cursor_coordinate.row),) + print(category_id) + + self.db_cursor.execute(sql, (category_id,)) + self.db_connection.commit() + + # supply the row key to `remove_row` to delete the row. + self.table.remove_row(row_key) + + def on_data_table_column_selected(self, event): + event.data_table.sort(event.column_key) + event.data_table.cursor_type = "row" + +# class KimaiScreen(Screen): +# def compose(self) -> ComposeResult: +# yield Placeholder("Help Screen") +# yield Footer() +# +# def __init__(self, db_cursor, db_connection): +# self.db_cursor = db_cursor +# self.db_connection = db_connection +# super().__init__() + + +class HamsterToolsApp(App): + BINDINGS = [ + ("a", "switch_mode('activities')", "Activities"), + ("c", "switch_mode('categories')", "Categories"), + # ("k", "switch_mode('kimai')", "Kimai"), + ("q", "quit", "Quit"), + ] + def __init__(self, db_cursor, db_connection): + self.db_cursor = db_cursor + self.db_connection = db_connection + + self.MODES = { + "categories": CategoriesScreen(db_cursor, db_connection), + "activities": ActivitiesScreen(db_cursor, db_connection), + # "kimai": KimaiScreen, + } + + super().__init__() + + def on_mount(self) -> None: + self.switch_mode("activities") + + def action_quit(self) -> None: + self.exit() + From 8e5e28ea6741077ee0f023e4e4a64d77b755bbf8 Mon Sep 17 00:00:00 2001 From: 3wc <3wc@doesthisthing.work> Date: Fri, 27 Oct 2023 04:04:30 +0100 Subject: [PATCH 04/46] =?UTF-8?q?Proper=20DB=20ORM,=20filtering=20?= =?UTF-8?q?=F0=9F=8E=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hamstertools/__init__.py | 3 +- hamstertools/app.py | 174 ++++++++++++++++++++------------------- hamstertools/db.py | 170 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 262 insertions(+), 85 deletions(-) create mode 100644 hamstertools/db.py diff --git a/hamstertools/__init__.py b/hamstertools/__init__.py index be12c92..bea9cf5 100755 --- a/hamstertools/__init__.py +++ b/hamstertools/__init__.py @@ -704,7 +704,8 @@ def _import(username, mapping_path=None, output=None, category_search=None, afte @cli.command() def app(): from .app import HamsterToolsApp - app = HamsterToolsApp(db_cursor=c, db_connection=conn) + #app = HamsterToolsApp(db_cursor=c, db_connection=conn) + app = HamsterToolsApp() app.run() diff --git a/hamstertools/app.py b/hamstertools/app.py index d595795..6bd04c4 100644 --- a/hamstertools/app.py +++ b/hamstertools/app.py @@ -1,8 +1,12 @@ from textual.app import App, ComposeResult -from textual.widgets import Header, Footer, DataTable, Placeholder +from textual.binding import Binding +from textual.widgets import Header, Footer, DataTable, Input +from textual.containers import Horizontal, Vertical from textual.coordinate import Coordinate from textual.screen import Screen +from textual.reactive import reactive +from .db import DatabaseManager, Category, Activity class ActivitiesScreen(Screen): BINDINGS = [ @@ -10,58 +14,44 @@ class ActivitiesScreen(Screen): ("s", "sort", "Sort"), ("r", "refresh", "Refresh"), ("d", "delete", "Delete activity"), + ("/", "filter", "Search"), + Binding(key="escape", action="cancelfilter", show=False), ] - def __init__(self, db_cursor, db_connection): - self.db_cursor = db_cursor - self.db_connection = db_connection + def __init__(self, db_manager): + self.db_manager = db_manager super().__init__() - def _refresh(self): + def _refresh(self, filter_query=None): self.table.clear() - sql = ''' - select - categories.id as category_id, - coalesce(categories.name, '') as category_name, - activities.id as activity_id, - activities.name as activity_name, - coalesce(facts_count, 0) as total_facts - from - activities - left join - categories - on - activities.category_id = categories.id - left join ( - select - activity_id, - count(*) as facts_count - from - facts - group by - activity_id - ) as facts_count_subquery - on - activities.id = facts_count_subquery.activity_id; - ''' + # List activities with the count of facts + activities = Activity.list_activities(self.db_manager, filter_query) - results = self.db_cursor.execute(sql) - # results = [[cell or "" for cell in row] for row in self.db_cursor.fetchall()] + self.table.add_rows([[ + activity.category_id, + activity.category_name, + activity.id, + activity.name, + activity.facts_count, + ] for activity in activities]) - self.table.add_rows(results) - self.table.sort(self.columns[1], self.columns[3]) + self.table.sort(*self.sort) def compose(self) -> ComposeResult: """create child widgets for the app.""" yield Header() - yield DataTable() + with Vertical(): + yield DataTable() + with Horizontal(): + yield Input(id="filter") yield Footer() def on_mount(self) -> None: self.table = self.query_one(DataTable) self.table.cursor_type = "row" self.columns = self.table.add_columns("category id","category","activity id","activity","entries") + self.sort = (self.columns[1], self.columns[3]) self._refresh() def action_refresh(self) -> None: @@ -70,6 +60,17 @@ class ActivitiesScreen(Screen): def action_sort(self) -> None: self.table.cursor_type = "column" + def action_filter(self) -> None: + filter_input = self.query_one("#filter") + filter_input.display = True + filter_input.focus() + print(filter_input) + + def action_cancelfilter(self) -> None: + filter_input = self.query_one("#filter") + filter_input.display = False + self._refresh() + def action_delete(self) -> None: # get the keys for the row and column under the cursor. row_key, _ = self.table.coordinate_to_cell_key(self.table.cursor_coordinate) @@ -78,72 +79,65 @@ class ActivitiesScreen(Screen): Coordinate(self.table.cursor_coordinate.row, 2), ) - sql = 'delete from activities where id = ?' - print(Coordinate(2, self.table.cursor_coordinate.row),) - print(activity_id) - - self.db_cursor.execute(sql, (activity_id,)) - self.db_connection.commit() + activity = Activity.get_by_id(self.db_manager, activity_id) + activity.delete() # supply the row key to `remove_row` to delete the row. self.table.remove_row(row_key) def on_data_table_column_selected(self, event): - event.data_table.sort(event.column_key) + self.sort = (event.column_key,) + event.data_table.sort(*self.sort) event.data_table.cursor_type = "row" + def on_input_changed(self, event): + self._refresh(event.value) + class CategoriesScreen(Screen): BINDINGS = [ ("s", "sort", "Sort"), ("r", "refresh", "Refresh"), ("d", "delete", "Delete category"), + ("/", "filter", "Search"), + Binding(key="escape", action="cancelfilter", show=False), ] - def __init__(self, db_cursor, db_connection): - self.db_cursor = db_cursor - self.db_connection = db_connection + filtering = reactive(False) + filter_query = reactive("") + + def __init__(self, db_manager): + self.db_manager = db_manager super().__init__() - def _refresh(self): + def _refresh(self, filter_query=None): self.table.clear() - sql = ''' - select - categories.id as category_id, - coalesce(categories.name, '') as category_name, - coalesce(activities_count, 0) as total_activities - from - categories - left join ( - select - category_id, - count(*) as activities_count - from - activities - group by - category_id - ) as activities_count_subquery - on - categories.id = activities_count_subquery.category_id; - ''' + categories = Category.list_categories(self.db_manager, + filter_query=filter_query) - results = self.db_cursor.execute(sql) - # results = [[cell or "" for cell in row] for row in self.db_cursor.fetchall()] + self.table.add_rows([[ + category.id, + category.name, + category.activity_count, + ] for category in categories]) - self.table.add_rows(results) - self.table.sort(self.columns[1]) + self.table.sort(self.sort) def compose(self) -> ComposeResult: """create child widgets for the app.""" yield Header() - yield DataTable() + with Vertical(): + yield DataTable() + with Horizontal(): + yield Input(id="filter") yield Footer() def on_mount(self) -> None: self.table = self.query_one(DataTable) self.table.cursor_type = "row" self.columns = self.table.add_columns("category id","category","activities") + self.sort = self.columns[1] self._refresh() def action_refresh(self) -> None: @@ -152,6 +146,20 @@ class CategoriesScreen(Screen): def action_sort(self) -> None: self.table.cursor_type = "column" + def action_filter(self) -> None: + filter_input = self.query_one("#filter") + filter_input.display = True + filter_input.focus() + print(filter_input) + + def action_cancelfilter(self) -> None: + filter_input = self.query_one("#filter") + filter_input.display = False + self._refresh() + + def on_input_changed(self, event): + self._refresh(event.value) + def action_delete(self) -> None: # get the keys for the row and column under the cursor. row_key, _ = self.table.coordinate_to_cell_key(self.table.cursor_coordinate) @@ -159,19 +167,16 @@ class CategoriesScreen(Screen): category_id = self.table.get_cell_at( Coordinate(self.table.cursor_coordinate.row, 0), ) - - sql = 'delete from categories where id = ?' - print(Coordinate(2, self.table.cursor_coordinate.row),) - print(category_id) - - self.db_cursor.execute(sql, (category_id,)) - self.db_connection.commit() + category = Category.get_by_id(self.db_manager, category_id) + category.delete() # supply the row key to `remove_row` to delete the row. self.table.remove_row(row_key) def on_data_table_column_selected(self, event): - event.data_table.sort(event.column_key) + """ Handle column selection for sort """ + self.sort = event.column_key + event.data_table.sort(self.sort) event.data_table.cursor_type = "row" # class KimaiScreen(Screen): @@ -186,19 +191,20 @@ class CategoriesScreen(Screen): class HamsterToolsApp(App): + CSS_PATH = 'app.tcss' BINDINGS = [ ("a", "switch_mode('activities')", "Activities"), ("c", "switch_mode('categories')", "Categories"), # ("k", "switch_mode('kimai')", "Kimai"), ("q", "quit", "Quit"), ] - def __init__(self, db_cursor, db_connection): - self.db_cursor = db_cursor - self.db_connection = db_connection + + def __init__(self): + self.db_manager = DatabaseManager('hamster-testing.db') self.MODES = { - "categories": CategoriesScreen(db_cursor, db_connection), - "activities": ActivitiesScreen(db_cursor, db_connection), + "categories": CategoriesScreen(self.db_manager), + "activities": ActivitiesScreen(self.db_manager) # "kimai": KimaiScreen, } @@ -209,4 +215,4 @@ class HamsterToolsApp(App): def action_quit(self) -> None: self.exit() - + self.db_manager.close() diff --git a/hamstertools/db.py b/hamstertools/db.py new file mode 100644 index 0000000..6193361 --- /dev/null +++ b/hamstertools/db.py @@ -0,0 +1,170 @@ +import sqlite3 + +class DatabaseManager: + def __init__(self, database_name): + self.conn = sqlite3.connect(database_name) + self.cursor = self.conn.cursor() + + def get_conn(self): + return self.conn + + def get_cursor(self): + return self.cursor + + def close(self): + self.conn.close() + +class BaseORM: + def __init__(self, db_manager, table_name, id, **kwargs): + self.db_manager = db_manager + self.conn = db_manager.get_conn() + self.cursor = db_manager.get_cursor() + self.id = id + self.table_name = table_name + for key, value in kwargs.items(): + setattr(self, key, value) + + def delete(self): + self.cursor.execute(f"DELETE FROM {self.table_name} WHERE id=?", (self.id,)) + self.conn.commit() + + +class Category(BaseORM): + def __init__(self, db_manager, id, name, activity_count): + super().__init__(db_manager, "categories", id, name=name, + activity_count=activity_count) + + @staticmethod + def list_categories(db_manager, filter_query=None): + cursor = db_manager.get_cursor() + where = "" + if filter_query is not None: + where = "WHERE categories.name LIKE ?" + sql = f""" + SELECT + categories.id, + COALESCE(categories.name, ""), + COUNT(activities.id) AS activity_count + FROM + categories + LEFT JOIN + activities + ON + categories.id = activities.category_id + {where} + GROUP BY + categories.id + """ + if filter_query is not None: + cursor.execute(sql, ("%{}%".format(filter_query),)) + else: + cursor.execute(sql) + rows = cursor.fetchall() + return [Category(db_manager, row[0], row[1], row[2]) for row in rows] + + @staticmethod + def get_by_id(db_manager, category_id): + cursor = db_manager.get_cursor() + cursor.execute(""" + SELECT + categories.id, + categories.name, + COUNT(activities.id) AS activity_count + FROM + categories + LEFT JOIN + activities + ON + categories.id = activities.category_id + WHERE + categories.id = ? + """, (category_id,)) + + row = cursor.fetchone() + if row: + return Category(db_manager, row[0], row[1], row[2]) + return None + + +class Activity(BaseORM): + def __init__(self, db_manager, id, name, category_id, category_name, facts_count): + super().__init__(db_manager, "activities", id, name=name, category_id=category_id) + self.category_name = category_name + self.facts_count = facts_count + + @staticmethod + def list_activities(db_manager, filter_query=None): + cursor = db_manager.get_cursor() + where = "" + if filter_query is not None: + where = "WHERE categories.name LIKE ? or activities.name like ?" + sql = f""" + SELECT + activities.id, + activities.name, + categories.id, + COALESCE(categories.name, ""), + COUNT(facts.id) AS facts_count + FROM + activities + LEFT JOIN + categories + ON + activities.category_id = categories.id + LEFT JOIN + facts + ON + activities.id = facts.activity_id + {where} + GROUP BY + activities.id + """ + + if filter_query is not None: + cursor.execute(sql, ("%{}%".format(filter_query),) * 2 ) + else: + cursor.execute(sql) + + rows = cursor.fetchall() + return [Activity(db_manager, row[0], row[1], row[2], row[3], row[4]) for row in rows] + + @staticmethod + def get_by_id(db_manager, activity_id): + cursor = db_manager.get_cursor() + cursor.execute(""" + SELECT + activities.id, + activities.name, + categories.id, + COALESCE(categories.name, ""), + COUNT(facts.id) AS facts_count + FROM + activities + LEFT JOIN + categories + ON + activities.category_id = categories.id + LEFT JOIN + facts + ON + activities.id = facts.activity_id + WHERE + activities.id = ? + """, (activity_id,)) + + row = cursor.fetchone() + if row: + return Activity(db_manager, row[0], row[1], row[2], row[3], row[4]) + return None + + +class Fact(BaseORM): + def __init__(self, db_manager, id, activity_id): + super().__init__(db_manager, "facts", id, activity_id=activity_id) + + @staticmethod + def list_facts(db_manager): + cursor = db_manager.get_cursor() + cursor.execute("SELECT * FROM facts") + rows = cursor.fetchall() + return [Fact(db_manager, row[0], row[1]) for row in rows] From cbbf952787ec2b4ec1be4523a0f1e65394bbde0c Mon Sep 17 00:00:00 2001 From: 3wc <3wc@doesthisthing.work> Date: Fri, 27 Oct 2023 16:28:46 +0100 Subject: [PATCH 05/46] Improve filtering, add "move facts" --- hamstertools/app.py | 48 +++++++++++++++++++++++++++++++++++++-------- hamstertools/db.py | 18 ++++++++++++++++- 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/hamstertools/app.py b/hamstertools/app.py index 6bd04c4..4ea2baa 100644 --- a/hamstertools/app.py +++ b/hamstertools/app.py @@ -12,6 +12,7 @@ class ActivitiesScreen(Screen): BINDINGS = [ ("q", "quit", "Quit"), ("s", "sort", "Sort"), + ("f", "move_facts", "Move facts"), ("r", "refresh", "Refresh"), ("d", "delete", "Delete activity"), ("/", "filter", "Search"), @@ -60,19 +61,30 @@ class ActivitiesScreen(Screen): def action_sort(self) -> None: self.table.cursor_type = "column" + def on_data_table_column_selected(self, event): + self.sort = (event.column_key,) + event.data_table.sort(*self.sort) + event.data_table.cursor_type = "row" + def action_filter(self) -> None: filter_input = self.query_one("#filter") filter_input.display = True + self._refresh(filter_input.value) filter_input.focus() - print(filter_input) + + def on_input_submitted(self, event): + self.table.focus() + def action_cancelfilter(self) -> None: filter_input = self.query_one("#filter") filter_input.display = False + filter_input.clear() + self.table.focus() self._refresh() def action_delete(self) -> None: - # get the keys for the row and column under the cursor. + # get the keys for the row and column under the cursor. row_key, _ = self.table.coordinate_to_cell_key(self.table.cursor_coordinate) activity_id = self.table.get_cell_at( @@ -85,15 +97,30 @@ class ActivitiesScreen(Screen): # supply the row key to `remove_row` to delete the row. self.table.remove_row(row_key) - def on_data_table_column_selected(self, event): - self.sort = (event.column_key,) - event.data_table.sort(*self.sort) - event.data_table.cursor_type = "row" + def action_move_facts(self) -> None: + row_idx: int = self.table.cursor_row + row_cells = self.table.get_row_at(row_idx) + self.move_from_activity = Activity.get_by_id(self.db_manager, row_cells[2]) + for col_idx, cell_value in enumerate(row_cells): + cell_coordinate = Coordinate(row_idx, col_idx) + self.table.update_cell_at( + cell_coordinate, + f"[red]{cell_value}[/red]", + ) + self.table.move_cursor(row=self.table.cursor_coordinate[0]+1) + + def on_data_table_row_selected(self, event): + if getattr(self, "move_from_activity", None) is not None: + move_to_activity = Activity.get_by_id(self.db_manager, self.table.get_cell_at( + Coordinate(event.cursor_row, 2) + )) + self.move_from_activity.move_facts(move_to_activity) + self._refresh() + del self.move_from_activity def on_input_changed(self, event): self._refresh(event.value) - class CategoriesScreen(Screen): BINDINGS = [ ("s", "sort", "Sort"), @@ -149,12 +176,17 @@ class CategoriesScreen(Screen): def action_filter(self) -> None: filter_input = self.query_one("#filter") filter_input.display = True + self._refresh(filter_input.value) filter_input.focus() - print(filter_input) + + def on_input_submitted(self, event): + self.table.focus() def action_cancelfilter(self) -> None: filter_input = self.query_one("#filter") filter_input.display = False + filter_input.clear() + self.table.focus() self._refresh() def on_input_changed(self, event): diff --git a/hamstertools/db.py b/hamstertools/db.py index 6193361..bf9bf3e 100644 --- a/hamstertools/db.py +++ b/hamstertools/db.py @@ -92,6 +92,22 @@ class Activity(BaseORM): self.category_name = category_name self.facts_count = facts_count + def move_facts(self, to_activity): + cursor = self.db_manager.get_cursor() + + print(f"moving from {self.id} to {to_activity.id}") + + cursor.execute(""" + UPDATE + facts + SET + activity_id = ? + WHERE + activity_id = ? + """, (to_activity.id, self.id)) + + self.conn.commit() + @staticmethod def list_activities(db_manager, filter_query=None): cursor = db_manager.get_cursor() @@ -121,7 +137,7 @@ class Activity(BaseORM): """ if filter_query is not None: - cursor.execute(sql, ("%{}%".format(filter_query),) * 2 ) + cursor.execute(sql, ("%{}%".format(filter_query),) * 2) else: cursor.execute(sql) From 2b7689c8408920a12e3627a67bff5718bead05e8 Mon Sep 17 00:00:00 2001 From: 3wc <3wc@doesthisthing.work> Date: Fri, 27 Oct 2023 16:29:12 +0100 Subject: [PATCH 06/46] Style datatable cursor --- hamstertools/app.tcss | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 hamstertools/app.tcss diff --git a/hamstertools/app.tcss b/hamstertools/app.tcss new file mode 100644 index 0000000..2eb8ed2 --- /dev/null +++ b/hamstertools/app.tcss @@ -0,0 +1,19 @@ +Screen { + layout: vertical; +} + +DataTable { + height: 90%; +} + +DataTable .datatable--cursor { + background: grey; +} + +DataTable:focus .datatable--cursor { + background: orange; +} + +#filter { + display: none; +} From 36f324e5ba67afa87bbfeaf4ea13817f284c17bf Mon Sep 17 00:00:00 2001 From: 3wc <3wc@doesthisthing.work> Date: Fri, 27 Oct 2023 16:39:58 +0100 Subject: [PATCH 07/46] Code refactor --- hamstertools/app.py | 130 ++++++++++++++---------------------------- hamstertools/app.tcss | 4 -- 2 files changed, 43 insertions(+), 91 deletions(-) diff --git a/hamstertools/app.py b/hamstertools/app.py index 4ea2baa..aef1a61 100644 --- a/hamstertools/app.py +++ b/hamstertools/app.py @@ -8,37 +8,11 @@ from textual.reactive import reactive from .db import DatabaseManager, Category, Activity -class ActivitiesScreen(Screen): - BINDINGS = [ - ("q", "quit", "Quit"), - ("s", "sort", "Sort"), - ("f", "move_facts", "Move facts"), - ("r", "refresh", "Refresh"), - ("d", "delete", "Delete activity"), - ("/", "filter", "Search"), - Binding(key="escape", action="cancelfilter", show=False), - ] - +class ListScreen(Screen): def __init__(self, db_manager): self.db_manager = db_manager super().__init__() - def _refresh(self, filter_query=None): - self.table.clear() - - # List activities with the count of facts - activities = Activity.list_activities(self.db_manager, filter_query) - - self.table.add_rows([[ - activity.category_id, - activity.category_name, - activity.id, - activity.name, - activity.facts_count, - ] for activity in activities]) - - self.table.sort(*self.sort) - def compose(self) -> ComposeResult: """create child widgets for the app.""" yield Header() @@ -48,13 +22,6 @@ class ActivitiesScreen(Screen): yield Input(id="filter") yield Footer() - def on_mount(self) -> None: - self.table = self.query_one(DataTable) - self.table.cursor_type = "row" - self.columns = self.table.add_columns("category id","category","activity id","activity","entries") - self.sort = (self.columns[1], self.columns[3]) - self._refresh() - def action_refresh(self) -> None: self._refresh() @@ -75,7 +42,6 @@ class ActivitiesScreen(Screen): def on_input_submitted(self, event): self.table.focus() - def action_cancelfilter(self) -> None: filter_input = self.query_one("#filter") filter_input.display = False @@ -83,6 +49,45 @@ class ActivitiesScreen(Screen): self.table.focus() self._refresh() + def on_input_changed(self, event): + self._refresh(event.value) + + +class ActivitiesScreen(ListScreen): + BINDINGS = [ + ("q", "quit", "Quit"), + ("s", "sort", "Sort"), + ("r", "refresh", "Refresh"), + ("/", "filter", "Search"), + ("d", "delete", "Delete activity"), + ("f", "move_facts", "Move facts"), + Binding(key="escape", action="cancelfilter", show=False), + ] + + + def _refresh(self, filter_query=None): + self.table.clear() + + # List activities with the count of facts + activities = Activity.list_activities(self.db_manager, filter_query) + + self.table.add_rows([[ + activity.category_id, + activity.category_name, + activity.id, + activity.name, + activity.facts_count, + ] for activity in activities]) + + self.table.sort(*self.sort) + + def on_mount(self) -> None: + self.table = self.query_one(DataTable) + self.table.cursor_type = "row" + self.columns = self.table.add_columns("category id","category","activity id","activity","entries") + self.sort = (self.columns[1], self.columns[3]) + self._refresh() + def action_delete(self) -> None: # get the keys for the row and column under the cursor. row_key, _ = self.table.coordinate_to_cell_key(self.table.cursor_coordinate) @@ -118,25 +123,16 @@ class ActivitiesScreen(Screen): self._refresh() del self.move_from_activity - def on_input_changed(self, event): - self._refresh(event.value) - -class CategoriesScreen(Screen): +class CategoriesScreen(ListScreen): BINDINGS = [ + ("q", "quit", "Quit"), ("s", "sort", "Sort"), ("r", "refresh", "Refresh"), - ("d", "delete", "Delete category"), ("/", "filter", "Search"), + ("d", "delete", "Delete category"), Binding(key="escape", action="cancelfilter", show=False), ] - filtering = reactive(False) - filter_query = reactive("") - - def __init__(self, db_manager): - self.db_manager = db_manager - super().__init__() - def _refresh(self, filter_query=None): self.table.clear() @@ -151,15 +147,6 @@ class CategoriesScreen(Screen): self.table.sort(self.sort) - def compose(self) -> ComposeResult: - """create child widgets for the app.""" - yield Header() - with Vertical(): - yield DataTable() - with Horizontal(): - yield Input(id="filter") - yield Footer() - def on_mount(self) -> None: self.table = self.query_one(DataTable) self.table.cursor_type = "row" @@ -167,31 +154,6 @@ class CategoriesScreen(Screen): self.sort = self.columns[1] self._refresh() - def action_refresh(self) -> None: - self._refresh() - - def action_sort(self) -> None: - self.table.cursor_type = "column" - - def action_filter(self) -> None: - filter_input = self.query_one("#filter") - filter_input.display = True - self._refresh(filter_input.value) - filter_input.focus() - - def on_input_submitted(self, event): - self.table.focus() - - def action_cancelfilter(self) -> None: - filter_input = self.query_one("#filter") - filter_input.display = False - filter_input.clear() - self.table.focus() - self._refresh() - - def on_input_changed(self, event): - self._refresh(event.value) - def action_delete(self) -> None: # get the keys for the row and column under the cursor. row_key, _ = self.table.coordinate_to_cell_key(self.table.cursor_coordinate) @@ -205,12 +167,6 @@ class CategoriesScreen(Screen): # supply the row key to `remove_row` to delete the row. self.table.remove_row(row_key) - def on_data_table_column_selected(self, event): - """ Handle column selection for sort """ - self.sort = event.column_key - event.data_table.sort(self.sort) - event.data_table.cursor_type = "row" - # class KimaiScreen(Screen): # def compose(self) -> ComposeResult: # yield Placeholder("Help Screen") diff --git a/hamstertools/app.tcss b/hamstertools/app.tcss index 2eb8ed2..0f6cab6 100644 --- a/hamstertools/app.tcss +++ b/hamstertools/app.tcss @@ -1,7 +1,3 @@ -Screen { - layout: vertical; -} - DataTable { height: 90%; } From b06cf1fc629650ef6b64b83323ce262d3bf452b7 Mon Sep 17 00:00:00 2001 From: 3wc <3wc@doesthisthing.work> Date: Fri, 27 Oct 2023 16:40:25 +0100 Subject: [PATCH 08/46] Reformatting --- hamstertools/app.py | 62 +++++++++++++++++++++++++++------------------ 1 file changed, 38 insertions(+), 24 deletions(-) diff --git a/hamstertools/app.py b/hamstertools/app.py index aef1a61..e0f25b9 100644 --- a/hamstertools/app.py +++ b/hamstertools/app.py @@ -4,10 +4,10 @@ from textual.widgets import Header, Footer, DataTable, Input from textual.containers import Horizontal, Vertical from textual.coordinate import Coordinate from textual.screen import Screen -from textual.reactive import reactive from .db import DatabaseManager, Category, Activity + class ListScreen(Screen): def __init__(self, db_manager): self.db_manager = db_manager @@ -64,27 +64,33 @@ class ActivitiesScreen(ListScreen): Binding(key="escape", action="cancelfilter", show=False), ] - def _refresh(self, filter_query=None): self.table.clear() # List activities with the count of facts activities = Activity.list_activities(self.db_manager, filter_query) - self.table.add_rows([[ - activity.category_id, - activity.category_name, - activity.id, - activity.name, - activity.facts_count, - ] for activity in activities]) + self.table.add_rows( + [ + [ + activity.category_id, + activity.category_name, + activity.id, + activity.name, + activity.facts_count, + ] + for activity in activities + ] + ) self.table.sort(*self.sort) def on_mount(self) -> None: self.table = self.query_one(DataTable) self.table.cursor_type = "row" - self.columns = self.table.add_columns("category id","category","activity id","activity","entries") + self.columns = self.table.add_columns( + "category id", "category", "activity id", "activity", "entries" + ) self.sort = (self.columns[1], self.columns[3]) self._refresh() @@ -112,17 +118,18 @@ class ActivitiesScreen(ListScreen): cell_coordinate, f"[red]{cell_value}[/red]", ) - self.table.move_cursor(row=self.table.cursor_coordinate[0]+1) + self.table.move_cursor(row=self.table.cursor_coordinate[0] + 1) def on_data_table_row_selected(self, event): if getattr(self, "move_from_activity", None) is not None: - move_to_activity = Activity.get_by_id(self.db_manager, self.table.get_cell_at( - Coordinate(event.cursor_row, 2) - )) + move_to_activity = Activity.get_by_id( + self.db_manager, self.table.get_cell_at(Coordinate(event.cursor_row, 2)) + ) self.move_from_activity.move_facts(move_to_activity) self._refresh() del self.move_from_activity + class CategoriesScreen(ListScreen): BINDINGS = [ ("q", "quit", "Quit"), @@ -136,21 +143,27 @@ class CategoriesScreen(ListScreen): def _refresh(self, filter_query=None): self.table.clear() - categories = Category.list_categories(self.db_manager, - filter_query=filter_query) + categories = Category.list_categories( + self.db_manager, filter_query=filter_query + ) - self.table.add_rows([[ - category.id, - category.name, - category.activity_count, - ] for category in categories]) + self.table.add_rows( + [ + [ + category.id, + category.name, + category.activity_count, + ] + for category in categories + ] + ) self.table.sort(self.sort) def on_mount(self) -> None: self.table = self.query_one(DataTable) self.table.cursor_type = "row" - self.columns = self.table.add_columns("category id","category","activities") + self.columns = self.table.add_columns("category id", "category", "activities") self.sort = self.columns[1] self._refresh() @@ -167,6 +180,7 @@ class CategoriesScreen(ListScreen): # supply the row key to `remove_row` to delete the row. self.table.remove_row(row_key) + # class KimaiScreen(Screen): # def compose(self) -> ComposeResult: # yield Placeholder("Help Screen") @@ -179,7 +193,7 @@ class CategoriesScreen(ListScreen): class HamsterToolsApp(App): - CSS_PATH = 'app.tcss' + CSS_PATH = "app.tcss" BINDINGS = [ ("a", "switch_mode('activities')", "Activities"), ("c", "switch_mode('categories')", "Categories"), @@ -188,7 +202,7 @@ class HamsterToolsApp(App): ] def __init__(self): - self.db_manager = DatabaseManager('hamster-testing.db') + self.db_manager = DatabaseManager("hamster-testing.db") self.MODES = { "categories": CategoriesScreen(self.db_manager), From eb33dfb99f0e5399d809d76e23167c21be0cbbf3 Mon Sep 17 00:00:00 2001 From: 3wc <3wc@doesthisthing.work> Date: Fri, 27 Oct 2023 16:48:03 +0100 Subject: [PATCH 09/46] Don't lose filter after moving facts --- hamstertools/app.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/hamstertools/app.py b/hamstertools/app.py index e0f25b9..ef75e95 100644 --- a/hamstertools/app.py +++ b/hamstertools/app.py @@ -126,7 +126,8 @@ class ActivitiesScreen(ListScreen): self.db_manager, self.table.get_cell_at(Coordinate(event.cursor_row, 2)) ) self.move_from_activity.move_facts(move_to_activity) - self._refresh() + filter_input = self.query_one("#filter") + self._refresh(filter_input.value) del self.move_from_activity From 7d8c37f75c43bb0af32aa8b646d007d6b0a0734e Mon Sep 17 00:00:00 2001 From: 3wc <3wc@doesthisthing.work> Date: Fri, 27 Oct 2023 16:51:44 +0100 Subject: [PATCH 10/46] Drop screen title for now --- hamstertools/app.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/hamstertools/app.py b/hamstertools/app.py index ef75e95..3fb004b 100644 --- a/hamstertools/app.py +++ b/hamstertools/app.py @@ -182,23 +182,11 @@ class CategoriesScreen(ListScreen): self.table.remove_row(row_key) -# class KimaiScreen(Screen): -# def compose(self) -> ComposeResult: -# yield Placeholder("Help Screen") -# yield Footer() -# -# def __init__(self, db_cursor, db_connection): -# self.db_cursor = db_cursor -# self.db_connection = db_connection -# super().__init__() - - class HamsterToolsApp(App): CSS_PATH = "app.tcss" BINDINGS = [ ("a", "switch_mode('activities')", "Activities"), ("c", "switch_mode('categories')", "Categories"), - # ("k", "switch_mode('kimai')", "Kimai"), ("q", "quit", "Quit"), ] @@ -208,7 +196,6 @@ class HamsterToolsApp(App): self.MODES = { "categories": CategoriesScreen(self.db_manager), "activities": ActivitiesScreen(self.db_manager) - # "kimai": KimaiScreen, } super().__init__() From 23e90a4413b18cc322af1ca617d30dc079f00d0b Mon Sep 17 00:00:00 2001 From: 3wc <3wc@doesthisthing.work> Date: Fri, 27 Oct 2023 19:32:16 +0100 Subject: [PATCH 11/46] Reformat db too --- hamstertools/db.py | 36 ++++++++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/hamstertools/db.py b/hamstertools/db.py index bf9bf3e..7959876 100644 --- a/hamstertools/db.py +++ b/hamstertools/db.py @@ -1,5 +1,6 @@ import sqlite3 + class DatabaseManager: def __init__(self, database_name): self.conn = sqlite3.connect(database_name) @@ -14,6 +15,7 @@ class DatabaseManager: def close(self): self.conn.close() + class BaseORM: def __init__(self, db_manager, table_name, id, **kwargs): self.db_manager = db_manager @@ -31,8 +33,9 @@ class BaseORM: class Category(BaseORM): def __init__(self, db_manager, id, name, activity_count): - super().__init__(db_manager, "categories", id, name=name, - activity_count=activity_count) + super().__init__( + db_manager, "categories", id, name=name, activity_count=activity_count + ) @staticmethod def list_categories(db_manager, filter_query=None): @@ -65,7 +68,8 @@ class Category(BaseORM): @staticmethod def get_by_id(db_manager, category_id): cursor = db_manager.get_cursor() - cursor.execute(""" + cursor.execute( + """ SELECT categories.id, categories.name, @@ -78,7 +82,9 @@ class Category(BaseORM): categories.id = activities.category_id WHERE categories.id = ? - """, (category_id,)) + """, + (category_id,), + ) row = cursor.fetchone() if row: @@ -88,7 +94,9 @@ class Category(BaseORM): class Activity(BaseORM): def __init__(self, db_manager, id, name, category_id, category_name, facts_count): - super().__init__(db_manager, "activities", id, name=name, category_id=category_id) + super().__init__( + db_manager, "activities", id, name=name, category_id=category_id + ) self.category_name = category_name self.facts_count = facts_count @@ -97,14 +105,17 @@ class Activity(BaseORM): print(f"moving from {self.id} to {to_activity.id}") - cursor.execute(""" + cursor.execute( + """ UPDATE facts SET activity_id = ? WHERE activity_id = ? - """, (to_activity.id, self.id)) + """, + (to_activity.id, self.id), + ) self.conn.commit() @@ -142,12 +153,15 @@ class Activity(BaseORM): cursor.execute(sql) rows = cursor.fetchall() - return [Activity(db_manager, row[0], row[1], row[2], row[3], row[4]) for row in rows] + return [ + Activity(db_manager, row[0], row[1], row[2], row[3], row[4]) for row in rows + ] @staticmethod def get_by_id(db_manager, activity_id): cursor = db_manager.get_cursor() - cursor.execute(""" + cursor.execute( + """ SELECT activities.id, activities.name, @@ -166,7 +180,9 @@ class Activity(BaseORM): activities.id = facts.activity_id WHERE activities.id = ? - """, (activity_id,)) + """, + (activity_id,), + ) row = cursor.fetchone() if row: From 8908290c4de87d2270ece29d238bb43429961f01 Mon Sep 17 00:00:00 2001 From: 3wc <3wc@doesthisthing.work> Date: Fri, 27 Oct 2023 21:01:45 +0100 Subject: [PATCH 12/46] Simplify db ORM method names --- hamstertools/app.py | 4 ++-- hamstertools/db.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/hamstertools/app.py b/hamstertools/app.py index 3fb004b..2b3b5ec 100644 --- a/hamstertools/app.py +++ b/hamstertools/app.py @@ -68,7 +68,7 @@ class ActivitiesScreen(ListScreen): self.table.clear() # List activities with the count of facts - activities = Activity.list_activities(self.db_manager, filter_query) + activities = Activity.list(self.db_manager, filter_query) self.table.add_rows( [ @@ -144,7 +144,7 @@ class CategoriesScreen(ListScreen): def _refresh(self, filter_query=None): self.table.clear() - categories = Category.list_categories( + categories = Category.list( self.db_manager, filter_query=filter_query ) diff --git a/hamstertools/db.py b/hamstertools/db.py index 7959876..225b7f2 100644 --- a/hamstertools/db.py +++ b/hamstertools/db.py @@ -38,7 +38,7 @@ class Category(BaseORM): ) @staticmethod - def list_categories(db_manager, filter_query=None): + def list(db_manager, filter_query=None): cursor = db_manager.get_cursor() where = "" if filter_query is not None: @@ -120,7 +120,7 @@ class Activity(BaseORM): self.conn.commit() @staticmethod - def list_activities(db_manager, filter_query=None): + def list(db_manager, filter_query=None): cursor = db_manager.get_cursor() where = "" if filter_query is not None: @@ -195,7 +195,7 @@ class Fact(BaseORM): super().__init__(db_manager, "facts", id, activity_id=activity_id) @staticmethod - def list_facts(db_manager): + def list(db_manager): cursor = db_manager.get_cursor() cursor.execute("SELECT * FROM facts") rows = cursor.fetchall() From d88098dd30ed097f991e9501ecf3a0d4f230dd4e Mon Sep 17 00:00:00 2001 From: 3wc <3wc@doesthisthing.work> Date: Fri, 27 Oct 2023 21:02:17 +0100 Subject: [PATCH 13/46] Initial Kimai API --- hamstertools/kimai.py | 59 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 hamstertools/kimai.py diff --git a/hamstertools/kimai.py b/hamstertools/kimai.py new file mode 100644 index 0000000..5d2f84c --- /dev/null +++ b/hamstertools/kimai.py @@ -0,0 +1,59 @@ +import requests +import requests_cache +import os + +class KimaiAPI(object): + # temporary hardcoded config + KIMAI_API_URL = 'https://kimai.autonomic.zone/api' + + KIMAI_USERNAME = '3wordchant' + KIMAI_API_KEY = os.environ['KIMAI_API_KEY'] + + auth_headers = { + 'X-AUTH-USER': KIMAI_USERNAME, + 'X-AUTH-TOKEN': KIMAI_API_KEY + } + + +class BaseAPI(object): + def __init__(self, api, **kwargs): + requests_cache.install_cache('kimai', backend='sqlite', expire_after=1800) + for key, value in kwargs.items(): + setattr(self, key, value) + + +class Customer(BaseAPI): + def __init__(self, api, id, name): + super().__init__(api, id=id, name=name) + + @staticmethod + def list(api): + response = requests.get( + f'{api.KIMAI_API_URL}/customers?visible=3', headers=api.auth_headers).json() + return [ + Customer(api, c['id'], c['name']) for c in response + ] + + def __repr__(self): + return f'Customer (id={self.id}, name={self.name})' + + +class Project(BaseAPI): + def __init__(self, api, id, name, customer): + super().__init__(api, id=id, name=name, customer=customer) + + @staticmethod + def list(api): + response = requests.get( + f'{api.KIMAI_API_URL}/projects?visible=3', headers=api.auth_headers).json() + return [ + Project(api, p['id'], p['name'], p['customer']) for p in response + ] + + def __repr__(self): + return f'Project (id={self.id}, name={self.name}, customer={self.customer})' + + +customers = Customer.list(KimaiAPI()) +projects = Project.list(KimaiAPI()) +from pdb import set_trace; set_trace() From a5eca9960e91cee441fd0302ad24a5c89c71a573 Mon Sep 17 00:00:00 2001 From: 3wc <3wc@doesthisthing.work> Date: Fri, 27 Oct 2023 21:11:29 +0100 Subject: [PATCH 14/46] Kimai API caching and nicer architecture --- hamstertools/kimai.py | 42 +++++++++++++++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/hamstertools/kimai.py b/hamstertools/kimai.py index 5d2f84c..0203f06 100644 --- a/hamstertools/kimai.py +++ b/hamstertools/kimai.py @@ -2,6 +2,11 @@ import requests import requests_cache import os + +class NotFound(Exception): + pass + + class KimaiAPI(object): # temporary hardcoded config KIMAI_API_URL = 'https://kimai.autonomic.zone/api' @@ -14,10 +19,16 @@ class KimaiAPI(object): 'X-AUTH-TOKEN': KIMAI_API_KEY } + def __init__(self): + requests_cache.install_cache('kimai', backend='sqlite', expire_after=1800) + self.customers_json = requests.get( + f'{self.KIMAI_API_URL}/customers?visible=3', headers=self.auth_headers).json() + self.projects_json = requests.get( + f'{self.KIMAI_API_URL}/projects?visible=3', headers=self.auth_headers).json() + class BaseAPI(object): def __init__(self, api, **kwargs): - requests_cache.install_cache('kimai', backend='sqlite', expire_after=1800) for key, value in kwargs.items(): setattr(self, key, value) @@ -28,12 +39,17 @@ class Customer(BaseAPI): @staticmethod def list(api): - response = requests.get( - f'{api.KIMAI_API_URL}/customers?visible=3', headers=api.auth_headers).json() return [ - Customer(api, c['id'], c['name']) for c in response + Customer(api, c['id'], c['name']) for c in api.customers_json ] + @staticmethod + def get_by_id(api, id): + for value in api.customers_json: + if value['id'] == id: + return Customer(api, value['id'], value['name']) + raise NotFound() + def __repr__(self): return f'Customer (id={self.id}, name={self.name})' @@ -44,16 +60,24 @@ class Project(BaseAPI): @staticmethod def list(api): - response = requests.get( - f'{api.KIMAI_API_URL}/projects?visible=3', headers=api.auth_headers).json() return [ - Project(api, p['id'], p['name'], p['customer']) for p in response + Project(api, p['id'], p['name'], Customer.get_by_id(api, p['customer'])) for p in api.projects_json ] + @staticmethod + def get_by_id(api, id): + for value in api.projects_json: + if value['id'] == id: + return Project(api, value['id'], value['name'], + Customer.get_by_id(api, value['customer'])) + raise NotFound() + def __repr__(self): return f'Project (id={self.id}, name={self.name}, customer={self.customer})' -customers = Customer.list(KimaiAPI()) -projects = Project.list(KimaiAPI()) +api = KimaiAPI() + +customers = Customer.list(api) +projects = Project.list(api) from pdb import set_trace; set_trace() From 4b85921b3e7901c0043c209888960ad2d5cada4f Mon Sep 17 00:00:00 2001 From: 3wc <3wc@doesthisthing.work> Date: Fri, 27 Oct 2023 22:00:03 +0100 Subject: [PATCH 15/46] Reasonably-working Kimai API data fetch'n'display --- hamstertools/__init__.py | 1 - hamstertools/app.py | 63 +++++++++++++++++++++++++++++++++++--- hamstertools/db.py | 66 ++++++++++++++++++++++++++++++++++++++++ hamstertools/kimai.py | 7 ++--- 4 files changed, 127 insertions(+), 10 deletions(-) diff --git a/hamstertools/__init__.py b/hamstertools/__init__.py index bea9cf5..882fa6d 100755 --- a/hamstertools/__init__.py +++ b/hamstertools/__init__.py @@ -704,7 +704,6 @@ def _import(username, mapping_path=None, output=None, category_search=None, afte @cli.command() def app(): from .app import HamsterToolsApp - #app = HamsterToolsApp(db_cursor=c, db_connection=conn) app = HamsterToolsApp() app.run() diff --git a/hamstertools/app.py b/hamstertools/app.py index 2b3b5ec..a2ca071 100644 --- a/hamstertools/app.py +++ b/hamstertools/app.py @@ -5,7 +5,8 @@ from textual.containers import Horizontal, Vertical from textual.coordinate import Coordinate from textual.screen import Screen -from .db import DatabaseManager, Category, Activity +from .db import DatabaseManager, Category, Activity, KimaiProject, KimaiCustomer +from .kimai import KimaiAPI, Customer as KimaiAPICustomer, Project as KimaiAPIProject, Activity as KimaiAPIActivity class ListScreen(Screen): @@ -55,7 +56,6 @@ class ListScreen(Screen): class ActivitiesScreen(ListScreen): BINDINGS = [ - ("q", "quit", "Quit"), ("s", "sort", "Sort"), ("r", "refresh", "Refresh"), ("/", "filter", "Search"), @@ -133,7 +133,6 @@ class ActivitiesScreen(ListScreen): class CategoriesScreen(ListScreen): BINDINGS = [ - ("q", "quit", "Quit"), ("s", "sort", "Sort"), ("r", "refresh", "Refresh"), ("/", "filter", "Search"), @@ -182,11 +181,66 @@ class CategoriesScreen(ListScreen): self.table.remove_row(row_key) +class KimaiScreen(ListScreen): + BINDINGS = [ + ("s", "sort", "Sort"), + ("r", "refresh", "Refresh"), + ("g", "get", "Get data"), + ("/", "filter", "Search"), + Binding(key="escape", action="cancelfilter", show=False), + ] + + def _refresh(self, filter_query=None): + self.table.clear() + + projects = KimaiProject.list( + self.db_manager + ) + + self.table.add_rows( + [ + [ + project.customer_id, + project.customer_name, + project.id, + project.name, + ] + for project in projects + ] + ) + + self.table.sort(self.sort) + + def action_get(self) -> None: + api = KimaiAPI() + + customers = KimaiAPICustomer.list(api) + for customer in customers: + KimaiCustomer(self.db_manager, id=customer.id, name=customer.name).save() + + projects = KimaiAPIProject.list(api) + for project in projects: + KimaiProject(self.db_manager, id=project.id, name=project.name, + customer_id=project.customer.id, customer_name="").save() + + self._refresh() + + def on_mount(self) -> None: + self.table = self.query_one(DataTable) + self.table.cursor_type = "row" + self.columns = self.table.add_columns("customer id", "customer", + "project id", "project") + # self.sort = (self.columns[1], self.columns[3]) + self.sort = self.columns[1] + self._refresh() + + class HamsterToolsApp(App): CSS_PATH = "app.tcss" BINDINGS = [ ("a", "switch_mode('activities')", "Activities"), ("c", "switch_mode('categories')", "Categories"), + ("k", "switch_mode('kimai')", "Kimai"), ("q", "quit", "Quit"), ] @@ -195,7 +249,8 @@ class HamsterToolsApp(App): self.MODES = { "categories": CategoriesScreen(self.db_manager), - "activities": ActivitiesScreen(self.db_manager) + "activities": ActivitiesScreen(self.db_manager), + "kimai": KimaiScreen(self.db_manager) } super().__init__() diff --git a/hamstertools/db.py b/hamstertools/db.py index 225b7f2..ace0312 100644 --- a/hamstertools/db.py +++ b/hamstertools/db.py @@ -200,3 +200,69 @@ class Fact(BaseORM): cursor.execute("SELECT * FROM facts") rows = cursor.fetchall() return [Fact(db_manager, row[0], row[1]) for row in rows] + + +class KimaiCustomer(BaseORM): + def __init__(self, db_manager, id, name): + super().__init__(db_manager, "kimai_customers", id, name=name) + + def save(self): + cursor = self.db_manager.get_cursor() + cursor.execute("SELECT id FROM kimai_customers WHERE id = ?", (self.id,)) + row = cursor.fetchone() + if row: + cursor.execute(""" + UPDATE kimai_customers SET name = ? WHERE id = ? + """, (self.name, self.id)) + else: + cursor.execute(""" + INSERT INTO kimai_customers (id, name) VALUES (?, ?) + """, (self.id, self.name)) + self.db_manager.get_conn().commit() + + + +class KimaiProject(BaseORM): + def __init__(self, db_manager, id, name, customer_id, customer_name): + super().__init__(db_manager, "kimai_projects", id, name=name, + customer_id=customer_id, customer_name=customer_name) + + def save(self): + cursor = self.db_manager.get_cursor() + cursor.execute("SELECT id FROM kimai_projects WHERE id = ?", (self.id,)) + row = cursor.fetchone() + if row: + cursor.execute(""" + UPDATE kimai_projects SET name = ?, customer_id = ? WHERE id = ? + """, (self.name, self.customer_id, self.id)) + else: + cursor.execute(""" + INSERT INTO kimai_projects (id, name, customer_id) VALUES (?, ?, ?) + """, (self.id, self.name, self.customer_id)) + self.db_manager.get_conn().commit() + + @staticmethod + def list(db_manager): + cursor = db_manager.get_cursor() + cursor.execute(""" + SELECT + kimai_projects.id, + COALESCE(kimai_projects.name, ""), + kimai_customers.id, + COALESCE(kimai_customers.name, "") + FROM + kimai_projects + LEFT JOIN + kimai_customers + ON + kimai_customers.id = kimai_projects.customer_id + GROUP BY + kimai_customers.id + """) + + rows = cursor.fetchall() + return [KimaiProject(db_manager, row[0], row[1], row[2], row[3]) for row in rows] + + +class KimaiACtivity(BaseORM): + pass diff --git a/hamstertools/kimai.py b/hamstertools/kimai.py index 0203f06..a37ca00 100644 --- a/hamstertools/kimai.py +++ b/hamstertools/kimai.py @@ -76,8 +76,5 @@ class Project(BaseAPI): return f'Project (id={self.id}, name={self.name}, customer={self.customer})' -api = KimaiAPI() - -customers = Customer.list(api) -projects = Project.list(api) -from pdb import set_trace; set_trace() +class Activity(): + pass From 65f16a252c1db32d3d16e5dea8366a92871bc368 Mon Sep 17 00:00:00 2001 From: 3wc <3wc@doesthisthing.work> Date: Fri, 27 Oct 2023 23:27:48 +0100 Subject: [PATCH 16/46] Working kimai filtering, diable requests_cache for now --- hamstertools/app.py | 4 +--- hamstertools/db.py | 18 +++++++++++++----- hamstertools/kimai.py | 4 ++-- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/hamstertools/app.py b/hamstertools/app.py index a2ca071..2165764 100644 --- a/hamstertools/app.py +++ b/hamstertools/app.py @@ -193,9 +193,7 @@ class KimaiScreen(ListScreen): def _refresh(self, filter_query=None): self.table.clear() - projects = KimaiProject.list( - self.db_manager - ) + projects = KimaiProject.list(self.db_manager, filter_query) self.table.add_rows( [ diff --git a/hamstertools/db.py b/hamstertools/db.py index ace0312..6712900 100644 --- a/hamstertools/db.py +++ b/hamstertools/db.py @@ -242,9 +242,13 @@ class KimaiProject(BaseORM): self.db_manager.get_conn().commit() @staticmethod - def list(db_manager): + def list(db_manager, filter_query=None): cursor = db_manager.get_cursor() - cursor.execute(""" + where = "" + if filter_query is not None: + where = "WHERE kimai_projects.name LIKE ? or kimai_customers.name like ?" + + sql = f""" SELECT kimai_projects.id, COALESCE(kimai_projects.name, ""), @@ -256,9 +260,13 @@ class KimaiProject(BaseORM): kimai_customers ON kimai_customers.id = kimai_projects.customer_id - GROUP BY - kimai_customers.id - """) + {where} + """ + + if filter_query is not None: + cursor.execute(sql, ("%{}%".format(filter_query),) * 2) + else: + cursor.execute(sql) rows = cursor.fetchall() return [KimaiProject(db_manager, row[0], row[1], row[2], row[3]) for row in rows] diff --git a/hamstertools/kimai.py b/hamstertools/kimai.py index a37ca00..19d5abd 100644 --- a/hamstertools/kimai.py +++ b/hamstertools/kimai.py @@ -1,5 +1,5 @@ import requests -import requests_cache +# import requests_cache import os @@ -20,7 +20,7 @@ class KimaiAPI(object): } def __init__(self): - requests_cache.install_cache('kimai', backend='sqlite', expire_after=1800) + # requests_cache.install_cache('kimai', backend='sqlite', expire_after=1800) self.customers_json = requests.get( f'{self.KIMAI_API_URL}/customers?visible=3', headers=self.auth_headers).json() self.projects_json = requests.get( From cd278b32aa49bf8daeaaa9b9fcce12d630927e48 Mon Sep 17 00:00:00 2001 From: 3wc <3wc@doesthisthing.work> Date: Sat, 28 Oct 2023 00:42:30 +0100 Subject: [PATCH 17/46] Switch to peewee ORM --- hamstertools/app.py | 135 ++++++++++++++------ hamstertools/db.py | 301 +++++++------------------------------------- 2 files changed, 142 insertions(+), 294 deletions(-) diff --git a/hamstertools/app.py b/hamstertools/app.py index 2165764..b87d7a7 100644 --- a/hamstertools/app.py +++ b/hamstertools/app.py @@ -5,15 +5,26 @@ from textual.containers import Horizontal, Vertical from textual.coordinate import Coordinate from textual.screen import Screen -from .db import DatabaseManager, Category, Activity, KimaiProject, KimaiCustomer -from .kimai import KimaiAPI, Customer as KimaiAPICustomer, Project as KimaiAPIProject, Activity as KimaiAPIActivity +from peewee import fn, JOIN + +from .db import ( + db, + HamsterCategory, + HamsterActivity, + HamsterFact, + KimaiProject, + KimaiCustomer, + KimaiActivity, +) +from .kimai import ( + KimaiAPI, + Customer as KimaiAPICustomer, + Project as KimaiAPIProject, + Activity as KimaiAPIActivity, +) class ListScreen(Screen): - def __init__(self, db_manager): - self.db_manager = db_manager - super().__init__() - def compose(self) -> ComposeResult: """create child widgets for the app.""" yield Header() @@ -67,14 +78,29 @@ class ActivitiesScreen(ListScreen): def _refresh(self, filter_query=None): self.table.clear() - # List activities with the count of facts - activities = Activity.list(self.db_manager, filter_query) + activities = ( + HamsterActivity.select( + HamsterActivity, + HamsterCategory, + fn.Count(HamsterFact.id).alias("facts_count") + ) + .join(HamsterCategory, JOIN.LEFT_OUTER) + .switch(HamsterActivity) + .join(HamsterFact, JOIN.LEFT_OUTER) + .group_by(HamsterActivity) + ) + + if filter_query: + activities = activities.where( + HamsterActivity.name.contains(filter_query) + | HamsterCategory.name.contains(filter_query) + ) self.table.add_rows( [ [ - activity.category_id, - activity.category_name, + activity.category.id, + activity.category.name, activity.id, activity.name, activity.facts_count, @@ -102,8 +128,8 @@ class ActivitiesScreen(ListScreen): Coordinate(self.table.cursor_coordinate.row, 2), ) - activity = Activity.get_by_id(self.db_manager, activity_id) - activity.delete() + activity = HamsterActivity.get(id=activity_id) + activity.delete_instance() # supply the row key to `remove_row` to delete the row. self.table.remove_row(row_key) @@ -111,7 +137,7 @@ class ActivitiesScreen(ListScreen): def action_move_facts(self) -> None: row_idx: int = self.table.cursor_row row_cells = self.table.get_row_at(row_idx) - self.move_from_activity = Activity.get_by_id(self.db_manager, row_cells[2]) + self.move_from_activity = HamsterActivity.get(id=row_cells[2]) for col_idx, cell_value in enumerate(row_cells): cell_coordinate = Coordinate(row_idx, col_idx) self.table.update_cell_at( @@ -122,10 +148,12 @@ class ActivitiesScreen(ListScreen): def on_data_table_row_selected(self, event): if getattr(self, "move_from_activity", None) is not None: - move_to_activity = Activity.get_by_id( - self.db_manager, self.table.get_cell_at(Coordinate(event.cursor_row, 2)) + move_to_activity = HamsterActivity.get( + self.table.get_cell_at(Coordinate(event.cursor_row, 2)) ) - self.move_from_activity.move_facts(move_to_activity) + HamsterFact.update({HamsterFact.activity: + move_to_activity}).where(HamsterFact.activity == + self.move_from_activity).execute() filter_input = self.query_one("#filter") self._refresh(filter_input.value) del self.move_from_activity @@ -143,16 +171,26 @@ class CategoriesScreen(ListScreen): def _refresh(self, filter_query=None): self.table.clear() - categories = Category.list( - self.db_manager, filter_query=filter_query + categories = ( + HamsterCategory.select( + HamsterCategory, + fn.Count(HamsterActivity.id).alias("activities_count") + ) + .join(HamsterActivity, JOIN.LEFT_OUTER) + .group_by(HamsterCategory) ) + if filter_query: + categories = categories.where( + HamsterCategory.name.contains(filter_query) + ) + self.table.add_rows( [ [ category.id, category.name, - category.activity_count, + category.activities_count, ] for category in categories ] @@ -174,8 +212,8 @@ class CategoriesScreen(ListScreen): category_id = self.table.get_cell_at( Coordinate(self.table.cursor_coordinate.row, 0), ) - category = Category.get_by_id(self.db_manager, category_id) - category.delete() + category = HamsterCategory.get(id=category_id) + category.delete_instance() # supply the row key to `remove_row` to delete the row. self.table.remove_row(row_key) @@ -193,15 +231,33 @@ class KimaiScreen(ListScreen): def _refresh(self, filter_query=None): self.table.clear() - projects = KimaiProject.list(self.db_manager, filter_query) + projects = ( + KimaiProject.select( + KimaiProject, + KimaiCustomer, + fn.Count(KimaiActivity.id).alias("activities_count") + ) + .join(KimaiCustomer, JOIN.LEFT_OUTER) + .switch(KimaiProject) + .join(KimaiActivity, JOIN.LEFT_OUTER) + .group_by(KimaiProject) + ) + + if filter_query: + projects = projects.where( + KimaiProject.name.contains(filter_query) + | KimaiCustomer.name.contains(filter_query) + ) + self.table.add_rows( [ [ - project.customer_id, - project.customer_name, + project.customer.id, + project.customer.name, project.id, project.name, + project.activities_count ] for project in projects ] @@ -213,21 +269,28 @@ class KimaiScreen(ListScreen): api = KimaiAPI() customers = KimaiAPICustomer.list(api) - for customer in customers: - KimaiCustomer(self.db_manager, id=customer.id, name=customer.name).save() + with db.atomic(): + KimaiCustomer.insert_many([{ + 'id': customer.id, + 'name': customer.name + } for customer in customers]).execute() projects = KimaiAPIProject.list(api) - for project in projects: - KimaiProject(self.db_manager, id=project.id, name=project.name, - customer_id=project.customer.id, customer_name="").save() + with db.atomic(): + KimaiProject.insert_many([{ + 'id': project.id, + 'name': project.name, + 'customer_id': project.customer.id + } for project in projects]).execute() self._refresh() def on_mount(self) -> None: self.table = self.query_one(DataTable) self.table.cursor_type = "row" - self.columns = self.table.add_columns("customer id", "customer", - "project id", "project") + self.columns = self.table.add_columns( + "customer id", "customer", "project id", "project", "activities" + ) # self.sort = (self.columns[1], self.columns[3]) self.sort = self.columns[1] self._refresh() @@ -243,12 +306,12 @@ class HamsterToolsApp(App): ] def __init__(self): - self.db_manager = DatabaseManager("hamster-testing.db") + db.init("hamster-testing.db") self.MODES = { - "categories": CategoriesScreen(self.db_manager), - "activities": ActivitiesScreen(self.db_manager), - "kimai": KimaiScreen(self.db_manager) + "categories": CategoriesScreen(), + "activities": ActivitiesScreen(), + "kimai": KimaiScreen(), } super().__init__() @@ -258,4 +321,4 @@ class HamsterToolsApp(App): def action_quit(self) -> None: self.exit() - self.db_manager.close() + db.close() diff --git a/hamstertools/db.py b/hamstertools/db.py index 6712900..336e47b 100644 --- a/hamstertools/db.py +++ b/hamstertools/db.py @@ -1,276 +1,61 @@ -import sqlite3 +import logging +from peewee import SqliteDatabase, Model, CharField, DateField, ForeignKeyField + +from textual.logging import TextualHandler + +logger = logging.getLogger('peewee') +logger.addHandler(TextualHandler()) +logger.setLevel(logging.DEBUG) + +db = SqliteDatabase(None) -class DatabaseManager: - def __init__(self, database_name): - self.conn = sqlite3.connect(database_name) - self.cursor = self.conn.cursor() +class HamsterCategory(Model): + name = CharField() - def get_conn(self): - return self.conn - - def get_cursor(self): - return self.cursor - - def close(self): - self.conn.close() + class Meta: + database = db + table_name = 'categories' -class BaseORM: - def __init__(self, db_manager, table_name, id, **kwargs): - self.db_manager = db_manager - self.conn = db_manager.get_conn() - self.cursor = db_manager.get_cursor() - self.id = id - self.table_name = table_name - for key, value in kwargs.items(): - setattr(self, key, value) +class HamsterActivity(Model): + name = CharField() + category = ForeignKeyField(HamsterCategory, backref='activities') - def delete(self): - self.cursor.execute(f"DELETE FROM {self.table_name} WHERE id=?", (self.id,)) - self.conn.commit() + class Meta: + database = db + table_name = 'activities' -class Category(BaseORM): - def __init__(self, db_manager, id, name, activity_count): - super().__init__( - db_manager, "categories", id, name=name, activity_count=activity_count - ) +class HamsterFact(Model): + activity = ForeignKeyField(HamsterActivity, backref='facts') - @staticmethod - def list(db_manager, filter_query=None): - cursor = db_manager.get_cursor() - where = "" - if filter_query is not None: - where = "WHERE categories.name LIKE ?" - sql = f""" - SELECT - categories.id, - COALESCE(categories.name, ""), - COUNT(activities.id) AS activity_count - FROM - categories - LEFT JOIN - activities - ON - categories.id = activities.category_id - {where} - GROUP BY - categories.id - """ - if filter_query is not None: - cursor.execute(sql, ("%{}%".format(filter_query),)) - else: - cursor.execute(sql) - rows = cursor.fetchall() - return [Category(db_manager, row[0], row[1], row[2]) for row in rows] - - @staticmethod - def get_by_id(db_manager, category_id): - cursor = db_manager.get_cursor() - cursor.execute( - """ - SELECT - categories.id, - categories.name, - COUNT(activities.id) AS activity_count - FROM - categories - LEFT JOIN - activities - ON - categories.id = activities.category_id - WHERE - categories.id = ? - """, - (category_id,), - ) - - row = cursor.fetchone() - if row: - return Category(db_manager, row[0], row[1], row[2]) - return None + class Meta: + database = db + table_name = 'facts' -class Activity(BaseORM): - def __init__(self, db_manager, id, name, category_id, category_name, facts_count): - super().__init__( - db_manager, "activities", id, name=name, category_id=category_id - ) - self.category_name = category_name - self.facts_count = facts_count +class KimaiCustomer(Model): + name = CharField() - def move_facts(self, to_activity): - cursor = self.db_manager.get_cursor() - - print(f"moving from {self.id} to {to_activity.id}") - - cursor.execute( - """ - UPDATE - facts - SET - activity_id = ? - WHERE - activity_id = ? - """, - (to_activity.id, self.id), - ) - - self.conn.commit() - - @staticmethod - def list(db_manager, filter_query=None): - cursor = db_manager.get_cursor() - where = "" - if filter_query is not None: - where = "WHERE categories.name LIKE ? or activities.name like ?" - sql = f""" - SELECT - activities.id, - activities.name, - categories.id, - COALESCE(categories.name, ""), - COUNT(facts.id) AS facts_count - FROM - activities - LEFT JOIN - categories - ON - activities.category_id = categories.id - LEFT JOIN - facts - ON - activities.id = facts.activity_id - {where} - GROUP BY - activities.id - """ - - if filter_query is not None: - cursor.execute(sql, ("%{}%".format(filter_query),) * 2) - else: - cursor.execute(sql) - - rows = cursor.fetchall() - return [ - Activity(db_manager, row[0], row[1], row[2], row[3], row[4]) for row in rows - ] - - @staticmethod - def get_by_id(db_manager, activity_id): - cursor = db_manager.get_cursor() - cursor.execute( - """ - SELECT - activities.id, - activities.name, - categories.id, - COALESCE(categories.name, ""), - COUNT(facts.id) AS facts_count - FROM - activities - LEFT JOIN - categories - ON - activities.category_id = categories.id - LEFT JOIN - facts - ON - activities.id = facts.activity_id - WHERE - activities.id = ? - """, - (activity_id,), - ) - - row = cursor.fetchone() - if row: - return Activity(db_manager, row[0], row[1], row[2], row[3], row[4]) - return None + class Meta: + database = db + table_name = 'kimai_customers' -class Fact(BaseORM): - def __init__(self, db_manager, id, activity_id): - super().__init__(db_manager, "facts", id, activity_id=activity_id) +class KimaiProject(Model): + name = CharField() + customer = ForeignKeyField(KimaiCustomer, backref='projects') - @staticmethod - def list(db_manager): - cursor = db_manager.get_cursor() - cursor.execute("SELECT * FROM facts") - rows = cursor.fetchall() - return [Fact(db_manager, row[0], row[1]) for row in rows] + class Meta: + database = db + table_name = 'kimai_projects' -class KimaiCustomer(BaseORM): - def __init__(self, db_manager, id, name): - super().__init__(db_manager, "kimai_customers", id, name=name) +class KimaiActivity(Model): + name = CharField() + project = ForeignKeyField(KimaiProject, backref='activities') - def save(self): - cursor = self.db_manager.get_cursor() - cursor.execute("SELECT id FROM kimai_customers WHERE id = ?", (self.id,)) - row = cursor.fetchone() - if row: - cursor.execute(""" - UPDATE kimai_customers SET name = ? WHERE id = ? - """, (self.name, self.id)) - else: - cursor.execute(""" - INSERT INTO kimai_customers (id, name) VALUES (?, ?) - """, (self.id, self.name)) - self.db_manager.get_conn().commit() - - - -class KimaiProject(BaseORM): - def __init__(self, db_manager, id, name, customer_id, customer_name): - super().__init__(db_manager, "kimai_projects", id, name=name, - customer_id=customer_id, customer_name=customer_name) - - def save(self): - cursor = self.db_manager.get_cursor() - cursor.execute("SELECT id FROM kimai_projects WHERE id = ?", (self.id,)) - row = cursor.fetchone() - if row: - cursor.execute(""" - UPDATE kimai_projects SET name = ?, customer_id = ? WHERE id = ? - """, (self.name, self.customer_id, self.id)) - else: - cursor.execute(""" - INSERT INTO kimai_projects (id, name, customer_id) VALUES (?, ?, ?) - """, (self.id, self.name, self.customer_id)) - self.db_manager.get_conn().commit() - - @staticmethod - def list(db_manager, filter_query=None): - cursor = db_manager.get_cursor() - where = "" - if filter_query is not None: - where = "WHERE kimai_projects.name LIKE ? or kimai_customers.name like ?" - - sql = f""" - SELECT - kimai_projects.id, - COALESCE(kimai_projects.name, ""), - kimai_customers.id, - COALESCE(kimai_customers.name, "") - FROM - kimai_projects - LEFT JOIN - kimai_customers - ON - kimai_customers.id = kimai_projects.customer_id - {where} - """ - - if filter_query is not None: - cursor.execute(sql, ("%{}%".format(filter_query),) * 2) - else: - cursor.execute(sql) - - rows = cursor.fetchall() - return [KimaiProject(db_manager, row[0], row[1], row[2], row[3]) for row in rows] - - -class KimaiACtivity(BaseORM): - pass + class Meta: + database = db + table_name = 'kimai_activities' From c68e373b188a61a253fdf17033e8017abc29d3f2 Mon Sep 17 00:00:00 2001 From: 3wc <3wc@doesthisthing.work> Date: Sat, 28 Oct 2023 01:21:23 +0100 Subject: [PATCH 18/46] Show kimai activity counts, clear data before get --- hamstertools/app.py | 14 +++++++++++++- hamstertools/db.py | 2 +- hamstertools/kimai.py | 37 +++++++++++++++++++++++++++++++------ 3 files changed, 45 insertions(+), 8 deletions(-) diff --git a/hamstertools/app.py b/hamstertools/app.py index b87d7a7..5e64149 100644 --- a/hamstertools/app.py +++ b/hamstertools/app.py @@ -240,6 +240,7 @@ class KimaiScreen(ListScreen): .join(KimaiCustomer, JOIN.LEFT_OUTER) .switch(KimaiProject) .join(KimaiActivity, JOIN.LEFT_OUTER) + .where(KimaiActivity.project.is_null(False)) .group_by(KimaiProject) ) @@ -249,7 +250,6 @@ class KimaiScreen(ListScreen): | KimaiCustomer.name.contains(filter_query) ) - self.table.add_rows( [ [ @@ -268,6 +268,10 @@ class KimaiScreen(ListScreen): def action_get(self) -> None: api = KimaiAPI() + KimaiCustomer.delete().execute() + KimaiProject.delete().execute() + KimaiActivity.delete().execute() + customers = KimaiAPICustomer.list(api) with db.atomic(): KimaiCustomer.insert_many([{ @@ -283,6 +287,14 @@ class KimaiScreen(ListScreen): 'customer_id': project.customer.id } for project in projects]).execute() + activities = KimaiAPIActivity.list(api) + with db.atomic(): + KimaiActivity.insert_many([{ + 'id': activity.id, + 'name': activity.name, + 'project_id': (activity.project and activity.project.id or None) + } for activity in activities]).execute() + self._refresh() def on_mount(self) -> None: diff --git a/hamstertools/db.py b/hamstertools/db.py index 336e47b..fad193b 100644 --- a/hamstertools/db.py +++ b/hamstertools/db.py @@ -1,5 +1,5 @@ import logging -from peewee import SqliteDatabase, Model, CharField, DateField, ForeignKeyField +from peewee import SqliteDatabase, Model, CharField, ForeignKeyField from textual.logging import TextualHandler diff --git a/hamstertools/kimai.py b/hamstertools/kimai.py index 19d5abd..8c0f51d 100644 --- a/hamstertools/kimai.py +++ b/hamstertools/kimai.py @@ -1,5 +1,5 @@ import requests -# import requests_cache +import requests_cache import os @@ -20,11 +20,13 @@ class KimaiAPI(object): } def __init__(self): - # requests_cache.install_cache('kimai', backend='sqlite', expire_after=1800) + requests_cache.install_cache('kimai', backend='sqlite', expire_after=1800) self.customers_json = requests.get( f'{self.KIMAI_API_URL}/customers?visible=3', headers=self.auth_headers).json() self.projects_json = requests.get( f'{self.KIMAI_API_URL}/projects?visible=3', headers=self.auth_headers).json() + self.activities_json = requests.get( + f'{self.KIMAI_API_URL}/activities?visible=3', headers=self.auth_headers).json() class BaseAPI(object): @@ -65,16 +67,39 @@ class Project(BaseAPI): ] @staticmethod - def get_by_id(api, id): + def get_by_id(api, id, none=False): for value in api.projects_json: if value['id'] == id: return Project(api, value['id'], value['name'], Customer.get_by_id(api, value['customer'])) - raise NotFound() + if not none: + raise NotFound() def __repr__(self): return f'Project (id={self.id}, name={self.name}, customer={self.customer})' -class Activity(): - pass +class Activity(BaseAPI): + def __init__(self, api, id, name, project): + super().__init__(api, id=id, name=name, project=project) + + @staticmethod + def list(api): + return [ + Activity(api, a['id'], a['name'], Project.get_by_id(api, + a['project'], + none=True)) + for a in api.activities_json + ] + + @staticmethod + def get_by_id(api, id, none=False): + for value in api.activities_json: + if value['id'] == id: + return Activity(api, value['id'], value['name'], + Project.get_by_id(api, value['project'])) + if not none: + raise NotFound() + + def __repr__(self): + return f'Activity (id={self.id}, name={self.name}, project={self.project})' From ca7cd1aaa3b110d9681f3925725e6d5d05ddc914 Mon Sep 17 00:00:00 2001 From: 3wc <3wc@doesthisthing.work> Date: Sat, 28 Oct 2023 23:40:27 +0100 Subject: [PATCH 19/46] Start switching to peewee for cli commands, add `kimai c2v2db` command --- hamstertools/__init__.py | 148 ++++++++++++++++----------------------- hamstertools/app.py | 4 +- hamstertools/db.py | 15 +++- 3 files changed, 78 insertions(+), 89 deletions(-) diff --git a/hamstertools/__init__.py b/hamstertools/__init__.py index 882fa6d..60e4785 100755 --- a/hamstertools/__init__.py +++ b/hamstertools/__init__.py @@ -7,76 +7,15 @@ import sys import click import requests -import sqlite3 +from peewee import fn, JOIN + +from .db import db, HamsterCategory, HamsterActivity, HamsterFact, KimaiCustomer, KimaiProject, KimaiActivity, HamsterKimaiMapping # HAMSTER_DIR = Path.home() / '.local/share/hamster' # HAMSTER_FILE = HAMSTER_DIR / 'hamster.db' HAMSTER_FILE = 'hamster-testing.db' -conn = sqlite3.connect(HAMSTER_FILE) -c = conn.cursor() - - -def get_categories(ids=None, search=None): - sql = ''' - SELECT - id, name - FROM - categories ''' - - args = [] - - if ids is not None: - sql = sql + 'WHERE id IN ({seq})'.format( - seq=','.join(['?'] * len(ids)) - ) - args = args + list(ids) - - if search is not None: - sql = sql + " WHERE name LIKE ?" - search = '%{0}%'.format(search) - args.append(search) - - results = c.execute(sql, args) - - results = c.fetchall() - - return results - - -def get_activities(ids=None, search=None, category_search=None): - sql = ''' - SELECT - activities.id, activities.name, categories.name, categories.id - FROM - activities - LEFT JOIN - categories - ON - activities.category_id = categories.id ''' - - args = [] - - if ids is not None: - sql = sql + 'WHERE activities.id IN ({seq})'.format( - seq=','.join(['?'] * len(ids)) - ) - args = args + list(ids) - - if search is not None: - sql = sql + " WHERE activities.name LIKE ?" - search = '%{0}%'.format(search) - args.append(search) - - if category_search is not None: - sql = sql + " WHERE categories.name LIKE ?" - category_search = '%{0}%'.format(category_search) - args.append(category_search) - - results = c.execute(sql, args) - results = c.fetchall() - - return results +db.init(HAMSTER_FILE) @click.group() @@ -93,10 +32,13 @@ def categories(): @click.option('--search', help='Search string') def list_categories(search): """ List / search categories """ - results = get_categories(search=search) + categories = HamsterCategory.select() - for r in results: - click.echo('@{0[0]}: {0[1]}'.format(r)) + if search is not None: + categories = categories.where(HamsterCategory.name.contains(search)) + + for c in categories: + click.echo(f'@{c.id}: {c.name}') @categories.command('delete') @@ -105,29 +47,19 @@ def delete_categories(ids): """ Delete categories specified by IDS """ click.secho('Deleting:', fg='red') - results = get_categories(ids) + categories = HamsterCategory.select( + HamsterCategory, + fn.Count(HamsterActivity.id).alias("activities_count") + ).join(HamsterActivity, JOIN.LEFT_OUTER).group_by(HamsterActivity).where(HamsterCategory.id.in_(ids)) - for r in results: - sql = 'select count(id) from activities where category_id = ?' - count = c.execute(sql, (r[0],)).fetchone()[0] - click.echo('@{0[0]}: {0[1]} ({1} activities)'.format(r, count)) + for c in categories: + click.echo(f'@{c.id}: {c.name} ({c.activities_count} activities)') click.confirm('Do you want to continue?', abort=True) - for r in results: - sql = 'DELETE FROM activities WHERE category_id = ?' - c.execute(sql, (r[0],)) + count = HamsterCategory.delete().where(HamsterCategory.id.in_(ids)).execute() - sql = 'DELETE FROM categories ' - - sql = sql + 'WHERE id IN ({seq})'.format( - seq=','.join(['?'] * len(ids)) - ) - - c.execute(sql, ids) - conn.commit() - - click.secho('Deleted {0} categories'.format(len(ids)), fg='green') + click.secho('Deleted {0} categories'.format(count), fg='green') @categories.command('rename') @@ -701,6 +633,50 @@ def _import(username, mapping_path=None, output=None, category_search=None, afte output_file.close() +@kimai.command() +def dbinit(): + db.create_tables([KimaiCustomer, KimaiProject, KimaiActivity, + HamsterKimaiMapping]) + + +@kimai.command() +def dbreset(): + HamsterKimaiMapping.delete().execute() + + +@kimai.command() +@click.option('-g', '--global', 'global_', help='Does this file contain mappings to global activties', is_flag=True) +@click.option('--mapping-path', help='Mapping file') +def csv2db(mapping_path=None, global_=False): + mapping_file = _get_kimai_mapping_file(mapping_path, None) + next(mapping_file) + mapping_reader = csv.reader(mapping_file) + + for row in mapping_reader: + hamster_category = HamsterCategory.get(name=row[0]) + hamster_activity = HamsterActivity.get(name=row[1]) + 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: + kimai_activity = KimaiActivity.get( + name=row[4], + ) + + HamsterKimaiMapping.create( + hamster_activity=hamster_activity, + kimai_customer=kimai_customer, + kimai_project=kimai_project, + kimai_activity=kimai_activity, + kimai_description=row[6], + kimai_tags=row[5] + ) + @cli.command() def app(): from .app import HamsterToolsApp diff --git a/hamstertools/app.py b/hamstertools/app.py index 5e64149..d9153e7 100644 --- a/hamstertools/app.py +++ b/hamstertools/app.py @@ -99,8 +99,8 @@ class ActivitiesScreen(ListScreen): self.table.add_rows( [ [ - activity.category.id, - activity.category.name, + activity.category_id, + (activity.category.name if (activity.category_id != -1) else ""), activity.id, activity.name, activity.facts_count, diff --git a/hamstertools/db.py b/hamstertools/db.py index fad193b..a1e3452 100644 --- a/hamstertools/db.py +++ b/hamstertools/db.py @@ -54,8 +54,21 @@ class KimaiProject(Model): class KimaiActivity(Model): name = CharField() - project = ForeignKeyField(KimaiProject, backref='activities') + project = ForeignKeyField(KimaiProject, backref='activities', null=True) class Meta: database = db table_name = 'kimai_activities' + + +class HamsterKimaiMapping(Model): + hamster_activity = ForeignKeyField(HamsterActivity, backref='mappings') + kimai_customer = ForeignKeyField(KimaiCustomer, backref='mappings') + kimai_project = ForeignKeyField(KimaiProject, backref='mappings') + kimai_activity = ForeignKeyField(KimaiActivity, backref='mappings') + kimai_description = CharField() + kimai_tags = CharField() + + class Meta: + database = db + table_name = 'hamster_kimai_mappings' From f8f83ce4d4efcdf49ceb247e3e4674a971032314 Mon Sep 17 00:00:00 2001 From: 3wc <3wc@doesthisthing.work> Date: Sun, 29 Oct 2023 09:28:25 +0000 Subject: [PATCH 20/46] Finish converting CLI commands to use peewee --- hamstertools/__init__.py | 321 +++++++++++++++++---------------------- hamstertools/db.py | 5 +- 2 files changed, 142 insertions(+), 184 deletions(-) diff --git a/hamstertools/__init__.py b/hamstertools/__init__.py index 60e4785..758d2e6 100755 --- a/hamstertools/__init__.py +++ b/hamstertools/__init__.py @@ -11,7 +11,7 @@ from peewee import fn, JOIN 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-testing.db' @@ -50,7 +50,7 @@ def delete_categories(ids): categories = HamsterCategory.select( 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: click.echo(f'@{c.id}: {c.name} ({c.activities_count} activities)') @@ -68,63 +68,58 @@ def delete_categories(ids): def rename_category(id_, name): """ 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 = ?' - - c.execute(sql, (name, r[0])) - conn.commit() + category.name = name + category.save() @categories.command('activities') @click.argument('ids', nargs=-1) def list_category_activities(ids): - """ Show activities for categories specified by IDS """ - sql = ''' - SELECT - activities.id, activities.name, categories.name - FROM - activities - LEFT JOIN - categories - ON - activities.category_id = categories.id - WHERE - categories.id IN ({seq}) - '''.format( - seq=','.join(['?'] * len(ids)) - ) + """ Show activities for categories specified by ids """ - results = c.execute(sql, ids) + activities = HamsterActivity.select( + HamsterActivity, + HamsterCategory.name + ).join(HamsterCategory, JOIN.LEFT_OUTER).where(HamsterCategory.id.in_(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') def tidy_categories(): """ 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' - categories = c.execute(sql).fetchall() + # Create a subquery to calculate the count of activities per category + subquery = ( + 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))) + # Use the subquery to filter categories where activities_count is 0 + 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: - click.echo('@{0[0]}: {0[1]}'.format(cat)) + click.echo(f'@{cat.id}: {cat.name}') click.confirm('Do you want to continue?', abort=True) - sql = 'DELETE FROM categories ' - - sql = sql + 'WHERE id IN ({seq})'.format( - seq=','.join(['?'] * len(categories)) - ) - - c.execute(sql, [cat[0] for cat in categories]) - conn.commit() + [cat.delete_instance() for cat in categories] @cli.group() @@ -138,18 +133,31 @@ def activities(): def list_activities(search, csv_output): """ 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: 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: - csv_writer.writerow([r[3], r[2], r[0], r[1]]) + csv_writer.writerow([ + a.category_id, + category_name, + a.id, + a.name + ]) 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') @@ -157,25 +165,25 @@ def list_activities(search, csv_output): def delete_activities(ids): """ Delete activities specified by IDS """ - results = get_activities(ids) + activities = HamsterActivity.select( + HamsterActivity, + HamsterCategory.name, + fn.Count(HamsterFact.id).alias("facts_count") + ).join(HamsterCategory, + JOIN.LEFT_OUTER).switch(HamsterActivity).join(HamsterFact, + JOIN.LEFT_OUTER).group_by(HamsterActivity).where( + HamsterActivity.id.in_(ids) + ) click.secho('Deleting:', fg='red') - for r in results: - sql = "SELECT COUNT(id) FROM facts WHERE activity_id = ?" - count = c.execute(sql, (r[0],)).fetchone()[0] - click.echo('@{0[0]}: {0[2]} ยป {0[1]} ({1} facts)'.format(r, count)) + 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) - sql = 'DELETE FROM activities ' - - sql = sql + 'WHERE id IN ({seq})'.format( - seq=','.join(['?'] * len(ids)) - ) - - c.execute(sql, ids) - conn.commit() + [a.delete_instance() for a in activities] click.secho('Deleted {0} activities'.format(len(ids)), fg='green') @@ -185,29 +193,20 @@ def delete_activities(ids): @click.argument('ids', nargs=-1) def move(category_id, ids): """ Move activities to another category """ - category = get_categories((category_id,))[0] - results = get_activities(ids) + category = HamsterCategory.get(id=category_id) + 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: - click.secho('@{0[3]}: {0[2]} ยป @{0[0]}: {0[1]}'.format(r), fg='blue') + for a in activities: + 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) - sql = ''' - UPDATE - activities - SET - category_id = ? - ''' - - sql = sql + 'WHERE id IN ({seq})'.format( - seq=','.join(['?'] * len(ids)) - ) - - c.execute(sql, (category[0], *ids)) - conn.commit() + for a in activities: + a.category = category + a.save() click.secho('Moved {0} activities'.format(len(ids)), fg='green') @@ -216,32 +215,15 @@ def move(category_id, ids): @click.argument('ids', nargs=-1) def list_facts(ids): """ Show facts for activities """ + activities = HamsterActivity.select().where(HamsterActivity.id.in_(ids)) - results = get_activities(ids) - - for r in results: + for a in activities: click.secho( - '@{0[0]}: {0[1]}'.format(r), fg='green' + f'@{a.id}: {a.name}', 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') + for f in a.facts: + click.secho(f'@{f.id}, {f.start_time}', fg='blue') @activities.command() @@ -249,82 +231,52 @@ def list_facts(ids): @click.argument('to_id') def move_facts(from_id, to_id): """ Move facts from one activity to another """ - from_activity = get_activities((from_id,))[0] - to_activity = get_activities((to_id,))[0] + from_activity = HamsterActivity.get(id=from_id) + 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( - 'Moving facts from "@{0[2]} ยป @{0[0]}: {0[1]}" to "@{1[2]} ยป @{1[0]}: {1[1]}"'.format( - from_activity, to_activity - ), fg='green' + f'Moving facts from "{from_category_name} ยป @{from_activity.id}: {from_activity.name}" to "@{to_category_name} ยป @{to_activity.id}: {to_activity.name}"', 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, (from_id,)) - - for r in results: - click.secho('@{0[0]}, {0[1]}'.format(r), fg='blue') + for f in from_activity.facts: + click.secho(f'@{f.id}, {f.start_time}', fg='blue') click.confirm('Do you want to continue?', abort=True) - c.execute( - 'UPDATE facts SET activity_id = ? WHERE activity_id = ?', - (to_id, from_id) - ) + count = HamsterFact.update(activity_id=to_activity.id).where(HamsterFact.activity == from_activity).execute() - conn.commit() - - click.secho('Moved {0} facts'.format(results.rowcount), fg='green') + click.secho('Moved {0} facts'.format(count), fg='green') click.confirm( - 'Would you like to delete @{0[2]} ยป @{0[0]}: {0[1]}?'.format( - from_activity), + f'Would you like to delete "{from_category_name} ยป @{from_activity.id}: {from_activity.name}?', abort=True ) - delete_activities((from_id,)) + from_activity.delete_instance() @activities.command() def find_duplicates(): """ Show activities which are not unique in their categories """ - sql = ''' - SELECT - categories.id, - categories.name, - activities.id, - activities.name, - COUNT(activities.id) c - FROM - activities - LEFT JOIN - categories - ON - activities.category_id = categories.id - GROUP BY - activities.name, - activities.category_id - HAVING c > 1 - ''' + non_unique_activities = ( + HamsterActivity + .select( + HamsterActivity, + HamsterCategory.id, + fn.COALESCE(HamsterCategory.name, 'None').alias("category_name") + ) + .join(HamsterCategory, JOIN.LEFT_OUTER) + .group_by(HamsterActivity.category_id, HamsterActivity.name) + .having(fn.COUNT(HamsterActivity.id) > 1) + ) - results = c.execute(sql) - - for r in results: + for activity in non_unique_activities: 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() @@ -353,11 +305,15 @@ def _get_kimai_mapping_file(path, category_search=None): '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([ - r[2], r[1] + a.category.name if a.category_id != -1 else "", + a.name ]) mapping_file.close() @@ -534,6 +490,12 @@ def _import(username, mapping_path=None, output=None, category_search=None, afte args = [] + facts = HamsterFact.select( + HamsterFact, + HamsterActivity, + HamsterCategory + ).join(HamsterActivity).join(HamsterCategory, JOIN.LEFT_OUTER) + sql = ''' SELECT facts.id, facts.start_time, facts.end_time, facts.description, @@ -547,20 +509,10 @@ def _import(username, mapping_path=None, output=None, category_search=None, afte categories ON activities.category_id = categories.id ''' if category_search is not None: - sql = sql + " WHERE categories.name LIKE ?" - category_search = '%{0}%'.format(category_search) - args.append(category_search) + facts = facts.where(HamsterCategory.name.contains(category_search)) if after is not None: - if category_search is not None: - 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() + facts = facts.where(HamsterFact.start_time >= after) if not show_missing: output_writer.writerow([ @@ -581,13 +533,13 @@ def _import(username, mapping_path=None, output=None, category_search=None, afte "InternalRate" ]) - for fact in results: - k = '{0}:{1}'.format(fact[6], fact[5]) + for fact in facts: + k = f'{fact.activity.category.name}:{fact.activity.name}' try: mapping_ = mapping[k] except KeyError: if show_missing: - output_writer.writerow([fact[6], fact[5]]) + output_writer.writerow([fact.activity.category.name, fact.activity.name]) click.secho( "Can't find mapping for '{0}', skipping".format(k), fg='yellow') continue @@ -595,19 +547,16 @@ def _import(username, mapping_path=None, output=None, category_search=None, afte if show_missing: continue - if fact[1] is None or fact[2] is None: - click.secho("Missing duration data '{0}-{1}', skipping".format( - fact[1], - fact[2] - ), fg='yellow') + if fact.start_time is None or fact.end_time is None: + click.secho(f"Missing duration data '{fact.start_time}-{fact.end_time}', skipping", fg='yellow') continue if len(mapping_) < 5: mapping_.append(None) date_start, date_end = ( - datetime.strptime(fact[2].split('.')[0], '%Y-%m-%d %H:%M:%S'), - datetime.strptime(fact[1].split('.')[0], '%Y-%m-%d %H:%M:%S') + datetime.strptime(fact.start_time.split('.')[0], '%Y-%m-%d %H:%M:%S'), + datetime.strptime(fact.end_time.split('.')[0], '%Y-%m-%d %H:%M:%S') ) duration = ( date_start - date_end @@ -623,7 +572,7 @@ def _import(username, mapping_path=None, output=None, category_search=None, afte mapping_[0], mapping_[1], mapping_[2], - fact[3] or mapping_[4] or '', + fact.description or mapping_[4] or '', '0', # Exported mapping_[3], '', # Hourly rate @@ -633,21 +582,26 @@ def _import(username, mapping_path=None, output=None, category_search=None, afte output_file.close() -@kimai.command() -def dbinit(): +@kimai.group('db') +def db_(): + pass + + +@db_.command() +def init(): db.create_tables([KimaiCustomer, KimaiProject, KimaiActivity, HamsterKimaiMapping]) -@kimai.command() -def dbreset(): +@db_.command() +def reset(): 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('--mapping-path', help='Mapping file') -def csv2db(mapping_path=None, global_=False): +def import_csv(mapping_path=None, global_=False): mapping_file = _get_kimai_mapping_file(mapping_path, None) next(mapping_file) mapping_reader = csv.reader(mapping_file) @@ -677,6 +631,7 @@ def csv2db(mapping_path=None, global_=False): kimai_tags=row[5] ) + @cli.command() def app(): from .app import HamsterToolsApp diff --git a/hamstertools/db.py b/hamstertools/db.py index a1e3452..0a59089 100644 --- a/hamstertools/db.py +++ b/hamstertools/db.py @@ -1,5 +1,5 @@ import logging -from peewee import SqliteDatabase, Model, CharField, ForeignKeyField +from peewee import SqliteDatabase, Model, CharField, ForeignKeyField, DateTimeField from textual.logging import TextualHandler @@ -29,6 +29,9 @@ class HamsterActivity(Model): class HamsterFact(Model): activity = ForeignKeyField(HamsterActivity, backref='facts') + start_time = DateTimeField() + end_time = DateTimeField(null=True) + description = CharField() class Meta: database = db From ccbbc8011645e766f67b443d7b49a08e197eda3e Mon Sep 17 00:00:00 2001 From: 3wc <3wc@doesthisthing.work> Date: Sun, 29 Oct 2023 13:37:40 +0000 Subject: [PATCH 21/46] Fix mapping import, show mapping count on list --- hamstertools/__init__.py | 9 +++++---- hamstertools/app.py | 31 ++++++++++++++++++++++++++----- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/hamstertools/__init__.py b/hamstertools/__init__.py index 758d2e6..84eb479 100755 --- a/hamstertools/__init__.py +++ b/hamstertools/__init__.py @@ -95,7 +95,6 @@ def list_category_activities(ids): def tidy_categories(): """ Remove categories with no activities """ - # Create a subquery to calculate the count of activities per category subquery = ( HamsterCategory .select(HamsterCategory, fn.COUNT(HamsterActivity.id).alias('activities_count')) @@ -104,7 +103,6 @@ def tidy_categories(): .alias('subquery') ) - # Use the subquery to filter categories where activities_count is 0 categories = ( HamsterCategory .select() @@ -601,17 +599,20 @@ def reset(): @db_.command() @click.option('-g', '--global', 'global_', help='Does this file contain mappings to global activties', is_flag=True) @click.option('--mapping-path', help='Mapping file') -def import_csv(mapping_path=None, global_=False): +def mapping2db(mapping_path=None, global_=False): mapping_file = _get_kimai_mapping_file(mapping_path, None) next(mapping_file) mapping_reader = csv.reader(mapping_file) for row in mapping_reader: + hamster_category = HamsterCategory.get(name=row[0]) - hamster_activity = HamsterActivity.get(name=row[1]) + hamster_activity = HamsterActivity.get(name=row[1], + category_id=hamster_category.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], diff --git a/hamstertools/app.py b/hamstertools/app.py index d9153e7..3eed92a 100644 --- a/hamstertools/app.py +++ b/hamstertools/app.py @@ -15,6 +15,7 @@ from .db import ( KimaiProject, KimaiCustomer, KimaiActivity, + HamsterKimaiMapping, ) from .kimai import ( KimaiAPI, @@ -78,15 +79,34 @@ class ActivitiesScreen(ListScreen): def _refresh(self, filter_query=None): 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 = ( HamsterActivity.select( HamsterActivity, - HamsterCategory, - fn.Count(HamsterFact.id).alias("facts_count") + HamsterCategory.id, + 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) .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) ) @@ -100,10 +120,11 @@ class ActivitiesScreen(ListScreen): [ [ activity.category_id, - (activity.category.name if (activity.category_id != -1) else ""), + activity.category_name, activity.id, activity.name, activity.facts_count, + activity.mappings_count, ] for activity in activities ] @@ -115,7 +136,7 @@ class ActivitiesScreen(ListScreen): self.table = self.query_one(DataTable) self.table.cursor_type = "row" 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._refresh() From 6b8b4c380e7180c57447ed7767e53537b6a0c7f9 Mon Sep 17 00:00:00 2001 From: 3wc <3wc@doesthisthing.work> Date: Sun, 29 Oct 2023 13:40:03 +0000 Subject: [PATCH 22/46] black reformat --- hamstertools/__init__.py | 548 +++++++++++++++++++++------------------ hamstertools/app.py | 92 ++++--- hamstertools/db.py | 32 +-- 3 files changed, 369 insertions(+), 303 deletions(-) diff --git a/hamstertools/__init__.py b/hamstertools/__init__.py index 84eb479..338ed39 100755 --- a/hamstertools/__init__.py +++ b/hamstertools/__init__.py @@ -9,11 +9,20 @@ import click import requests 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-testing.db' +HAMSTER_FILE = "hamster-testing.db" db.init(HAMSTER_FILE) @@ -28,45 +37,49 @@ def categories(): pass -@categories.command('list') -@click.option('--search', help='Search string') +@categories.command("list") +@click.option("--search", help="Search string") def list_categories(search): - """ List / search categories """ + """List / search categories""" categories = HamsterCategory.select() if search is not None: categories = categories.where(HamsterCategory.name.contains(search)) for c in categories: - click.echo(f'@{c.id}: {c.name}') + click.echo(f"@{c.id}: {c.name}") -@categories.command('delete') -@click.argument('ids', nargs=-1) +@categories.command("delete") +@click.argument("ids", nargs=-1) def delete_categories(ids): - """ Delete categories specified by IDS """ - click.secho('Deleting:', fg='red') + """Delete categories specified by IDS""" + click.secho("Deleting:", fg="red") - categories = HamsterCategory.select( - HamsterCategory, - fn.Count(HamsterActivity.id).alias("activities_count") - ).join(HamsterActivity, JOIN.LEFT_OUTER).group_by(HamsterCategory).where(HamsterCategory.id.in_(ids)) + categories = ( + HamsterCategory.select( + HamsterCategory, fn.Count(HamsterActivity.id).alias("activities_count") + ) + .join(HamsterActivity, JOIN.LEFT_OUTER) + .group_by(HamsterCategory) + .where(HamsterCategory.id.in_(ids)) + ) 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() - click.secho('Deleted {0} categories'.format(count), fg='green') + click.secho("Deleted {0} categories".format(count), fg="green") -@categories.command('rename') -@click.argument('id_', metavar='ID') -@click.argument('name') +@categories.command("rename") +@click.argument("id_", metavar="ID") +@click.argument("name") def rename_category(id_, name): - """ Rename a category """ + """Rename a category""" category = HamsterCategory.get(id=id_) @@ -76,46 +89,46 @@ def rename_category(id_, name): category.save() -@categories.command('activities') -@click.argument('ids', nargs=-1) +@categories.command("activities") +@click.argument("ids", nargs=-1) def list_category_activities(ids): - """ Show activities for categories specified by ids """ + """Show activities for categories specified by ids""" - activities = HamsterActivity.select( - HamsterActivity, - HamsterCategory.name - ).join(HamsterCategory, JOIN.LEFT_OUTER).where(HamsterCategory.id.in_(ids)) + activities = ( + HamsterActivity.select(HamsterActivity, HamsterCategory.name) + .join(HamsterCategory, JOIN.LEFT_OUTER) + .where(HamsterCategory.id.in_(ids)) + ) for a in activities: - click.echo(f'@{a.id}: {a.category.name} ยป {a.name}') + click.echo(f"@{a.id}: {a.category.name} ยป {a.name}") - -@categories.command('tidy') +@categories.command("tidy") def tidy_categories(): - """ Remove categories with no activities """ + """Remove categories with no activities""" subquery = ( - HamsterCategory - .select(HamsterCategory, fn.COUNT(HamsterActivity.id).alias('activities_count')) + HamsterCategory.select( + HamsterCategory, fn.COUNT(HamsterActivity.id).alias("activities_count") + ) .join(HamsterActivity, JOIN.LEFT_OUTER) .group_by(HamsterCategory) - .alias('subquery') + .alias("subquery") ) categories = ( - HamsterCategory - .select() + 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())) + click.echo("Found {0} empty categories:".format(categories.count())) for cat in categories: - click.echo(f'@{cat.id}: {cat.name}') + 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) [cat.delete_instance() for cat in categories] @@ -125,18 +138,16 @@ def activities(): pass -@activities.command('list') -@click.option('--search', help='Search string') -@click.option('--csv/--no-csv', 'csv_output', default=False, help='CSV output') +@activities.command("list") +@click.option("--search", help="Search string") +@click.option("--csv/--no-csv", "csv_output", default=False, help="CSV output") def list_activities(search, csv_output): - """ List / search activities """ + """List / search activities""" - activities = HamsterActivity.select( - HamsterActivity, - HamsterCategory - ).join(HamsterCategory, JOIN.LEFT_OUTER).order_by( - HamsterCategory.name, - HamsterActivity.name + activities = ( + HamsterActivity.select(HamsterActivity, HamsterCategory) + .join(HamsterCategory, JOIN.LEFT_OUTER) + .order_by(HamsterCategory.name, HamsterActivity.name) ) if search is not None: @@ -148,109 +159,114 @@ def list_activities(search, csv_output): for a in activities: category_name = a.category.name if a.category_id != -1 else "" if csv_output: - csv_writer.writerow([ - a.category_id, - category_name, - a.id, - a.name - ]) + csv_writer.writerow([a.category_id, category_name, a.id, a.name]) else: - click.echo(f'@{a.category_id}: {category_name} ยป {a.id}: {a.name}') + click.echo(f"@{a.category_id}: {category_name} ยป {a.id}: {a.name}") -@activities.command('delete') -@click.argument('ids', nargs=-1) +@activities.command("delete") +@click.argument("ids", nargs=-1) def delete_activities(ids): - """ Delete activities specified by IDS """ + """Delete activities specified by IDS""" - activities = HamsterActivity.select( - HamsterActivity, - HamsterCategory.name, - fn.Count(HamsterFact.id).alias("facts_count") - ).join(HamsterCategory, - JOIN.LEFT_OUTER).switch(HamsterActivity).join(HamsterFact, - JOIN.LEFT_OUTER).group_by(HamsterActivity).where( - HamsterActivity.id.in_(ids) + activities = ( + HamsterActivity.select( + HamsterActivity, + HamsterCategory.name, + fn.Count(HamsterFact.id).alias("facts_count"), + ) + .join(HamsterCategory, JOIN.LEFT_OUTER) + .switch(HamsterActivity) + .join(HamsterFact, JOIN.LEFT_OUTER) + .group_by(HamsterActivity) + .where(HamsterActivity.id.in_(ids)) ) - click.secho('Deleting:', fg='red') + click.secho("Deleting:", fg="red") 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.echo(f"@{a.id}: {category_name} ยป {a.name} ({a.facts_count} facts)") - click.confirm('Do you want to continue?', abort=True) + 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') + click.secho("Deleted {0} activities".format(len(ids)), fg="green") @activities.command() -@click.argument('category_id') -@click.argument('ids', nargs=-1) +@click.argument("category_id") +@click.argument("ids", nargs=-1) def move(category_id, ids): - """ Move activities to another category """ + """Move activities to another category""" category = HamsterCategory.get(id=category_id) activities = HamsterActivity.select().where(HamsterActivity.id.in_(ids)) - click.secho(f'Moving to "@{category.id}: {category.name}":', fg='green') + click.secho(f'Moving to "@{category.id}: {category.name}":', fg="green") for a in activities: 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.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) for a in activities: a.category = category a.save() - click.secho('Moved {0} activities'.format(len(ids)), fg='green') + click.secho("Moved {0} activities".format(len(ids)), fg="green") @activities.command() -@click.argument('ids', nargs=-1) +@click.argument("ids", nargs=-1) def list_facts(ids): - """ Show facts for activities """ + """Show facts for activities""" activities = HamsterActivity.select().where(HamsterActivity.id.in_(ids)) for a in activities: - click.secho( - f'@{a.id}: {a.name}', fg='green' - ) + click.secho(f"@{a.id}: {a.name}", fg="green") for f in a.facts: - click.secho(f'@{f.id}, {f.start_time}', fg='blue') + click.secho(f"@{f.id}, {f.start_time}", fg="blue") @activities.command() -@click.argument('from_id') -@click.argument('to_id') +@click.argument("from_id") +@click.argument("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 = HamsterActivity.get(id=from_id) 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 "" + 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( - f'Moving facts from "{from_category_name} ยป @{from_activity.id}: {from_activity.name}" to "@{to_category_name} ยป @{to_activity.id}: {to_activity.name}"', fg='green' + f'Moving facts from "{from_category_name} ยป @{from_activity.id}: {from_activity.name}" to "@{to_category_name} ยป @{to_activity.id}: {to_activity.name}"', + fg="green", ) for f in from_activity.facts: - click.secho(f'@{f.id}, {f.start_time}', fg='blue') + click.secho(f"@{f.id}, {f.start_time}", fg="blue") - click.confirm('Do you want to continue?', abort=True) + click.confirm("Do you want to continue?", abort=True) - count = HamsterFact.update(activity_id=to_activity.id).where(HamsterFact.activity == from_activity).execute() + count = ( + HamsterFact.update(activity_id=to_activity.id) + .where(HamsterFact.activity == from_activity) + .execute() + ) - click.secho('Moved {0} facts'.format(count), fg='green') + click.secho("Moved {0} facts".format(count), fg="green") click.confirm( f'Would you like to delete "{from_category_name} ยป @{from_activity.id}: {from_activity.name}?', - abort=True + abort=True, ) from_activity.delete_instance() @@ -258,14 +274,13 @@ def move_facts(from_id, to_id): @activities.command() def find_duplicates(): - """ Show activities which are not unique in their categories """ + """Show activities which are not unique in their categories""" non_unique_activities = ( - HamsterActivity - .select( + HamsterActivity.select( HamsterActivity, HamsterCategory.id, - fn.COALESCE(HamsterCategory.name, 'None').alias("category_name") + fn.COALESCE(HamsterCategory.name, "None").alias("category_name"), ) .join(HamsterCategory, JOIN.LEFT_OUTER) .group_by(HamsterActivity.category_id, HamsterActivity.name) @@ -274,7 +289,9 @@ def find_duplicates(): for activity in non_unique_activities: click.secho( - f"@{activity.category_id}: {activity.category_name} ยป @{activity.id}: {activity.name}", fg='blue') + f"@{activity.category_id}: {activity.category_name} ยป @{activity.id}: {activity.name}", + fg="blue", + ) @cli.group() @@ -286,50 +303,51 @@ def _get_kimai_mapping_file(path, category_search=None): try: return open(path) except FileNotFoundError: - click.confirm( - 'Mapping file {} not found, create it?:'.format(path), - abort=True - ) - mapping_file = open(path, 'w') + click.confirm("Mapping file {} not found, create it?:".format(path), abort=True) + mapping_file = open(path, "w") mapping_writer = csv.writer(mapping_file) - mapping_writer.writerow([ - 'FROM category', - 'FROM activity', - 'TO Customer', - 'TO Project', - 'TO Activity', - 'TO Tag', - 'TO Note' - ]) + mapping_writer.writerow( + [ + "FROM category", + "FROM activity", + "TO Customer", + "TO Project", + "TO Activity", + "TO Tag", + "TO Note", + ] + ) - activities = HamsterActivity.select( - HamsterActivity, - HamsterCategory - ).join(HamsterCategory, JOIN.LEFT_OUTER) + activities = HamsterActivity.select(HamsterActivity, HamsterCategory).join( + HamsterCategory, JOIN.LEFT_OUTER + ) for a in activities: - mapping_writer.writerow([ - a.category.name if a.category_id != -1 else "", - a.name - ]) + mapping_writer.writerow( + [a.category.name if a.category_id != -1 else "", a.name] + ) mapping_file.close() return open(path) @kimai.command() -@click.option('--mapping-path', help='Mapping file (default ~/.local/share/hamster/mapping.kimai.csv)', multiple=True) -@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') +@click.option( + "--mapping-path", + help="Mapping file (default ~/.local/share/hamster/mapping.kimai.csv)", + multiple=True, +) +@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): """ 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: mapping_files = [] @@ -340,30 +358,27 @@ def sync(username, api_key, just_errors, ignore_activities, mapping_path=None): mapping_reader = csv.reader(chain(*mapping_files)) else: 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_reader = csv.reader(mapping_file) next(mapping_reader) - mapping_data = [ - [row[2], row[3], row[4]] - for row in mapping_reader - ] + mapping_data = [[row[2], row[3], row[4]] for row in mapping_reader] mapping_file.close() - auth_headers = { - 'X-AUTH-USER': username, - 'X-AUTH-TOKEN': api_key - } + auth_headers = {"X-AUTH-USER": username, "X-AUTH-TOKEN": api_key} 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( - 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( - 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_projects = [] @@ -372,93 +387,115 @@ def sync(username, api_key, just_errors, ignore_activities, mapping_path=None): for row in mapping_data: # Check if each mapping still exists in Kimai - matching_customers = list( - filter(lambda x: x['name'] == row[0], customers)) + matching_customers = list(filter(lambda x: x["name"] == row[0], customers)) if row[0] in found_customers: just_errors or click.secho( - "Skipping existing customer '{0}'".format(row[0]), fg='green') + "Skipping existing customer '{0}'".format(row[0]), fg="green" + ) else: if len(matching_customers) > 1: 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 elif len(matching_customers) < 1: - click.secho("Missing customer '{0}'".format( - row[0]), fg='yellow') + click.secho("Missing customer '{0}'".format(row[0]), fg="yellow") continue else: 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]) - project_str = ':'.join(row[0:2]) - matching_projects = list(filter( - lambda x: x['name'] == row[1] and - x['customer'] == matching_customers[0]['id'], - projects) + project_str = ":".join(row[0:2]) + matching_projects = list( + filter( + lambda x: x["name"] == row[1] + and x["customer"] == matching_customers[0]["id"], + projects, + ) ) if project_str in found_projects: just_errors or click.secho( - "Skipping existing project '{0}'".format(project_str), fg='green') + "Skipping existing project '{0}'".format(project_str), fg="green" + ) else: if len(matching_projects) > 1: - click.secho("More than one match for project '{0}'".format( - project_str), fg='red') + click.secho( + "More than one match for project '{0}'".format(project_str), + fg="red", + ) continue elif len(matching_projects) < 1: - click.secho("Missing project '{0}'".format( - project_str), fg='yellow') + click.secho("Missing project '{0}'".format(project_str), fg="yellow") continue else: 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) if ignore_activities: continue - activity_str = ':'.join(row) + activity_str = ":".join(row) if activity_str in found_activities: just_errors or click.secho( - "Skipping existing activity '{0}'".format(activity_str), fg='green') + "Skipping existing activity '{0}'".format(activity_str), fg="green" + ) else: - matching_activities = list(filter( - lambda x: x['name'] == row[2] - and x['project'] == matching_projects[0]['id'], - activities - )) + matching_activities = list( + filter( + lambda x: x["name"] == row[2] + and x["project"] == matching_projects[0]["id"], + activities, + ) + ) if len(matching_activities) > 1: - click.secho("More than one match for activity '{0}'".format( - activity_str), fg='red') + click.secho( + "More than one match for activity '{0}'".format(activity_str), + fg="red", + ) elif len(matching_activities) < 1: - click.secho("Missing activity '{0}'".format( - activity_str), fg='yellow') + click.secho("Missing activity '{0}'".format(activity_str), fg="yellow") else: 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) -@kimai.command('import') -@click.option('--mapping-path', help='Mapping file (default ~/.local/share/hamster/mapping.kimai.csv)', multiple=True) -@click.option('--output', help='Output file (default kimai.csv)') -@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): +@kimai.command("import") +@click.option( + "--mapping-path", + help="Mapping file (default ~/.local/share/hamster/mapping.kimai.csv)", + multiple=True, +) +@click.option("--output", help="Output file (default kimai.csv)") +@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 """ if mapping_path is None: - mapping_path = HAMSTER_DIR / 'mapping.kimai.csv' + mapping_path = HAMSTER_DIR / "mapping.kimai.csv" if output is None: - timestamp = datetime.now().strftime('%F') - output = f'kimai_{timestamp}.csv' + timestamp = datetime.now().strftime("%F") + output = f"kimai_{timestamp}.csv" if type(mapping_path) == tuple: mapping_files = [] @@ -473,7 +510,7 @@ def _import(username, mapping_path=None, output=None, category_search=None, afte mapping_reader = csv.reader(mapping_file) 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 } @@ -483,18 +520,18 @@ def _import(username, mapping_path=None, output=None, category_search=None, afte else: mapping_file.close() - output_file = open(output, 'w') + output_file = open(output, "w") output_writer = csv.writer(output_file) args = [] - facts = HamsterFact.select( - HamsterFact, - HamsterActivity, - HamsterCategory - ).join(HamsterActivity).join(HamsterCategory, JOIN.LEFT_OUTER) + facts = ( + HamsterFact.select(HamsterFact, HamsterActivity, HamsterCategory) + .join(HamsterActivity) + .join(HamsterCategory, JOIN.LEFT_OUTER) + ) - sql = ''' + sql = """ SELECT facts.id, facts.start_time, facts.end_time, facts.description, activities.id, activities.name, @@ -504,7 +541,7 @@ def _import(username, mapping_path=None, output=None, category_search=None, afte LEFT JOIN activities ON facts.activity_id = activities.id LEFT JOIN - categories ON activities.category_id = categories.id ''' + categories ON activities.category_id = categories.id """ if category_search is not None: facts = facts.where(HamsterCategory.name.contains(category_search)) @@ -513,82 +550,87 @@ def _import(username, mapping_path=None, output=None, category_search=None, afte facts = facts.where(HamsterFact.start_time >= after) if not show_missing: - output_writer.writerow([ - "Date", - "From", - "To", - "Duration", - "Rate", - "User", - "Customer", - "Project", - "Activity", - "Description", - "Exported", - "Tags", - "HourlyRate", - "FixedRate", - "InternalRate" - ]) + output_writer.writerow( + [ + "Date", + "From", + "To", + "Duration", + "Rate", + "User", + "Customer", + "Project", + "Activity", + "Description", + "Exported", + "Tags", + "HourlyRate", + "FixedRate", + "InternalRate", + ] + ) for fact in facts: - k = f'{fact.activity.category.name}:{fact.activity.name}' + k = f"{fact.activity.category.name}:{fact.activity.name}" try: mapping_ = mapping[k] except KeyError: if show_missing: - output_writer.writerow([fact.activity.category.name, fact.activity.name]) - click.secho( - "Can't find mapping for '{0}', skipping".format(k), fg='yellow') + output_writer.writerow( + [fact.activity.category.name, fact.activity.name] + ) + click.secho("Can't find mapping for '{0}', skipping".format(k), fg="yellow") continue if show_missing: continue if fact.start_time is None or fact.end_time is None: - click.secho(f"Missing duration data '{fact.start_time}-{fact.end_time}', skipping", fg='yellow') + click.secho( + f"Missing duration data '{fact.start_time}-{fact.end_time}', skipping", + fg="yellow", + ) continue if len(mapping_) < 5: mapping_.append(None) date_start, date_end = ( - datetime.strptime(fact.start_time.split('.')[0], '%Y-%m-%d %H:%M:%S'), - datetime.strptime(fact.end_time.split('.')[0], '%Y-%m-%d %H:%M:%S') + datetime.strptime(fact.start_time.split(".")[0], "%Y-%m-%d %H:%M:%S"), + datetime.strptime(fact.end_time.split(".")[0], "%Y-%m-%d %H:%M:%S"), ) - duration = ( - date_start - date_end - ).seconds / 3600 + duration = (date_start - date_end).seconds / 3600 - output_writer.writerow([ - date_start.strftime('%Y-%m-%d'), - date_start.strftime('%H:%M'), - '', # To (time) - duration, - '', # Rate - username, - mapping_[0], - mapping_[1], - mapping_[2], - fact.description or mapping_[4] or '', - '0', # Exported - mapping_[3], - '', # Hourly rate - '', # Fixed rate - ]) + output_writer.writerow( + [ + date_start.strftime("%Y-%m-%d"), + date_start.strftime("%H:%M"), + "", # To (time) + duration, + "", # Rate + username, + mapping_[0], + mapping_[1], + mapping_[2], + fact.description or mapping_[4] or "", + "0", # Exported + mapping_[3], + "", # Hourly rate + "", # Fixed rate + ] + ) output_file.close() -@kimai.group('db') +@kimai.group("db") def db_(): pass @db_.command() def init(): - db.create_tables([KimaiCustomer, KimaiProject, KimaiActivity, - HamsterKimaiMapping]) + db.create_tables([KimaiCustomer, KimaiProject, KimaiActivity, HamsterKimaiMapping]) @db_.command() @@ -597,27 +639,30 @@ def reset(): @db_.command() -@click.option('-g', '--global', 'global_', help='Does this file contain mappings to global activties', is_flag=True) -@click.option('--mapping-path', help='Mapping file') +@click.option( + "-g", + "--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) next(mapping_file) mapping_reader = csv.reader(mapping_file) - + for row in mapping_reader: hamster_category = HamsterCategory.get(name=row[0]) - hamster_activity = HamsterActivity.get(name=row[1], - category_id=hamster_category.id) + hamster_activity = HamsterActivity.get( + name=row[1], category_id=hamster_category.id + ) kimai_customer = KimaiCustomer.get(name=row[2]) - kimai_project = KimaiProject.get(name=row[3], - customer_id=kimai_customer.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_activity = KimaiActivity.get(name=row[4], project_id=kimai_project.id) except KimaiActivity.DoesNotExist: kimai_activity = KimaiActivity.get( name=row[4], @@ -629,20 +674,21 @@ def mapping2db(mapping_path=None, global_=False): kimai_project=kimai_project, kimai_activity=kimai_activity, kimai_description=row[6], - kimai_tags=row[5] + kimai_tags=row[5], ) @cli.command() def app(): from .app import HamsterToolsApp + app = HamsterToolsApp() app.run() @cli.command() def hamster(): - click.echo('๐Ÿน') + click.echo("๐Ÿน") if __name__ == "__main__": diff --git a/hamstertools/app.py b/hamstertools/app.py index 3eed92a..4560800 100644 --- a/hamstertools/app.py +++ b/hamstertools/app.py @@ -80,33 +80,45 @@ class ActivitiesScreen(ListScreen): self.table.clear() facts_count_query = ( - HamsterFact - .select(HamsterFact.activity_id, fn.COUNT(HamsterFact.id).alias('facts_count')) + HamsterFact.select( + HamsterFact.activity_id, fn.COUNT(HamsterFact.id).alias("facts_count") + ) .group_by(HamsterFact.activity_id) - .alias('facts_count_query') + .alias("facts_count_query") ) mappings_count_query = ( - HamsterKimaiMapping - .select(HamsterKimaiMapping.hamster_activity_id, - fn.COUNT(HamsterKimaiMapping.id).alias('mappings_count')) + HamsterKimaiMapping.select( + HamsterKimaiMapping.hamster_activity_id, + fn.COUNT(HamsterKimaiMapping.id).alias("mappings_count"), + ) .group_by(HamsterKimaiMapping.hamster_activity_id) - .alias('mappings_count_query') + .alias("mappings_count_query") ) activities = ( HamsterActivity.select( HamsterActivity, HamsterCategory.id, - 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') + 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) .switch(HamsterActivity) - .join(facts_count_query, JOIN.LEFT_OUTER, on=(HamsterActivity.id == facts_count_query.c.activity_id)) + .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)) + .join( + mappings_count_query, + JOIN.LEFT_OUTER, + on=(HamsterActivity.id == mappings_count_query.c.hamster_activity_id), + ) .group_by(HamsterActivity) ) @@ -172,9 +184,9 @@ class ActivitiesScreen(ListScreen): move_to_activity = HamsterActivity.get( self.table.get_cell_at(Coordinate(event.cursor_row, 2)) ) - HamsterFact.update({HamsterFact.activity: - move_to_activity}).where(HamsterFact.activity == - self.move_from_activity).execute() + HamsterFact.update({HamsterFact.activity: move_to_activity}).where( + HamsterFact.activity == self.move_from_activity + ).execute() filter_input = self.query_one("#filter") self._refresh(filter_input.value) del self.move_from_activity @@ -194,17 +206,14 @@ class CategoriesScreen(ListScreen): categories = ( HamsterCategory.select( - HamsterCategory, - fn.Count(HamsterActivity.id).alias("activities_count") + HamsterCategory, fn.Count(HamsterActivity.id).alias("activities_count") ) .join(HamsterActivity, JOIN.LEFT_OUTER) .group_by(HamsterCategory) ) if filter_query: - categories = categories.where( - HamsterCategory.name.contains(filter_query) - ) + categories = categories.where(HamsterCategory.name.contains(filter_query)) self.table.add_rows( [ @@ -256,7 +265,7 @@ class KimaiScreen(ListScreen): KimaiProject.select( KimaiProject, KimaiCustomer, - fn.Count(KimaiActivity.id).alias("activities_count") + fn.Count(KimaiActivity.id).alias("activities_count"), ) .join(KimaiCustomer, JOIN.LEFT_OUTER) .switch(KimaiProject) @@ -278,7 +287,7 @@ class KimaiScreen(ListScreen): project.customer.name, project.id, project.name, - project.activities_count + project.activities_count, ] for project in projects ] @@ -295,26 +304,37 @@ class KimaiScreen(ListScreen): customers = KimaiAPICustomer.list(api) with db.atomic(): - KimaiCustomer.insert_many([{ - 'id': customer.id, - 'name': customer.name - } for customer in customers]).execute() + KimaiCustomer.insert_many( + [{"id": customer.id, "name": customer.name} for customer in customers] + ).execute() projects = KimaiAPIProject.list(api) with db.atomic(): - KimaiProject.insert_many([{ - 'id': project.id, - 'name': project.name, - 'customer_id': project.customer.id - } for project in projects]).execute() + KimaiProject.insert_many( + [ + { + "id": project.id, + "name": project.name, + "customer_id": project.customer.id, + } + for project in projects + ] + ).execute() activities = KimaiAPIActivity.list(api) with db.atomic(): - KimaiActivity.insert_many([{ - 'id': activity.id, - 'name': activity.name, - 'project_id': (activity.project and activity.project.id or None) - } for activity in activities]).execute() + KimaiActivity.insert_many( + [ + { + "id": activity.id, + "name": activity.name, + "project_id": ( + activity.project and activity.project.id or None + ), + } + for activity in activities + ] + ).execute() self._refresh() diff --git a/hamstertools/db.py b/hamstertools/db.py index 0a59089..e628d9f 100644 --- a/hamstertools/db.py +++ b/hamstertools/db.py @@ -3,7 +3,7 @@ from peewee import SqliteDatabase, Model, CharField, ForeignKeyField, DateTimeFi from textual.logging import TextualHandler -logger = logging.getLogger('peewee') +logger = logging.getLogger("peewee") logger.addHandler(TextualHandler()) logger.setLevel(logging.DEBUG) @@ -15,27 +15,27 @@ class HamsterCategory(Model): class Meta: database = db - table_name = 'categories' + table_name = "categories" class HamsterActivity(Model): name = CharField() - category = ForeignKeyField(HamsterCategory, backref='activities') + category = ForeignKeyField(HamsterCategory, backref="activities") class Meta: database = db - table_name = 'activities' + table_name = "activities" 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: database = db - table_name = 'facts' + table_name = "facts" class KimaiCustomer(Model): @@ -43,35 +43,35 @@ class KimaiCustomer(Model): class Meta: database = db - table_name = 'kimai_customers' + table_name = "kimai_customers" class KimaiProject(Model): name = CharField() - customer = ForeignKeyField(KimaiCustomer, backref='projects') + customer = ForeignKeyField(KimaiCustomer, backref="projects") class Meta: database = db - table_name = 'kimai_projects' + table_name = "kimai_projects" class KimaiActivity(Model): name = CharField() - project = ForeignKeyField(KimaiProject, backref='activities', null=True) + project = ForeignKeyField(KimaiProject, backref="activities", null=True) class Meta: database = db - table_name = 'kimai_activities' + table_name = "kimai_activities" class HamsterKimaiMapping(Model): - hamster_activity = ForeignKeyField(HamsterActivity, backref='mappings') - kimai_customer = ForeignKeyField(KimaiCustomer, backref='mappings') - kimai_project = ForeignKeyField(KimaiProject, backref='mappings') - kimai_activity = ForeignKeyField(KimaiActivity, backref='mappings') + hamster_activity = ForeignKeyField(HamsterActivity, backref="mappings") + kimai_customer = ForeignKeyField(KimaiCustomer, backref="mappings") + kimai_project = ForeignKeyField(KimaiProject, backref="mappings") + kimai_activity = ForeignKeyField(KimaiActivity, backref="mappings") kimai_description = CharField() kimai_tags = CharField() class Meta: database = db - table_name = 'hamster_kimai_mappings' + table_name = "hamster_kimai_mappings" From f7edf1839172aa30d4b5228aba0cdcab9a064248 Mon Sep 17 00:00:00 2001 From: 3wc <3wc@doesthisthing.work> Date: Sun, 29 Oct 2023 21:49:26 +0000 Subject: [PATCH 23/46] Fuckkk yeah, working mapping-adding --- hamstertools/app.py | 214 ++++++++++++++++++++++++++++++++++++++++-- hamstertools/app.tcss | 31 ++++++ 2 files changed, 236 insertions(+), 9 deletions(-) diff --git a/hamstertools/app.py b/hamstertools/app.py index 4560800..a8fe020 100644 --- a/hamstertools/app.py +++ b/hamstertools/app.py @@ -1,9 +1,15 @@ +from textual import on from textual.app import App, ComposeResult 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.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 @@ -66,13 +72,174 @@ class ListScreen(Screen): 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 = [ ("s", "sort", "Sort"), ("r", "refresh", "Refresh"), ("/", "filter", "Search"), - ("d", "delete", "Delete activity"), + ("d", "delete", "Delete"), ("f", "move_facts", "Move facts"), + ("e", "edit", "Edit"), + ("m", "mapping", "Mapping"), Binding(key="escape", action="cancelfilter", show=False), ] @@ -191,8 +358,37 @@ class ActivitiesScreen(ListScreen): self._refresh(filter_input.value) 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 = [ ("s", "sort", "Sort"), ("r", "refresh", "Refresh"), @@ -249,7 +445,7 @@ class CategoriesScreen(ListScreen): self.table.remove_row(row_key) -class KimaiScreen(ListScreen): +class KimaiProjectListScreen(ListScreen): BINDINGS = [ ("s", "sort", "Sort"), ("r", "refresh", "Refresh"), @@ -362,9 +558,9 @@ class HamsterToolsApp(App): db.init("hamster-testing.db") self.MODES = { - "categories": CategoriesScreen(), - "activities": ActivitiesScreen(), - "kimai": KimaiScreen(), + "categories": CategoryListScreen(), + "activities": ActivityListScreen(), + "kimai": KimaiProjectListScreen(), } super().__init__() diff --git a/hamstertools/app.tcss b/hamstertools/app.tcss index 0f6cab6..e6e8785 100644 --- a/hamstertools/app.tcss +++ b/hamstertools/app.tcss @@ -13,3 +13,34 @@ DataTable:focus .datatable--cursor { #filter { 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; +} From 4b8df705275a6866e950ef5a9739d11cb91ec992 Mon Sep 17 00:00:00 2001 From: 3wc <3wc@doesthisthing.work> Date: Sun, 29 Oct 2023 21:56:39 +0000 Subject: [PATCH 24/46] More formatting --- hamstertools/app.py | 134 +++++++++++++++++++++++------------------- hamstertools/kimai.py | 65 +++++++++++--------- 2 files changed, 109 insertions(+), 90 deletions(-) diff --git a/hamstertools/app.py b/hamstertools/app.py index a8fe020..80e101f 100644 --- a/hamstertools/app.py +++ b/hamstertools/app.py @@ -73,9 +73,7 @@ class ListScreen(Screen): class ActivityEditScreen(ModalScreen): - BINDINGS = [ - ("escape", "cancel", "Cancel") - ] + BINDINGS = [("escape", "cancel", "Cancel")] def compose(self) -> ComposeResult: yield Grid( @@ -93,7 +91,7 @@ class ActivityMappingScreen(ModalScreen): BINDINGS = [ ("ctrl+g", "global", "Toggle global"), ("ctrl+s", "save", "Save"), - ("escape", "cancel", "Cancel") + ("escape", "cancel", "Cancel"), ] customer_id = None @@ -111,12 +109,8 @@ class ActivityMappingScreen(ModalScreen): 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) + 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 = [ @@ -125,24 +119,21 @@ class ActivityMappingScreen(ModalScreen): KimaiProject.customer_id == self.customer_id ) ] - return ActivityMappingScreen._filter_dropdowns(projects, - input_state.value) + return ActivityMappingScreen._filter_dropdowns(projects, input_state.value) def _get_activities(self, input_state): activities = KimaiActivity.select() - if self.query_one('#global').value: + if self.query_one("#global").value: activities = activities.where( KimaiActivity.project_id.is_null(), ) else: - activities = activities.where( - KimaiActivity.project_id == self.project_id - ) + 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) + return ActivityMappingScreen._filter_dropdowns( + [DropdownItem(a.name, str(a.id)) for a in activities], input_state.value + ) def compose(self) -> ComposeResult: yield Vertical( @@ -154,78 +145,88 @@ class ActivityMappingScreen(ModalScreen): AutoComplete( Input(placeholder="Type to search...", id="customer"), Dropdown(items=self._get_customers), - ) + ), ), Horizontal( Label("Project"), AutoComplete( - Input(placeholder="Type to search...", id='project'), + Input(placeholder="Type to search...", id="project"), Dropdown(items=self._get_projects), - ) + ), ), Horizontal( Label("Activity"), AutoComplete( - Input(placeholder="Type to search...", id='activity'), + Input(placeholder="Type to search...", id="activity"), Dropdown(items=self._get_activities), - ) + ), ), Horizontal( Label("Description"), - Input(id='description'), + Input(id="description"), ), Horizontal( Label("Tags"), - Input(id='tags'), + Input(id="tags"), ), - Horizontal(Checkbox("Global", id='global')), + Horizontal(Checkbox("Global", id="global")), ) - @on(Input.Submitted, '#customer') + @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() + self.customer_id = str( + event.control.parent.dropdown.selected_item.left_meta + ) + self.query_one("#project").focus() - @on(DescendantBlur, '#customer') + @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) + self.customer_id = str( + event.control.parent.dropdown.selected_item.left_meta + ) - @on(Input.Submitted, '#project') + @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() + self.query_one("#activity").focus() - @on(DescendantBlur, '#project') + @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') + @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() + self.activity_id = str( + event.control.parent.dropdown.selected_item.left_meta + ) + self.query_one("#activity").focus() - @on(DescendantBlur, '#activity') + @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) + 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 + 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, - }) + 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) @@ -365,27 +366,38 @@ class ActivityListScreen(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), + selected_activity = ( + HamsterActivity.select( + HamsterActivity, + fn.COALESCE(HamsterCategory.name, "None").alias("category_name"), ) - ).get() + .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 = 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) + self.app.push_screen( + ActivityMappingScreen( + category=selected_activity.category_name, + activity=selected_activity.name, + ), + handle_mapping, + ) class CategoryListScreen(ListScreen): diff --git a/hamstertools/kimai.py b/hamstertools/kimai.py index 8c0f51d..a36aeff 100644 --- a/hamstertools/kimai.py +++ b/hamstertools/kimai.py @@ -9,24 +9,24 @@ class NotFound(Exception): class KimaiAPI(object): # temporary hardcoded config - KIMAI_API_URL = 'https://kimai.autonomic.zone/api' + KIMAI_API_URL = "https://kimai.autonomic.zone/api" - KIMAI_USERNAME = '3wordchant' - KIMAI_API_KEY = os.environ['KIMAI_API_KEY'] + KIMAI_USERNAME = "3wordchant" + KIMAI_API_KEY = os.environ["KIMAI_API_KEY"] - auth_headers = { - 'X-AUTH-USER': KIMAI_USERNAME, - 'X-AUTH-TOKEN': KIMAI_API_KEY - } + auth_headers = {"X-AUTH-USER": KIMAI_USERNAME, "X-AUTH-TOKEN": KIMAI_API_KEY} def __init__(self): - requests_cache.install_cache('kimai', backend='sqlite', expire_after=1800) + requests_cache.install_cache("kimai", backend="sqlite", expire_after=1800) self.customers_json = requests.get( - f'{self.KIMAI_API_URL}/customers?visible=3', headers=self.auth_headers).json() + f"{self.KIMAI_API_URL}/customers?visible=3", headers=self.auth_headers + ).json() self.projects_json = requests.get( - f'{self.KIMAI_API_URL}/projects?visible=3', headers=self.auth_headers).json() + f"{self.KIMAI_API_URL}/projects?visible=3", headers=self.auth_headers + ).json() self.activities_json = requests.get( - f'{self.KIMAI_API_URL}/activities?visible=3', headers=self.auth_headers).json() + f"{self.KIMAI_API_URL}/activities?visible=3", headers=self.auth_headers + ).json() class BaseAPI(object): @@ -41,19 +41,17 @@ class Customer(BaseAPI): @staticmethod def list(api): - return [ - Customer(api, c['id'], c['name']) for c in api.customers_json - ] + return [Customer(api, c["id"], c["name"]) for c in api.customers_json] @staticmethod def get_by_id(api, id): for value in api.customers_json: - if value['id'] == id: - return Customer(api, value['id'], value['name']) + if value["id"] == id: + return Customer(api, value["id"], value["name"]) raise NotFound() def __repr__(self): - return f'Customer (id={self.id}, name={self.name})' + return f"Customer (id={self.id}, name={self.name})" class Project(BaseAPI): @@ -63,20 +61,25 @@ class Project(BaseAPI): @staticmethod def list(api): return [ - Project(api, p['id'], p['name'], Customer.get_by_id(api, p['customer'])) for p in api.projects_json + Project(api, p["id"], p["name"], Customer.get_by_id(api, p["customer"])) + for p in api.projects_json ] @staticmethod def get_by_id(api, id, none=False): for value in api.projects_json: - if value['id'] == id: - return Project(api, value['id'], value['name'], - Customer.get_by_id(api, value['customer'])) + if value["id"] == id: + return Project( + api, + value["id"], + value["name"], + Customer.get_by_id(api, value["customer"]), + ) if not none: raise NotFound() def __repr__(self): - return f'Project (id={self.id}, name={self.name}, customer={self.customer})' + return f"Project (id={self.id}, name={self.name}, customer={self.customer})" class Activity(BaseAPI): @@ -86,20 +89,24 @@ class Activity(BaseAPI): @staticmethod def list(api): return [ - Activity(api, a['id'], a['name'], Project.get_by_id(api, - a['project'], - none=True)) + Activity( + api, a["id"], a["name"], Project.get_by_id(api, a["project"], none=True) + ) for a in api.activities_json ] @staticmethod def get_by_id(api, id, none=False): for value in api.activities_json: - if value['id'] == id: - return Activity(api, value['id'], value['name'], - Project.get_by_id(api, value['project'])) + if value["id"] == id: + return Activity( + api, + value["id"], + value["name"], + Project.get_by_id(api, value["project"]), + ) if not none: raise NotFound() def __repr__(self): - return f'Activity (id={self.id}, name={self.name}, project={self.project})' + return f"Activity (id={self.id}, name={self.name}, project={self.project})" From 8e2eead5402e62c9b7a4480cee51ab323566b7ee Mon Sep 17 00:00:00 2001 From: 3wc <3wc@doesthisthing.work> Date: Sun, 29 Oct 2023 22:28:01 +0000 Subject: [PATCH 25/46] smol tweaks --- hamstertools/app.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/hamstertools/app.py b/hamstertools/app.py index 80e101f..cb07d49 100644 --- a/hamstertools/app.py +++ b/hamstertools/app.py @@ -444,7 +444,6 @@ class CategoryListScreen(ListScreen): self._refresh() def action_delete(self) -> None: - # get the keys for the row and column under the cursor. row_key, _ = self.table.coordinate_to_cell_key(self.table.cursor_coordinate) category_id = self.table.get_cell_at( @@ -453,7 +452,6 @@ class CategoryListScreen(ListScreen): category = HamsterCategory.get(id=category_id) category.delete_instance() - # supply the row key to `remove_row` to delete the row. self.table.remove_row(row_key) @@ -581,5 +579,5 @@ class HamsterToolsApp(App): self.switch_mode("activities") def action_quit(self) -> None: - self.exit() db.close() + self.exit() From 0c58555b43e1fc80037ee24465c82d484a1c8f71 Mon Sep 17 00:00:00 2001 From: 3wc <3wc@doesthisthing.work> Date: Sun, 29 Oct 2023 23:12:39 +0000 Subject: [PATCH 26/46] Hot damn working date filtering --- hamstertools/app.py | 79 ++++++++++++++++++++++++++++++------------- hamstertools/app.tcss | 4 +++ 2 files changed, 59 insertions(+), 24 deletions(-) diff --git a/hamstertools/app.py b/hamstertools/app.py index cb07d49..e429a41 100644 --- a/hamstertools/app.py +++ b/hamstertools/app.py @@ -1,3 +1,5 @@ +from datetime import datetime + from textual import on from textual.app import App, ComposeResult from textual.binding import Binding @@ -33,12 +35,13 @@ from .kimai import ( class ListScreen(Screen): def compose(self) -> ComposeResult: - """create child widgets for the app.""" yield Header() with Vertical(): yield DataTable() - with Horizontal(): - yield Input(id="filter") + with Horizontal(id="filter"): + yield Input(id="search", placeholder="Category/activity name contains text") + yield Input(id="date", + placeholder='After date, in {0} format'.format(datetime.now().strftime('%Y-%m-%d'))) yield Footer() def action_refresh(self) -> None: @@ -53,23 +56,24 @@ class ListScreen(Screen): event.data_table.cursor_type = "row" def action_filter(self) -> None: - filter_input = self.query_one("#filter") - filter_input.display = True - self._refresh(filter_input.value) - filter_input.focus() + self.query_one("#filter").display = True + self._refresh() + self.query_one("#filter #search").focus() def on_input_submitted(self, event): self.table.focus() def action_cancelfilter(self) -> None: - filter_input = self.query_one("#filter") - filter_input.display = False - filter_input.clear() + self.query_one("#filter").display = False + self.query_one("#filter #search").clear() + self.query_one("#filter #date").clear() self.table.focus() self._refresh() - def on_input_changed(self, event): - self._refresh(event.value) + @on(Input.Changed, '#filter Input') + def filter(self, event): + self._refresh() + class ActivityEditScreen(ModalScreen): @@ -244,7 +248,7 @@ class ActivityListScreen(ListScreen): Binding(key="escape", action="cancelfilter", show=False), ] - def _refresh(self, filter_query=None): + def _refresh(self): self.table.clear() facts_count_query = ( @@ -268,6 +272,7 @@ class ActivityListScreen(ListScreen): HamsterActivity.select( HamsterActivity, HamsterCategory.id, + HamsterFact.start_time, 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( @@ -276,6 +281,8 @@ class ActivityListScreen(ListScreen): ) .join(HamsterCategory, JOIN.LEFT_OUTER) .switch(HamsterActivity) + .join(HamsterFact, JOIN.LEFT_OUTER) + .switch(HamsterActivity) .join( facts_count_query, JOIN.LEFT_OUTER, @@ -290,12 +297,22 @@ class ActivityListScreen(ListScreen): .group_by(HamsterActivity) ) - if filter_query: + filter_search = self.query_one('#filter #search').value + if filter_search is not None: activities = activities.where( - HamsterActivity.name.contains(filter_query) - | HamsterCategory.name.contains(filter_query) + HamsterActivity.name.contains(filter_search) + | HamsterCategory.name.contains(filter_search) ) + filter_date = self.query_one('#filter #date').value + if filter_date is not None: + try: + activities = activities.where( + HamsterFact.start_time > datetime.strptime(filter_date, '%Y-%m-%d') + ) + except ValueError: + pass + self.table.add_rows( [ [ @@ -355,8 +372,7 @@ class ActivityListScreen(ListScreen): HamsterFact.update({HamsterFact.activity: move_to_activity}).where( HamsterFact.activity == self.move_from_activity ).execute() - filter_input = self.query_one("#filter") - self._refresh(filter_input.value) + self._refresh() del self.move_from_activity def action_edit(self): @@ -388,8 +404,8 @@ class ActivityListScreen(ListScreen): hamster_activity=selected_activity, **mapping ) m.save() - filter_input = self.query_one("#filter") - self._refresh(filter_input.value) + filter_search = self.query_one("#search") + self._refresh() self.app.push_screen( ActivityMappingScreen( @@ -409,19 +425,34 @@ class CategoryListScreen(ListScreen): Binding(key="escape", action="cancelfilter", show=False), ] - def _refresh(self, filter_query=None): + def _refresh(self): self.table.clear() categories = ( HamsterCategory.select( - HamsterCategory, fn.Count(HamsterActivity.id).alias("activities_count") + HamsterCategory, + fn.Count(HamsterActivity.id).alias("activities_count"), + HamsterFact.start_time ) .join(HamsterActivity, JOIN.LEFT_OUTER) + .join(HamsterFact, JOIN.LEFT_OUTER) .group_by(HamsterCategory) ) - if filter_query: - categories = categories.where(HamsterCategory.name.contains(filter_query)) + filter_search = self.query_one('#filter #search').value + if filter_search is not None: + categories = categories.where( + HamsterCategory.name.contains(filter_search) + ) + + filter_date = self.query_one('#filter #date').value + if filter_date is not None: + try: + categories = categories.where( + HamsterFact.start_time > datetime.strptime(filter_date, '%Y-%m-%d') + ) + except ValueError: + pass self.table.add_rows( [ diff --git a/hamstertools/app.tcss b/hamstertools/app.tcss index e6e8785..e36a803 100644 --- a/hamstertools/app.tcss +++ b/hamstertools/app.tcss @@ -14,6 +14,10 @@ DataTable:focus .datatable--cursor { display: none; } +#filter Input { + width: 50%; +} + ActivityEditScreen, ActivityMappingScreen { align: center middle; } From e8ff5f14113fda727cfce65a1906dfb479dc1bde Mon Sep 17 00:00:00 2001 From: 3wc <3wc@doesthisthing.work> Date: Tue, 31 Oct 2023 23:55:19 +0000 Subject: [PATCH 27/46] Activity editing --- hamstertools/app.py | 82 +++++++++++++++++++++++++++++++++++++------ hamstertools/app.tcss | 42 ++++++++++++++-------- hamstertools/kimai.py | 2 +- 3 files changed, 100 insertions(+), 26 deletions(-) diff --git a/hamstertools/app.py b/hamstertools/app.py index e429a41..25eba38 100644 --- a/hamstertools/app.py +++ b/hamstertools/app.py @@ -8,7 +8,6 @@ from textual.events import DescendantBlur from textual.widgets import Header, Footer, DataTable, Input, Button, Label, Checkbox from textual.containers import Horizontal, Vertical from textual.coordinate import Coordinate -from textual.reactive import reactive from textual.screen import Screen, ModalScreen from textual_autocomplete import AutoComplete, Dropdown, DropdownItem @@ -75,21 +74,65 @@ class ListScreen(Screen): self._refresh() - class ActivityEditScreen(ModalScreen): - BINDINGS = [("escape", "cancel", "Cancel")] + BINDINGS = [ + ("escape", "cancel", "Cancel"), + ("ctrl+s", "save", "Save"), + ] + + category_id = None + category_name = '' + + def _get_categories(self, input_state): + categories = [DropdownItem(c.name, str(c.id)) for c in HamsterCategory.select()] + return ActivityMappingScreen._filter_dropdowns(categories, input_state.value) + + def __init__(self, category, activity): + if category is not None: + self.category_id = category.id + self.category_name = category.name + self.activity_name = activity.name + super().__init__() 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", + yield Vertical( + Horizontal(Label("Category:"), + AutoComplete( + Input(placeholder="Type to search...", id="category", + value=self.category_name), + Dropdown(items=self._get_categories), + ), + ), + Horizontal(Label("Activity:"), Input(value=self.activity_name, + id='activity')), ) + @on(Input.Submitted, "#category") + def category_submitted(self, event): + if event.control.parent.dropdown.selected_item is not None: + self.category_id = str( + event.control.parent.dropdown.selected_item.left_meta + ) + self.query_one("#activity").focus() + + @on(DescendantBlur, "#category") + def category_blur(self, event): + if event.control.parent.dropdown.selected_item is not None: + self.category_id = str( + event.control.parent.dropdown.selected_item.left_meta + ) + def action_cancel(self): self.dismiss(None) + def action_save(self): + self.dismiss( + { + "category": self.category_id, + "activity": self.query_one('#activity').value, + } + ) + class ActivityMappingScreen(ModalScreen): BINDINGS = [ @@ -376,10 +419,27 @@ class ActivityListScreen(ListScreen): del self.move_from_activity def action_edit(self): - def handle_edit(properties): - print(properties) + row_idx: int = self.table.cursor_row + row_cells = self.table.get_row_at(row_idx) - self.app.push_screen(ActivityEditScreen(), handle_edit) + try: + category = HamsterCategory.get(id=row_cells[0]) + except HamsterCategory.DoesNotExist: + category = None + activity = HamsterActivity.get(id=row_cells[2]) + + def handle_edit(properties): + if properties is None: + return + activity.name = properties['activity'] + activity.category_id = properties['category'] + activity.save() + self._refresh() + + self.app.push_screen(ActivityEditScreen( + category=category, + activity=activity + ), handle_edit) def action_mapping(self): selected_activity = ( diff --git a/hamstertools/app.tcss b/hamstertools/app.tcss index e36a803..3f74269 100644 --- a/hamstertools/app.tcss +++ b/hamstertools/app.tcss @@ -1,50 +1,64 @@ DataTable { - height: 90%; + height: 90%; } DataTable .datatable--cursor { - background: grey; + background: grey; } DataTable:focus .datatable--cursor { - background: orange; + background: orange; } #filter { - display: none; + display: none; } #filter Input { - width: 50%; + width: 50%; } ActivityEditScreen, ActivityMappingScreen { align: center middle; } +ActivityEditScreen > Vertical, ActivityMappingScreen > Vertical { padding: 0 1; - width: auto; + width: 80; height: 30; border: thick $background 80%; background: $surface; } -ActivityMappingScreen Horizontal { - align: left middle; - width: auto; +ActivityEditScreen > Vertical { + height: 10; } +ActivityMappingScreen Horizontal { + align: left middle; + width: auto; +} + +ActivityEditScreen Horizontal { + width: 80; +} + +ActivityEditScreen Label, ActivityMappingScreen Label { - padding: 0 1; - width: auto; - border: blank; + padding: 0 1; + width: auto; + border: blank; } ActivityMappingScreen AutoComplete { - width: 80; + width: 80; } #description, #tags { - width: 30; + width: 30; +} + +ActivityEditScreen Input { + width: 60; } diff --git a/hamstertools/kimai.py b/hamstertools/kimai.py index a36aeff..d2b08da 100644 --- a/hamstertools/kimai.py +++ b/hamstertools/kimai.py @@ -17,7 +17,7 @@ class KimaiAPI(object): auth_headers = {"X-AUTH-USER": KIMAI_USERNAME, "X-AUTH-TOKEN": KIMAI_API_KEY} def __init__(self): - requests_cache.install_cache("kimai", backend="sqlite", expire_after=1800) + # requests_cache.install_cache("kimai", backend="sqlite", expire_after=1800) self.customers_json = requests.get( f"{self.KIMAI_API_URL}/customers?visible=3", headers=self.auth_headers ).json() From ebf2dca6951e5c13729a4618b76b661de3ccb14a Mon Sep 17 00:00:00 2001 From: 3wc <3wc@doesthisthing.work> Date: Wed, 1 Nov 2023 19:28:55 +0000 Subject: [PATCH 28/46] =?UTF-8?q?Fuckin=20yaldi,=20working=20kimai=20impor?= =?UTF-8?q?t=20=F0=9F=92=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hamstertools/__init__.py | 12 +++-- hamstertools/app.py | 13 ++--- hamstertools/db.py | 16 +++++- hamstertools/kimai.py | 111 +++++++++++++++++++++++++++++++-------- 4 files changed, 118 insertions(+), 34 deletions(-) diff --git a/hamstertools/__init__.py b/hamstertools/__init__.py index 338ed39..17f77dd 100755 --- a/hamstertools/__init__.py +++ b/hamstertools/__init__.py @@ -17,7 +17,8 @@ from .db import ( KimaiCustomer, KimaiProject, KimaiActivity, - HamsterKimaiMapping, + HamsterActivityKimaiMapping, + HamsterFactKimaiImport ) HAMSTER_DIR = Path.home() / ".local/share/hamster" @@ -630,12 +631,13 @@ def db_(): @db_.command() def init(): - db.create_tables([KimaiCustomer, KimaiProject, KimaiActivity, HamsterKimaiMapping]) + db.create_tables([KimaiCustomer, KimaiProject, KimaiActivity, + HamsterActivityKimaiMapping, HamsterFactKimaiImport]) @db_.command() def reset(): - HamsterKimaiMapping.delete().execute() + HamsterActivityKimaiMapping.delete().execute() @db_.command() @@ -653,7 +655,6 @@ def mapping2db(mapping_path=None, global_=False): mapping_reader = csv.reader(mapping_file) for row in mapping_reader: - hamster_category = HamsterCategory.get(name=row[0]) hamster_activity = HamsterActivity.get( name=row[1], category_id=hamster_category.id @@ -666,9 +667,10 @@ def mapping2db(mapping_path=None, global_=False): except KimaiActivity.DoesNotExist: kimai_activity = KimaiActivity.get( name=row[4], + project_id=None ) - HamsterKimaiMapping.create( + HamsterActivityKimaiMapping.create( hamster_activity=hamster_activity, kimai_customer=kimai_customer, kimai_project=kimai_project, diff --git a/hamstertools/app.py b/hamstertools/app.py index 25eba38..b7d3b30 100644 --- a/hamstertools/app.py +++ b/hamstertools/app.py @@ -22,7 +22,7 @@ from .db import ( KimaiProject, KimaiCustomer, KimaiActivity, - HamsterKimaiMapping, + HamsterActivityKimaiMapping, ) from .kimai import ( KimaiAPI, @@ -303,11 +303,11 @@ class ActivityListScreen(ListScreen): ) mappings_count_query = ( - HamsterKimaiMapping.select( - HamsterKimaiMapping.hamster_activity_id, - fn.COUNT(HamsterKimaiMapping.id).alias("mappings_count"), + HamsterActivityKimaiMapping.select( + HamsterActivityKimaiMapping.hamster_activity_id, + fn.COUNT(HamsterActivityKimaiMapping.id).alias("mappings_count"), ) - .group_by(HamsterKimaiMapping.hamster_activity_id) + .group_by(HamsterActivityKimaiMapping.hamster_activity_id) .alias("mappings_count_query") ) @@ -460,7 +460,7 @@ class ActivityListScreen(ListScreen): def handle_mapping(mapping): if mapping is None: return - m = HamsterKimaiMapping.create( + m = HamsterActivityKimaiMapping.create( hamster_activity=selected_activity, **mapping ) m.save() @@ -613,6 +613,7 @@ class KimaiProjectListScreen(ListScreen): "id": project.id, "name": project.name, "customer_id": project.customer.id, + "allow_global_activities": project.allow_global_activities } for project in projects ] diff --git a/hamstertools/db.py b/hamstertools/db.py index e628d9f..a47d8c2 100644 --- a/hamstertools/db.py +++ b/hamstertools/db.py @@ -1,5 +1,6 @@ +from datetime import datetime import logging -from peewee import SqliteDatabase, Model, CharField, ForeignKeyField, DateTimeField +from peewee import SqliteDatabase, Model, CharField, ForeignKeyField, DateTimeField, SmallIntegerField, BooleanField from textual.logging import TextualHandler @@ -49,6 +50,7 @@ class KimaiCustomer(Model): class KimaiProject(Model): name = CharField() customer = ForeignKeyField(KimaiCustomer, backref="projects") + allow_global_activities = BooleanField(default=True) class Meta: database = db @@ -64,7 +66,7 @@ class KimaiActivity(Model): table_name = "kimai_activities" -class HamsterKimaiMapping(Model): +class HamsterActivityKimaiMapping(Model): hamster_activity = ForeignKeyField(HamsterActivity, backref="mappings") kimai_customer = ForeignKeyField(KimaiCustomer, backref="mappings") kimai_project = ForeignKeyField(KimaiProject, backref="mappings") @@ -75,3 +77,13 @@ class HamsterKimaiMapping(Model): class Meta: database = db table_name = "hamster_kimai_mappings" + + +class HamsterFactKimaiImport(Model): + hamster_fact = ForeignKeyField(HamsterFact, backref="mappings") + kimai_id = SmallIntegerField() + imported = DateTimeField(default=datetime.now) + + class Meta: + database = db + table_name = "hamster_fact_kimai_imports" diff --git a/hamstertools/kimai.py b/hamstertools/kimai.py index d2b08da..caa9640 100644 --- a/hamstertools/kimai.py +++ b/hamstertools/kimai.py @@ -1,7 +1,10 @@ +from datetime import datetime import requests import requests_cache import os +from dataclasses import dataclass, field + class NotFound(Exception): pass @@ -9,7 +12,8 @@ class NotFound(Exception): class KimaiAPI(object): # temporary hardcoded config - KIMAI_API_URL = "https://kimai.autonomic.zone/api" + # KIMAI_API_URL = "https://kimai.autonomic.zone/api" + KIMAI_API_URL = "https://kimaitest.autonomic.zone/api" KIMAI_USERNAME = "3wordchant" KIMAI_API_KEY = os.environ["KIMAI_API_KEY"] @@ -17,16 +21,19 @@ class KimaiAPI(object): auth_headers = {"X-AUTH-USER": KIMAI_USERNAME, "X-AUTH-TOKEN": KIMAI_API_KEY} def __init__(self): - # requests_cache.install_cache("kimai", backend="sqlite", expire_after=1800) - self.customers_json = requests.get( - f"{self.KIMAI_API_URL}/customers?visible=3", headers=self.auth_headers - ).json() - self.projects_json = requests.get( - f"{self.KIMAI_API_URL}/projects?visible=3", headers=self.auth_headers - ).json() - self.activities_json = requests.get( - f"{self.KIMAI_API_URL}/activities?visible=3", headers=self.auth_headers - ).json() + requests_cache.install_cache("kimai", backend="sqlite", expire_after=1800) + self.customers_json = self.get("customers", {"visible": 3}) + self.projects_json = self.get("projects", {"visible": 3}) + self.activities_json = self.get("activities", {"visible": 3}) + self.user_json = self.get("users/me") + + def get(self, endpoint, params=None): + return requests.get(f"{self.KIMAI_API_URL}/{endpoint}", + params=params, headers=self.auth_headers).json() + + def post(self, endpoint, data): + return requests.post(f"{self.KIMAI_API_URL}/{endpoint}", + json=data, headers=self.auth_headers) class BaseAPI(object): @@ -54,14 +61,20 @@ class Customer(BaseAPI): return f"Customer (id={self.id}, name={self.name})" +@dataclass class Project(BaseAPI): - def __init__(self, api, id, name, customer): - super().__init__(api, id=id, name=name, customer=customer) + api: KimaiAPI = field(repr=False) + id: int + name: str + customer: Customer + allow_global_activities: bool = field(default=True) @staticmethod def list(api): return [ - Project(api, p["id"], p["name"], Customer.get_by_id(api, p["customer"])) + Project(api, p["id"], p["name"], Customer.get_by_id(api, + p["customer"]), + p["globalActivities"]) for p in api.projects_json ] @@ -74,17 +87,18 @@ class Project(BaseAPI): value["id"], value["name"], Customer.get_by_id(api, value["customer"]), + value["globalActivities"] ) if not none: raise NotFound() - def __repr__(self): - return f"Project (id={self.id}, name={self.name}, customer={self.customer})" - +@dataclass class Activity(BaseAPI): - def __init__(self, api, id, name, project): - super().__init__(api, id=id, name=name, project=project) + api: KimaiAPI = field(repr=False) + id: int + name: str + project: Project @staticmethod def list(api): @@ -108,5 +122,60 @@ class Activity(BaseAPI): if not none: raise NotFound() - def __repr__(self): - return f"Activity (id={self.id}, name={self.name}, project={self.project})" + +@dataclass +class Timesheet(BaseAPI): + api: KimaiAPI = field(repr=False) + activity: Activity + project: Project + begin: datetime + end: datetime + id: int = field(default=None) + description: str = field(default="") + tags: str = field(default="") + + @staticmethod + def list(api): + return [ + Timesheet( + api, + Activity.get_by_id(api, t["activity"], none=True), + Project.get_by_id(api, t["project"], none=True), + t["begin"], + t["end"], + t["id"], + t["description"], + t["tags"], + ) + for t in api.get( + 'timesheets', + ) + ] + + @staticmethod + def get_by_id(api, id, none=False): + t = api.get( + f'timesheets/{id}', + ) + return Timesheet( + api, + Activity.get_by_id(api, t["activity"], none=True), + Project.get_by_id(api, t["project"], none=True), + t["begin"], + t["end"], + t["id"], + t["description"], + t["tags"], + ) + + def upload(self): + return self.api.post('timesheets', { + "begin": self.begin.isoformat(), + "end": self.end.isoformat(), + "project": self.project.id, + "activity": self.activity.id, + "description": self.description, + # FIXME: support multiple users + # "user": self., + "tags": self.tags, + }) From 05928b124454e6ceefb4c47520b671a5e08453dd Mon Sep 17 00:00:00 2001 From: 3wc <3wc@doesthisthing.work> Date: Wed, 1 Nov 2023 19:32:00 +0000 Subject: [PATCH 29/46] Add (temporary?) scripts --- scripts/apitest.py | 30 ++++++++++++++++++ scripts/upload.py | 79 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 scripts/apitest.py create mode 100644 scripts/upload.py diff --git a/scripts/apitest.py b/scripts/apitest.py new file mode 100644 index 0000000..1ffd641 --- /dev/null +++ b/scripts/apitest.py @@ -0,0 +1,30 @@ +import os +import sys +sys.path.append(os.path.join(os.path.dirname(__file__), "..")) + +from datetime import datetime, timedelta +import logging + +from hamstertools.kimai import * + +logging.basicConfig() +logging.getLogger().setLevel(logging.DEBUG) +requests_log = logging.getLogger("requests.packages.urllib3") +requests_log.setLevel(logging.DEBUG) +requests_log.propagate = True + +api = KimaiAPI() + +# print(Timesheet.list(api)) + +t = Timesheet(api, + activity=Activity.get_by_id(api, 613), + project=Project.get_by_id(api, 233), + begin=datetime.now() - timedelta(minutes=10), + end=datetime.now(), +) + +# r = t.upload() +# from pdb import set_trace; set_trace() + +print(Timesheet.get_by_id(api, 30683)) diff --git a/scripts/upload.py b/scripts/upload.py new file mode 100644 index 0000000..4431632 --- /dev/null +++ b/scripts/upload.py @@ -0,0 +1,79 @@ +import os +import sys +sys.path.append(os.path.join(os.path.dirname(__file__), "..")) + +from datetime import datetime + +from peewee import JOIN + +from hamstertools.db import * +from hamstertools.kimai import * + +api = KimaiAPI() + +DATE_FROM='2023-10-01' +DATE_TO='2023-11-01' +SEARCH='auto' + +facts = HamsterFact.select( + HamsterFact, + HamsterActivity, + HamsterCategory +).join( + HamsterActivity, JOIN.LEFT_OUTER +).join( + HamsterCategory, JOIN.LEFT_OUTER +).where( + (HamsterFact.start_time > datetime.strptime(DATE_FROM, "%Y-%m-%d")) + & (HamsterFact.start_time < datetime.strptime(DATE_TO, "%Y-%m-%d")) + & HamsterCategory.name.contains(SEARCH) +) + +has_errors = False + +# check data +for f in facts: + mappings = f.activity.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 mappings[0].kimai_activity.project is None and not mappings[0].kimai_project.allow_global_activities: + print(f'fact {f.id}: project @{mappings[0].kimai_project.id} {mappings[0].kimai_project.name} does not allow global activity {mappings[0].kimai_activity}') + has_errors = True + continue + +# if has_errors: +# sys.exit(1) + +# upload data +for f in facts: + try: + mapping = f.activity.mappings[0] + except IndexError: + print(f"no mapping, skipping {f.id} ({f.activity.category.name} ยป {f.activity.name})") + continue + t = Timesheet(api, + activity=mapping.kimai_activity, + project=mapping.kimai_project, + begin=f.start_time, + end=f.end_time, + description=f.description if f.description != '' else mapping.kimai_description, + # tags=f.tags if f.tags != '' else mapping.kimai_tags + ) + r = t.upload().json() + if r.get("code", 200) != 200: + print(r) + print(f"{f.id} ({f.activity.category.name} ยป {f.activity.name})") + from pdb import set_trace; set_trace() + + else: + HamsterFactKimaiImport.create( + hamster_fact=f, + kimai_id=r['id'] + ).save() + print(f'Created Kimai timesheet {r["id"]}') From cb909540fad24a1b0a072276face246892fb8fab Mon Sep 17 00:00:00 2001 From: 3wc <3wc@doesthisthing.work> Date: Wed, 1 Nov 2023 19:33:30 +0000 Subject: [PATCH 30/46] =?UTF-8?q?Reformatting=20=F0=9F=A7=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hamstertools/__init__.py | 18 ++++++----- hamstertools/app.py | 66 ++++++++++++++++++++++------------------ hamstertools/db.py | 16 +++++++--- hamstertools/kimai.py | 61 +++++++++++++++++++++---------------- scripts/apitest.py | 4 ++- scripts/upload.py | 63 ++++++++++++++++++++++---------------- 6 files changed, 134 insertions(+), 94 deletions(-) diff --git a/hamstertools/__init__.py b/hamstertools/__init__.py index 17f77dd..bcaa4b0 100755 --- a/hamstertools/__init__.py +++ b/hamstertools/__init__.py @@ -18,7 +18,7 @@ from .db import ( KimaiProject, KimaiActivity, HamsterActivityKimaiMapping, - HamsterFactKimaiImport + HamsterFactKimaiImport, ) HAMSTER_DIR = Path.home() / ".local/share/hamster" @@ -631,8 +631,15 @@ def db_(): @db_.command() def init(): - db.create_tables([KimaiCustomer, KimaiProject, KimaiActivity, - HamsterActivityKimaiMapping, HamsterFactKimaiImport]) + db.create_tables( + [ + KimaiCustomer, + KimaiProject, + KimaiActivity, + HamsterActivityKimaiMapping, + HamsterFactKimaiImport, + ] + ) @db_.command() @@ -665,10 +672,7 @@ def mapping2db(mapping_path=None, global_=False): try: kimai_activity = KimaiActivity.get(name=row[4], project_id=kimai_project.id) except KimaiActivity.DoesNotExist: - kimai_activity = KimaiActivity.get( - name=row[4], - project_id=None - ) + kimai_activity = KimaiActivity.get(name=row[4], project_id=None) HamsterActivityKimaiMapping.create( hamster_activity=hamster_activity, diff --git a/hamstertools/app.py b/hamstertools/app.py index b7d3b30..7c956c6 100644 --- a/hamstertools/app.py +++ b/hamstertools/app.py @@ -38,9 +38,15 @@ class ListScreen(Screen): with Vertical(): yield DataTable() with Horizontal(id="filter"): - yield Input(id="search", placeholder="Category/activity name contains text") - yield Input(id="date", - placeholder='After date, in {0} format'.format(datetime.now().strftime('%Y-%m-%d'))) + yield Input( + id="search", placeholder="Category/activity name contains text" + ) + yield Input( + id="date", + placeholder="After date, in {0} format".format( + datetime.now().strftime("%Y-%m-%d") + ), + ) yield Footer() def action_refresh(self) -> None: @@ -69,7 +75,7 @@ class ListScreen(Screen): self.table.focus() self._refresh() - @on(Input.Changed, '#filter Input') + @on(Input.Changed, "#filter Input") def filter(self, event): self._refresh() @@ -81,7 +87,7 @@ class ActivityEditScreen(ModalScreen): ] category_id = None - category_name = '' + category_name = "" def _get_categories(self, input_state): categories = [DropdownItem(c.name, str(c.id)) for c in HamsterCategory.select()] @@ -96,15 +102,20 @@ class ActivityEditScreen(ModalScreen): def compose(self) -> ComposeResult: yield Vertical( - Horizontal(Label("Category:"), + Horizontal( + Label("Category:"), AutoComplete( - Input(placeholder="Type to search...", id="category", - value=self.category_name), + Input( + placeholder="Type to search...", + id="category", + value=self.category_name, + ), Dropdown(items=self._get_categories), ), ), - Horizontal(Label("Activity:"), Input(value=self.activity_name, - id='activity')), + Horizontal( + Label("Activity:"), Input(value=self.activity_name, id="activity") + ), ) @on(Input.Submitted, "#category") @@ -129,7 +140,7 @@ class ActivityEditScreen(ModalScreen): self.dismiss( { "category": self.category_id, - "activity": self.query_one('#activity').value, + "activity": self.query_one("#activity").value, } ) @@ -340,18 +351,18 @@ class ActivityListScreen(ListScreen): .group_by(HamsterActivity) ) - filter_search = self.query_one('#filter #search').value + filter_search = self.query_one("#filter #search").value if filter_search is not None: activities = activities.where( HamsterActivity.name.contains(filter_search) | HamsterCategory.name.contains(filter_search) ) - filter_date = self.query_one('#filter #date').value + filter_date = self.query_one("#filter #date").value if filter_date is not None: try: activities = activities.where( - HamsterFact.start_time > datetime.strptime(filter_date, '%Y-%m-%d') + HamsterFact.start_time > datetime.strptime(filter_date, "%Y-%m-%d") ) except ValueError: pass @@ -431,15 +442,14 @@ class ActivityListScreen(ListScreen): def handle_edit(properties): if properties is None: return - activity.name = properties['activity'] - activity.category_id = properties['category'] + activity.name = properties["activity"] + activity.category_id = properties["category"] activity.save() self._refresh() - self.app.push_screen(ActivityEditScreen( - category=category, - activity=activity - ), handle_edit) + self.app.push_screen( + ActivityEditScreen(category=category, activity=activity), handle_edit + ) def action_mapping(self): selected_activity = ( @@ -490,26 +500,24 @@ class CategoryListScreen(ListScreen): categories = ( HamsterCategory.select( - HamsterCategory, + HamsterCategory, fn.Count(HamsterActivity.id).alias("activities_count"), - HamsterFact.start_time + HamsterFact.start_time, ) .join(HamsterActivity, JOIN.LEFT_OUTER) .join(HamsterFact, JOIN.LEFT_OUTER) .group_by(HamsterCategory) ) - filter_search = self.query_one('#filter #search').value + filter_search = self.query_one("#filter #search").value if filter_search is not None: - categories = categories.where( - HamsterCategory.name.contains(filter_search) - ) + categories = categories.where(HamsterCategory.name.contains(filter_search)) - filter_date = self.query_one('#filter #date').value + filter_date = self.query_one("#filter #date").value if filter_date is not None: try: categories = categories.where( - HamsterFact.start_time > datetime.strptime(filter_date, '%Y-%m-%d') + HamsterFact.start_time > datetime.strptime(filter_date, "%Y-%m-%d") ) except ValueError: pass @@ -613,7 +621,7 @@ class KimaiProjectListScreen(ListScreen): "id": project.id, "name": project.name, "customer_id": project.customer.id, - "allow_global_activities": project.allow_global_activities + "allow_global_activities": project.allow_global_activities, } for project in projects ] diff --git a/hamstertools/db.py b/hamstertools/db.py index a47d8c2..67ed5d6 100644 --- a/hamstertools/db.py +++ b/hamstertools/db.py @@ -1,12 +1,20 @@ from datetime import datetime import logging -from peewee import SqliteDatabase, Model, CharField, ForeignKeyField, DateTimeField, SmallIntegerField, BooleanField +from peewee import ( + SqliteDatabase, + Model, + CharField, + ForeignKeyField, + DateTimeField, + SmallIntegerField, + BooleanField, +) from textual.logging import TextualHandler -logger = logging.getLogger("peewee") -logger.addHandler(TextualHandler()) -logger.setLevel(logging.DEBUG) +# logger = logging.getLogger("peewee") +# logger.addHandler(TextualHandler()) +# logger.setLevel(logging.DEBUG) db = SqliteDatabase(None) diff --git a/hamstertools/kimai.py b/hamstertools/kimai.py index caa9640..48d0758 100644 --- a/hamstertools/kimai.py +++ b/hamstertools/kimai.py @@ -28,12 +28,14 @@ class KimaiAPI(object): self.user_json = self.get("users/me") def get(self, endpoint, params=None): - return requests.get(f"{self.KIMAI_API_URL}/{endpoint}", - params=params, headers=self.auth_headers).json() + return requests.get( + f"{self.KIMAI_API_URL}/{endpoint}", params=params, headers=self.auth_headers + ).json() def post(self, endpoint, data): - return requests.post(f"{self.KIMAI_API_URL}/{endpoint}", - json=data, headers=self.auth_headers) + return requests.post( + f"{self.KIMAI_API_URL}/{endpoint}", json=data, headers=self.auth_headers + ) class BaseAPI(object): @@ -72,9 +74,13 @@ class Project(BaseAPI): @staticmethod def list(api): return [ - Project(api, p["id"], p["name"], Customer.get_by_id(api, - p["customer"]), - p["globalActivities"]) + Project( + api, + p["id"], + p["name"], + Customer.get_by_id(api, p["customer"]), + p["globalActivities"], + ) for p in api.projects_json ] @@ -87,7 +93,7 @@ class Project(BaseAPI): value["id"], value["name"], Customer.get_by_id(api, value["customer"]), - value["globalActivities"] + value["globalActivities"], ) if not none: raise NotFound() @@ -143,19 +149,19 @@ class Timesheet(BaseAPI): Project.get_by_id(api, t["project"], none=True), t["begin"], t["end"], - t["id"], - t["description"], - t["tags"], + t["id"], + t["description"], + t["tags"], ) for t in api.get( - 'timesheets', + "timesheets", ) ] @staticmethod def get_by_id(api, id, none=False): t = api.get( - f'timesheets/{id}', + f"timesheets/{id}", ) return Timesheet( api, @@ -163,19 +169,22 @@ class Timesheet(BaseAPI): Project.get_by_id(api, t["project"], none=True), t["begin"], t["end"], - t["id"], - t["description"], - t["tags"], + t["id"], + t["description"], + t["tags"], ) def upload(self): - return self.api.post('timesheets', { - "begin": self.begin.isoformat(), - "end": self.end.isoformat(), - "project": self.project.id, - "activity": self.activity.id, - "description": self.description, - # FIXME: support multiple users - # "user": self., - "tags": self.tags, - }) + return self.api.post( + "timesheets", + { + "begin": self.begin.isoformat(), + "end": self.end.isoformat(), + "project": self.project.id, + "activity": self.activity.id, + "description": self.description, + # FIXME: support multiple users + # "user": self., + "tags": self.tags, + }, + ) diff --git a/scripts/apitest.py b/scripts/apitest.py index 1ffd641..266409a 100644 --- a/scripts/apitest.py +++ b/scripts/apitest.py @@ -1,5 +1,6 @@ import os import sys + sys.path.append(os.path.join(os.path.dirname(__file__), "..")) from datetime import datetime, timedelta @@ -17,7 +18,8 @@ api = KimaiAPI() # print(Timesheet.list(api)) -t = Timesheet(api, +t = Timesheet( + api, activity=Activity.get_by_id(api, 613), project=Project.get_by_id(api, 233), begin=datetime.now() - timedelta(minutes=10), diff --git a/scripts/upload.py b/scripts/upload.py index 4431632..fdeb772 100644 --- a/scripts/upload.py +++ b/scripts/upload.py @@ -1,5 +1,6 @@ import os import sys + sys.path.append(os.path.join(os.path.dirname(__file__), "..")) from datetime import datetime @@ -11,22 +12,19 @@ from hamstertools.kimai import * api = KimaiAPI() -DATE_FROM='2023-10-01' -DATE_TO='2023-11-01' -SEARCH='auto' +DATE_FROM = "2023-10-01" +DATE_TO = "2023-11-01" +SEARCH = "auto" -facts = HamsterFact.select( - HamsterFact, - HamsterActivity, - HamsterCategory -).join( - HamsterActivity, JOIN.LEFT_OUTER -).join( - HamsterCategory, JOIN.LEFT_OUTER -).where( - (HamsterFact.start_time > datetime.strptime(DATE_FROM, "%Y-%m-%d")) - & (HamsterFact.start_time < datetime.strptime(DATE_TO, "%Y-%m-%d")) - & HamsterCategory.name.contains(SEARCH) +facts = ( + HamsterFact.select(HamsterFact, HamsterActivity, HamsterCategory) + .join(HamsterActivity, JOIN.LEFT_OUTER) + .join(HamsterCategory, JOIN.LEFT_OUTER) + .where( + (HamsterFact.start_time > datetime.strptime(DATE_FROM, "%Y-%m-%d")) + & (HamsterFact.start_time < datetime.strptime(DATE_TO, "%Y-%m-%d")) + & HamsterCategory.name.contains(SEARCH) + ) ) has_errors = False @@ -35,15 +33,24 @@ has_errors = False for f in facts: mappings = f.activity.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') + 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') + print( + f"fact {f.id}: activity @{f.activity.id} {f.activity.name} has multiple mappings" + ) has_errors = True continue - if mappings[0].kimai_activity.project is None and not mappings[0].kimai_project.allow_global_activities: - print(f'fact {f.id}: project @{mappings[0].kimai_project.id} {mappings[0].kimai_project.name} does not allow global activity {mappings[0].kimai_activity}') + if ( + mappings[0].kimai_activity.project is None + and not mappings[0].kimai_project.allow_global_activities + ): + print( + f"fact {f.id}: project @{mappings[0].kimai_project.id} {mappings[0].kimai_project.name} does not allow global activity {mappings[0].kimai_activity}" + ) has_errors = True continue @@ -55,25 +62,27 @@ for f in facts: try: mapping = f.activity.mappings[0] except IndexError: - print(f"no mapping, skipping {f.id} ({f.activity.category.name} ยป {f.activity.name})") + print( + f"no mapping, skipping {f.id} ({f.activity.category.name} ยป {f.activity.name})" + ) continue - t = Timesheet(api, + t = Timesheet( + api, activity=mapping.kimai_activity, project=mapping.kimai_project, begin=f.start_time, end=f.end_time, - description=f.description if f.description != '' else mapping.kimai_description, + description=f.description if f.description != "" else mapping.kimai_description, # tags=f.tags if f.tags != '' else mapping.kimai_tags ) r = t.upload().json() if r.get("code", 200) != 200: print(r) print(f"{f.id} ({f.activity.category.name} ยป {f.activity.name})") - from pdb import set_trace; set_trace() + from pdb import set_trace + + set_trace() 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"]}') From 19e230932fb359704352c9c9aba68a9e7727c9d3 Mon Sep 17 00:00:00 2001 From: 3wc <3wc@doesthisthing.work> Date: Wed, 1 Nov 2023 19:39:10 +0000 Subject: [PATCH 31/46] Skip imported facts, more 4matting --- hamstertools/db.py | 2 +- scripts/apitest.py | 2 +- scripts/upload.py | 23 +++++++++++++++++++++-- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/hamstertools/db.py b/hamstertools/db.py index 67ed5d6..c22c594 100644 --- a/hamstertools/db.py +++ b/hamstertools/db.py @@ -88,7 +88,7 @@ class HamsterActivityKimaiMapping(Model): class HamsterFactKimaiImport(Model): - hamster_fact = ForeignKeyField(HamsterFact, backref="mappings") + hamster_fact = ForeignKeyField(HamsterFact, backref="imports") kimai_id = SmallIntegerField() imported = DateTimeField(default=datetime.now) diff --git a/scripts/apitest.py b/scripts/apitest.py index 266409a..98bc63f 100644 --- a/scripts/apitest.py +++ b/scripts/apitest.py @@ -6,7 +6,7 @@ sys.path.append(os.path.join(os.path.dirname(__file__), "..")) from datetime import datetime, timedelta import logging -from hamstertools.kimai import * +from hamstertools.kimai import KimaiAPI, Timesheet, Project, Activity logging.basicConfig() logging.getLogger().setLevel(logging.DEBUG) diff --git a/scripts/upload.py b/scripts/upload.py index fdeb772..caae2a4 100644 --- a/scripts/upload.py +++ b/scripts/upload.py @@ -7,8 +7,13 @@ from datetime import datetime from peewee import JOIN -from hamstertools.db import * -from hamstertools.kimai import * +from hamstertools.db import ( + HamsterCategory, + HamsterActivity, + HamsterFact, + HamsterFactKimaiImport, +) +from hamstertools.kimai import KimaiAPI, Timesheet, Project, Activity api = KimaiAPI() @@ -53,6 +58,13 @@ for f in facts: ) has_errors = True continue + if f.imports.count() > 0: + print( + f"fact {f.id}: activity @{f.activity.id} {f.activity.name} was already imported {f.imports.count()} time(s)" + ) + has_errors = True + continue + # if has_errors: # sys.exit(1) @@ -66,6 +78,13 @@ for f in facts: f"no mapping, skipping {f.id} ({f.activity.category.name} ยป {f.activity.name})" ) continue + + if f.imports.count() > 0: + print( + f"already imported, skipping {f.id} ({f.activity.category.name} ยป {f.activity.name})" + ) + continue + t = Timesheet( api, activity=mapping.kimai_activity, From 77f87e5299443678cc160cee494e66492a7b722f Mon Sep 17 00:00:00 2001 From: 3wc <3wc@doesthisthing.work> Date: Wed, 1 Nov 2023 20:56:04 +0000 Subject: [PATCH 32/46] Move debugging output to `-d` option --- hamstertools/__init__.py | 13 +++++++++++-- hamstertools/db.py | 6 ------ scripts/apitest.py | 7 ------- 3 files changed, 11 insertions(+), 15 deletions(-) diff --git a/hamstertools/__init__.py b/hamstertools/__init__.py index bcaa4b0..3d5672f 100755 --- a/hamstertools/__init__.py +++ b/hamstertools/__init__.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3.7 import csv +import logging from datetime import datetime from itertools import chain from pathlib import Path @@ -8,6 +9,7 @@ import sys import click import requests from peewee import fn, JOIN +from textual.logging import TextualHandler from .db import ( db, @@ -29,8 +31,15 @@ db.init(HAMSTER_FILE) @click.group() -def cli(): - pass +@click.option("-d", "--debug", is_flag=True) +def cli(debug): + if debug: + peewee_logger = logging.getLogger("peewee") + peewee_logger.addHandler(TextualHandler()) + peewee_logger.setLevel(logging.DEBUG) + requests_log = logging.getLogger("requests.packages.urllib3") + requests_log.setLevel(logging.DEBUG) + requests_log.propagate = True @cli.group() diff --git a/hamstertools/db.py b/hamstertools/db.py index c22c594..f54c696 100644 --- a/hamstertools/db.py +++ b/hamstertools/db.py @@ -1,5 +1,4 @@ from datetime import datetime -import logging from peewee import ( SqliteDatabase, Model, @@ -10,11 +9,6 @@ from peewee import ( BooleanField, ) -from textual.logging import TextualHandler - -# logger = logging.getLogger("peewee") -# logger.addHandler(TextualHandler()) -# logger.setLevel(logging.DEBUG) db = SqliteDatabase(None) diff --git a/scripts/apitest.py b/scripts/apitest.py index 98bc63f..99624e4 100644 --- a/scripts/apitest.py +++ b/scripts/apitest.py @@ -4,16 +4,9 @@ import sys sys.path.append(os.path.join(os.path.dirname(__file__), "..")) from datetime import datetime, timedelta -import logging from hamstertools.kimai import KimaiAPI, Timesheet, Project, Activity -logging.basicConfig() -logging.getLogger().setLevel(logging.DEBUG) -requests_log = logging.getLogger("requests.packages.urllib3") -requests_log.setLevel(logging.DEBUG) -requests_log.propagate = True - api = KimaiAPI() # print(Timesheet.list(api)) From 21fc28a14e34b5e446fa0a237577b559185faee2 Mon Sep 17 00:00:00 2001 From: 3wc <3wc@doesthisthing.work> Date: Wed, 1 Nov 2023 21:11:21 +0000 Subject: [PATCH 33/46] Break out sync to separate file --- hamstertools/__init__.py | 16 ++++++++--- hamstertools/app.py | 43 ++---------------------------- hamstertools/sync.py | 57 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 44 deletions(-) create mode 100644 hamstertools/sync.py diff --git a/hamstertools/__init__.py b/hamstertools/__init__.py index 3d5672f..1d91ba2 100755 --- a/hamstertools/__init__.py +++ b/hamstertools/__init__.py @@ -22,6 +22,7 @@ from .db import ( HamsterActivityKimaiMapping, HamsterFactKimaiImport, ) +from .sync import sync HAMSTER_DIR = Path.home() / ".local/share/hamster" # HAMSTER_FILE = HAMSTER_DIR / 'hamster.db' @@ -34,9 +35,13 @@ db.init(HAMSTER_FILE) @click.option("-d", "--debug", is_flag=True) def cli(debug): if debug: + logging.basicConfig() + logging.getLogger().setLevel(logging.DEBUG) + peewee_logger = logging.getLogger("peewee") peewee_logger.addHandler(TextualHandler()) peewee_logger.setLevel(logging.DEBUG) + requests_log = logging.getLogger("requests.packages.urllib3") requests_log.setLevel(logging.DEBUG) requests_log.propagate = True @@ -342,7 +347,7 @@ def _get_kimai_mapping_file(path, category_search=None): return open(path) -@kimai.command() +@kimai.command("sync") @click.option( "--mapping-path", help="Mapping file (default ~/.local/share/hamster/mapping.kimai.csv)", @@ -352,9 +357,9 @@ def _get_kimai_mapping_file(path, category_search=None): @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 check(username, api_key, just_errors, ignore_activities, mapping_path=None): """ - Download customer / project / activity data from Kimai + Check customer / project / activity data from Kimai """ kimai_api_url = "https://kimai.autonomic.zone/api" @@ -656,6 +661,11 @@ def reset(): HamsterActivityKimaiMapping.delete().execute() +@db_.command("sync") +def kimai_db_sync(): + sync() + + @db_.command() @click.option( "-g", diff --git a/hamstertools/app.py b/hamstertools/app.py index 7c956c6..ea41aa6 100644 --- a/hamstertools/app.py +++ b/hamstertools/app.py @@ -30,6 +30,7 @@ from .kimai import ( Project as KimaiAPIProject, Activity as KimaiAPIActivity, ) +from .sync import sync class ListScreen(Screen): @@ -601,47 +602,7 @@ class KimaiProjectListScreen(ListScreen): self.table.sort(self.sort) def action_get(self) -> None: - api = KimaiAPI() - - KimaiCustomer.delete().execute() - KimaiProject.delete().execute() - KimaiActivity.delete().execute() - - customers = KimaiAPICustomer.list(api) - with db.atomic(): - KimaiCustomer.insert_many( - [{"id": customer.id, "name": customer.name} for customer in customers] - ).execute() - - projects = KimaiAPIProject.list(api) - with db.atomic(): - KimaiProject.insert_many( - [ - { - "id": project.id, - "name": project.name, - "customer_id": project.customer.id, - "allow_global_activities": project.allow_global_activities, - } - for project in projects - ] - ).execute() - - activities = KimaiAPIActivity.list(api) - with db.atomic(): - KimaiActivity.insert_many( - [ - { - "id": activity.id, - "name": activity.name, - "project_id": ( - activity.project and activity.project.id or None - ), - } - for activity in activities - ] - ).execute() - + sync() self._refresh() def on_mount(self) -> None: diff --git a/hamstertools/sync.py b/hamstertools/sync.py new file mode 100644 index 0000000..4e7b108 --- /dev/null +++ b/hamstertools/sync.py @@ -0,0 +1,57 @@ +from .kimai import ( + KimaiAPI, + Customer as KimaiAPICustomer, + Project as KimaiAPIProject, + Activity as KimaiAPIActivity, +) +from .db import ( + db, + HamsterCategory, + HamsterActivity, + HamsterFact, + KimaiProject, + KimaiCustomer, + KimaiActivity, + HamsterActivityKimaiMapping, +) + + +def sync() -> None: + api = KimaiAPI() + + KimaiCustomer.delete().execute() + KimaiProject.delete().execute() + KimaiActivity.delete().execute() + + customers = KimaiAPICustomer.list(api) + with db.atomic(): + KimaiCustomer.insert_many( + [{"id": customer.id, "name": customer.name} for customer in customers] + ).execute() + + projects = KimaiAPIProject.list(api) + with db.atomic(): + KimaiProject.insert_many( + [ + { + "id": project.id, + "name": project.name, + "customer_id": project.customer.id, + "allow_global_activities": project.allow_global_activities, + } + for project in projects + ] + ).execute() + + activities = KimaiAPIActivity.list(api) + with db.atomic(): + KimaiActivity.insert_many( + [ + { + "id": activity.id, + "name": activity.name, + "project_id": (activity.project and activity.project.id or None), + } + for activity in activities + ] + ).execute() From be1a8a7f7ab9f9b2dc17444a5c99007b834ee004 Mon Sep 17 00:00:00 2001 From: 3wc <3wc@doesthisthing.work> Date: Wed, 1 Nov 2023 21:56:08 +0000 Subject: [PATCH 34/46] Tweak `kimai check` command --- hamstertools/__init__.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/hamstertools/__init__.py b/hamstertools/__init__.py index 1d91ba2..e3ffc96 100755 --- a/hamstertools/__init__.py +++ b/hamstertools/__init__.py @@ -347,14 +347,14 @@ def _get_kimai_mapping_file(path, category_search=None): return open(path) -@kimai.command("sync") +@kimai.command() @click.option( "--mapping-path", help="Mapping file (default ~/.local/share/hamster/mapping.kimai.csv)", multiple=True, ) @click.argument("username") -@click.argument("api_key") +@click.argument("api_key", envvar="KIMAI_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 check(username, api_key, just_errors, ignore_activities, mapping_path=None): @@ -364,18 +364,19 @@ def check(username, api_key, just_errors, ignore_activities, mapping_path=None): kimai_api_url = "https://kimai.autonomic.zone/api" - if type(mapping_path) == tuple: - mapping_files = [] - for mapping_path_item in mapping_path: - mapping_file = _get_kimai_mapping_file(mapping_path_item) - next(mapping_file) - mapping_files.append(mapping_file) - mapping_reader = csv.reader(chain(*mapping_files)) - else: - if mapping_path is None: - mapping_path = HAMSTER_DIR / "mapping.kimai.csv" - mapping_file = _get_kimai_mapping_file(mapping_path) - mapping_reader = csv.reader(mapping_file) + if len(mapping_path) == 0: + mapping_path = (HAMSTER_DIR / "mapping.kimai.csv",) + + mapping_files = [] + for mapping_path_item in mapping_path: + if not Path(mapping_path_item).exists(): + raise click.UsageError(f'{mapping_path_item} does not exist') + + mapping_file = _get_kimai_mapping_file(mapping_path_item) + next(mapping_file) + mapping_files.append(mapping_file) + + mapping_reader = csv.reader(chain(*mapping_files)) next(mapping_reader) @@ -401,7 +402,6 @@ def check(username, api_key, just_errors, ignore_activities, mapping_path=None): for row in mapping_data: # Check if each mapping still exists in Kimai - matching_customers = list(filter(lambda x: x["name"] == row[0], customers)) if row[0] in found_customers: just_errors or click.secho( From ce68a03ea902318d1b42de8b87dcda2356442b80 Mon Sep 17 00:00:00 2001 From: 3wc <3wc@doesthisthing.work> Date: Thu, 2 Nov 2023 22:43:11 +0000 Subject: [PATCH 35/46] Mapping editing --- hamstertools/__init__.py | 2 +- hamstertools/app.py | 73 +++++++++++++++++++++++++++++++--------- hamstertools/sync.py | 4 --- 3 files changed, 58 insertions(+), 21 deletions(-) diff --git a/hamstertools/__init__.py b/hamstertools/__init__.py index e3ffc96..24a851c 100755 --- a/hamstertools/__init__.py +++ b/hamstertools/__init__.py @@ -370,7 +370,7 @@ def check(username, api_key, just_errors, ignore_activities, mapping_path=None): mapping_files = [] for mapping_path_item in mapping_path: if not Path(mapping_path_item).exists(): - raise click.UsageError(f'{mapping_path_item} does not exist') + raise click.UsageError(f"{mapping_path_item} does not exist") mapping_file = _get_kimai_mapping_file(mapping_path_item) next(mapping_file) diff --git a/hamstertools/app.py b/hamstertools/app.py index ea41aa6..dcd4964 100644 --- a/hamstertools/app.py +++ b/hamstertools/app.py @@ -156,10 +156,26 @@ class ActivityMappingScreen(ModalScreen): customer_id = None project_id = None activity_id = None + customer = "" + project = "" + activity = "" + description = "" + tags = "" + + def __init__(self, category, activity, mapping=None): + self.hamster_category = category + self.hamster_activity = activity + + if mapping is not None: + self.customer_id = mapping.kimai_customer_id + self.customer = mapping.kimai_customer.name + self.project_id = mapping.kimai_project_id + self.project = mapping.kimai_project.name + self.activity_id = mapping.kimai_activity_id + self.activity = mapping.kimai_activity.name + self.description = mapping.kimai_description + self.tags = mapping.kimai_tags - def __init__(self, category, activity): - self.category = category - self.activity = activity super().__init__() @staticmethod @@ -197,36 +213,48 @@ class ActivityMappingScreen(ModalScreen): def compose(self) -> ComposeResult: yield Vertical( Horizontal( - Label(f"Mapping for {self.activity}@{self.category}"), + Label(f"Mapping for {self.hamster_activity}@{self.hamster_category}"), ), Horizontal( Label("Customer"), AutoComplete( - Input(placeholder="Type to search...", id="customer"), + Input( + placeholder="Type to search...", + id="customer", + value=self.customer, + ), Dropdown(items=self._get_customers), ), ), Horizontal( Label("Project"), AutoComplete( - Input(placeholder="Type to search...", id="project"), + Input( + placeholder="Type to search...", + id="project", + value=self.project, + ), Dropdown(items=self._get_projects), ), ), Horizontal( Label("Activity"), AutoComplete( - Input(placeholder="Type to search...", id="activity"), + Input( + placeholder="Type to search...", + id="activity", + value=self.activity, + ), Dropdown(items=self._get_activities), ), ), Horizontal( Label("Description"), - Input(id="description"), + Input(id="description", value=self.description), ), Horizontal( Label("Tags"), - Input(id="tags"), + Input(id="tags", value=self.tags), ), Horizontal(Checkbox("Global", id="global")), ) @@ -468,20 +496,33 @@ class ActivityListScreen(ListScreen): .get() ) - def handle_mapping(mapping): - if mapping is None: - return - m = HamsterActivityKimaiMapping.create( - hamster_activity=selected_activity, **mapping + mapping = None + try: + mapping = HamsterActivityKimaiMapping.get( + hamster_activity=selected_activity ) - m.save() - filter_search = self.query_one("#search") + except HamsterActivityKimaiMapping.DoesNotExist: + pass + + def handle_mapping(mapping_data): + if mapping_data is None: + return + if mapping is not None: + mapping_ = mapping + for key, value in mapping_data.items(): + setattr(mapping_, key, value) + else: + mapping_ = HamsterActivityKimaiMapping.create( + hamster_activity=selected_activity, **mapping_data + ) + mapping_.save() self._refresh() self.app.push_screen( ActivityMappingScreen( category=selected_activity.category_name, activity=selected_activity.name, + mapping=mapping, ), handle_mapping, ) diff --git a/hamstertools/sync.py b/hamstertools/sync.py index 4e7b108..a03fe50 100644 --- a/hamstertools/sync.py +++ b/hamstertools/sync.py @@ -6,13 +6,9 @@ from .kimai import ( ) from .db import ( db, - HamsterCategory, - HamsterActivity, - HamsterFact, KimaiProject, KimaiCustomer, KimaiActivity, - HamsterActivityKimaiMapping, ) From ff013525af387f87c89b2ccd1bad71f30c34112b Mon Sep 17 00:00:00 2001 From: 3wc <3wc@doesthisthing.work> Date: Fri, 3 Nov 2023 00:13:54 +0000 Subject: [PATCH 36/46] Refucktoring --- hamstertools/app.py | 659 +------------------------ hamstertools/{kimai.py => kimaiapi.py} | 0 hamstertools/sync.py | 2 +- 3 files changed, 5 insertions(+), 656 deletions(-) rename hamstertools/{kimai.py => kimaiapi.py} (100%) diff --git a/hamstertools/app.py b/hamstertools/app.py index dcd4964..7d30ea5 100644 --- a/hamstertools/app.py +++ b/hamstertools/app.py @@ -1,660 +1,9 @@ -from datetime import datetime +from textual.app import App -from textual import on -from textual.app import App, ComposeResult -from textual.binding import Binding -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.coordinate import Coordinate -from textual.screen import Screen, ModalScreen +from .db import db -from textual_autocomplete import AutoComplete, Dropdown, DropdownItem - -from peewee import fn, JOIN - -from .db import ( - db, - HamsterCategory, - HamsterActivity, - HamsterFact, - KimaiProject, - KimaiCustomer, - KimaiActivity, - HamsterActivityKimaiMapping, -) -from .kimai import ( - KimaiAPI, - Customer as KimaiAPICustomer, - Project as KimaiAPIProject, - Activity as KimaiAPIActivity, -) -from .sync import sync - - -class ListScreen(Screen): - def compose(self) -> ComposeResult: - yield Header() - with Vertical(): - yield DataTable() - with Horizontal(id="filter"): - yield Input( - id="search", placeholder="Category/activity name contains text" - ) - yield Input( - id="date", - placeholder="After date, in {0} format".format( - datetime.now().strftime("%Y-%m-%d") - ), - ) - yield Footer() - - def action_refresh(self) -> None: - self._refresh() - - def action_sort(self) -> None: - self.table.cursor_type = "column" - - def on_data_table_column_selected(self, event): - self.sort = (event.column_key,) - event.data_table.sort(*self.sort) - event.data_table.cursor_type = "row" - - def action_filter(self) -> None: - self.query_one("#filter").display = True - self._refresh() - self.query_one("#filter #search").focus() - - def on_input_submitted(self, event): - self.table.focus() - - def action_cancelfilter(self) -> None: - self.query_one("#filter").display = False - self.query_one("#filter #search").clear() - self.query_one("#filter #date").clear() - self.table.focus() - self._refresh() - - @on(Input.Changed, "#filter Input") - def filter(self, event): - self._refresh() - - -class ActivityEditScreen(ModalScreen): - BINDINGS = [ - ("escape", "cancel", "Cancel"), - ("ctrl+s", "save", "Save"), - ] - - category_id = None - category_name = "" - - def _get_categories(self, input_state): - categories = [DropdownItem(c.name, str(c.id)) for c in HamsterCategory.select()] - return ActivityMappingScreen._filter_dropdowns(categories, input_state.value) - - def __init__(self, category, activity): - if category is not None: - self.category_id = category.id - self.category_name = category.name - self.activity_name = activity.name - super().__init__() - - def compose(self) -> ComposeResult: - yield Vertical( - Horizontal( - Label("Category:"), - AutoComplete( - Input( - placeholder="Type to search...", - id="category", - value=self.category_name, - ), - Dropdown(items=self._get_categories), - ), - ), - Horizontal( - Label("Activity:"), Input(value=self.activity_name, id="activity") - ), - ) - - @on(Input.Submitted, "#category") - def category_submitted(self, event): - if event.control.parent.dropdown.selected_item is not None: - self.category_id = str( - event.control.parent.dropdown.selected_item.left_meta - ) - self.query_one("#activity").focus() - - @on(DescendantBlur, "#category") - def category_blur(self, event): - if event.control.parent.dropdown.selected_item is not None: - self.category_id = str( - event.control.parent.dropdown.selected_item.left_meta - ) - - def action_cancel(self): - self.dismiss(None) - - def action_save(self): - self.dismiss( - { - "category": self.category_id, - "activity": self.query_one("#activity").value, - } - ) - - -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 - customer = "" - project = "" - activity = "" - description = "" - tags = "" - - def __init__(self, category, activity, mapping=None): - self.hamster_category = category - self.hamster_activity = activity - - if mapping is not None: - self.customer_id = mapping.kimai_customer_id - self.customer = mapping.kimai_customer.name - self.project_id = mapping.kimai_project_id - self.project = mapping.kimai_project.name - self.activity_id = mapping.kimai_activity_id - self.activity = mapping.kimai_activity.name - self.description = mapping.kimai_description - self.tags = mapping.kimai_tags - - 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.hamster_activity}@{self.hamster_category}"), - ), - Horizontal( - Label("Customer"), - AutoComplete( - Input( - placeholder="Type to search...", - id="customer", - value=self.customer, - ), - Dropdown(items=self._get_customers), - ), - ), - Horizontal( - Label("Project"), - AutoComplete( - Input( - placeholder="Type to search...", - id="project", - value=self.project, - ), - Dropdown(items=self._get_projects), - ), - ), - Horizontal( - Label("Activity"), - AutoComplete( - Input( - placeholder="Type to search...", - id="activity", - value=self.activity, - ), - Dropdown(items=self._get_activities), - ), - ), - Horizontal( - Label("Description"), - Input(id="description", value=self.description), - ), - Horizontal( - Label("Tags"), - Input(id="tags", value=self.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 = [ - ("s", "sort", "Sort"), - ("r", "refresh", "Refresh"), - ("/", "filter", "Search"), - ("d", "delete", "Delete"), - ("f", "move_facts", "Move facts"), - ("e", "edit", "Edit"), - ("m", "mapping", "Mapping"), - Binding(key="escape", action="cancelfilter", show=False), - ] - - def _refresh(self): - 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 = ( - HamsterActivityKimaiMapping.select( - HamsterActivityKimaiMapping.hamster_activity_id, - fn.COUNT(HamsterActivityKimaiMapping.id).alias("mappings_count"), - ) - .group_by(HamsterActivityKimaiMapping.hamster_activity_id) - .alias("mappings_count_query") - ) - - activities = ( - HamsterActivity.select( - HamsterActivity, - HamsterCategory.id, - HamsterFact.start_time, - 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) - .switch(HamsterActivity) - .join(HamsterFact, JOIN.LEFT_OUTER) - .switch(HamsterActivity) - .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) - ) - - filter_search = self.query_one("#filter #search").value - if filter_search is not None: - activities = activities.where( - HamsterActivity.name.contains(filter_search) - | HamsterCategory.name.contains(filter_search) - ) - - filter_date = self.query_one("#filter #date").value - if filter_date is not None: - try: - activities = activities.where( - HamsterFact.start_time > datetime.strptime(filter_date, "%Y-%m-%d") - ) - except ValueError: - pass - - self.table.add_rows( - [ - [ - activity.category_id, - activity.category_name, - activity.id, - activity.name, - activity.facts_count, - activity.mappings_count, - ] - for activity in activities - ] - ) - - self.table.sort(*self.sort) - - def on_mount(self) -> None: - self.table = self.query_one(DataTable) - self.table.cursor_type = "row" - self.columns = self.table.add_columns( - "category id", "category", "activity id", "activity", "entries", "mappings" - ) - self.sort = (self.columns[1], self.columns[3]) - self._refresh() - - def action_delete(self) -> None: - # get the keys for the row and column under the cursor. - row_key, _ = self.table.coordinate_to_cell_key(self.table.cursor_coordinate) - - activity_id = self.table.get_cell_at( - Coordinate(self.table.cursor_coordinate.row, 2), - ) - - activity = HamsterActivity.get(id=activity_id) - activity.delete_instance() - - # supply the row key to `remove_row` to delete the row. - self.table.remove_row(row_key) - - def action_move_facts(self) -> None: - row_idx: int = self.table.cursor_row - row_cells = self.table.get_row_at(row_idx) - self.move_from_activity = HamsterActivity.get(id=row_cells[2]) - for col_idx, cell_value in enumerate(row_cells): - cell_coordinate = Coordinate(row_idx, col_idx) - self.table.update_cell_at( - cell_coordinate, - f"[red]{cell_value}[/red]", - ) - self.table.move_cursor(row=self.table.cursor_coordinate[0] + 1) - - def on_data_table_row_selected(self, event): - if getattr(self, "move_from_activity", None) is not None: - move_to_activity = HamsterActivity.get( - self.table.get_cell_at(Coordinate(event.cursor_row, 2)) - ) - HamsterFact.update({HamsterFact.activity: move_to_activity}).where( - HamsterFact.activity == self.move_from_activity - ).execute() - self._refresh() - del self.move_from_activity - - def action_edit(self): - row_idx: int = self.table.cursor_row - row_cells = self.table.get_row_at(row_idx) - - try: - category = HamsterCategory.get(id=row_cells[0]) - except HamsterCategory.DoesNotExist: - category = None - activity = HamsterActivity.get(id=row_cells[2]) - - def handle_edit(properties): - if properties is None: - return - activity.name = properties["activity"] - activity.category_id = properties["category"] - activity.save() - self._refresh() - - self.app.push_screen( - ActivityEditScreen(category=category, activity=activity), 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() - ) - - mapping = None - try: - mapping = HamsterActivityKimaiMapping.get( - hamster_activity=selected_activity - ) - except HamsterActivityKimaiMapping.DoesNotExist: - pass - - def handle_mapping(mapping_data): - if mapping_data is None: - return - if mapping is not None: - mapping_ = mapping - for key, value in mapping_data.items(): - setattr(mapping_, key, value) - else: - mapping_ = HamsterActivityKimaiMapping.create( - hamster_activity=selected_activity, **mapping_data - ) - mapping_.save() - self._refresh() - - self.app.push_screen( - ActivityMappingScreen( - category=selected_activity.category_name, - activity=selected_activity.name, - mapping=mapping, - ), - handle_mapping, - ) - - -class CategoryListScreen(ListScreen): - BINDINGS = [ - ("s", "sort", "Sort"), - ("r", "refresh", "Refresh"), - ("/", "filter", "Search"), - ("d", "delete", "Delete category"), - Binding(key="escape", action="cancelfilter", show=False), - ] - - def _refresh(self): - self.table.clear() - - categories = ( - HamsterCategory.select( - HamsterCategory, - fn.Count(HamsterActivity.id).alias("activities_count"), - HamsterFact.start_time, - ) - .join(HamsterActivity, JOIN.LEFT_OUTER) - .join(HamsterFact, JOIN.LEFT_OUTER) - .group_by(HamsterCategory) - ) - - filter_search = self.query_one("#filter #search").value - if filter_search is not None: - categories = categories.where(HamsterCategory.name.contains(filter_search)) - - filter_date = self.query_one("#filter #date").value - if filter_date is not None: - try: - categories = categories.where( - HamsterFact.start_time > datetime.strptime(filter_date, "%Y-%m-%d") - ) - except ValueError: - pass - - self.table.add_rows( - [ - [ - category.id, - category.name, - category.activities_count, - ] - for category in categories - ] - ) - - self.table.sort(self.sort) - - def on_mount(self) -> None: - self.table = self.query_one(DataTable) - self.table.cursor_type = "row" - self.columns = self.table.add_columns("category id", "category", "activities") - self.sort = self.columns[1] - self._refresh() - - def action_delete(self) -> None: - row_key, _ = self.table.coordinate_to_cell_key(self.table.cursor_coordinate) - - category_id = self.table.get_cell_at( - Coordinate(self.table.cursor_coordinate.row, 0), - ) - category = HamsterCategory.get(id=category_id) - category.delete_instance() - - self.table.remove_row(row_key) - - -class KimaiProjectListScreen(ListScreen): - BINDINGS = [ - ("s", "sort", "Sort"), - ("r", "refresh", "Refresh"), - ("g", "get", "Get data"), - ("/", "filter", "Search"), - Binding(key="escape", action="cancelfilter", show=False), - ] - - def _refresh(self, filter_query=None): - self.table.clear() - - projects = ( - KimaiProject.select( - KimaiProject, - KimaiCustomer, - fn.Count(KimaiActivity.id).alias("activities_count"), - ) - .join(KimaiCustomer, JOIN.LEFT_OUTER) - .switch(KimaiProject) - .join(KimaiActivity, JOIN.LEFT_OUTER) - .where(KimaiActivity.project.is_null(False)) - .group_by(KimaiProject) - ) - - if filter_query: - projects = projects.where( - KimaiProject.name.contains(filter_query) - | KimaiCustomer.name.contains(filter_query) - ) - - self.table.add_rows( - [ - [ - project.customer.id, - project.customer.name, - project.id, - project.name, - project.activities_count, - ] - for project in projects - ] - ) - - self.table.sort(self.sort) - - def action_get(self) -> None: - sync() - self._refresh() - - def on_mount(self) -> None: - self.table = self.query_one(DataTable) - self.table.cursor_type = "row" - self.columns = self.table.add_columns( - "customer id", "customer", "project id", "project", "activities" - ) - # self.sort = (self.columns[1], self.columns[3]) - self.sort = self.columns[1] - self._refresh() +from .screens.hamster import CategoryListScreen, ActivityListScreen +from .screens.kimai import KimaiProjectListScreen class HamsterToolsApp(App): diff --git a/hamstertools/kimai.py b/hamstertools/kimaiapi.py similarity index 100% rename from hamstertools/kimai.py rename to hamstertools/kimaiapi.py diff --git a/hamstertools/sync.py b/hamstertools/sync.py index a03fe50..3ced975 100644 --- a/hamstertools/sync.py +++ b/hamstertools/sync.py @@ -1,4 +1,4 @@ -from .kimai import ( +from .kimaiapi import ( KimaiAPI, Customer as KimaiAPICustomer, Project as KimaiAPIProject, From d3c2da74e6e25e30916d7273b3e33c1e4ecb605a Mon Sep 17 00:00:00 2001 From: 3wc <3wc@doesthisthing.work> Date: Fri, 3 Nov 2023 21:52:08 +0000 Subject: [PATCH 37/46] Switch to tabs --- hamstertools/__init__.py | 3 +- hamstertools/app.py | 16 +- hamstertools/db.py | 1 + hamstertools/kimaiapi.py | 6 +- hamstertools/screens/__init__.py | 0 hamstertools/screens/hamster.py | 563 +++++++++++++++++++++++++++++++ hamstertools/screens/kimai.py | 97 ++++++ hamstertools/screens/list.py | 52 +++ scripts/upload.py | 2 +- 9 files changed, 724 insertions(+), 16 deletions(-) create mode 100644 hamstertools/screens/__init__.py create mode 100644 hamstertools/screens/hamster.py create mode 100644 hamstertools/screens/kimai.py create mode 100644 hamstertools/screens/list.py diff --git a/hamstertools/__init__.py b/hamstertools/__init__.py index 24a851c..e014db1 100755 --- a/hamstertools/__init__.py +++ b/hamstertools/__init__.py @@ -25,8 +25,7 @@ from .db import ( from .sync import sync HAMSTER_DIR = Path.home() / ".local/share/hamster" -# HAMSTER_FILE = HAMSTER_DIR / 'hamster.db' -HAMSTER_FILE = "hamster-testing.db" +HAMSTER_FILE = HAMSTER_DIR / 'hamster.db' db.init(HAMSTER_FILE) diff --git a/hamstertools/app.py b/hamstertools/app.py index 7d30ea5..6992ae7 100644 --- a/hamstertools/app.py +++ b/hamstertools/app.py @@ -2,32 +2,28 @@ from textual.app import App from .db import db -from .screens.hamster import CategoryListScreen, ActivityListScreen -from .screens.kimai import KimaiProjectListScreen +from .screens.hamster import HamsterScreen +from .screens.kimai import KimaiScreen class HamsterToolsApp(App): CSS_PATH = "app.tcss" BINDINGS = [ - ("a", "switch_mode('activities')", "Activities"), - ("c", "switch_mode('categories')", "Categories"), + ("h", "switch_mode('hamster')", "Hamster"), ("k", "switch_mode('kimai')", "Kimai"), ("q", "quit", "Quit"), ] def __init__(self): - db.init("hamster-testing.db") - self.MODES = { - "categories": CategoryListScreen(), - "activities": ActivityListScreen(), - "kimai": KimaiProjectListScreen(), + "hamster": HamsterScreen(), + "kimai": KimaiScreen(), } super().__init__() def on_mount(self) -> None: - self.switch_mode("activities") + self.switch_mode("hamster") def action_quit(self) -> None: db.close() diff --git a/hamstertools/db.py b/hamstertools/db.py index f54c696..d8d5c18 100644 --- a/hamstertools/db.py +++ b/hamstertools/db.py @@ -1,4 +1,5 @@ from datetime import datetime + from peewee import ( SqliteDatabase, Model, diff --git a/hamstertools/kimaiapi.py b/hamstertools/kimaiapi.py index 48d0758..9333d3b 100644 --- a/hamstertools/kimaiapi.py +++ b/hamstertools/kimaiapi.py @@ -12,8 +12,8 @@ class NotFound(Exception): class KimaiAPI(object): # temporary hardcoded config - # KIMAI_API_URL = "https://kimai.autonomic.zone/api" - KIMAI_API_URL = "https://kimaitest.autonomic.zone/api" + KIMAI_API_URL = "https://kimai.autonomic.zone/api" + # KIMAI_API_URL = "https://kimaitest.autonomic.zone/api" KIMAI_USERNAME = "3wordchant" KIMAI_API_KEY = os.environ["KIMAI_API_KEY"] @@ -23,7 +23,7 @@ class KimaiAPI(object): def __init__(self): requests_cache.install_cache("kimai", backend="sqlite", expire_after=1800) self.customers_json = self.get("customers", {"visible": 3}) - self.projects_json = self.get("projects", {"visible": 3}) + self.projects_json = self.get("projects", {"visible": 3, "ignoreDates": 1}) self.activities_json = self.get("activities", {"visible": 3}) self.user_json = self.get("users/me") diff --git a/hamstertools/screens/__init__.py b/hamstertools/screens/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hamstertools/screens/hamster.py b/hamstertools/screens/hamster.py new file mode 100644 index 0000000..8e6f59b --- /dev/null +++ b/hamstertools/screens/hamster.py @@ -0,0 +1,563 @@ +from datetime import datetime + +from textual import on +from textual.app import ComposeResult +from textual.binding import Binding +from textual.coordinate import Coordinate +from textual.containers import Horizontal, Vertical +from textual.events import DescendantBlur +from textual.screen import Screen, ModalScreen +from textual.widgets import Header, Footer, DataTable, Input, Label, Checkbox, TabbedContent, TabPane + +from peewee import fn, JOIN + +from textual_autocomplete import AutoComplete, Dropdown, DropdownItem + +from ..db import ( + HamsterCategory, + HamsterActivity, + HamsterFact, + KimaiProject, + KimaiCustomer, + KimaiActivity, + HamsterActivityKimaiMapping, +) + +from .list import ListPane + + +class ActivityEditScreen(ModalScreen): + BINDINGS = [ + ("escape", "cancel", "Cancel"), + ("ctrl+s", "save", "Save"), + ] + + category_id = None + category_name = "" + + def _get_categories(self, input_state): + categories = [DropdownItem(c.name, str(c.id)) for c in HamsterCategory.select()] + return ActivityMappingScreen._filter_dropdowns(categories, input_state.value) + + def __init__(self, category, activity): + if category is not None: + self.category_id = category.id + self.category_name = category.name + self.activity_name = activity.name + super().__init__() + + def compose(self) -> ComposeResult: + yield Vertical( + Horizontal( + Label("Category:"), + AutoComplete( + Input( + placeholder="Type to search...", + id="category", + value=self.category_name, + ), + Dropdown(items=self._get_categories), + ), + ), + Horizontal( + Label("Activity:"), Input(value=self.activity_name, id="activity") + ), + ) + + @on(Input.Submitted, "#category") + def category_submitted(self, event): + if event.control.parent.dropdown.selected_item is not None: + self.category_id = str( + event.control.parent.dropdown.selected_item.left_meta + ) + self.query_one("#activity").focus() + + @on(DescendantBlur, "#category") + def category_blur(self, event): + if event.control.parent.dropdown.selected_item is not None: + self.category_id = str( + event.control.parent.dropdown.selected_item.left_meta + ) + + def action_cancel(self): + self.dismiss(None) + + def action_save(self): + self.dismiss( + { + "category": self.category_id, + "activity": self.query_one("#activity").value, + } + ) + + +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 + customer = "" + project = "" + activity = "" + description = "" + tags = "" + + def __init__(self, category, activity, mapping=None): + self.hamster_category = category + self.hamster_activity = activity + + if mapping is not None: + self.customer_id = mapping.kimai_customer_id + self.customer = mapping.kimai_customer.name + self.project_id = mapping.kimai_project_id + self.project = mapping.kimai_project.name + self.activity_id = mapping.kimai_activity_id + self.activity = mapping.kimai_activity.name + self.description = mapping.kimai_description + self.tags = mapping.kimai_tags + + 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.hamster_activity}@{self.hamster_category}"), + ), + Horizontal( + Label("Customer"), + AutoComplete( + Input( + placeholder="Type to search...", + id="customer", + value=self.customer, + ), + Dropdown(items=self._get_customers), + ), + ), + Horizontal( + Label("Project"), + AutoComplete( + Input( + placeholder="Type to search...", + id="project", + value=self.project, + ), + Dropdown(items=self._get_projects), + ), + ), + Horizontal( + Label("Activity"), + AutoComplete( + Input( + placeholder="Type to search...", + id="activity", + value=self.activity, + ), + Dropdown(items=self._get_activities), + ), + ), + Horizontal( + Label("Description"), + Input(id="description", value=self.description), + ), + Horizontal( + Label("Tags"), + Input(id="tags", value=self.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 ActivityList(ListPane): + BINDINGS = [ + ("s", "sort", "Sort"), + ("r", "refresh", "Refresh"), + ("/", "filter", "Search"), + ("d", "delete", "Delete"), + ("f", "move_facts", "Move"), + ("e", "edit", "Edit"), + ("m", "mapping", "Mapping"), + Binding(key="escape", action="cancelfilter", show=False), + ] + + def _refresh(self): + 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 = ( + HamsterActivityKimaiMapping.select( + HamsterActivityKimaiMapping.hamster_activity_id, + fn.COUNT(HamsterActivityKimaiMapping.id).alias("mappings_count"), + ) + .group_by(HamsterActivityKimaiMapping.hamster_activity_id) + .alias("mappings_count_query") + ) + + activities = ( + HamsterActivity.select( + HamsterActivity, + HamsterCategory.id, + HamsterFact.start_time, + 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) + .switch(HamsterActivity) + .join(HamsterFact, JOIN.LEFT_OUTER) + .switch(HamsterActivity) + .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) + ) + + filter_search = self.query_one("#filter #search").value + if filter_search is not None: + activities = activities.where( + HamsterActivity.name.contains(filter_search) + | HamsterCategory.name.contains(filter_search) + ) + + filter_date = self.query_one("#filter #date").value + if filter_date is not None: + try: + activities = activities.where( + HamsterFact.start_time > datetime.strptime(filter_date, "%Y-%m-%d") + ) + except ValueError: + pass + + self.table.add_rows( + [ + [ + activity.category_id, + activity.category_name, + activity.id, + activity.name, + activity.facts_count, + activity.mappings_count, + ] + for activity in activities + ] + ) + + self.table.sort(*self.sort) + + def on_mount(self) -> None: + self.table = self.query_one(DataTable) + self.table.cursor_type = "row" + self.columns = self.table.add_columns( + "category id", "category", "activity id", "activity", "entries", "mappings" + ) + self.sort = (self.columns[1], self.columns[3]) + self._refresh() + + def action_delete(self) -> None: + # get the keys for the row and column under the cursor. + row_key, _ = self.table.coordinate_to_cell_key(self.table.cursor_coordinate) + + activity_id = self.table.get_cell_at( + Coordinate(self.table.cursor_coordinate.row, 2), + ) + + activity = HamsterActivity.get(id=activity_id) + activity.delete_instance() + + # supply the row key to `remove_row` to delete the row. + self.table.remove_row(row_key) + + def action_move_facts(self) -> None: + row_idx: int = self.table.cursor_row + row_cells = self.table.get_row_at(row_idx) + self.move_from_activity = HamsterActivity.get(id=row_cells[2]) + for col_idx, cell_value in enumerate(row_cells): + cell_coordinate = Coordinate(row_idx, col_idx) + self.table.update_cell_at( + cell_coordinate, + f"[red]{cell_value}[/red]", + ) + self.table.move_cursor(row=self.table.cursor_coordinate[0] + 1) + + def on_data_table_row_selected(self, event): + if getattr(self, "move_from_activity", None) is not None: + move_to_activity = HamsterActivity.get( + self.table.get_cell_at(Coordinate(event.cursor_row, 2)) + ) + HamsterFact.update({HamsterFact.activity: move_to_activity}).where( + HamsterFact.activity == self.move_from_activity + ).execute() + self._refresh() + del self.move_from_activity + + def action_edit(self): + row_idx: int = self.table.cursor_row + row_cells = self.table.get_row_at(row_idx) + + try: + category = HamsterCategory.get(id=row_cells[0]) + except HamsterCategory.DoesNotExist: + category = None + activity = HamsterActivity.get(id=row_cells[2]) + + def handle_edit(properties): + if properties is None: + return + activity.name = properties["activity"] + activity.category_id = properties["category"] + activity.save() + self._refresh() + + self.app.push_screen( + ActivityEditScreen(category=category, activity=activity), 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() + ) + + mapping = None + try: + mapping = HamsterActivityKimaiMapping.get( + hamster_activity=selected_activity + ) + except HamsterActivityKimaiMapping.DoesNotExist: + pass + + def handle_mapping(mapping_data): + if mapping_data is None: + return + if mapping is not None: + mapping_ = mapping + for key, value in mapping_data.items(): + setattr(mapping_, key, value) + else: + mapping_ = HamsterActivityKimaiMapping.create( + hamster_activity=selected_activity, **mapping_data + ) + mapping_.save() + self._refresh() + + self.app.push_screen( + ActivityMappingScreen( + category=selected_activity.category_name, + activity=selected_activity.name, + mapping=mapping, + ), + handle_mapping, + ) + + +class CategoryList(ListPane): + BINDINGS = [ + ("s", "sort", "Sort"), + ("r", "refresh", "Refresh"), + ("/", "filter", "Search"), + ("d", "delete", "Delete category"), + Binding(key="escape", action="cancelfilter", show=False), + ] + + def _refresh(self): + self.table.clear() + + categories = ( + HamsterCategory.select( + HamsterCategory, + fn.Count(HamsterActivity.id).alias("activities_count"), + HamsterFact.start_time, + ) + .join(HamsterActivity, JOIN.LEFT_OUTER) + .join(HamsterFact, JOIN.LEFT_OUTER) + .group_by(HamsterCategory) + ) + + filter_search = self.query_one("#filter #search").value + if filter_search is not None: + categories = categories.where(HamsterCategory.name.contains(filter_search)) + + filter_date = self.query_one("#filter #date").value + if filter_date is not None: + try: + categories = categories.where( + HamsterFact.start_time > datetime.strptime(filter_date, "%Y-%m-%d") + ) + except ValueError: + pass + + self.table.add_rows( + [ + [ + category.id, + category.name, + category.activities_count, + ] + for category in categories + ] + ) + + self.table.sort(self.sort) + + def on_mount(self) -> None: + self.table = self.query_one(DataTable) + self.table.cursor_type = "row" + self.columns = self.table.add_columns("category id", "category", "activities") + self.sort = self.columns[1] + self._refresh() + + def action_delete(self) -> None: + row_key, _ = self.table.coordinate_to_cell_key(self.table.cursor_coordinate) + + category_id = self.table.get_cell_at( + Coordinate(self.table.cursor_coordinate.row, 0), + ) + category = HamsterCategory.get(id=category_id) + category.delete_instance() + + self.table.remove_row(row_key) + + +class HamsterScreen(Screen): + BINDINGS = [ + ("c", "show_tab('categories')", "Categories"), + ("a", "show_tab('activities')", "Activities"), + ] + + SUB_TITLE = "Hamster" + + def compose(self) -> ComposeResult: + yield Header() + with TabbedContent(initial="activities"): + with TabPane("Categories", id="categories"): + yield CategoryList() + with TabPane("Activities", id="activities"): + yield ActivityList() + yield Footer() + + def action_show_tab(self, tab: str) -> None: + """Switch to a new tab.""" + self.get_child_by_type(TabbedContent).active = tab diff --git a/hamstertools/screens/kimai.py b/hamstertools/screens/kimai.py new file mode 100644 index 0000000..597c3e1 --- /dev/null +++ b/hamstertools/screens/kimai.py @@ -0,0 +1,97 @@ +from textual.app import ComposeResult +from textual.binding import Binding +from textual.screen import Screen +from textual.widgets import DataTable, TabbedContent, TabPane, Header, Footer + +from peewee import fn, JOIN + +from ..sync import sync +from ..db import ( + KimaiProject, + KimaiCustomer, + KimaiActivity, +) + +from .list import ListPane + + +class KimaiProjectList(ListPane): + BINDINGS = [ + ("s", "sort", "Sort"), + ("r", "refresh", "Refresh"), + ("g", "get", "Get data"), + ("/", "filter", "Search"), + Binding(key="escape", action="cancelfilter", show=False), + ] + + def _refresh(self, filter_query=None): + self.table.clear() + + projects = ( + KimaiProject.select( + KimaiProject, + KimaiCustomer, + fn.Count(KimaiActivity.id).alias("activities_count"), + ) + .join(KimaiCustomer, JOIN.LEFT_OUTER) + .switch(KimaiProject) + .join(KimaiActivity, JOIN.LEFT_OUTER) + .where(KimaiActivity.project.is_null(False)) + .group_by(KimaiProject) + ) + + if filter_query: + projects = projects.where( + KimaiProject.name.contains(filter_query) + | KimaiCustomer.name.contains(filter_query) + ) + + self.table.add_rows( + [ + [ + project.customer.id, + project.customer.name, + project.id, + project.name, + project.activities_count, + ] + for project in projects + ] + ) + + self.table.sort(self.sort) + + def action_get(self) -> None: + sync() + self._refresh() + + def on_mount(self) -> None: + self.table = self.query_one(DataTable) + self.table.cursor_type = "row" + self.columns = self.table.add_columns( + "customer id", "customer", "project id", "project", "activities" + ) + # self.sort = (self.columns[1], self.columns[3]) + self.sort = self.columns[1] + self._refresh() + + +class KimaiScreen(Screen): + BINDINGS = [ + ("p", "show_tab('projects')", "Projects"), + ] + + SUB_TITLE = "Kimai" + + def compose(self) -> ComposeResult: + yield Header() + with TabbedContent(initial="activities"): + with TabPane("Categories", id="categories"): + yield KimaiProjectList() + with TabPane("Activities", id="activities"): + yield KimaiProjectList() + yield Footer() + + def action_show_tab(self, tab: str) -> None: + """Switch to a new tab.""" + self.get_child_by_type(TabbedContent).active = tab diff --git a/hamstertools/screens/list.py b/hamstertools/screens/list.py new file mode 100644 index 0000000..0ba6d6c --- /dev/null +++ b/hamstertools/screens/list.py @@ -0,0 +1,52 @@ +from datetime import datetime + +from textual import on +from textual.app import ComposeResult +from textual.containers import Horizontal, Vertical, Container +from textual.widgets import DataTable, Input + + +class ListPane(Container): + def compose(self) -> ComposeResult: + with Vertical(): + yield DataTable() + with Horizontal(id="filter"): + yield Input( + id="search", placeholder="Category/activity name contains text" + ) + yield Input( + id="date", + placeholder="After date, in {0} format".format( + datetime.now().strftime("%Y-%m-%d") + ), + ) + + def action_refresh(self) -> None: + self._refresh() + + def action_sort(self) -> None: + self.table.cursor_type = "column" + + def on_data_table_column_selected(self, event): + self.sort = (event.column_key,) + event.data_table.sort(*self.sort) + event.data_table.cursor_type = "row" + + def action_filter(self) -> None: + self.query_one("#filter").display = True + self._refresh() + self.query_one("#filter #search").focus() + + def on_input_submitted(self, event): + self.table.focus() + + def action_cancelfilter(self) -> None: + self.query_one("#filter").display = False + self.query_one("#filter #search").clear() + self.query_one("#filter #date").clear() + self.table.focus() + self._refresh() + + @on(Input.Changed, "#filter Input") + def filter(self, event): + self._refresh() diff --git a/scripts/upload.py b/scripts/upload.py index caae2a4..b107566 100644 --- a/scripts/upload.py +++ b/scripts/upload.py @@ -13,7 +13,7 @@ from hamstertools.db import ( HamsterFact, HamsterFactKimaiImport, ) -from hamstertools.kimai import KimaiAPI, Timesheet, Project, Activity +from hamstertools.kimaiapi import KimaiAPI, Timesheet, Project, Activity api = KimaiAPI() From fd28955da0b5244fda7b85e3a89e3ded30a7d037 Mon Sep 17 00:00:00 2001 From: 3wc <3wc@doesthisthing.work> Date: Fri, 3 Nov 2023 23:42:11 +0000 Subject: [PATCH 38/46] Add kimai "visible" field, moar kimai screens --- hamstertools/__init__.py | 2 +- hamstertools/db.py | 3 + hamstertools/kimaiapi.py | 53 +++++++++++------- hamstertools/screens/hamster.py | 16 +++++- hamstertools/screens/kimai.py | 98 ++++++++++++++++++++++++++++++--- hamstertools/sync.py | 13 ++++- 6 files changed, 152 insertions(+), 33 deletions(-) diff --git a/hamstertools/__init__.py b/hamstertools/__init__.py index e014db1..6b042cf 100755 --- a/hamstertools/__init__.py +++ b/hamstertools/__init__.py @@ -25,7 +25,7 @@ from .db import ( from .sync import sync HAMSTER_DIR = Path.home() / ".local/share/hamster" -HAMSTER_FILE = HAMSTER_DIR / 'hamster.db' +HAMSTER_FILE = HAMSTER_DIR / "hamster.db" db.init(HAMSTER_FILE) diff --git a/hamstertools/db.py b/hamstertools/db.py index d8d5c18..b0bc465 100644 --- a/hamstertools/db.py +++ b/hamstertools/db.py @@ -43,6 +43,7 @@ class HamsterFact(Model): class KimaiCustomer(Model): + visible = BooleanField(default=True) name = CharField() class Meta: @@ -53,6 +54,7 @@ class KimaiCustomer(Model): class KimaiProject(Model): name = CharField() customer = ForeignKeyField(KimaiCustomer, backref="projects") + visible = BooleanField(default=True) allow_global_activities = BooleanField(default=True) class Meta: @@ -63,6 +65,7 @@ class KimaiProject(Model): class KimaiActivity(Model): name = CharField() project = ForeignKeyField(KimaiProject, backref="activities", null=True) + visible = BooleanField(default=True) class Meta: database = db diff --git a/hamstertools/kimaiapi.py b/hamstertools/kimaiapi.py index 9333d3b..828984f 100644 --- a/hamstertools/kimaiapi.py +++ b/hamstertools/kimaiapi.py @@ -44,24 +44,26 @@ class BaseAPI(object): setattr(self, key, value) +@dataclass class Customer(BaseAPI): - def __init__(self, api, id, name): - super().__init__(api, id=id, name=name) + api: KimaiAPI = field(repr=False) + id: int + name: str + visible: bool = field(default=True) @staticmethod def list(api): - return [Customer(api, c["id"], c["name"]) for c in api.customers_json] + return [ + Customer(api, c["id"], c["name"], c["visible"]) for c in api.customers_json + ] @staticmethod def get_by_id(api, id): - for value in api.customers_json: - if value["id"] == id: - return Customer(api, value["id"], value["name"]) + for c in api.customers_json: + if c["id"] == id: + return Customer(api, c["id"], c["name"], c["visible"]) raise NotFound() - def __repr__(self): - return f"Customer (id={self.id}, name={self.name})" - @dataclass class Project(BaseAPI): @@ -70,6 +72,7 @@ class Project(BaseAPI): name: str customer: Customer allow_global_activities: bool = field(default=True) + visible: bool = field(default=True) @staticmethod def list(api): @@ -80,20 +83,22 @@ class Project(BaseAPI): p["name"], Customer.get_by_id(api, p["customer"]), p["globalActivities"], + p["visible"], ) for p in api.projects_json ] @staticmethod def get_by_id(api, id, none=False): - for value in api.projects_json: - if value["id"] == id: + for p in api.projects_json: + if p["id"] == id: return Project( api, - value["id"], - value["name"], - Customer.get_by_id(api, value["customer"]), - value["globalActivities"], + p["id"], + p["name"], + Customer.get_by_id(api, p["customer"]), + p["globalActivities"], + p["visible"], ) if not none: raise NotFound() @@ -105,25 +110,31 @@ class Activity(BaseAPI): id: int name: str project: Project + visible: bool = field(default=True) @staticmethod def list(api): return [ Activity( - api, a["id"], a["name"], Project.get_by_id(api, a["project"], none=True) + api, + a["id"], + a["name"], + Project.get_by_id(api, a["project"], none=True), + a["visible"], ) for a in api.activities_json ] @staticmethod def get_by_id(api, id, none=False): - for value in api.activities_json: - if value["id"] == id: + for a in api.activities_json: + if a["id"] == id: return Activity( api, - value["id"], - value["name"], - Project.get_by_id(api, value["project"]), + a["id"], + a["name"], + Project.get_by_id(api, a["project"]), + a["visible"], ) if not none: raise NotFound() diff --git a/hamstertools/screens/hamster.py b/hamstertools/screens/hamster.py index 8e6f59b..56a06c5 100644 --- a/hamstertools/screens/hamster.py +++ b/hamstertools/screens/hamster.py @@ -7,7 +7,16 @@ from textual.coordinate import Coordinate from textual.containers import Horizontal, Vertical from textual.events import DescendantBlur from textual.screen import Screen, ModalScreen -from textual.widgets import Header, Footer, DataTable, Input, Label, Checkbox, TabbedContent, TabPane +from textual.widgets import ( + Header, + Footer, + DataTable, + Input, + Label, + Checkbox, + TabbedContent, + TabPane, +) from peewee import fn, JOIN @@ -558,6 +567,11 @@ class HamsterScreen(Screen): yield ActivityList() yield Footer() + def on_mount(self) -> None: + self.query_one("TabbedContent Tabs").can_focus = False + self.query_one("#activities DataTable").focus() + def action_show_tab(self, tab: str) -> None: """Switch to a new tab.""" self.get_child_by_type(TabbedContent).active = tab + self.query_one(f"#{tab} DataTable").focus() diff --git a/hamstertools/screens/kimai.py b/hamstertools/screens/kimai.py index 597c3e1..0ca0eaa 100644 --- a/hamstertools/screens/kimai.py +++ b/hamstertools/screens/kimai.py @@ -15,6 +15,10 @@ from ..db import ( from .list import ListPane +class KimaiCustomerList(ListPane): + pass + + class KimaiProjectList(ListPane): BINDINGS = [ ("s", "sort", "Sort"), @@ -24,9 +28,11 @@ class KimaiProjectList(ListPane): Binding(key="escape", action="cancelfilter", show=False), ] - def _refresh(self, filter_query=None): + def _refresh(self): self.table.clear() + filter_search = self.query_one("#filter #search").value + projects = ( KimaiProject.select( KimaiProject, @@ -40,10 +46,10 @@ class KimaiProjectList(ListPane): .group_by(KimaiProject) ) - if filter_query: + if filter_search: projects = projects.where( - KimaiProject.name.contains(filter_query) - | KimaiCustomer.name.contains(filter_query) + KimaiProject.name.contains(filter_search) + | KimaiCustomer.name.contains(filter_search) ) self.table.add_rows( @@ -54,6 +60,7 @@ class KimaiProjectList(ListPane): project.id, project.name, project.activities_count, + project.visible, ] for project in projects ] @@ -69,29 +76,104 @@ class KimaiProjectList(ListPane): self.table = self.query_one(DataTable) self.table.cursor_type = "row" self.columns = self.table.add_columns( - "customer id", "customer", "project id", "project", "activities" + "customer id", "customer", "project id", "project", "activities", "visible" ) # self.sort = (self.columns[1], self.columns[3]) self.sort = self.columns[1] self._refresh() +class KimaiActivityList(ListPane): + BINDINGS = [ + ("s", "sort", "Sort"), + ("r", "refresh", "Refresh"), + ("g", "get", "Get data"), + ("/", "filter", "Search"), + Binding(key="escape", action="cancelfilter", show=False), + ] + + def _refresh(self): + self.table.clear() + + filter_search = self.query_one("#filter #search").value + + activities = ( + KimaiActivity.select( + KimaiActivity, + fn.COALESCE(KimaiProject.name, "None").alias("project_name"), + fn.COALESCE(KimaiCustomer.name, "None").alias("customer_name"), + ) + .join(KimaiProject, JOIN.LEFT_OUTER) + .join(KimaiCustomer, JOIN.LEFT_OUTER) + .group_by(KimaiActivity) + ) + + if filter_search: + activities = activities.where(KimaiActivity.name.contains(filter_search)) + + self.table.add_rows( + [ + [ + # activity.project.customer_id if activity.project is not None else '', + # activity.customer_name, + str(activity.project_id) if activity.project_id is not None else "", + activity.project_name, + activity.id, + activity.name, + activity.visible, + ] + for activity in activities + ] + ) + + self.table.sort(self.sort) + + def action_get(self) -> None: + sync() + self._refresh() + + def on_mount(self) -> None: + self.table = self.query_one(DataTable) + self.table.cursor_type = "row" + self.columns = self.table.add_columns( + # "customer id", + # "customer", + "project id", + "project", + "id", + "name", + "visible", + ) + # self.sort = (self.columns[1], self.columns[3]) + self.sort = self.columns[3] + self._refresh() + + class KimaiScreen(Screen): BINDINGS = [ + ("c", "show_tab('customers')", "Customers"), ("p", "show_tab('projects')", "Projects"), + ("a", "show_tab('activities')", "Activities"), ] SUB_TITLE = "Kimai" def compose(self) -> ComposeResult: yield Header() - with TabbedContent(initial="activities"): - with TabPane("Categories", id="categories"): + with TabbedContent(initial="projects"): + with TabPane("Customers", id="customers"): + yield KimaiCustomerList() + with TabPane("Projects", id="projects"): yield KimaiProjectList() with TabPane("Activities", id="activities"): - yield KimaiProjectList() + yield KimaiActivityList() yield Footer() + def on_mount(self) -> None: + self.query_one("TabbedContent Tabs").can_focus = False + self.query_one("#projects DataTable").focus() + def action_show_tab(self, tab: str) -> None: """Switch to a new tab.""" self.get_child_by_type(TabbedContent).active = tab + self.query_one(f"#{tab} DataTable").focus() diff --git a/hamstertools/sync.py b/hamstertools/sync.py index 3ced975..fad0b42 100644 --- a/hamstertools/sync.py +++ b/hamstertools/sync.py @@ -22,7 +22,14 @@ def sync() -> None: customers = KimaiAPICustomer.list(api) with db.atomic(): KimaiCustomer.insert_many( - [{"id": customer.id, "name": customer.name} for customer in customers] + [ + { + "id": customer.id, + "name": customer.name, + "visible": customer.visible, + } + for customer in customers + ] ).execute() projects = KimaiAPIProject.list(api) @@ -34,6 +41,7 @@ def sync() -> None: "name": project.name, "customer_id": project.customer.id, "allow_global_activities": project.allow_global_activities, + "visible": project.visible, } for project in projects ] @@ -46,7 +54,8 @@ def sync() -> None: { "id": activity.id, "name": activity.name, - "project_id": (activity.project and activity.project.id or None), + "project_id": (activity.project.id if activity.project else None), + "visible": activity.visible, } for activity in activities ] From a01652f301d898c72c5acb1717d05eba6cd8432c Mon Sep 17 00:00:00 2001 From: 3wc <3wc@doesthisthing.work> Date: Sat, 4 Nov 2023 00:01:53 +0000 Subject: [PATCH 39/46] Tidy up kimai screen a bit --- hamstertools/screens/kimai.py | 26 +++++++++++++------------- hamstertools/utils.py | 2 ++ 2 files changed, 15 insertions(+), 13 deletions(-) create mode 100644 hamstertools/utils.py diff --git a/hamstertools/screens/kimai.py b/hamstertools/screens/kimai.py index 0ca0eaa..dac5593 100644 --- a/hamstertools/screens/kimai.py +++ b/hamstertools/screens/kimai.py @@ -5,6 +5,7 @@ from textual.widgets import DataTable, TabbedContent, TabPane, Header, Footer from peewee import fn, JOIN +from ..utils import truncate from ..sync import sync from ..db import ( KimaiProject, @@ -36,7 +37,8 @@ class KimaiProjectList(ListPane): projects = ( KimaiProject.select( KimaiProject, - KimaiCustomer, + KimaiCustomer.id, + fn.COALESCE(KimaiCustomer.name, "").alias("customer_name"), fn.Count(KimaiActivity.id).alias("activities_count"), ) .join(KimaiCustomer, JOIN.LEFT_OUTER) @@ -55,8 +57,8 @@ class KimaiProjectList(ListPane): self.table.add_rows( [ [ - project.customer.id, - project.customer.name, + str(project.customer_id) if project.customer_id is not None else "", + project.customer_name, project.id, project.name, project.activities_count, @@ -66,7 +68,7 @@ class KimaiProjectList(ListPane): ] ) - self.table.sort(self.sort) + self.table.sort(*self.sort) def action_get(self) -> None: sync() @@ -78,8 +80,7 @@ class KimaiProjectList(ListPane): self.columns = self.table.add_columns( "customer id", "customer", "project id", "project", "activities", "visible" ) - # self.sort = (self.columns[1], self.columns[3]) - self.sort = self.columns[1] + self.sort = (self.columns[1], self.columns[3]) self._refresh() @@ -100,8 +101,8 @@ class KimaiActivityList(ListPane): activities = ( KimaiActivity.select( KimaiActivity, - fn.COALESCE(KimaiProject.name, "None").alias("project_name"), - fn.COALESCE(KimaiCustomer.name, "None").alias("customer_name"), + fn.COALESCE(KimaiProject.name, "").alias("project_name"), + fn.COALESCE(KimaiCustomer.name, "").alias("customer_name"), ) .join(KimaiProject, JOIN.LEFT_OUTER) .join(KimaiCustomer, JOIN.LEFT_OUTER) @@ -117,16 +118,16 @@ class KimaiActivityList(ListPane): # activity.project.customer_id if activity.project is not None else '', # activity.customer_name, str(activity.project_id) if activity.project_id is not None else "", - activity.project_name, + truncate(activity.project_name, 40), activity.id, - activity.name, + truncate(activity.name, 40), activity.visible, ] for activity in activities ] ) - self.table.sort(self.sort) + self.table.sort(*self.sort) def action_get(self) -> None: sync() @@ -144,8 +145,7 @@ class KimaiActivityList(ListPane): "name", "visible", ) - # self.sort = (self.columns[1], self.columns[3]) - self.sort = self.columns[3] + self.sort = (self.columns[1], self.columns[3]) self._refresh() diff --git a/hamstertools/utils.py b/hamstertools/utils.py new file mode 100644 index 0000000..40e57cc --- /dev/null +++ b/hamstertools/utils.py @@ -0,0 +1,2 @@ +def truncate(string: str, length: int) -> str: + return string[: length - 2] + ".." if len(string) > 52 else string From 1145f5e806f2df35ab616b0c62215686a78ca59d Mon Sep 17 00:00:00 2001 From: 3wc <3wc@doesthisthing.work> Date: Fri, 17 Nov 2023 22:54:25 +0000 Subject: [PATCH 40/46] API improvements --- hamstertools/app.py | 10 ++++++++++ hamstertools/kimaiapi.py | 32 +++++++++++++++++++++++++++++--- hamstertools/screens/kimai.py | 19 +++++++++++++++++++ 3 files changed, 58 insertions(+), 3 deletions(-) diff --git a/hamstertools/app.py b/hamstertools/app.py index 6992ae7..3e036e7 100644 --- a/hamstertools/app.py +++ b/hamstertools/app.py @@ -1,6 +1,7 @@ from textual.app import App from .db import db +from .kimaiapi import KimaiAPI from .screens.hamster import HamsterScreen from .screens.kimai import KimaiScreen @@ -8,12 +9,21 @@ from .screens.kimai import KimaiScreen class HamsterToolsApp(App): CSS_PATH = "app.tcss" + BINDINGS = [ ("h", "switch_mode('hamster')", "Hamster"), ("k", "switch_mode('kimai')", "Kimai"), ("q", "quit", "Quit"), ] + api_ = None + + @property + def api(self) -> KimaiAPI: + if self.api_ is None: + self.api_ = KimaiAPI() + return self.api_ + def __init__(self): self.MODES = { "hamster": HamsterScreen(), diff --git a/hamstertools/kimaiapi.py b/hamstertools/kimaiapi.py index 828984f..725bc5f 100644 --- a/hamstertools/kimaiapi.py +++ b/hamstertools/kimaiapi.py @@ -21,16 +21,22 @@ class KimaiAPI(object): auth_headers = {"X-AUTH-USER": KIMAI_USERNAME, "X-AUTH-TOKEN": KIMAI_API_KEY} def __init__(self): - requests_cache.install_cache("kimai", backend="sqlite", expire_after=1800) + # requests_cache.install_cache("kimai", backend="sqlite", expire_after=1800) self.customers_json = self.get("customers", {"visible": 3}) self.projects_json = self.get("projects", {"visible": 3, "ignoreDates": 1}) self.activities_json = self.get("activities", {"visible": 3}) self.user_json = self.get("users/me") def get(self, endpoint, params=None): - return requests.get( + result = requests.get( f"{self.KIMAI_API_URL}/{endpoint}", params=params, headers=self.auth_headers ).json() + try: + if result["code"] != 200: + raise NotFound() + except (KeyError, TypeError): + pass + return result def post(self, endpoint, data): return requests.post( @@ -133,7 +139,7 @@ class Activity(BaseAPI): api, a["id"], a["name"], - Project.get_by_id(api, a["project"]), + Project.get_by_id(api, a["project"], none), a["visible"], ) if not none: @@ -169,6 +175,26 @@ class Timesheet(BaseAPI): ) ] + @staticmethod + def list_by(api, **kwargs): + kwargs['size'] = 10000 + return [ + Timesheet( + api, + Activity.get_by_id(api, t["activity"], none=True), + Project.get_by_id(api, t["project"], none=True), + t["begin"], + t["end"], + t["id"], + t["description"], + t["tags"], + ) + for t in api.get( + "timesheets", + params=kwargs + ) + ] + @staticmethod def get_by_id(api, id, none=False): t = api.get( diff --git a/hamstertools/screens/kimai.py b/hamstertools/screens/kimai.py index dac5593..2b66337 100644 --- a/hamstertools/screens/kimai.py +++ b/hamstertools/screens/kimai.py @@ -1,4 +1,5 @@ from textual.app import ComposeResult +from textual.coordinate import Coordinate from textual.binding import Binding from textual.screen import Screen from textual.widgets import DataTable, TabbedContent, TabPane, Header, Footer @@ -12,6 +13,9 @@ from ..db import ( KimaiCustomer, KimaiActivity, ) +from ..kimaiapi import ( + Timesheet as KimaiAPITimesheet +) from .list import ListPane @@ -89,6 +93,7 @@ class KimaiActivityList(ListPane): ("s", "sort", "Sort"), ("r", "refresh", "Refresh"), ("g", "get", "Get data"), + ("#", "count", "Count"), ("/", "filter", "Search"), Binding(key="escape", action="cancelfilter", show=False), ] @@ -122,6 +127,7 @@ class KimaiActivityList(ListPane): activity.id, truncate(activity.name, 40), activity.visible, + '?' ] for activity in activities ] @@ -144,10 +150,23 @@ class KimaiActivityList(ListPane): "id", "name", "visible", + "times", ) self.sort = (self.columns[1], self.columns[3]) self._refresh() + def action_count(self) -> None: + row_idx: int = self.table.cursor_row + row_cells = self.table.get_row_at(row_idx) + + activity_id = row_cells[2] + count = len(KimaiAPITimesheet.list_by(self.app.api, activity=activity_id)) + + self.table.update_cell_at( + Coordinate(row_idx, 5), + count + ) + class KimaiScreen(Screen): BINDINGS = [ From b62eb5cb223fa04190c5f8d20fe1d61f55cb9d2c Mon Sep 17 00:00:00 2001 From: 3wc <3wc@doesthisthing.work> Date: Fri, 17 Nov 2023 23:22:21 +0000 Subject: [PATCH 41/46] Yeet upload script into a command --- hamstertools/__init__.py | 98 ++++++++++++++++++++++++++++++++++- scripts/upload.py | 107 --------------------------------------- 2 files changed, 96 insertions(+), 109 deletions(-) delete mode 100644 scripts/upload.py diff --git a/hamstertools/__init__.py b/hamstertools/__init__.py index 6b042cf..06a2264 100755 --- a/hamstertools/__init__.py +++ b/hamstertools/__init__.py @@ -22,6 +22,7 @@ from .db import ( HamsterActivityKimaiMapping, HamsterFactKimaiImport, ) +from .kimaiapi import KimaiAPI, Timesheet from .sync import sync HAMSTER_DIR = Path.home() / ".local/share/hamster" @@ -481,7 +482,7 @@ def check(username, api_key, just_errors, ignore_activities, mapping_path=None): found_activities.append(activity_str) -@kimai.command("import") +@kimai.command("csv") @click.option( "--mapping-path", help="Mapping file (default ~/.local/share/hamster/mapping.kimai.csv)", @@ -492,7 +493,7 @@ def check(username, api_key, just_errors, ignore_activities, mapping_path=None): @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( +def _csv( username, mapping_path=None, output=None, @@ -637,6 +638,99 @@ def _import( output_file.close() +@kimai.command("import") +@click.argument("search") +@click.argument("after") +@click.argument("before") +def _import(search, after, before): + api = KimaiAPI() + + SEARCH = "auto" + + facts = ( + HamsterFact.select(HamsterFact, HamsterActivity, HamsterCategory) + .join(HamsterActivity, JOIN.LEFT_OUTER) + .join(HamsterCategory, 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.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 ( + mappings[0].kimai_activity.project is None + 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}", + fg="red" + ) + has_errors = True + continue + if f.imports.count() > 0: + click.secho( + f"fact {f.id}: activity @{f.activity.id} {f.activity.name} was already imported {f.imports.count()} time(s)", + fg="yellow" + ) + continue + + if has_errors: + sys.exit(1) + + # upload data + for f in facts: + try: + mapping = f.activity.mappings[0] + except IndexError: + print( + f"no mapping, skipping {f.id} ({f.activity.category.name} ยป {f.activity.name})" + ) + continue + + if f.imports.count() > 0: + print( + f"already imported, skipping {f.id} ({f.activity.category.name} ยป {f.activity.name})" + ) + continue + + t = Timesheet( + api, + activity=mapping.kimai_activity, + project=mapping.kimai_project, + begin=f.start_time, + end=f.end_time, + description=f.description if f.description != "" else mapping.kimai_description, + # tags=f.tags if f.tags != '' else mapping.kimai_tags + ) + r = t.upload().json() + if r.get("code", 200) != 200: + print(r) + print(f"{f.id} ({f.activity.category.name} ยป {f.activity.name})") + from pdb import set_trace + set_trace() + + else: + HamsterFactKimaiImport.create(hamster_fact=f, kimai_id=r["id"]).save() + print(f'Created Kimai timesheet {r["id"]}') + @kimai.group("db") def db_(): pass diff --git a/scripts/upload.py b/scripts/upload.py deleted file mode 100644 index b107566..0000000 --- a/scripts/upload.py +++ /dev/null @@ -1,107 +0,0 @@ -import os -import sys - -sys.path.append(os.path.join(os.path.dirname(__file__), "..")) - -from datetime import datetime - -from peewee import JOIN - -from hamstertools.db import ( - HamsterCategory, - HamsterActivity, - HamsterFact, - HamsterFactKimaiImport, -) -from hamstertools.kimaiapi import KimaiAPI, Timesheet, Project, Activity - -api = KimaiAPI() - -DATE_FROM = "2023-10-01" -DATE_TO = "2023-11-01" -SEARCH = "auto" - -facts = ( - HamsterFact.select(HamsterFact, HamsterActivity, HamsterCategory) - .join(HamsterActivity, JOIN.LEFT_OUTER) - .join(HamsterCategory, JOIN.LEFT_OUTER) - .where( - (HamsterFact.start_time > datetime.strptime(DATE_FROM, "%Y-%m-%d")) - & (HamsterFact.start_time < datetime.strptime(DATE_TO, "%Y-%m-%d")) - & HamsterCategory.name.contains(SEARCH) - ) -) - -has_errors = False - -# check data -for f in facts: - mappings = f.activity.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 ( - mappings[0].kimai_activity.project is None - and not mappings[0].kimai_project.allow_global_activities - ): - print( - f"fact {f.id}: project @{mappings[0].kimai_project.id} {mappings[0].kimai_project.name} does not allow global activity {mappings[0].kimai_activity}" - ) - has_errors = True - continue - if f.imports.count() > 0: - print( - f"fact {f.id}: activity @{f.activity.id} {f.activity.name} was already imported {f.imports.count()} time(s)" - ) - has_errors = True - continue - - -# if has_errors: -# sys.exit(1) - -# upload data -for f in facts: - try: - mapping = f.activity.mappings[0] - except IndexError: - print( - f"no mapping, skipping {f.id} ({f.activity.category.name} ยป {f.activity.name})" - ) - continue - - if f.imports.count() > 0: - print( - f"already imported, skipping {f.id} ({f.activity.category.name} ยป {f.activity.name})" - ) - continue - - t = Timesheet( - api, - activity=mapping.kimai_activity, - project=mapping.kimai_project, - begin=f.start_time, - end=f.end_time, - description=f.description if f.description != "" else mapping.kimai_description, - # tags=f.tags if f.tags != '' else mapping.kimai_tags - ) - r = t.upload().json() - if r.get("code", 200) != 200: - print(r) - print(f"{f.id} ({f.activity.category.name} ยป {f.activity.name})") - from pdb import set_trace - - set_trace() - - else: - HamsterFactKimaiImport.create(hamster_fact=f, kimai_id=r["id"]).save() - print(f'Created Kimai timesheet {r["id"]}') From 26b8b5f334dc3cf3a022cbaf4c373ff8679e7942 Mon Sep 17 00:00:00 2001 From: 3wc <3wc@doesthisthing.work> Date: Sat, 18 Nov 2023 11:23:54 +0000 Subject: [PATCH 42/46] Re4matting --- hamstertools/__init__.py | 10 +++++++--- hamstertools/kimaiapi.py | 7 ++----- hamstertools/screens/kimai.py | 11 +++-------- 3 files changed, 12 insertions(+), 16 deletions(-) diff --git a/hamstertools/__init__.py b/hamstertools/__init__.py index 06a2264..5e0d22a 100755 --- a/hamstertools/__init__.py +++ b/hamstertools/__init__.py @@ -681,14 +681,14 @@ def _import(search, after, before): ): 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}", - fg="red" + fg="red", ) has_errors = True continue if f.imports.count() > 0: click.secho( f"fact {f.id}: activity @{f.activity.id} {f.activity.name} was already imported {f.imports.count()} time(s)", - fg="yellow" + fg="yellow", ) continue @@ -717,7 +717,9 @@ def _import(search, after, before): project=mapping.kimai_project, begin=f.start_time, end=f.end_time, - description=f.description if f.description != "" else mapping.kimai_description, + description=f.description + if f.description != "" + else mapping.kimai_description, # tags=f.tags if f.tags != '' else mapping.kimai_tags ) r = t.upload().json() @@ -725,12 +727,14 @@ def _import(search, after, before): print(r) print(f"{f.id} ({f.activity.category.name} ยป {f.activity.name})") from pdb import set_trace + set_trace() else: HamsterFactKimaiImport.create(hamster_fact=f, kimai_id=r["id"]).save() print(f'Created Kimai timesheet {r["id"]}') + @kimai.group("db") def db_(): pass diff --git a/hamstertools/kimaiapi.py b/hamstertools/kimaiapi.py index 725bc5f..2b49900 100644 --- a/hamstertools/kimaiapi.py +++ b/hamstertools/kimaiapi.py @@ -177,7 +177,7 @@ class Timesheet(BaseAPI): @staticmethod def list_by(api, **kwargs): - kwargs['size'] = 10000 + kwargs["size"] = 10000 return [ Timesheet( api, @@ -189,10 +189,7 @@ class Timesheet(BaseAPI): t["description"], t["tags"], ) - for t in api.get( - "timesheets", - params=kwargs - ) + for t in api.get("timesheets", params=kwargs) ] @staticmethod diff --git a/hamstertools/screens/kimai.py b/hamstertools/screens/kimai.py index 2b66337..fb09f00 100644 --- a/hamstertools/screens/kimai.py +++ b/hamstertools/screens/kimai.py @@ -13,9 +13,7 @@ from ..db import ( KimaiCustomer, KimaiActivity, ) -from ..kimaiapi import ( - Timesheet as KimaiAPITimesheet -) +from ..kimaiapi import Timesheet as KimaiAPITimesheet from .list import ListPane @@ -127,7 +125,7 @@ class KimaiActivityList(ListPane): activity.id, truncate(activity.name, 40), activity.visible, - '?' + "?", ] for activity in activities ] @@ -162,10 +160,7 @@ class KimaiActivityList(ListPane): activity_id = row_cells[2] count = len(KimaiAPITimesheet.list_by(self.app.api, activity=activity_id)) - self.table.update_cell_at( - Coordinate(row_idx, 5), - count - ) + self.table.update_cell_at(Coordinate(row_idx, 5), count) class KimaiScreen(Screen): From 2a1d13236ba7aa433b245faaceccd4cbc71b7473 Mon Sep 17 00:00:00 2001 From: 3wc <3wc@doesthisthing.work> Date: Mon, 25 Dec 2023 19:12:03 -0300 Subject: [PATCH 43/46] Update requirements --- requirements.txt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/requirements.txt b/requirements.txt index 4525569..22989dd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,7 @@ click==8.0.3 requests==2.26.0 +peewee==3.17.0 +requests-cache==1.1.1 +textual==0.44.1 +textual-autocomplete==2.1.0b0 +textual-dev==1.2.1 From fea15472e3e21e50829a801f495af4fa3c144a0f Mon Sep 17 00:00:00 2001 From: 3wc <3wc@doesthisthing.work> Date: Mon, 25 Dec 2023 19:28:42 -0300 Subject: [PATCH 44/46] Add a confirmation dialogue when deleting activites.. ..which have >0 facts. Re #2 --- hamstertools/app.tcss | 24 ++++++++++++++++++- hamstertools/screens/hamster.py | 41 +++++++++++++++++++++++++++++---- 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/hamstertools/app.tcss b/hamstertools/app.tcss index 3f74269..43f9aca 100644 --- a/hamstertools/app.tcss +++ b/hamstertools/app.tcss @@ -18,7 +18,7 @@ DataTable:focus .datatable--cursor { width: 50%; } -ActivityEditScreen, ActivityMappingScreen { +ActivityEditScreen, ActivityMappingScreen, ActivityDeleteConfirmScreen { align: center middle; } @@ -62,3 +62,25 @@ ActivityMappingScreen AutoComplete { ActivityEditScreen Input { width: 60; } + +#dialog { + grid-size: 2; + grid-gutter: 1 2; + grid-rows: 1fr 3; + padding: 0 1; + width: 60; + height: 11; + border: thick $background 80%; + background: $surface; +} + +#question { + column-span: 2; + height: 1fr; + width: 1fr; + content-align: center middle; +} + +Button { + width: 100%; +} diff --git a/hamstertools/screens/hamster.py b/hamstertools/screens/hamster.py index 56a06c5..313bd8b 100644 --- a/hamstertools/screens/hamster.py +++ b/hamstertools/screens/hamster.py @@ -4,7 +4,7 @@ from textual import on from textual.app import ComposeResult from textual.binding import Binding from textual.coordinate import Coordinate -from textual.containers import Horizontal, Vertical +from textual.containers import Horizontal, Vertical, Grid from textual.events import DescendantBlur from textual.screen import Screen, ModalScreen from textual.widgets import ( @@ -16,6 +16,7 @@ from textual.widgets import ( Checkbox, TabbedContent, TabPane, + Button ) from peewee import fn, JOIN @@ -273,6 +274,29 @@ class ActivityMappingScreen(ModalScreen): self.dismiss(None) +class ActivityDeleteConfirmScreen(ModalScreen): + BINDINGS = [ + ("escape", "cancel", "Cancel"), + ] + + def compose(self) -> ComposeResult: + yield Grid( + Label("Are you sure you want to delete this activity?", id="question"), + Button("Confirm", variant="error", id="confirm"), + Button("Cancel", variant="primary", id="cancel"), + id="dialog", + ) + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "quit": + self.dismiss(True) + else: + self.dismiss(False) + + def action_cancel(self): + self.dismiss(False) + + class ActivityList(ListPane): BINDINGS = [ ("s", "sort", "Sort"), @@ -384,10 +408,19 @@ class ActivityList(ListPane): ) activity = HamsterActivity.get(id=activity_id) - activity.delete_instance() - # supply the row key to `remove_row` to delete the row. - self.table.remove_row(row_key) + def check_delete(delete: bool) -> None: + """Called when QuitScreen is dismissed.""" + if delete: + activity.delete_instance() + + # supply the row key to `remove_row` to delete the row. + self.table.remove_row(row_key) + + if activity.facts.count() > 0: + self.app.push_screen(ActivityDeleteConfirmScreen(), check_delete) + else: + check_delete(True) def action_move_facts(self) -> None: row_idx: int = self.table.cursor_row From 1db3591c0517c33b314c915a8250b05e3f5ee684 Mon Sep 17 00:00:00 2001 From: 3wc <3wc@doesthisthing.work> Date: Sat, 10 Feb 2024 03:35:00 -0300 Subject: [PATCH 45/46] More error output if global activities forbidden --- hamstertools/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/hamstertools/__init__.py b/hamstertools/__init__.py index 5e0d22a..cfcdb20 100755 --- a/hamstertools/__init__.py +++ b/hamstertools/__init__.py @@ -680,8 +680,7 @@ def _import(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}", - 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 From 6e68e85954887b47c2130a8b2048af9c5bbc01b2 Mon Sep 17 00:00:00 2001 From: 3wc <3wc@doesthisthing.work> Date: Sat, 10 Feb 2024 03:35:19 -0300 Subject: [PATCH 46/46] Drop click from requirements --- requirements.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 22989dd..c66cd2b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,3 @@ -click==8.0.3 requests==2.26.0 peewee==3.17.0 requests-cache==1.1.1