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,
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,

View File

@ -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
@ -23,7 +22,7 @@ from .db import (
KimaiProject,
KimaiCustomer,
KimaiActivity,
HamsterKimaiMapping,
HamsterActivityKimaiMapping,
)
from .kimai import (
KimaiAPI,
@ -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 = [
@ -260,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")
)
@ -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 = (
@ -400,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()
@ -553,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
]

View File

@ -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;
}

View File

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

View File

@ -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"]
@ -18,15 +22,18 @@ class KimaiAPI(object):
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()
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,
})