hamster-tools/hamstertools/app.py

584 lines
18 KiB
Python
Raw Normal View History

2023-10-29 21:49:26 +00:00
from textual import on
2023-10-27 00:13:08 +00:00
from textual.app import App, ComposeResult
2023-10-27 03:04:30 +00:00
from textual.binding import Binding
2023-10-29 21:49:26 +00:00
from textual.containers import Grid
from textual.events import DescendantBlur
from textual.widgets import Header, Footer, DataTable, Input, Button, Label, Checkbox
2023-10-27 03:04:30 +00:00
from textual.containers import Horizontal, Vertical
from textual.coordinate import Coordinate
2023-10-29 21:49:26 +00:00
from textual.reactive import reactive
from textual.screen import Screen, ModalScreen
from textual_autocomplete import AutoComplete, Dropdown, DropdownItem
2023-10-27 00:13:08 +00:00
2023-10-27 23:42:30 +00:00
from peewee import fn, JOIN
from .db import (
db,
HamsterCategory,
HamsterActivity,
HamsterFact,
KimaiProject,
KimaiCustomer,
KimaiActivity,
HamsterKimaiMapping,
2023-10-27 23:42:30 +00:00
)
from .kimai import (
KimaiAPI,
Customer as KimaiAPICustomer,
Project as KimaiAPIProject,
Activity as KimaiAPIActivity,
)
2023-10-27 00:13:08 +00:00
2023-10-27 15:40:25 +00:00
2023-10-27 15:39:58 +00:00
class ListScreen(Screen):
def compose(self) -> ComposeResult:
2023-10-27 01:26:15 +00:00
"""create child widgets for the app."""
yield Header()
2023-10-27 03:04:30 +00:00
with Vertical():
yield DataTable()
with Horizontal():
yield Input(id="filter")
yield Footer()
2023-10-27 00:13:08 +00:00
def action_refresh(self) -> None:
self._refresh()
def action_sort(self) -> None:
self.table.cursor_type = "column"
2023-10-27 15:28:46 +00:00
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"
2023-10-27 03:04:30 +00:00
def action_filter(self) -> None:
filter_input = self.query_one("#filter")
filter_input.display = True
2023-10-27 15:28:46 +00:00
self._refresh(filter_input.value)
2023-10-27 03:04:30 +00:00
filter_input.focus()
2023-10-27 15:28:46 +00:00
def on_input_submitted(self, event):
self.table.focus()
2023-10-27 03:04:30 +00:00
def action_cancelfilter(self) -> None:
filter_input = self.query_one("#filter")
filter_input.display = False
2023-10-27 15:28:46 +00:00
filter_input.clear()
self.table.focus()
2023-10-27 03:04:30 +00:00
self._refresh()
2023-10-27 15:39:58 +00:00
def on_input_changed(self, event):
self._refresh(event.value)
2023-10-29 21:49:26 +00:00
class ActivityEditScreen(ModalScreen):
2023-10-29 21:56:39 +00:00
BINDINGS = [("escape", "cancel", "Cancel")]
2023-10-29 21:49:26 +00:00
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"),
2023-10-29 21:56:39 +00:00
("escape", "cancel", "Cancel"),
2023-10-29 21:49:26 +00:00
]
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):
2023-10-29 21:56:39 +00:00
customers = [DropdownItem(c.name, str(c.id)) for c in KimaiCustomer.select()]
return ActivityMappingScreen._filter_dropdowns(customers, input_state.value)
2023-10-29 21:49:26 +00:00
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
)
]
2023-10-29 21:56:39 +00:00
return ActivityMappingScreen._filter_dropdowns(projects, input_state.value)
2023-10-29 21:49:26 +00:00
def _get_activities(self, input_state):
activities = KimaiActivity.select()
2023-10-29 21:56:39 +00:00
if self.query_one("#global").value:
2023-10-29 21:49:26 +00:00
activities = activities.where(
KimaiActivity.project_id.is_null(),
)
else:
2023-10-29 21:56:39 +00:00
activities = activities.where(KimaiActivity.project_id == self.project_id)
2023-10-29 21:49:26 +00:00
2023-10-29 21:56:39 +00:00
return ActivityMappingScreen._filter_dropdowns(
[DropdownItem(a.name, str(a.id)) for a in activities], input_state.value
)
2023-10-29 21:49:26 +00:00
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),
2023-10-29 21:56:39 +00:00
),
2023-10-29 21:49:26 +00:00
),
Horizontal(
Label("Project"),
AutoComplete(
2023-10-29 21:56:39 +00:00
Input(placeholder="Type to search...", id="project"),
2023-10-29 21:49:26 +00:00
Dropdown(items=self._get_projects),
2023-10-29 21:56:39 +00:00
),
2023-10-29 21:49:26 +00:00
),
Horizontal(
Label("Activity"),
AutoComplete(
2023-10-29 21:56:39 +00:00
Input(placeholder="Type to search...", id="activity"),
2023-10-29 21:49:26 +00:00
Dropdown(items=self._get_activities),
2023-10-29 21:56:39 +00:00
),
2023-10-29 21:49:26 +00:00
),
Horizontal(
Label("Description"),
2023-10-29 21:56:39 +00:00
Input(id="description"),
2023-10-29 21:49:26 +00:00
),
Horizontal(
Label("Tags"),
2023-10-29 21:56:39 +00:00
Input(id="tags"),
2023-10-29 21:49:26 +00:00
),
2023-10-29 21:56:39 +00:00
Horizontal(Checkbox("Global", id="global")),
2023-10-29 21:49:26 +00:00
)
2023-10-29 21:56:39 +00:00
@on(Input.Submitted, "#customer")
2023-10-29 21:49:26 +00:00
def customer_submitted(self, event):
if event.control.parent.dropdown.selected_item is not None:
2023-10-29 21:56:39 +00:00
self.customer_id = str(
event.control.parent.dropdown.selected_item.left_meta
)
self.query_one("#project").focus()
2023-10-29 21:49:26 +00:00
2023-10-29 21:56:39 +00:00
@on(DescendantBlur, "#customer")
2023-10-29 21:49:26 +00:00
def customer_blur(self, event):
if event.control.parent.dropdown.selected_item is not None:
2023-10-29 21:56:39 +00:00
self.customer_id = str(
event.control.parent.dropdown.selected_item.left_meta
)
2023-10-29 21:49:26 +00:00
2023-10-29 21:56:39 +00:00
@on(Input.Submitted, "#project")
2023-10-29 21:49:26 +00:00
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)
2023-10-29 21:56:39 +00:00
self.query_one("#activity").focus()
2023-10-29 21:49:26 +00:00
2023-10-29 21:56:39 +00:00
@on(DescendantBlur, "#project")
2023-10-29 21:49:26 +00:00
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)
2023-10-29 21:56:39 +00:00
@on(Input.Submitted, "#activity")
2023-10-29 21:49:26 +00:00
def activity_submitted(self, event):
if event.control.parent.dropdown.selected_item is not None:
2023-10-29 21:56:39 +00:00
self.activity_id = str(
event.control.parent.dropdown.selected_item.left_meta
)
self.query_one("#activity").focus()
2023-10-29 21:49:26 +00:00
2023-10-29 21:56:39 +00:00
@on(DescendantBlur, "#activity")
2023-10-29 21:49:26 +00:00
def activity_blur(self, event):
if event.control.parent.dropdown.selected_item is not None:
2023-10-29 21:56:39 +00:00
self.activity_id = str(
event.control.parent.dropdown.selected_item.left_meta
)
2023-10-29 21:49:26 +00:00
def action_global(self):
2023-10-29 21:56:39 +00:00
self.query_one("#global").value = not self.query_one("#global").value
2023-10-29 21:49:26 +00:00
def action_save(self):
2023-10-29 21:56:39 +00:00
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,
}
)
2023-10-29 21:49:26 +00:00
def action_cancel(self):
self.dismiss(None)
class ActivityListScreen(ListScreen):
2023-10-27 15:39:58 +00:00
BINDINGS = [
("s", "sort", "Sort"),
("r", "refresh", "Refresh"),
("/", "filter", "Search"),
2023-10-29 21:49:26 +00:00
("d", "delete", "Delete"),
2023-10-27 15:39:58 +00:00
("f", "move_facts", "Move facts"),
2023-10-29 21:49:26 +00:00
("e", "edit", "Edit"),
("m", "mapping", "Mapping"),
2023-10-27 15:39:58 +00:00
Binding(key="escape", action="cancelfilter", show=False),
]
def _refresh(self, filter_query=None):
self.table.clear()
facts_count_query = (
2023-10-29 13:40:03 +00:00
HamsterFact.select(
HamsterFact.activity_id, fn.COUNT(HamsterFact.id).alias("facts_count")
)
.group_by(HamsterFact.activity_id)
2023-10-29 13:40:03 +00:00
.alias("facts_count_query")
)
mappings_count_query = (
2023-10-29 13:40:03 +00:00
HamsterKimaiMapping.select(
HamsterKimaiMapping.hamster_activity_id,
fn.COUNT(HamsterKimaiMapping.id).alias("mappings_count"),
)
.group_by(HamsterKimaiMapping.hamster_activity_id)
2023-10-29 13:40:03 +00:00
.alias("mappings_count_query")
)
2023-10-27 23:42:30 +00:00
activities = (
HamsterActivity.select(
HamsterActivity,
HamsterCategory.id,
2023-10-29 13:40:03 +00:00
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"
),
2023-10-27 23:42:30 +00:00
)
.join(HamsterCategory, JOIN.LEFT_OUTER)
.switch(HamsterActivity)
2023-10-29 13:40:03 +00:00
.join(
facts_count_query,
JOIN.LEFT_OUTER,
on=(HamsterActivity.id == facts_count_query.c.activity_id),
)
.switch(HamsterActivity)
2023-10-29 13:40:03 +00:00
.join(
mappings_count_query,
JOIN.LEFT_OUTER,
on=(HamsterActivity.id == mappings_count_query.c.hamster_activity_id),
)
2023-10-27 23:42:30 +00:00
.group_by(HamsterActivity)
)
if filter_query:
activities = activities.where(
HamsterActivity.name.contains(filter_query)
| HamsterCategory.name.contains(filter_query)
)
2023-10-27 15:39:58 +00:00
2023-10-27 15:40:25 +00:00
self.table.add_rows(
[
[
activity.category_id,
activity.category_name,
2023-10-27 15:40:25 +00:00
activity.id,
activity.name,
activity.facts_count,
activity.mappings_count,
2023-10-27 15:40:25 +00:00
]
for activity in activities
]
)
2023-10-27 15:39:58 +00:00
self.table.sort(*self.sort)
def on_mount(self) -> None:
self.table = self.query_one(DataTable)
self.table.cursor_type = "row"
2023-10-27 15:40:25 +00:00
self.columns = self.table.add_columns(
"category id", "category", "activity id", "activity", "entries", "mappings"
2023-10-27 15:40:25 +00:00
)
2023-10-27 15:39:58 +00:00
self.sort = (self.columns[1], self.columns[3])
self._refresh()
2023-10-27 01:26:15 +00:00
def action_delete(self) -> None:
2023-10-27 15:28:46 +00:00
# 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),
)
2023-10-27 23:42:30 +00:00
activity = HamsterActivity.get(id=activity_id)
activity.delete_instance()
2023-10-27 01:26:15 +00:00
# supply the row key to `remove_row` to delete the row.
self.table.remove_row(row_key)
2023-10-27 15:28:46 +00:00
def action_move_facts(self) -> None:
row_idx: int = self.table.cursor_row
row_cells = self.table.get_row_at(row_idx)
2023-10-27 23:42:30 +00:00
self.move_from_activity = HamsterActivity.get(id=row_cells[2])
2023-10-27 15:28:46 +00:00
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]",
)
2023-10-27 15:40:25 +00:00
self.table.move_cursor(row=self.table.cursor_coordinate[0] + 1)
2023-10-27 15:28:46 +00:00
def on_data_table_row_selected(self, event):
if getattr(self, "move_from_activity", None) is not None:
2023-10-27 23:42:30 +00:00
move_to_activity = HamsterActivity.get(
self.table.get_cell_at(Coordinate(event.cursor_row, 2))
2023-10-27 15:40:25 +00:00
)
2023-10-29 13:40:03 +00:00
HamsterFact.update({HamsterFact.activity: move_to_activity}).where(
HamsterFact.activity == self.move_from_activity
).execute()
2023-10-27 15:48:03 +00:00
filter_input = self.query_one("#filter")
self._refresh(filter_input.value)
2023-10-27 15:28:46 +00:00
del self.move_from_activity
2023-10-27 01:26:15 +00:00
2023-10-29 21:49:26 +00:00
def action_edit(self):
def handle_edit(properties):
print(properties)
self.app.push_screen(ActivityEditScreen(), handle_edit)
def action_mapping(self):
2023-10-29 21:56:39 +00:00
selected_activity = (
HamsterActivity.select(
HamsterActivity,
fn.COALESCE(HamsterCategory.name, "None").alias("category_name"),
2023-10-29 21:49:26 +00:00
)
2023-10-29 21:56:39 +00:00
.join(HamsterCategory, JOIN.LEFT_OUTER)
.where(
HamsterActivity.id
== self.table.get_cell_at(
Coordinate(self.table.cursor_coordinate.row, 2),
)
)
.get()
)
2023-10-29 21:49:26 +00:00
def handle_mapping(mapping):
if mapping is None:
return
2023-10-29 21:56:39 +00:00
m = HamsterKimaiMapping.create(
hamster_activity=selected_activity, **mapping
)
2023-10-29 21:49:26 +00:00
m.save()
filter_input = self.query_one("#filter")
self._refresh(filter_input.value)
2023-10-29 21:56:39 +00:00
self.app.push_screen(
ActivityMappingScreen(
category=selected_activity.category_name,
activity=selected_activity.name,
),
handle_mapping,
)
2023-10-29 21:49:26 +00:00
2023-10-27 15:40:25 +00:00
2023-10-29 21:49:26 +00:00
class CategoryListScreen(ListScreen):
2023-10-27 01:26:15 +00:00
BINDINGS = [
("s", "sort", "Sort"),
("r", "refresh", "Refresh"),
2023-10-27 03:04:30 +00:00
("/", "filter", "Search"),
2023-10-27 15:39:58 +00:00
("d", "delete", "Delete category"),
2023-10-27 03:04:30 +00:00
Binding(key="escape", action="cancelfilter", show=False),
2023-10-27 01:26:15 +00:00
]
2023-10-27 03:04:30 +00:00
def _refresh(self, filter_query=None):
2023-10-27 01:26:15 +00:00
self.table.clear()
2023-10-27 23:42:30 +00:00
categories = (
HamsterCategory.select(
2023-10-29 13:40:03 +00:00
HamsterCategory, fn.Count(HamsterActivity.id).alias("activities_count")
2023-10-27 23:42:30 +00:00
)
.join(HamsterActivity, JOIN.LEFT_OUTER)
.group_by(HamsterCategory)
2023-10-27 15:40:25 +00:00
)
2023-10-27 03:04:30 +00:00
2023-10-27 23:42:30 +00:00
if filter_query:
2023-10-29 13:40:03 +00:00
categories = categories.where(HamsterCategory.name.contains(filter_query))
2023-10-27 23:42:30 +00:00
2023-10-27 15:40:25 +00:00
self.table.add_rows(
[
[
category.id,
category.name,
2023-10-27 23:42:30 +00:00
category.activities_count,
2023-10-27 15:40:25 +00:00
]
for category in categories
]
)
2023-10-27 03:04:30 +00:00
self.table.sort(self.sort)
2023-10-27 01:26:15 +00:00
def on_mount(self) -> None:
self.table = self.query_one(DataTable)
self.table.cursor_type = "row"
2023-10-27 15:40:25 +00:00
self.columns = self.table.add_columns("category id", "category", "activities")
2023-10-27 03:04:30 +00:00
self.sort = self.columns[1]
2023-10-27 01:26:15 +00:00
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),
)
2023-10-27 23:42:30 +00:00
category = HamsterCategory.get(id=category_id)
category.delete_instance()
2023-10-27 01:26:15 +00:00
self.table.remove_row(row_key)
2023-10-27 15:40:25 +00:00
2023-10-29 21:49:26 +00:00
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()
2023-10-27 23:42:30 +00:00
projects = (
KimaiProject.select(
KimaiProject,
KimaiCustomer,
2023-10-29 13:40:03 +00:00
fn.Count(KimaiActivity.id).alias("activities_count"),
2023-10-27 23:42:30 +00:00
)
.join(KimaiCustomer, JOIN.LEFT_OUTER)
.switch(KimaiProject)
.join(KimaiActivity, JOIN.LEFT_OUTER)
.where(KimaiActivity.project.is_null(False))
2023-10-27 23:42:30 +00:00
.group_by(KimaiProject)
)
if filter_query:
projects = projects.where(
KimaiProject.name.contains(filter_query)
| KimaiCustomer.name.contains(filter_query)
)
self.table.add_rows(
[
[
2023-10-27 23:42:30 +00:00
project.customer.id,
project.customer.name,
project.id,
project.name,
2023-10-29 13:40:03 +00:00
project.activities_count,
]
for project in projects
]
)
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)
2023-10-27 23:42:30 +00:00
with db.atomic():
2023-10-29 13:40:03 +00:00
KimaiCustomer.insert_many(
[{"id": customer.id, "name": customer.name} for customer in customers]
).execute()
projects = KimaiAPIProject.list(api)
2023-10-27 23:42:30 +00:00
with db.atomic():
2023-10-29 13:40:03 +00:00
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():
2023-10-29 13:40:03 +00:00
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:
self.table = self.query_one(DataTable)
self.table.cursor_type = "row"
2023-10-27 23:42:30 +00:00
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()
2023-10-27 01:26:15 +00:00
class HamsterToolsApp(App):
2023-10-27 15:40:25 +00:00
CSS_PATH = "app.tcss"
2023-10-27 01:26:15 +00:00
BINDINGS = [
("a", "switch_mode('activities')", "Activities"),
("c", "switch_mode('categories')", "Categories"),
("k", "switch_mode('kimai')", "Kimai"),
2023-10-27 01:26:15 +00:00
("q", "quit", "Quit"),
]
2023-10-27 03:04:30 +00:00
def __init__(self):
2023-10-27 23:42:30 +00:00
db.init("hamster-testing.db")
2023-10-27 01:26:15 +00:00
self.MODES = {
2023-10-29 21:49:26 +00:00
"categories": CategoryListScreen(),
"activities": ActivityListScreen(),
"kimai": KimaiProjectListScreen(),
2023-10-27 01:26:15 +00:00
}
super().__init__()
def on_mount(self) -> None:
self.switch_mode("activities")
def action_quit(self) -> None:
2023-10-27 23:42:30 +00:00
db.close()
2023-10-29 22:28:01 +00:00
self.exit()