Compare commits

...

2 Commits

Author SHA1 Message Date
3wc
ebf2dca695 Fuckin yaldi, working kimai import 💅 2023-11-01 19:28:55 +00:00
3wc
e8ff5f1411 Activity editing 2023-10-31 23:55:19 +00:00
5 changed files with 216 additions and 58 deletions

View File

@ -17,7 +17,8 @@ from .db import (
KimaiCustomer, KimaiCustomer,
KimaiProject, KimaiProject,
KimaiActivity, KimaiActivity,
HamsterKimaiMapping, HamsterActivityKimaiMapping,
HamsterFactKimaiImport
) )
HAMSTER_DIR = Path.home() / ".local/share/hamster" HAMSTER_DIR = Path.home() / ".local/share/hamster"
@ -630,12 +631,13 @@ def db_():
@db_.command() @db_.command()
def init(): def init():
db.create_tables([KimaiCustomer, KimaiProject, KimaiActivity, HamsterKimaiMapping]) db.create_tables([KimaiCustomer, KimaiProject, KimaiActivity,
HamsterActivityKimaiMapping, HamsterFactKimaiImport])
@db_.command() @db_.command()
def reset(): def reset():
HamsterKimaiMapping.delete().execute() HamsterActivityKimaiMapping.delete().execute()
@db_.command() @db_.command()
@ -653,7 +655,6 @@ def mapping2db(mapping_path=None, global_=False):
mapping_reader = csv.reader(mapping_file) mapping_reader = csv.reader(mapping_file)
for row in mapping_reader: for row in mapping_reader:
hamster_category = HamsterCategory.get(name=row[0]) hamster_category = HamsterCategory.get(name=row[0])
hamster_activity = HamsterActivity.get( hamster_activity = HamsterActivity.get(
name=row[1], category_id=hamster_category.id name=row[1], category_id=hamster_category.id
@ -666,9 +667,10 @@ def mapping2db(mapping_path=None, global_=False):
except KimaiActivity.DoesNotExist: except KimaiActivity.DoesNotExist:
kimai_activity = KimaiActivity.get( kimai_activity = KimaiActivity.get(
name=row[4], name=row[4],
project_id=None
) )
HamsterKimaiMapping.create( HamsterActivityKimaiMapping.create(
hamster_activity=hamster_activity, hamster_activity=hamster_activity,
kimai_customer=kimai_customer, kimai_customer=kimai_customer,
kimai_project=kimai_project, kimai_project=kimai_project,

View File

@ -8,7 +8,6 @@ from textual.events import DescendantBlur
from textual.widgets import Header, Footer, DataTable, Input, Button, Label, Checkbox from textual.widgets import Header, Footer, DataTable, Input, Button, Label, Checkbox
from textual.containers import Horizontal, Vertical from textual.containers import Horizontal, Vertical
from textual.coordinate import Coordinate from textual.coordinate import Coordinate
from textual.reactive import reactive
from textual.screen import Screen, ModalScreen from textual.screen import Screen, ModalScreen
from textual_autocomplete import AutoComplete, Dropdown, DropdownItem from textual_autocomplete import AutoComplete, Dropdown, DropdownItem
@ -23,7 +22,7 @@ from .db import (
KimaiProject, KimaiProject,
KimaiCustomer, KimaiCustomer,
KimaiActivity, KimaiActivity,
HamsterKimaiMapping, HamsterActivityKimaiMapping,
) )
from .kimai import ( from .kimai import (
KimaiAPI, KimaiAPI,
@ -75,21 +74,65 @@ class ListScreen(Screen):
self._refresh() self._refresh()
class ActivityEditScreen(ModalScreen): 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: def compose(self) -> ComposeResult:
yield Grid( yield Vertical(
Label("Are you sure you want to quit?", id="question"), Horizontal(Label("Category:"),
Button("Quit", variant="error", id="quit"), AutoComplete(
Button("Cancel", variant="primary", id="cancel"), Input(placeholder="Type to search...", id="category",
id="dialog", 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): def action_cancel(self):
self.dismiss(None) self.dismiss(None)
def action_save(self):
self.dismiss(
{
"category": self.category_id,
"activity": self.query_one('#activity').value,
}
)
class ActivityMappingScreen(ModalScreen): class ActivityMappingScreen(ModalScreen):
BINDINGS = [ BINDINGS = [
@ -260,11 +303,11 @@ class ActivityListScreen(ListScreen):
) )
mappings_count_query = ( mappings_count_query = (
HamsterKimaiMapping.select( HamsterActivityKimaiMapping.select(
HamsterKimaiMapping.hamster_activity_id, HamsterActivityKimaiMapping.hamster_activity_id,
fn.COUNT(HamsterKimaiMapping.id).alias("mappings_count"), fn.COUNT(HamsterActivityKimaiMapping.id).alias("mappings_count"),
) )
.group_by(HamsterKimaiMapping.hamster_activity_id) .group_by(HamsterActivityKimaiMapping.hamster_activity_id)
.alias("mappings_count_query") .alias("mappings_count_query")
) )
@ -376,10 +419,27 @@ class ActivityListScreen(ListScreen):
del self.move_from_activity del self.move_from_activity
def action_edit(self): def action_edit(self):
def handle_edit(properties): row_idx: int = self.table.cursor_row
print(properties) 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): def action_mapping(self):
selected_activity = ( selected_activity = (
@ -400,7 +460,7 @@ class ActivityListScreen(ListScreen):
def handle_mapping(mapping): def handle_mapping(mapping):
if mapping is None: if mapping is None:
return return
m = HamsterKimaiMapping.create( m = HamsterActivityKimaiMapping.create(
hamster_activity=selected_activity, **mapping hamster_activity=selected_activity, **mapping
) )
m.save() m.save()
@ -553,6 +613,7 @@ class KimaiProjectListScreen(ListScreen):
"id": project.id, "id": project.id,
"name": project.name, "name": project.name,
"customer_id": project.customer.id, "customer_id": project.customer.id,
"allow_global_activities": project.allow_global_activities
} }
for project in projects for project in projects
] ]

View File

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

View File

@ -1,5 +1,6 @@
from datetime import datetime
import logging 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 from textual.logging import TextualHandler
@ -49,6 +50,7 @@ class KimaiCustomer(Model):
class KimaiProject(Model): class KimaiProject(Model):
name = CharField() name = CharField()
customer = ForeignKeyField(KimaiCustomer, backref="projects") customer = ForeignKeyField(KimaiCustomer, backref="projects")
allow_global_activities = BooleanField(default=True)
class Meta: class Meta:
database = db database = db
@ -64,7 +66,7 @@ class KimaiActivity(Model):
table_name = "kimai_activities" table_name = "kimai_activities"
class HamsterKimaiMapping(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")
kimai_project = ForeignKeyField(KimaiProject, backref="mappings") kimai_project = ForeignKeyField(KimaiProject, backref="mappings")
@ -75,3 +77,13 @@ class HamsterKimaiMapping(Model):
class Meta: class Meta:
database = db database = db
table_name = "hamster_kimai_mappings" 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"

View File

@ -1,7 +1,10 @@
from datetime import datetime
import requests import requests
import requests_cache import requests_cache
import os import os
from dataclasses import dataclass, field
class NotFound(Exception): class NotFound(Exception):
pass pass
@ -9,7 +12,8 @@ class NotFound(Exception):
class KimaiAPI(object): class KimaiAPI(object):
# temporary hardcoded config # 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_USERNAME = "3wordchant"
KIMAI_API_KEY = os.environ["KIMAI_API_KEY"] KIMAI_API_KEY = os.environ["KIMAI_API_KEY"]
@ -18,15 +22,18 @@ class KimaiAPI(object):
def __init__(self): 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( self.customers_json = self.get("customers", {"visible": 3})
f"{self.KIMAI_API_URL}/customers?visible=3", headers=self.auth_headers self.projects_json = self.get("projects", {"visible": 3})
).json() self.activities_json = self.get("activities", {"visible": 3})
self.projects_json = requests.get( self.user_json = self.get("users/me")
f"{self.KIMAI_API_URL}/projects?visible=3", headers=self.auth_headers
).json() def get(self, endpoint, params=None):
self.activities_json = requests.get( return requests.get(f"{self.KIMAI_API_URL}/{endpoint}",
f"{self.KIMAI_API_URL}/activities?visible=3", headers=self.auth_headers params=params, headers=self.auth_headers).json()
).json()
def post(self, endpoint, data):
return requests.post(f"{self.KIMAI_API_URL}/{endpoint}",
json=data, headers=self.auth_headers)
class BaseAPI(object): class BaseAPI(object):
@ -54,14 +61,20 @@ class Customer(BaseAPI):
return f"Customer (id={self.id}, name={self.name})" return f"Customer (id={self.id}, name={self.name})"
@dataclass
class Project(BaseAPI): class Project(BaseAPI):
def __init__(self, api, id, name, customer): api: KimaiAPI = field(repr=False)
super().__init__(api, id=id, name=name, customer=customer) id: int
name: str
customer: Customer
allow_global_activities: bool = field(default=True)
@staticmethod @staticmethod
def list(api): def list(api):
return [ 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 for p in api.projects_json
] ]
@ -74,17 +87,18 @@ class Project(BaseAPI):
value["id"], value["id"],
value["name"], value["name"],
Customer.get_by_id(api, value["customer"]), Customer.get_by_id(api, value["customer"]),
value["globalActivities"]
) )
if not none: if not none:
raise NotFound() raise NotFound()
def __repr__(self):
return f"Project (id={self.id}, name={self.name}, customer={self.customer})"
@dataclass
class Activity(BaseAPI): class Activity(BaseAPI):
def __init__(self, api, id, name, project): api: KimaiAPI = field(repr=False)
super().__init__(api, id=id, name=name, project=project) id: int
name: str
project: Project
@staticmethod @staticmethod
def list(api): def list(api):
@ -108,5 +122,60 @@ class Activity(BaseAPI):
if not none: if not none:
raise NotFound() 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,
})