timedctl 5.2.0__tar.gz → 5.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {timedctl-5.2.0 → timedctl-5.3.0}/PKG-INFO +21 -2
- {timedctl-5.2.0 → timedctl-5.3.0}/README.md +20 -1
- {timedctl-5.2.0 → timedctl-5.3.0}/pyproject.toml +16 -1
- {timedctl-5.2.0 → timedctl-5.3.0}/timedctl/helpers.py +14 -14
- {timedctl-5.2.0 → timedctl-5.3.0}/timedctl/timedctl.py +212 -182
- {timedctl-5.2.0 → timedctl-5.3.0}/LICENSE +0 -0
- {timedctl-5.2.0 → timedctl-5.3.0}/timedctl/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: timedctl
|
|
3
|
-
Version: 5.
|
|
3
|
+
Version: 5.3.0
|
|
4
4
|
Summary: CLI for timed
|
|
5
5
|
License: AGPL-3.0-only
|
|
6
6
|
Author: Arthur Deierlein
|
|
@@ -35,12 +35,31 @@ People on other distributions can use pip to install the package from pypi:
|
|
|
35
35
|
$ pip install timedctl
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
+
### Shell completion
|
|
39
|
+
`timedctl` support shell completion for the unaliased commands:
|
|
40
|
+
|
|
41
|
+
**bash**
|
|
42
|
+
```bash
|
|
43
|
+
_TIMEDCTL_COMPLETE=bash_source timedctl >> ~/.bashrc`
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
**zsh**
|
|
47
|
+
```bash
|
|
48
|
+
_TIMEDCTL_COMPLETE=zsh_source timedctl >> ~/.zshrc`
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
**fish**
|
|
52
|
+
```bash
|
|
53
|
+
_TIMEDCTL_COMPLETE=fish_source timedctl > ~/.config/fish/completions/timedctl.fish`
|
|
54
|
+
```
|
|
55
|
+
|
|
38
56
|
## Local development
|
|
39
57
|
Clone the repository and install the dependencies with `poetry install`. You can now run the project with `poetry run timedctl`. For building wheels, you can use `poetry build`.
|
|
58
|
+
Run tests with `poetry run pytest --cov --cov-fail-under 100`.
|
|
40
59
|
|
|
41
60
|
## Known issues
|
|
42
61
|
* Make sure to have a polkit-agent running, otherwise the poetry installation during the installation on arch might fail.
|
|
43
|
-
* You need a keyring installed in order for timedctl to store the SSO token, for example `gnome-keyring`.
|
|
62
|
+
* You need a keyring installed in order for timedctl to store the SSO token, for example `gnome-keyring`.
|
|
44
63
|
|
|
45
64
|
## Feature roadmap
|
|
46
65
|
- [x] SSO auth
|
|
@@ -14,12 +14,31 @@ People on other distributions can use pip to install the package from pypi:
|
|
|
14
14
|
$ pip install timedctl
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
+
### Shell completion
|
|
18
|
+
`timedctl` support shell completion for the unaliased commands:
|
|
19
|
+
|
|
20
|
+
**bash**
|
|
21
|
+
```bash
|
|
22
|
+
_TIMEDCTL_COMPLETE=bash_source timedctl >> ~/.bashrc`
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
**zsh**
|
|
26
|
+
```bash
|
|
27
|
+
_TIMEDCTL_COMPLETE=zsh_source timedctl >> ~/.zshrc`
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
**fish**
|
|
31
|
+
```bash
|
|
32
|
+
_TIMEDCTL_COMPLETE=fish_source timedctl > ~/.config/fish/completions/timedctl.fish`
|
|
33
|
+
```
|
|
34
|
+
|
|
17
35
|
## Local development
|
|
18
36
|
Clone the repository and install the dependencies with `poetry install`. You can now run the project with `poetry run timedctl`. For building wheels, you can use `poetry build`.
|
|
37
|
+
Run tests with `poetry run pytest --cov --cov-fail-under 100`.
|
|
19
38
|
|
|
20
39
|
## Known issues
|
|
21
40
|
* Make sure to have a polkit-agent running, otherwise the poetry installation during the installation on arch might fail.
|
|
22
|
-
* You need a keyring installed in order for timedctl to store the SSO token, for example `gnome-keyring`.
|
|
41
|
+
* You need a keyring installed in order for timedctl to store the SSO token, for example `gnome-keyring`.
|
|
23
42
|
|
|
24
43
|
## Feature roadmap
|
|
25
44
|
- [x] SSO auth
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "timedctl"
|
|
3
|
-
version = "5.
|
|
3
|
+
version = "5.3.0"
|
|
4
4
|
description = "CLI for timed"
|
|
5
5
|
authors = ["Arthur Deierlein <arthur.deierlein@adfinis.com>", "Gian Klug <gian.klug@adfinis.com>"]
|
|
6
6
|
readme = "README.md"
|
|
@@ -22,6 +22,8 @@ black = "^23.3.0"
|
|
|
22
22
|
pytest = "^7.3.2"
|
|
23
23
|
isort = "^5.12.0"
|
|
24
24
|
flake8 = "^6.0.0"
|
|
25
|
+
ruff = "^0.0.282"
|
|
26
|
+
pytest-cov = "^4.1.0"
|
|
25
27
|
|
|
26
28
|
[build-system]
|
|
27
29
|
requires = ["poetry-core"]
|
|
@@ -37,3 +39,16 @@ version_toml = [
|
|
|
37
39
|
major_on_zero = false
|
|
38
40
|
branch = "main"
|
|
39
41
|
build_command = "pip install poetry && poetry build"
|
|
42
|
+
|
|
43
|
+
[tool.ruff]
|
|
44
|
+
# TODO: add "ANN" again in the future
|
|
45
|
+
select = ["E", "F", "C4", "PL", "C90", "I", "N", "UP", "B", "S", "A", "COM", "PT", "Q", "T20", "SLF", "TD", "FIX", "PIE"]
|
|
46
|
+
# same as black
|
|
47
|
+
line-length = 88
|
|
48
|
+
format = "github"
|
|
49
|
+
fixable = ["ALL"]
|
|
50
|
+
# ignore these for now
|
|
51
|
+
ignore = ["PLR0913"]
|
|
52
|
+
|
|
53
|
+
[tool.ruff.per-file-ignores]
|
|
54
|
+
"tests/*" = ["S101"]
|
|
@@ -1,14 +1,14 @@
|
|
|
1
|
+
"""
|
|
2
|
+
API unrelated helper functions.
|
|
3
|
+
"""
|
|
4
|
+
import datetime
|
|
1
5
|
import json
|
|
2
|
-
import rich
|
|
3
6
|
import re
|
|
4
|
-
import pyfzf
|
|
5
|
-
import click
|
|
6
|
-
import datetime
|
|
7
7
|
import sys
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
import click
|
|
10
|
+
import pyfzf
|
|
11
|
+
import rich
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
def msg(message, nonl=False):
|
|
@@ -70,21 +70,21 @@ def time_sum(arr):
|
|
|
70
70
|
return str(total)
|
|
71
71
|
|
|
72
72
|
|
|
73
|
-
def output_formatted(data,
|
|
73
|
+
def output_formatted(data, output_format):
|
|
74
74
|
"""Output data in a specified format."""
|
|
75
|
-
match
|
|
75
|
+
match output_format:
|
|
76
76
|
case "json":
|
|
77
|
-
print(json.dumps(data, indent=4))
|
|
77
|
+
rich.print(json.dumps(data, indent=4))
|
|
78
78
|
case "csv":
|
|
79
79
|
keys = data[0].keys()
|
|
80
80
|
output = ",".join(keys) + "\n"
|
|
81
81
|
for obj in data:
|
|
82
82
|
output += ",".join(obj.values()) + "\n"
|
|
83
|
-
print(output)
|
|
83
|
+
rich.print(output)
|
|
84
84
|
case "text":
|
|
85
85
|
for obj in data:
|
|
86
86
|
for key, val in obj.items():
|
|
87
|
-
print(f"[{key}]: {val}, ", end="")
|
|
88
|
-
print("")
|
|
87
|
+
rich.print(f"[{key}]: {val}, ", end="")
|
|
88
|
+
rich.print("")
|
|
89
89
|
case _:
|
|
90
|
-
print("Invalid format")
|
|
90
|
+
rich.print("Invalid format")
|
|
@@ -7,6 +7,7 @@ import re
|
|
|
7
7
|
|
|
8
8
|
import click
|
|
9
9
|
import pyfzf
|
|
10
|
+
import requests
|
|
10
11
|
import terminaltables
|
|
11
12
|
import tomllib
|
|
12
13
|
from click_aliases import ClickAliasedGroup
|
|
@@ -15,12 +16,12 @@ from libtimed.oidc import OIDCClient
|
|
|
15
16
|
from tomlkit import dump
|
|
16
17
|
|
|
17
18
|
from timedctl.helpers import (
|
|
18
|
-
msg,
|
|
19
19
|
error_handler,
|
|
20
20
|
fzf_wrapper,
|
|
21
|
+
msg,
|
|
22
|
+
output_formatted,
|
|
21
23
|
time_picker,
|
|
22
24
|
time_sum,
|
|
23
|
-
output_formatted,
|
|
24
25
|
)
|
|
25
26
|
|
|
26
27
|
|
|
@@ -36,17 +37,18 @@ def load_config():
|
|
|
36
37
|
|
|
37
38
|
# Get the path to the config file based on the $XDG_config_HOME environment variable
|
|
38
39
|
if not os.getenv("HOME"):
|
|
39
|
-
raise
|
|
40
|
+
raise OSError("$HOME is not set")
|
|
40
41
|
|
|
41
42
|
xdg_config_home = os.getenv(
|
|
42
|
-
"XDG_CONFIG_HOME",
|
|
43
|
+
"XDG_CONFIG_HOME",
|
|
44
|
+
os.path.join(os.getenv("HOME"), ".config"),
|
|
43
45
|
)
|
|
44
46
|
config_dir = os.path.join(xdg_config_home, "timedctl")
|
|
45
47
|
config_file = os.path.join(config_dir, "config.toml")
|
|
46
48
|
|
|
47
49
|
if not os.path.isfile(config_file):
|
|
48
50
|
os.makedirs(config_dir, exist_ok=True)
|
|
49
|
-
|
|
51
|
+
click.echo("No config file found. Please enter the following infos.")
|
|
50
52
|
for key in cfg:
|
|
51
53
|
cfg[key] = input(f"{key} ({cfg[key]}): ")
|
|
52
54
|
with open(config_file, "w", encoding="utf-8") as file:
|
|
@@ -81,7 +83,8 @@ def client_setup():
|
|
|
81
83
|
def select_report(date):
|
|
82
84
|
"""FZF prompt to select a report."""
|
|
83
85
|
reports = timed.reports.get(
|
|
84
|
-
filters={"date": date},
|
|
86
|
+
filters={"date": date},
|
|
87
|
+
include="task,task.project,task.project.customer",
|
|
85
88
|
)
|
|
86
89
|
report_view = []
|
|
87
90
|
for report in reports:
|
|
@@ -93,10 +96,10 @@ def select_report(date):
|
|
|
93
96
|
str(report["attributes"]["duration"]),
|
|
94
97
|
task["id"],
|
|
95
98
|
report["id"],
|
|
96
|
-
]
|
|
99
|
+
],
|
|
97
100
|
)
|
|
98
101
|
# get longest key per value
|
|
99
|
-
max_key_lengths = [max(map(len, col)) for col in zip(*report_view)]
|
|
102
|
+
max_key_lengths = [max(map(len, col)) for col in zip(*report_view, strict=False)]
|
|
100
103
|
# pad all the values
|
|
101
104
|
report_view = [
|
|
102
105
|
[
|
|
@@ -109,7 +112,7 @@ def select_report(date):
|
|
|
109
112
|
fzf_obj = []
|
|
110
113
|
for row in report_view:
|
|
111
114
|
fzf_obj.append(
|
|
112
|
-
[" | ".join([row[0], row[1], row[2]]), row[1], row[2], row[3], row[4]]
|
|
115
|
+
[" | ".join([row[0], row[1], row[2]]), row[1], row[2], row[3], row[4]],
|
|
113
116
|
)
|
|
114
117
|
|
|
115
118
|
report = fzf_wrapper(fzf_obj, [0], "Select a report: ")
|
|
@@ -121,9 +124,9 @@ def select_activity(date):
|
|
|
121
124
|
activities = timed.activities.get(filters={"date": date})
|
|
122
125
|
activity_view = []
|
|
123
126
|
# loop through all activities
|
|
124
|
-
for
|
|
127
|
+
for activity_obj in activities:
|
|
125
128
|
# check if there is an actual task, else use an unknown task
|
|
126
|
-
task_data =
|
|
129
|
+
task_data = activity_obj["relationships"]["task"]["data"]
|
|
127
130
|
if task_data:
|
|
128
131
|
task = timed.tasks.get(id=task_data["id"], cached=True)
|
|
129
132
|
else:
|
|
@@ -131,16 +134,16 @@ def select_activity(date):
|
|
|
131
134
|
activity_view.append(
|
|
132
135
|
[
|
|
133
136
|
task["attributes"]["name"],
|
|
134
|
-
|
|
135
|
-
|
|
137
|
+
activity_obj["attributes"]["comment"],
|
|
138
|
+
activity_obj["attributes"]["from-time"].strftime("%H:%M:%S")
|
|
136
139
|
+ " - "
|
|
137
|
-
+
|
|
140
|
+
+ activity_obj["attributes"]["to-time"].strftime("%H:%M:%S"),
|
|
138
141
|
task["id"],
|
|
139
|
-
|
|
140
|
-
]
|
|
142
|
+
activity_obj["id"],
|
|
143
|
+
],
|
|
141
144
|
)
|
|
142
145
|
# get longest key per value
|
|
143
|
-
max_key_lengths = [max(map(len, col)) for col in zip(*activity_view)]
|
|
146
|
+
max_key_lengths = [max(map(len, col)) for col in zip(*activity_view, strict=False)]
|
|
144
147
|
# pad all the values
|
|
145
148
|
activity_view = [
|
|
146
149
|
[
|
|
@@ -153,92 +156,190 @@ def select_activity(date):
|
|
|
153
156
|
fzf_obj = []
|
|
154
157
|
for row in activity_view:
|
|
155
158
|
fzf_obj.append(
|
|
156
|
-
[" | ".join([row[0], row[1], row[2]]), row[1], row[2], row[3], row[4]]
|
|
159
|
+
[" | ".join([row[0], row[1], row[2]]), row[1], row[2], row[3], row[4]],
|
|
157
160
|
)
|
|
158
161
|
|
|
159
|
-
|
|
160
|
-
return
|
|
162
|
+
activity_obj = fzf_wrapper(fzf_obj, [0], "Select an activity: ")
|
|
163
|
+
return activity_obj
|
|
161
164
|
|
|
162
165
|
|
|
163
|
-
def format_activity(
|
|
166
|
+
def format_activity(activity_obj):
|
|
164
167
|
"""Format an activity for display."""
|
|
165
|
-
task_obj =
|
|
168
|
+
task_obj = activity_obj["relationships"]["task"]
|
|
166
169
|
|
|
167
170
|
task = task_obj["attributes"]["name"]
|
|
168
171
|
|
|
169
172
|
project_obj = timed.projects.get(
|
|
170
|
-
id=task_obj["relationships"]["project"]["id"],
|
|
173
|
+
id=task_obj["relationships"]["project"]["id"],
|
|
174
|
+
cached=True,
|
|
171
175
|
)
|
|
172
176
|
project = project_obj["attributes"]["name"]
|
|
173
177
|
|
|
174
178
|
customer_obj = timed.customers.get(
|
|
175
|
-
id=project_obj["relationships"]["customer"]["data"]["id"],
|
|
179
|
+
id=project_obj["relationships"]["customer"]["data"]["id"],
|
|
180
|
+
cached=True,
|
|
176
181
|
)
|
|
177
182
|
customer = customer_obj["attributes"]["name"]
|
|
178
183
|
return f"{customer} > {project} > {task}"
|
|
179
184
|
|
|
180
185
|
|
|
186
|
+
def get_customer_by_name(customers, name, archived):
|
|
187
|
+
"""Get customer by name."""
|
|
188
|
+
customers = timed.customers.get(cached=True, filters={"archived": archived})
|
|
189
|
+
customer = [c for c in customers if c["attributes"]["name"] == name]
|
|
190
|
+
if len(customer) == 0:
|
|
191
|
+
error_handler("ERR_CUSTOMER_NOT_FOUND")
|
|
192
|
+
customer_id = customer[0]["id"]
|
|
193
|
+
return customer_id
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def get_project_by_name(projects, name, customer_id, archived):
|
|
197
|
+
"""Get project by name."""
|
|
198
|
+
projects = timed.projects.get(
|
|
199
|
+
cached=True,
|
|
200
|
+
filters={"customer": customer_id, "archived": archived},
|
|
201
|
+
)
|
|
202
|
+
project = [c for c in projects if c["attributes"]["name"] == name]
|
|
203
|
+
if len(project) == 0:
|
|
204
|
+
error_handler("ERR_PROJECT_NOT_FOUND")
|
|
205
|
+
project_id = project[0]["id"]
|
|
206
|
+
return project_id
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def get_task_by_name(tasks, name, project_id, archived):
|
|
210
|
+
"""Get task by name."""
|
|
211
|
+
tasks = timed.tasks.get(
|
|
212
|
+
cached=True,
|
|
213
|
+
filters={"project": project_id, "archived": archived},
|
|
214
|
+
)
|
|
215
|
+
task = [c for c in tasks if c["attributes"]["name"] == name]
|
|
216
|
+
if len(task) == 0:
|
|
217
|
+
error_handler("ERR_TASK_NOT_FOUND")
|
|
218
|
+
task_id = task[0]["id"]
|
|
219
|
+
return task_id
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def select_task(customer, project, task, show_archived):
|
|
223
|
+
"""Select a task ID with fzf."""
|
|
224
|
+
# select a customer
|
|
225
|
+
customers = timed.customers.get(filters={"archived": show_archived}, cached=True)
|
|
226
|
+
if customer:
|
|
227
|
+
customer_id = get_customer_by_name(customers, customer, show_archived)
|
|
228
|
+
else:
|
|
229
|
+
customer_id = fzf_wrapper(
|
|
230
|
+
customers,
|
|
231
|
+
["attributes", "name"],
|
|
232
|
+
"Select a customer: ",
|
|
233
|
+
)["id"]
|
|
234
|
+
# get projects
|
|
235
|
+
projects = timed.projects.get(
|
|
236
|
+
filters={"customer": customer_id, "archived": show_archived},
|
|
237
|
+
cached=True,
|
|
238
|
+
)
|
|
239
|
+
# select a project
|
|
240
|
+
if project:
|
|
241
|
+
project_id = get_project_by_name(projects, project, customer_id, show_archived)
|
|
242
|
+
else:
|
|
243
|
+
project_id = fzf_wrapper(
|
|
244
|
+
projects,
|
|
245
|
+
["attributes", "name"],
|
|
246
|
+
"Select a project: ",
|
|
247
|
+
)["id"]
|
|
248
|
+
# get tasks
|
|
249
|
+
tasks = timed.tasks.get(
|
|
250
|
+
filters={"project": project_id, "archived": show_archived},
|
|
251
|
+
cached=True,
|
|
252
|
+
)
|
|
253
|
+
# select a task
|
|
254
|
+
if task:
|
|
255
|
+
task_id = get_task_by_name(tasks, task, project_id, show_archived)
|
|
256
|
+
else:
|
|
257
|
+
task_id = fzf_wrapper(tasks, ["attributes", "name"], "Select a task: ")["id"]
|
|
258
|
+
return task_id
|
|
259
|
+
|
|
260
|
+
|
|
181
261
|
timed = client_setup()
|
|
182
262
|
|
|
183
263
|
|
|
184
264
|
@click.group(cls=ClickAliasedGroup)
|
|
185
265
|
def timedctl():
|
|
186
266
|
"""Use timedctl."""
|
|
187
|
-
|
|
267
|
+
# pylint: disable=W0107
|
|
188
268
|
|
|
189
269
|
|
|
190
270
|
@timedctl.group(cls=ClickAliasedGroup, aliases=["g", "show", "describe"])
|
|
191
271
|
def get():
|
|
192
272
|
"""Get different things."""
|
|
193
|
-
|
|
273
|
+
# pylint: disable=W0107
|
|
194
274
|
|
|
195
275
|
|
|
196
276
|
@get.group(cls=ClickAliasedGroup)
|
|
197
277
|
def data():
|
|
198
278
|
"""Get raw data for building custom scripts."""
|
|
199
|
-
|
|
279
|
+
# pylint: disable=W0107
|
|
200
280
|
|
|
201
281
|
|
|
202
282
|
@data.command("customers")
|
|
203
|
-
@click.option(
|
|
204
|
-
|
|
283
|
+
@click.option(
|
|
284
|
+
"--format",
|
|
285
|
+
"output_format",
|
|
286
|
+
default="json",
|
|
287
|
+
type=click.Choice(["json", "csv", "text"]),
|
|
288
|
+
)
|
|
289
|
+
def get_customers(output_format):
|
|
205
290
|
"""Get customers."""
|
|
206
291
|
customers = timed.customers.get(cached=True)
|
|
207
|
-
|
|
292
|
+
output = []
|
|
208
293
|
for customer in customers:
|
|
209
|
-
|
|
210
|
-
output_formatted(
|
|
294
|
+
output.append({"id": customer["id"], "name": customer["attributes"]["name"]})
|
|
295
|
+
output_formatted(output, output_format)
|
|
211
296
|
|
|
212
297
|
|
|
213
298
|
@data.command("projects")
|
|
214
|
-
@click.option(
|
|
299
|
+
@click.option(
|
|
300
|
+
"--format",
|
|
301
|
+
"output_format",
|
|
302
|
+
default="json",
|
|
303
|
+
type=click.Choice(["json", "csv", "text"]),
|
|
304
|
+
)
|
|
215
305
|
@click.option("--customer-id", default=None, type=int)
|
|
216
306
|
@click.option("--customer-name", default=None, type=str)
|
|
217
|
-
|
|
307
|
+
@click.option("--archived", default=False, is_flag=True)
|
|
308
|
+
def get_projects(output_format, customer_id, customer_name, archived):
|
|
218
309
|
"""Get projects."""
|
|
219
310
|
if not (customer_id or customer_name):
|
|
220
311
|
error_handler("ERR_MISSING_ARGUMENTS")
|
|
221
312
|
# Get customer ID if name is specified
|
|
222
313
|
if not customer_id:
|
|
223
314
|
customers = timed.customers.get(cached=True)
|
|
224
|
-
|
|
225
|
-
if len(customer) == 0:
|
|
226
|
-
error_handler("ERR_CUSTOMER_NOT_FOUND")
|
|
227
|
-
customer_id = customer[0]["id"]
|
|
315
|
+
customer_id = get_customer_by_name(customers, customer_name, archived)
|
|
228
316
|
projects = timed.projects.get(cached=True, filters={"customer": customer_id})
|
|
229
|
-
|
|
317
|
+
output = []
|
|
230
318
|
for project in projects:
|
|
231
|
-
|
|
232
|
-
output_formatted(
|
|
319
|
+
output.append({"id": project["id"], "name": project["attributes"]["name"]})
|
|
320
|
+
output_formatted(output, output_format)
|
|
233
321
|
|
|
234
322
|
|
|
235
323
|
@data.command("tasks")
|
|
236
|
-
@click.option(
|
|
324
|
+
@click.option(
|
|
325
|
+
"--format",
|
|
326
|
+
"output_format",
|
|
327
|
+
default="json",
|
|
328
|
+
type=click.Choice(["json", "csv", "text"]),
|
|
329
|
+
)
|
|
237
330
|
@click.option("--customer-id", default=None, type=int)
|
|
238
331
|
@click.option("--customer-name", default=None, type=str)
|
|
239
332
|
@click.option("--project-id", default=None, type=int)
|
|
240
333
|
@click.option("--project-name", default=None, type=str)
|
|
241
|
-
|
|
334
|
+
@click.option("--archived", default=False, is_flag=True)
|
|
335
|
+
def get_tasks(
|
|
336
|
+
output_format,
|
|
337
|
+
customer_id,
|
|
338
|
+
customer_name,
|
|
339
|
+
project_id,
|
|
340
|
+
project_name,
|
|
341
|
+
archived,
|
|
342
|
+
):
|
|
242
343
|
"""Get tasks."""
|
|
243
344
|
if project_name and not (customer_id or customer_name):
|
|
244
345
|
error_handler("ERR_CUSTOMER_INFO_MISSING")
|
|
@@ -249,24 +350,16 @@ def get_tasks(format, customer_id, customer_name, project_id, project_name):
|
|
|
249
350
|
# we need an id for the customer
|
|
250
351
|
if not customer_id:
|
|
251
352
|
customers = timed.customers.get(cached=True)
|
|
252
|
-
|
|
253
|
-
c for c in customers if c["attributes"]["name"] == customer_name
|
|
254
|
-
]
|
|
255
|
-
if len(customer) == 0:
|
|
256
|
-
error_handler("ERR_CUSTOMER_NOT_FOUND")
|
|
257
|
-
customer_id = customer[0]["id"]
|
|
353
|
+
customer_id = get_customer_by_name(customers, customer_name, archived)
|
|
258
354
|
# get the project id
|
|
259
355
|
projects = timed.projects.get(cached=True, filters={"customer": customer_id})
|
|
260
|
-
|
|
261
|
-
if len(project) == 0:
|
|
262
|
-
error_handler("ERR_PROJECT_NOT_FOUND")
|
|
263
|
-
project_id = project[0]["id"]
|
|
356
|
+
project_id = get_project_by_name(projects, project_name, customer_id, archived)
|
|
264
357
|
# get the tasks for the specified project
|
|
265
358
|
tasks = timed.tasks.get(cached=True, filters={"project": project_id})
|
|
266
|
-
|
|
359
|
+
output = []
|
|
267
360
|
for task in tasks:
|
|
268
|
-
|
|
269
|
-
output_formatted(
|
|
361
|
+
output.append({"id": task["id"], "name": task["attributes"]["name"]})
|
|
362
|
+
output_formatted(output, output_format)
|
|
270
363
|
|
|
271
364
|
|
|
272
365
|
@get.command("overtime", aliases=["t", "ot", "undertime"])
|
|
@@ -275,7 +368,7 @@ def get_overtime(date):
|
|
|
275
368
|
"""Get overtime of user."""
|
|
276
369
|
user = timed.users.me["id"]
|
|
277
370
|
overtime = timed.overtime.get({"user": user, "date": date})
|
|
278
|
-
msg(f"
|
|
371
|
+
msg(f"Current overtime is: {overtime}")
|
|
279
372
|
|
|
280
373
|
|
|
281
374
|
@get.command("reports", aliases=["report", "r"])
|
|
@@ -283,32 +376,23 @@ def get_overtime(date):
|
|
|
283
376
|
def get_reports(date):
|
|
284
377
|
"""Get reports."""
|
|
285
378
|
reports = timed.reports.get(
|
|
286
|
-
filters={"date": date},
|
|
379
|
+
filters={"date": date},
|
|
380
|
+
include="task,task.project,task.project.customer",
|
|
287
381
|
)
|
|
288
382
|
table = [["Customer", "Project", "Task", "Comment", "Duration"]]
|
|
289
383
|
for report in reports:
|
|
290
384
|
task_obj = report["relationships"]["task"]
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
385
|
+
project_obj = task_obj["relationships"]["project"]
|
|
386
|
+
customer_obj = project_obj["relationships"]["customer"]
|
|
387
|
+
# get name attributes
|
|
388
|
+
task, project, customer = (
|
|
389
|
+
x["attributes"]["name"] for x in [task_obj, project_obj, customer_obj]
|
|
295
390
|
)
|
|
296
|
-
|
|
391
|
+
comment = report["attributes"]["comment"]
|
|
392
|
+
duration = report["attributes"]["duration"]
|
|
297
393
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
)
|
|
301
|
-
customer = customer_obj["attributes"]["name"]
|
|
302
|
-
|
|
303
|
-
table.append(
|
|
304
|
-
[
|
|
305
|
-
customer,
|
|
306
|
-
project,
|
|
307
|
-
task,
|
|
308
|
-
report["attributes"]["comment"],
|
|
309
|
-
report["attributes"]["duration"],
|
|
310
|
-
]
|
|
311
|
-
)
|
|
394
|
+
table.append([customer, project, task, comment, duration])
|
|
395
|
+
# create the output
|
|
312
396
|
output = terminaltables.SingleTable(table)
|
|
313
397
|
msg(f"Reports for {date if date is not None else 'today'}:")
|
|
314
398
|
click.echo(output.table)
|
|
@@ -333,7 +417,7 @@ def get_activities(date):
|
|
|
333
417
|
activity_obj["attributes"]["to-time"].strftime("%H:%M:%S")
|
|
334
418
|
if activity_obj["attributes"]["to-time"] is not None
|
|
335
419
|
else "active",
|
|
336
|
-
]
|
|
420
|
+
],
|
|
337
421
|
)
|
|
338
422
|
output = terminaltables.SingleTable(table)
|
|
339
423
|
msg(f"Activities for {date if date is not None else 'today'}:")
|
|
@@ -343,12 +427,13 @@ def get_activities(date):
|
|
|
343
427
|
@get.command("absences", aliases=["abs"])
|
|
344
428
|
def get_absences():
|
|
345
429
|
"""Get absences."""
|
|
430
|
+
error_handler("ERR_NOT_IMPLEMENTED")
|
|
346
431
|
|
|
347
432
|
|
|
348
433
|
@timedctl.group(cls=ClickAliasedGroup, aliases=["rm", "d", "remove", "del"])
|
|
349
434
|
def delete():
|
|
350
435
|
"""Delete different things."""
|
|
351
|
-
|
|
436
|
+
# pylint: disable=W0107
|
|
352
437
|
|
|
353
438
|
|
|
354
439
|
@delete.command("report", aliases=["r"])
|
|
@@ -357,11 +442,12 @@ def delete_report(date):
|
|
|
357
442
|
"""Delete report(s)."""
|
|
358
443
|
report = select_report(date)
|
|
359
444
|
res = pyfzf.FzfPrompt().prompt(
|
|
360
|
-
["Yes", "No"],
|
|
445
|
+
["Yes", "No"],
|
|
446
|
+
f"--prompt 'Are you sure? Delete \"{report[1]}\"?'",
|
|
361
447
|
)
|
|
362
448
|
if res[0] == "Yes":
|
|
363
449
|
req = timed.reports.delete(report[-1])
|
|
364
|
-
if req.status_code ==
|
|
450
|
+
if req.status_code == requests.codes["no_content"]:
|
|
365
451
|
msg(f'Deleted report "{report[1]}"')
|
|
366
452
|
else:
|
|
367
453
|
error_handler("ERR_DELETION_FAILED")
|
|
@@ -384,7 +470,7 @@ def delete_absence():
|
|
|
384
470
|
@timedctl.group(cls=ClickAliasedGroup, aliases=["a", "create"])
|
|
385
471
|
def add():
|
|
386
472
|
"""Add different things."""
|
|
387
|
-
|
|
473
|
+
# pylint: disable=W0107
|
|
388
474
|
|
|
389
475
|
|
|
390
476
|
@add.command("report", aliases=["r"])
|
|
@@ -395,44 +481,16 @@ def add():
|
|
|
395
481
|
@click.option("--duration", default=None)
|
|
396
482
|
@click.option("--show-archived", default=False, is_flag=True)
|
|
397
483
|
def add_report(
|
|
398
|
-
customer,
|
|
399
|
-
|
|
484
|
+
customer,
|
|
485
|
+
project,
|
|
486
|
+
task,
|
|
487
|
+
description,
|
|
488
|
+
duration,
|
|
489
|
+
show_archived,
|
|
490
|
+
): # ruff: noqa: PLR0913
|
|
400
491
|
"""Add report(s)."""
|
|
401
|
-
# ask the user to select a customer
|
|
402
|
-
msg("Select a customer")
|
|
403
|
-
# select a customer
|
|
404
|
-
customers = timed.customers.get(filters={"archived": show_archived}, cached=True)
|
|
405
|
-
if customer:
|
|
406
|
-
customer = [c for c in customers if c["attributes"]["name"] == customer]
|
|
407
|
-
if len(customer) == 0:
|
|
408
|
-
error_handler("ERR_CUSTOMER_NOT_FOUND")
|
|
409
|
-
customer = customer[0]
|
|
410
|
-
else:
|
|
411
|
-
customer = fzf_wrapper(customers, ["attributes", "name"], "Select a customer: ")
|
|
412
|
-
# get projects
|
|
413
|
-
projects = timed.projects.get(
|
|
414
|
-
filters={"customer": customer["id"], "archived": show_archived}, cached=True
|
|
415
|
-
)
|
|
416
|
-
# select a project
|
|
417
|
-
if project:
|
|
418
|
-
project = [p for p in projects if p["attributes"]["name"] == project]
|
|
419
|
-
if len(project) == 0:
|
|
420
|
-
error_handler("ERR_PROJECT_NOT_FOUND")
|
|
421
|
-
project = project[0]
|
|
422
|
-
else:
|
|
423
|
-
project = fzf_wrapper(projects, ["attributes", "name"], "Select a project: ")
|
|
424
|
-
# get tasks
|
|
425
|
-
tasks = timed.tasks.get(
|
|
426
|
-
filters={"project": project["id"], "archived": show_archived}, cached=True
|
|
427
|
-
)
|
|
428
492
|
# select a task
|
|
429
|
-
|
|
430
|
-
task = [t for t in tasks if t["attributes"]["name"] == task]
|
|
431
|
-
if len(task) == 0:
|
|
432
|
-
error_handler("ERR_TASK_NOT_FOUND")
|
|
433
|
-
task = task[0]
|
|
434
|
-
else:
|
|
435
|
-
task = fzf_wrapper(tasks, ["attributes", "name"], "Select a task: ")
|
|
493
|
+
task_id = select_task(customer, project, task, show_archived)
|
|
436
494
|
# ask the user to enter a description
|
|
437
495
|
if not description:
|
|
438
496
|
msg("Enter a description")
|
|
@@ -448,10 +506,10 @@ def add_report(
|
|
|
448
506
|
res = timed.reports.post(
|
|
449
507
|
{"duration": duration, "comment": description},
|
|
450
508
|
{
|
|
451
|
-
"task":
|
|
509
|
+
"task": task_id,
|
|
452
510
|
},
|
|
453
511
|
)
|
|
454
|
-
if res.status_code ==
|
|
512
|
+
if res.status_code == requests.codes["created"]:
|
|
455
513
|
msg("Report created successfully")
|
|
456
514
|
return
|
|
457
515
|
# handle exception
|
|
@@ -473,7 +531,7 @@ def add_absence():
|
|
|
473
531
|
@timedctl.group(cls=ClickAliasedGroup, aliases=["e", "edit", "update"])
|
|
474
532
|
def edit():
|
|
475
533
|
"""Edit different things."""
|
|
476
|
-
|
|
534
|
+
# pylint: disable=W0107
|
|
477
535
|
|
|
478
536
|
|
|
479
537
|
@edit.command("report", aliases=["r"])
|
|
@@ -488,9 +546,11 @@ def edit_report(date):
|
|
|
488
546
|
res = pyfzf.FzfPrompt().prompt(["No", "Yes"], "--prompt 'Are you sure?'")
|
|
489
547
|
if res == ["Yes"]:
|
|
490
548
|
res = timed.reports.patch(
|
|
491
|
-
report[-1],
|
|
549
|
+
report[-1],
|
|
550
|
+
{"comment": comment, "duration": duration},
|
|
551
|
+
{"task": report[-2]},
|
|
492
552
|
)
|
|
493
|
-
if res.status_code ==
|
|
553
|
+
if res.status_code == requests.codes["ok"]:
|
|
494
554
|
msg("Report updated successfully")
|
|
495
555
|
return
|
|
496
556
|
# handle exception
|
|
@@ -514,7 +574,7 @@ def edit_absence():
|
|
|
514
574
|
@timedctl.group(cls=ClickAliasedGroup, aliases=["ac"])
|
|
515
575
|
def activity():
|
|
516
576
|
"""Do stuff with activities."""
|
|
517
|
-
|
|
577
|
+
# pylint: disable=W0107
|
|
518
578
|
|
|
519
579
|
|
|
520
580
|
@activity.command("start", aliases=["add", "a"])
|
|
@@ -525,47 +585,14 @@ def activity():
|
|
|
525
585
|
@click.option("--show-archived", default=False, is_flag=True)
|
|
526
586
|
def start_activity(comment, customer, project, task, show_archived):
|
|
527
587
|
"""Start recording activity."""
|
|
528
|
-
|
|
529
|
-
# ask the user to select a customer
|
|
530
|
-
msg("Select a customer")
|
|
531
|
-
# select a customer
|
|
532
|
-
if customer:
|
|
533
|
-
customer = [c for c in customers if c["attributes"]["name"] == customer]
|
|
534
|
-
if len(customer) == 0:
|
|
535
|
-
error_handler("ERR_CUSTOMER_NOT_FOUND")
|
|
536
|
-
customer = customer[0]
|
|
537
|
-
else:
|
|
538
|
-
customer = fzf_wrapper(customers, ["attributes", "name"], "Select a customer: ")
|
|
539
|
-
# get projects
|
|
540
|
-
projects = timed.projects.get(
|
|
541
|
-
filters={"customer": customer["id"], "archived": show_archived}, cached=True
|
|
542
|
-
)
|
|
543
|
-
# select a project
|
|
544
|
-
if project:
|
|
545
|
-
project = [p for p in projects if p["attributes"]["name"] == project]
|
|
546
|
-
if len(project) == 0:
|
|
547
|
-
error_handler("ERR_PROJECT_NOT_FOUND")
|
|
548
|
-
project = project[0]
|
|
549
|
-
else:
|
|
550
|
-
project = fzf_wrapper(projects, ["attributes", "name"], "Select a project: ")
|
|
551
|
-
# get tasks
|
|
552
|
-
tasks = timed.tasks.get(
|
|
553
|
-
filters={"project": project["id"], "archived": show_archived}, cached=True
|
|
554
|
-
)
|
|
555
|
-
# select a task
|
|
556
|
-
if task:
|
|
557
|
-
task = [t for t in tasks if t["attributes"]["name"] == task]
|
|
558
|
-
if len(task) == 0:
|
|
559
|
-
error_handler("ERR_TASK_NOT_FOUND")
|
|
560
|
-
task = task[0]
|
|
561
|
-
else:
|
|
562
|
-
task = fzf_wrapper(tasks, ["attributes", "name"], "Select a task: ")
|
|
588
|
+
task_id = select_task(customer, project, task, show_archived)
|
|
563
589
|
# create the activity
|
|
564
590
|
res = timed.activities.start(
|
|
565
|
-
attributes={"comment": comment},
|
|
591
|
+
attributes={"comment": comment},
|
|
592
|
+
relationships={"task": task_id},
|
|
566
593
|
)
|
|
567
594
|
|
|
568
|
-
if res.status_code ==
|
|
595
|
+
if res.status_code == requests.codes["created"]:
|
|
569
596
|
msg(f"Activity {comment} started successfully.")
|
|
570
597
|
return
|
|
571
598
|
# handle exception
|
|
@@ -586,17 +613,17 @@ def stop_activity():
|
|
|
586
613
|
@click.option("--short", default=False, is_flag=True)
|
|
587
614
|
def show_activity(short):
|
|
588
615
|
"""Show current activity."""
|
|
589
|
-
|
|
590
|
-
current_activity = timed.activities.get(
|
|
591
|
-
filters={"id": timed.activities.current["id"]},
|
|
592
|
-
include="task,task.project,task.project.customer",
|
|
593
|
-
)[0]
|
|
594
|
-
comment = " > " + current_activity["attributes"]["comment"] if not short else ""
|
|
595
|
-
start = current_activity["attributes"]["from-time"].strftime("%H:%M:%S")
|
|
616
|
+
current_activity = timed.activities.current
|
|
596
617
|
if current_activity:
|
|
618
|
+
activity_obj = timed.activities.get(
|
|
619
|
+
filters={"id": current_activity["id"]},
|
|
620
|
+
include="task,task.project,task.project.customer",
|
|
621
|
+
)[0]
|
|
622
|
+
comment = " > " + activity_obj["attributes"]["comment"] if not short else ""
|
|
623
|
+
start = activity_obj["attributes"]["from-time"].strftime("%H:%M:%S")
|
|
597
624
|
msg(
|
|
598
|
-
f"Current activity: {format_activity(
|
|
599
|
-
+ f"{start})"
|
|
625
|
+
f"Current activity: {format_activity(activity_obj)}{comment} (Since "
|
|
626
|
+
+ f"{start})",
|
|
600
627
|
)
|
|
601
628
|
else:
|
|
602
629
|
error_handler("ERR_NO_CURRENT_ACTIVITY")
|
|
@@ -611,14 +638,15 @@ def restart_activity(date):
|
|
|
611
638
|
timed.activities.stop()
|
|
612
639
|
msg("Stopped current activity.")
|
|
613
640
|
# select an activity
|
|
614
|
-
|
|
641
|
+
activity_obj = select_activity(date)
|
|
615
642
|
# grab attributes
|
|
616
|
-
comment =
|
|
617
|
-
task_id =
|
|
643
|
+
comment = activity_obj[1]
|
|
644
|
+
task_id = activity_obj[3]
|
|
618
645
|
res = timed.activities.start(
|
|
619
|
-
attributes={"comment": comment},
|
|
646
|
+
attributes={"comment": comment},
|
|
647
|
+
relationships={"task": task_id},
|
|
620
648
|
)
|
|
621
|
-
if res.status_code ==
|
|
649
|
+
if res.status_code == requests.codes["created"]:
|
|
622
650
|
msg(f'Activity "{comment}" restarted successfully.')
|
|
623
651
|
return
|
|
624
652
|
# handle exception
|
|
@@ -630,9 +658,9 @@ def restart_activity(date):
|
|
|
630
658
|
def delete_activity(date):
|
|
631
659
|
"""Delete an activity."""
|
|
632
660
|
# select an activity
|
|
633
|
-
|
|
634
|
-
if timed.activities.delete(
|
|
635
|
-
msg(f"Activity {
|
|
661
|
+
activity_obj = select_activity(date)
|
|
662
|
+
if timed.activities.delete(activity_obj[-1]):
|
|
663
|
+
msg(f"Activity {activity_obj[1]} deleted successfully.")
|
|
636
664
|
return
|
|
637
665
|
error_handler("ERR_ACTIVITY_DELETE_FAILED")
|
|
638
666
|
|
|
@@ -665,10 +693,12 @@ def activity_generate_timesheet():
|
|
|
665
693
|
report = report[0]
|
|
666
694
|
# deserialize the timedelta
|
|
667
695
|
hours, minutes, seconds = report["attributes"]["duration"].split(
|
|
668
|
-
":"
|
|
696
|
+
":",
|
|
669
697
|
)
|
|
670
698
|
old_duration = datetime.timedelta(
|
|
671
|
-
hours=int(hours),
|
|
699
|
+
hours=int(hours),
|
|
700
|
+
minutes=int(minutes),
|
|
701
|
+
seconds=int(seconds),
|
|
672
702
|
)
|
|
673
703
|
# calculate the new duration
|
|
674
704
|
report["attributes"]["duration"] = old_duration + duration
|
|
@@ -680,7 +710,7 @@ def activity_generate_timesheet():
|
|
|
680
710
|
)
|
|
681
711
|
else:
|
|
682
712
|
# create report
|
|
683
|
-
|
|
713
|
+
res = timed.reports.post(
|
|
684
714
|
{
|
|
685
715
|
"duration": duration,
|
|
686
716
|
"comment": attr["comment"],
|
|
@@ -688,7 +718,7 @@ def activity_generate_timesheet():
|
|
|
688
718
|
{"task": task},
|
|
689
719
|
)
|
|
690
720
|
# append the report to the known reports
|
|
691
|
-
reports.append(
|
|
721
|
+
reports.append(res.json()["data"])
|
|
692
722
|
# update activity to be transferred
|
|
693
723
|
attr["transferred"] = True
|
|
694
724
|
timed.activities.patch(activity_obj["id"], attr, {"task": task})
|
|
File without changes
|
|
File without changes
|