Merge branch 'tag-management'

This commit is contained in:
3wc 2024-12-07 10:08:44 -05:00
commit 5ed5a73950
10 changed files with 389 additions and 134 deletions

View File

@ -12,6 +12,7 @@ from peewee import fn, JOIN
from textual.logging import TextualHandler from textual.logging import TextualHandler
from .db import ( from .db import (
KimaiTag,
db, db,
HamsterCategory, HamsterCategory,
HamsterActivity, HamsterActivity,
@ -719,9 +720,12 @@ def _import(search, after, before):
description=f.description description=f.description
if f.description != "" if f.description != ""
else mapping.kimai_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() 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: if r.get("code", 200) != 200:
print(r) print(r)
print(f"{f.id} ({f.activity.category.name} » {f.activity.name})") print(f"{f.id} ({f.activity.category.name} » {f.activity.name})")
@ -746,6 +750,7 @@ def init():
KimaiCustomer, KimaiCustomer,
KimaiProject, KimaiProject,
KimaiActivity, KimaiActivity,
KimaiTag,
HamsterActivityKimaiMapping, HamsterActivityKimaiMapping,
HamsterFactKimaiImport, HamsterFactKimaiImport,
] ]

View File

@ -18,11 +18,12 @@ DataTable:focus .datatable--cursor {
width: 50%; width: 50%;
} }
ActivityEditScreen, ActivityMappingScreen, ActivityDeleteConfirmScreen { ActivityEditScreen, ActivityMappingScreen, ActivityDeleteConfirmScreen, TagEditScreen {
align: center middle; align: center middle;
} }
ActivityEditScreen > Vertical, ActivityEditScreen > Vertical,
TagEditScreen > Vertical,
ActivityMappingScreen > Vertical { ActivityMappingScreen > Vertical {
padding: 0 1; padding: 0 1;
width: 80; width: 80;
@ -31,7 +32,8 @@ ActivityMappingScreen > Vertical {
background: $surface; background: $surface;
} }
ActivityEditScreen > Vertical { ActivityEditScreen > Vertical,
TagEditScreen > Vertical {
height: 10; height: 10;
} }
@ -55,7 +57,7 @@ ActivityMappingScreen AutoComplete {
width: 80; width: 80;
} }
#description, #tags { #description, #activity_tags {
width: 30; width: 30;
} }

View File

@ -8,6 +8,7 @@ from peewee import (
DateTimeField, DateTimeField,
SmallIntegerField, SmallIntegerField,
BooleanField, BooleanField,
CompositeKey
) )
@ -42,6 +43,25 @@ class HamsterFact(Model):
table_name = "facts" 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): class KimaiCustomer(Model):
visible = BooleanField(default=True) visible = BooleanField(default=True)
name = CharField() name = CharField()
@ -72,6 +92,15 @@ class KimaiActivity(Model):
table_name = "kimai_activities" table_name = "kimai_activities"
class KimaiTag(Model):
name = CharField()
visible = BooleanField(default=True)
class Meta:
database = db
table_name = "kimai_tags"
class HamsterActivityKimaiMapping(Model): class HamsterActivityKimaiMapping(Model):
hamster_activity = ForeignKeyField(HamsterActivity, backref="mappings") hamster_activity = ForeignKeyField(HamsterActivity, backref="mappings")
kimai_customer = ForeignKeyField(KimaiCustomer, backref="mappings") kimai_customer = ForeignKeyField(KimaiCustomer, backref="mappings")

View File

@ -1,4 +1,5 @@
from datetime import datetime from datetime import datetime
import pdb
import requests import requests
import requests_cache import requests_cache
import os import os
@ -25,6 +26,7 @@ class KimaiAPI(object):
self.customers_json = self.get("customers", {"visible": 3}) self.customers_json = self.get("customers", {"visible": 3})
self.projects_json = self.get("projects", {"visible": 3, "ignoreDates": 1}) self.projects_json = self.get("projects", {"visible": 3, "ignoreDates": 1})
self.activities_json = self.get("activities", {"visible": 3}) 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") self.user_json = self.get("users/me")
def get(self, endpoint, params=None): def get(self, endpoint, params=None):
@ -145,6 +147,38 @@ class Activity(BaseAPI):
if not none: if not none:
raise NotFound() 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 @dataclass
class Timesheet(BaseAPI): class Timesheet(BaseAPI):

View File

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

View File

@ -1,39 +1,18 @@
from datetime import datetime
from datetime import datetime
from peewee import JOIN, fn
from textual import on from textual import on
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.binding import Binding from textual.binding import Binding
from textual.containers import Grid, Horizontal, Vertical
from textual.coordinate import Coordinate from textual.coordinate import Coordinate
from textual.containers import Horizontal, Vertical, Grid
from textual.events import DescendantBlur from textual.events import DescendantBlur
from textual.screen import Screen, ModalScreen from textual.screen import ModalScreen
from textual.widgets import ( from textual.widgets import Button, Checkbox, DataTable, Input, Label
Header,
Footer,
DataTable,
Input,
Label,
Checkbox,
TabbedContent,
TabPane,
Button
)
from peewee import fn, JOIN
from textual_autocomplete import AutoComplete, Dropdown, DropdownItem from textual_autocomplete import AutoComplete, Dropdown, DropdownItem
from ..db import ( from hamstertools.db import HamsterActivity, HamsterActivityKimaiMapping, HamsterCategory, HamsterFact, KimaiActivity, KimaiCustomer, KimaiProject
HamsterCategory, from hamstertools.screens.list import ListPane
HamsterActivity,
HamsterFact,
KimaiProject,
KimaiCustomer,
KimaiActivity,
HamsterActivityKimaiMapping,
)
from .list import ListPane
class ActivityEditScreen(ModalScreen): class ActivityEditScreen(ModalScreen):
@ -209,7 +188,7 @@ class ActivityMappingScreen(ModalScreen):
), ),
Horizontal( Horizontal(
Label("Tags"), Label("Tags"),
Input(id="tags", value=self.tags), Input(id="activity_tags", value=self.tags),
), ),
Horizontal(Checkbox("Global", id="global")), Horizontal(Checkbox("Global", id="global")),
) )
@ -265,7 +244,7 @@ class ActivityMappingScreen(ModalScreen):
"kimai_project_id": self.project_id, "kimai_project_id": self.project_id,
"kimai_activity_id": self.activity_id, "kimai_activity_id": self.activity_id,
"kimai_description": self.query_one("#description").value, "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, "global": self.query_one("#global").value,
} }
) )
@ -513,98 +492,3 @@ class ActivityList(ListPane):
), ),
handle_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 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()

View File

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

View File

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

View File

@ -6,16 +6,16 @@ from textual.widgets import DataTable, TabbedContent, TabPane, Header, Footer
from peewee import fn, JOIN from peewee import fn, JOIN
from ..utils import truncate from ...utils import truncate
from ..sync import sync from ...sync import sync
from ..db import ( from ...db import (
KimaiProject, KimaiProject,
KimaiCustomer, KimaiCustomer,
KimaiActivity, KimaiActivity,
) )
from ..kimaiapi import Timesheet as KimaiAPITimesheet from ...kimaiapi import Timesheet as KimaiAPITimesheet
from .list import ListPane from ..list import ListPane
class KimaiCustomerList(ListPane): class KimaiCustomerList(ListPane):

View File

@ -3,8 +3,10 @@ from .kimaiapi import (
Customer as KimaiAPICustomer, Customer as KimaiAPICustomer,
Project as KimaiAPIProject, Project as KimaiAPIProject,
Activity as KimaiAPIActivity, Activity as KimaiAPIActivity,
Tag as KimaiAPITag,
) )
from .db import ( from .db import (
KimaiTag,
db, db,
KimaiProject, KimaiProject,
KimaiCustomer, KimaiCustomer,
@ -18,6 +20,7 @@ def sync() -> None:
KimaiCustomer.delete().execute() KimaiCustomer.delete().execute()
KimaiProject.delete().execute() KimaiProject.delete().execute()
KimaiActivity.delete().execute() KimaiActivity.delete().execute()
KimaiTag.delete().execute()
customers = KimaiAPICustomer.list(api) customers = KimaiAPICustomer.list(api)
with db.atomic(): with db.atomic():
@ -60,3 +63,16 @@ def sync() -> None:
for activity in activities for activity in activities
] ]
).execute() ).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()