From fd651ff25a9d924331ddd627143c3b6825c55565 Mon Sep 17 00:00:00 2001 From: 3wc <3wc@doesthisthing.work> Date: Sat, 7 Dec 2024 10:08:20 -0500 Subject: [PATCH] Add hamster tag management, reinstate tags during import, reorganise code --- hamstertools/__init__.py | 7 +- hamstertools/app.tcss | 8 +- hamstertools/db.py | 29 +++ hamstertools/kimaiapi.py | 34 ++++ hamstertools/screens/hamster/__init__.py | 42 +++++ .../{hamster.py => hamster/activities.py} | 134 +------------- hamstertools/screens/hamster/categories.py | 78 +++++++++ hamstertools/screens/hamster/tags.py | 165 ++++++++++++++++++ .../screens/{kimai.py => kimai/__init__.py} | 10 +- hamstertools/sync.py | 16 ++ 10 files changed, 389 insertions(+), 134 deletions(-) create mode 100644 hamstertools/screens/hamster/__init__.py rename hamstertools/screens/{hamster.py => hamster/activities.py} (82%) create mode 100644 hamstertools/screens/hamster/categories.py create mode 100644 hamstertools/screens/hamster/tags.py rename hamstertools/screens/{kimai.py => kimai/__init__.py} (97%) diff --git a/hamstertools/__init__.py b/hamstertools/__init__.py index cfcdb20..377ad1a 100755 --- a/hamstertools/__init__.py +++ b/hamstertools/__init__.py @@ -12,6 +12,7 @@ from peewee import fn, JOIN from textual.logging import TextualHandler from .db import ( + KimaiTag, db, HamsterCategory, HamsterActivity, @@ -719,9 +720,12 @@ def _import(search, after, before): description=f.description if f.description != "" else mapping.kimai_description, - # tags=f.tags if f.tags != '' else mapping.kimai_tags + tags=",".join([t.tag.name for t in f.tags]) if len(f.tags) > 0 else mapping.kimai_tags ) r = t.upload().json() + if len(f.tags) > 0 or mapping.kimai_tags: + print(",".join([t.tag.name for t in f.tags]) if len(f.tags)> 0 else mapping.kimai_tags) + print(r["tags"]) if r.get("code", 200) != 200: print(r) print(f"{f.id} ({f.activity.category.name} ยป {f.activity.name})") @@ -746,6 +750,7 @@ def init(): KimaiCustomer, KimaiProject, KimaiActivity, + KimaiTag, HamsterActivityKimaiMapping, HamsterFactKimaiImport, ] diff --git a/hamstertools/app.tcss b/hamstertools/app.tcss index 43f9aca..a740b1c 100644 --- a/hamstertools/app.tcss +++ b/hamstertools/app.tcss @@ -18,11 +18,12 @@ DataTable:focus .datatable--cursor { width: 50%; } -ActivityEditScreen, ActivityMappingScreen, ActivityDeleteConfirmScreen { +ActivityEditScreen, ActivityMappingScreen, ActivityDeleteConfirmScreen, TagEditScreen { align: center middle; } ActivityEditScreen > Vertical, +TagEditScreen > Vertical, ActivityMappingScreen > Vertical { padding: 0 1; width: 80; @@ -31,7 +32,8 @@ ActivityMappingScreen > Vertical { background: $surface; } -ActivityEditScreen > Vertical { +ActivityEditScreen > Vertical, +TagEditScreen > Vertical { height: 10; } @@ -55,7 +57,7 @@ ActivityMappingScreen AutoComplete { width: 80; } -#description, #tags { +#description, #activity_tags { width: 30; } diff --git a/hamstertools/db.py b/hamstertools/db.py index b0bc465..3827284 100644 --- a/hamstertools/db.py +++ b/hamstertools/db.py @@ -8,6 +8,7 @@ from peewee import ( DateTimeField, SmallIntegerField, BooleanField, + CompositeKey ) @@ -42,6 +43,25 @@ class HamsterFact(Model): table_name = "facts" +class HamsterTag(Model): + name = CharField() + + class Meta: + database = db + table_name = "tags" + + + +class HamsterFactTag(Model): + fact = ForeignKeyField(HamsterFact, backref="tags") + tag = ForeignKeyField(HamsterTag, backref="facts") + + class Meta: + database = db + table_name = "fact_tags" + primary_key = CompositeKey('fact', 'tag') + + class KimaiCustomer(Model): visible = BooleanField(default=True) name = CharField() @@ -72,6 +92,15 @@ class KimaiActivity(Model): table_name = "kimai_activities" +class KimaiTag(Model): + name = CharField() + visible = BooleanField(default=True) + + class Meta: + database = db + table_name = "kimai_tags" + + class HamsterActivityKimaiMapping(Model): hamster_activity = ForeignKeyField(HamsterActivity, backref="mappings") kimai_customer = ForeignKeyField(KimaiCustomer, backref="mappings") diff --git a/hamstertools/kimaiapi.py b/hamstertools/kimaiapi.py index 2b49900..1d7c042 100644 --- a/hamstertools/kimaiapi.py +++ b/hamstertools/kimaiapi.py @@ -1,4 +1,5 @@ from datetime import datetime +import pdb import requests import requests_cache import os @@ -25,6 +26,7 @@ class KimaiAPI(object): 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.tags_json = self.get("tags/find?name=", {"visible": 3}) self.user_json = self.get("users/me") def get(self, endpoint, params=None): @@ -145,6 +147,38 @@ class Activity(BaseAPI): if not none: raise NotFound() +@dataclass +class Tag(BaseAPI): + api: KimaiAPI = field(repr=False) + id: int + name: str + visible: bool = field(default=True) + + @staticmethod + def list(api): + return [ + Tag( + api, + t["id"], + t["name"], + t["visible"], + ) + for t in api.tags_json + ] + + @staticmethod + def get_by_id(api, id, none=False): + for t in api.tags_json: + if t["id"] == id: + return Tag( + api, + t["id"], + t["name"], + t["visible"], + ) + if not none: + raise NotFound() + @dataclass class Timesheet(BaseAPI): diff --git a/hamstertools/screens/hamster/__init__.py b/hamstertools/screens/hamster/__init__.py new file mode 100644 index 0000000..e57f4dd --- /dev/null +++ b/hamstertools/screens/hamster/__init__.py @@ -0,0 +1,42 @@ +from textual.app import ComposeResult +from textual.screen import Screen +from textual.widgets import ( + Header, + Footer, + TabbedContent, + TabPane, +) + +from .activities import ActivityList +from .categories import CategoryList +from .tags import TagList + + +class HamsterScreen(Screen): + BINDINGS = [ + ("c", "show_tab('categories')", "Categories"), + ("a", "show_tab('activities')", "Activities"), + ("t", "show_tab('tags')", "Tags"), + ] + + 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() + with TabPane("Tags", id="tags"): + yield TagList() + 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/hamster.py b/hamstertools/screens/hamster/activities.py similarity index 82% rename from hamstertools/screens/hamster.py rename to hamstertools/screens/hamster/activities.py index 313bd8b..28e72b0 100644 --- a/hamstertools/screens/hamster.py +++ b/hamstertools/screens/hamster/activities.py @@ -1,39 +1,18 @@ -from datetime import datetime +from datetime import datetime +from peewee import JOIN, fn from textual import on from textual.app import ComposeResult from textual.binding import Binding +from textual.containers import Grid, Horizontal, Vertical from textual.coordinate import Coordinate -from textual.containers import Horizontal, Vertical, Grid from textual.events import DescendantBlur -from textual.screen import Screen, ModalScreen -from textual.widgets import ( - Header, - Footer, - DataTable, - Input, - Label, - Checkbox, - TabbedContent, - TabPane, - Button -) - -from peewee import fn, JOIN - +from textual.screen import ModalScreen +from textual.widgets import Button, Checkbox, DataTable, Input, Label from textual_autocomplete import AutoComplete, Dropdown, DropdownItem -from ..db import ( - HamsterCategory, - HamsterActivity, - HamsterFact, - KimaiProject, - KimaiCustomer, - KimaiActivity, - HamsterActivityKimaiMapping, -) - -from .list import ListPane +from hamstertools.db import HamsterActivity, HamsterActivityKimaiMapping, HamsterCategory, HamsterFact, KimaiActivity, KimaiCustomer, KimaiProject +from hamstertools.screens.list import ListPane class ActivityEditScreen(ModalScreen): @@ -209,7 +188,7 @@ class ActivityMappingScreen(ModalScreen): ), Horizontal( Label("Tags"), - Input(id="tags", value=self.tags), + Input(id="activity_tags", value=self.tags), ), Horizontal(Checkbox("Global", id="global")), ) @@ -265,7 +244,7 @@ class ActivityMappingScreen(ModalScreen): "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, + "kimai_tags": self.query_one("#activity_tags").value, "global": self.query_one("#global").value, } ) @@ -513,98 +492,3 @@ class ActivityList(ListPane): ), 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 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/hamster/categories.py b/hamstertools/screens/hamster/categories.py new file mode 100644 index 0000000..b3701f3 --- /dev/null +++ b/hamstertools/screens/hamster/categories.py @@ -0,0 +1,78 @@ + +from datetime import datetime +from peewee import JOIN, fn +from textual.binding import Binding +from textual.coordinate import Coordinate +from textual.widgets import DataTable +from hamstertools.db import HamsterActivity, HamsterCategory, HamsterFact +from hamstertools.screens.list import ListPane + + +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) + + diff --git a/hamstertools/screens/hamster/tags.py b/hamstertools/screens/hamster/tags.py new file mode 100644 index 0000000..b41eee4 --- /dev/null +++ b/hamstertools/screens/hamster/tags.py @@ -0,0 +1,165 @@ + +from peewee import JOIN, fn +from textual import on +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal, Vertical +from textual.coordinate import Coordinate +from textual.events import DescendantBlur +from textual.screen import ModalScreen +from textual.widgets import DataTable, Input, Label +from textual_autocomplete import AutoComplete, Dropdown +from hamstertools.db import HamsterFactTag, HamsterTag +from hamstertools.screens.list import ListPane + + +class TagEditScreen(ModalScreen): + BINDINGS = [ + ("escape", "cancel", "Cancel"), + ("ctrl+s", "save", "Save"), + ] + + def __init__(self, tag): + self.tag_name = tag.name + super().__init__() + + def compose(self) -> ComposeResult: + yield Vertical( + Horizontal( + Label("Tag:"), Input(value=self.tag_name, id="tag") + ), + ) + + def action_cancel(self): + self.dismiss(None) + + def action_save(self): + self.dismiss( + { + "tag": self.query_one("#tag").value, + } + ) + + + + +class TagList(ListPane): + BINDINGS = [ + # ("s", "sort", "Sort"), + # ("r", "refresh", "Refresh"), + ("/", "filter", "Search"), + ("d", "delete", "Delete"), + ("f", "move_facts", "Move"), + ("e", "edit", "Edit"), + Binding(key="escape", action="cancelfilter", show=False), + ] + + move_from_tag = None + + def _refresh(self): + self.table.clear() + + facts_count_query = ( + HamsterFactTag.select( + HamsterFactTag.tag_id, fn.COUNT(HamsterFactTag.tag_id).alias("facts_count") + ) + .group_by(HamsterFactTag.tag_id) + .alias("facts_count_query") + ) + + tags = ( + HamsterTag.select( + HamsterTag, + HamsterTag.name, + fn.COALESCE(facts_count_query.c.facts_count, 0).alias("facts_count"), + ) + .join(HamsterFactTag, JOIN.LEFT_OUTER) + .switch(HamsterTag) + .join( + facts_count_query, + JOIN.LEFT_OUTER, + on=(HamsterTag.id == facts_count_query.c.tag_id), + ) + .group_by(HamsterTag) + ) + + filter_search = self.query_one("#filter #search").value + if filter_search is not None: + tags = tags.where( + HamsterTag.name.contains(filter_search) + ) + + self.table.add_rows( + [ + [ + tag.id, + tag.name, + tag.facts_count, + ] + for tag in tags + ] + ) + + self.table.sort(*self.sort) + + def action_delete(self) -> None: + row_key, _ = self.table.coordinate_to_cell_key(self.table.cursor_coordinate) + + tag_id = self.table.get_cell_at( + Coordinate(self.table.cursor_coordinate.row, 0), + ) + tag = HamsterTag.get(id=tag_id) + tag.delete_instance() + + 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_tag = HamsterTag.get(id=row_cells[0]) + 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_tag", None) is not None: + move_to_tag = HamsterTag.get( + self.table.get_cell_at(Coordinate(event.cursor_row, 0)) + ) + HamsterFactTag.update({HamsterFactTag.tag: move_to_tag}).where( + HamsterFactTag.tag == self.move_from_tag + ).execute() + self._refresh() + del self.move_from_tag + + def action_edit(self): + row_idx: int = self.table.cursor_row + row_cells = self.table.get_row_at(row_idx) + + tag = HamsterTag.get(id=row_cells[0]) + + def handle_edit(properties): + if properties is None: + return + tag.name = properties["tag"] + tag.save() + self._refresh() + + self.app.push_screen( + TagEditScreen(tag=tag), handle_edit + ) + + + def on_mount(self) -> None: + self.table = self.query_one(DataTable) + self.table.cursor_type = "row" + self.columns = self.table.add_columns( + "tag id", "tag", "facts" + ) + self.sort = (self.columns[1],) + self._refresh() + diff --git a/hamstertools/screens/kimai.py b/hamstertools/screens/kimai/__init__.py similarity index 97% rename from hamstertools/screens/kimai.py rename to hamstertools/screens/kimai/__init__.py index fb09f00..f99b08c 100644 --- a/hamstertools/screens/kimai.py +++ b/hamstertools/screens/kimai/__init__.py @@ -6,16 +6,16 @@ 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 ( +from ...utils import truncate +from ...sync import sync +from ...db import ( KimaiProject, KimaiCustomer, KimaiActivity, ) -from ..kimaiapi import Timesheet as KimaiAPITimesheet +from ...kimaiapi import Timesheet as KimaiAPITimesheet -from .list import ListPane +from ..list import ListPane class KimaiCustomerList(ListPane): diff --git a/hamstertools/sync.py b/hamstertools/sync.py index fad0b42..0ad18ed 100644 --- a/hamstertools/sync.py +++ b/hamstertools/sync.py @@ -3,8 +3,10 @@ from .kimaiapi import ( Customer as KimaiAPICustomer, Project as KimaiAPIProject, Activity as KimaiAPIActivity, + Tag as KimaiAPITag, ) from .db import ( + KimaiTag, db, KimaiProject, KimaiCustomer, @@ -18,6 +20,7 @@ def sync() -> None: KimaiCustomer.delete().execute() KimaiProject.delete().execute() KimaiActivity.delete().execute() + KimaiTag.delete().execute() customers = KimaiAPICustomer.list(api) with db.atomic(): @@ -60,3 +63,16 @@ def sync() -> None: for activity in activities ] ).execute() + + tags = KimaiAPITag.list(api) + with db.atomic(): + KimaiTag.insert_many( + [ + { + "id": tag.id, + "name": tag.name, + "visible": tag.visible, + } + for tag in tags + ] + ).execute()