papi-projects 0.4.2__tar.gz → 0.4.4__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.
- {papi_projects-0.4.2 → papi_projects-0.4.4}/PKG-INFO +1 -1
- {papi_projects-0.4.2 → papi_projects-0.4.4}/papi/__init__.py +13 -0
- {papi_projects-0.4.2 → papi_projects-0.4.4}/papi/wrappers.py +23 -12
- {papi_projects-0.4.2 → papi_projects-0.4.4}/papi_projects.egg-info/PKG-INFO +1 -1
- {papi_projects-0.4.2 → papi_projects-0.4.4}/pyproject.toml +1 -1
- {papi_projects-0.4.2 → papi_projects-0.4.4}/scripts/collate_toggl_hours.py +11 -2
- papi_projects-0.4.4/scripts/generate_timesheet.py +242 -0
- papi_projects-0.4.2/scripts/generate_timesheet.py +0 -154
- {papi_projects-0.4.2 → papi_projects-0.4.4}/README.md +0 -0
- {papi_projects-0.4.2 → papi_projects-0.4.4}/papi/project.py +0 -0
- {papi_projects-0.4.2 → papi_projects-0.4.4}/papi/task.py +0 -0
- {papi_projects-0.4.2 → papi_projects-0.4.4}/papi/user.py +0 -0
- {papi_projects-0.4.2 → papi_projects-0.4.4}/papi/workorder.py +0 -0
- {papi_projects-0.4.2 → papi_projects-0.4.4}/papi_projects.egg-info/SOURCES.txt +0 -0
- {papi_projects-0.4.2 → papi_projects-0.4.4}/papi_projects.egg-info/dependency_links.txt +0 -0
- {papi_projects-0.4.2 → papi_projects-0.4.4}/papi_projects.egg-info/entry_points.txt +0 -0
- {papi_projects-0.4.2 → papi_projects-0.4.4}/papi_projects.egg-info/requires.txt +0 -0
- {papi_projects-0.4.2 → papi_projects-0.4.4}/papi_projects.egg-info/top_level.txt +0 -0
- {papi_projects-0.4.2 → papi_projects-0.4.4}/scripts/__init__.py +0 -0
- {papi_projects-0.4.2 → papi_projects-0.4.4}/scripts/create_notion_project.py +0 -0
- {papi_projects-0.4.2 → papi_projects-0.4.4}/scripts/create_notion_task.py +0 -0
- {papi_projects-0.4.2 → papi_projects-0.4.4}/scripts/create_project.py +0 -0
- {papi_projects-0.4.2 → papi_projects-0.4.4}/scripts/create_toggl_project.py +0 -0
- {papi_projects-0.4.2 → papi_projects-0.4.4}/scripts/test_notion.py +0 -0
- {papi_projects-0.4.2 → papi_projects-0.4.4}/setup.cfg +0 -0
- {papi_projects-0.4.2 → papi_projects-0.4.4}/tests/test_project.py +0 -0
- {papi_projects-0.4.2 → papi_projects-0.4.4}/tests/test_task.py +0 -0
- {papi_projects-0.4.2 → papi_projects-0.4.4}/tests/test_user.py +0 -0
- {papi_projects-0.4.2 → papi_projects-0.4.4}/tests/test_wrappers.py +0 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import os
|
|
2
2
|
import logging
|
|
3
|
+
from decimal import Decimal, ROUND_UP
|
|
3
4
|
from pathlib import Path
|
|
4
5
|
|
|
5
6
|
try:
|
|
@@ -81,6 +82,18 @@ if missing:
|
|
|
81
82
|
raise RuntimeError(f"Missing required config values: {', '.join(missing)}")
|
|
82
83
|
|
|
83
84
|
|
|
85
|
+
def round_up_to_multiple(hours: float, multiple: float = 7.5) -> float:
|
|
86
|
+
"""Round hours up to the nearest multiple of `multiple` (default 7.5).
|
|
87
|
+
|
|
88
|
+
Values that are already an exact multiple are left unchanged, e.g. 7.5 -> 7.5,
|
|
89
|
+
1 -> 7.5, 10 -> 15. Uses Decimal to avoid floating-point rounding surprises.
|
|
90
|
+
"""
|
|
91
|
+
h = Decimal(str(hours))
|
|
92
|
+
m = Decimal(str(multiple))
|
|
93
|
+
multiples = (h / m).quantize(Decimal("1"), rounding=ROUND_UP)
|
|
94
|
+
return float(multiples * m)
|
|
95
|
+
|
|
96
|
+
|
|
84
97
|
def setup_logger(enable_logging: bool, log_level: str = 'INFO', log_file: str = None):
|
|
85
98
|
logger = logging.getLogger('papi')
|
|
86
99
|
|
|
@@ -176,14 +176,18 @@ class TogglTrackWrapper(Protocol):
|
|
|
176
176
|
if isinstance(times_json, list):
|
|
177
177
|
times = {}
|
|
178
178
|
for t in times_json:
|
|
179
|
-
pid
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
179
|
+
if "pid" in t:
|
|
180
|
+
pid = t["pid"]
|
|
181
|
+
seconds = t["duration"]
|
|
182
|
+
hours = seconds / 60 / 60
|
|
183
|
+
if pid not in times:
|
|
184
|
+
times[pid] = hours
|
|
185
|
+
else:
|
|
186
|
+
times[pid] += hours
|
|
187
|
+
if times:
|
|
188
|
+
return times
|
|
189
|
+
else:
|
|
190
|
+
return None
|
|
187
191
|
else:
|
|
188
192
|
warnings.warn(times_json)
|
|
189
193
|
return None
|
|
@@ -419,7 +423,11 @@ class NotionWrapper(Protocol):
|
|
|
419
423
|
t = b["type"]
|
|
420
424
|
if t not in ALLOWED:
|
|
421
425
|
continue
|
|
422
|
-
|
|
426
|
+
block_data = {
|
|
427
|
+
k: v for k, v in b[t].items()
|
|
428
|
+
if v is not None and not (k == "color" and v == "default")
|
|
429
|
+
}
|
|
430
|
+
minimal = {"type": t, t: block_data}
|
|
423
431
|
if b.get("has_children"):
|
|
424
432
|
child_raw = self._fetch_template_blocks(b["id"])
|
|
425
433
|
minimal["children"] = self._clean_blocks(child_raw)
|
|
@@ -1069,20 +1077,23 @@ class NotionWrapper(Protocol):
|
|
|
1069
1077
|
|
|
1070
1078
|
props = page.get("properties", {})
|
|
1071
1079
|
|
|
1080
|
+
nid = page.get("id")
|
|
1072
1081
|
try:
|
|
1073
1082
|
pid = project_id or props["Project ID"]["title"][0]["plain_text"]
|
|
1074
1083
|
name = props["Description"]["rich_text"][0]["plain_text"]
|
|
1075
|
-
owner = [u
|
|
1084
|
+
owner = [u.get("name") for u in props["Owner"]["people"] if u.get("name")]
|
|
1076
1085
|
status = props["Status"]["status"]["name"]
|
|
1077
1086
|
priority = props["Priority"]["select"]["name"]
|
|
1078
1087
|
user_id = props["PI Code"]["rollup"]["array"][0]["rich_text"][0]["plain_text"]
|
|
1079
|
-
nid = page["id"]
|
|
1080
1088
|
if len(props["Workorder"]["relation"]):
|
|
1081
1089
|
workorder_page_id = props["Workorder"]["relation"][0]["id"]
|
|
1082
1090
|
else:
|
|
1083
1091
|
workorder_page_id = None
|
|
1084
1092
|
except (KeyError, IndexError) as e:
|
|
1085
|
-
logger.error(
|
|
1093
|
+
logger.error(
|
|
1094
|
+
f"Malformed project properties for Project ID={project_id!r} "
|
|
1095
|
+
f"on Notion page {nid!r}: {e!r}"
|
|
1096
|
+
)
|
|
1086
1097
|
return None
|
|
1087
1098
|
|
|
1088
1099
|
if workorders_db_id is not None and workorder_page_id is not None:
|
|
@@ -2,7 +2,7 @@ import argparse
|
|
|
2
2
|
import pendulum
|
|
3
3
|
import warnings
|
|
4
4
|
from papi.wrappers import TogglTrackWrapper
|
|
5
|
-
from papi import config
|
|
5
|
+
from papi import config, round_up_to_multiple
|
|
6
6
|
|
|
7
7
|
def main():
|
|
8
8
|
"""Main function of collate-toggl-hours script"""
|
|
@@ -22,6 +22,11 @@ def main():
|
|
|
22
22
|
help="output TSV filename, omit to write to stdout",
|
|
23
23
|
default=False,
|
|
24
24
|
)
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"--round",
|
|
27
|
+
action="store_true",
|
|
28
|
+
help="round each project's total hours up to the nearest 7.5",
|
|
29
|
+
)
|
|
25
30
|
args = parser.parse_args()
|
|
26
31
|
|
|
27
32
|
# Set up start/end date
|
|
@@ -51,9 +56,13 @@ def main():
|
|
|
51
56
|
# Get tracked hours and tracked project IDs/names
|
|
52
57
|
|
|
53
58
|
tracked_hours = toggl.get_user_hours(start_time=start_time, end_time=end_time)
|
|
59
|
+
print(tracked_hours)
|
|
54
60
|
projects = {p["id"]: p["name"] for p in toggl.get_user_projects() if p["id"] in tracked_hours}
|
|
55
61
|
hours_per_project = [(projects[t], tracked_hours[t]) for t in tracked_hours]
|
|
56
62
|
|
|
63
|
+
if args.round:
|
|
64
|
+
hours_per_project = [(name, round_up_to_multiple(hours)) for name, hours in hours_per_project]
|
|
65
|
+
|
|
57
66
|
output = args.output
|
|
58
67
|
|
|
59
68
|
if output:
|
|
@@ -69,4 +78,4 @@ def main():
|
|
|
69
78
|
warnings.warn("Start time must not be more than 3 months ago!")
|
|
70
79
|
|
|
71
80
|
if __name__ == "__main__":
|
|
72
|
-
main()
|
|
81
|
+
main()
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import pendulum
|
|
3
|
+
import warnings
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from papi.wrappers import TogglTrackWrapper, NotionWrapper
|
|
7
|
+
from papi import config, setup_logger, round_up_to_multiple
|
|
8
|
+
from papi.project import get_project_ids, check_project_id
|
|
9
|
+
from decimal import Decimal, ROUND_UP
|
|
10
|
+
import pandas as pd
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
import tomllib
|
|
14
|
+
except ModuleNotFoundError:
|
|
15
|
+
import tomli as tomllib
|
|
16
|
+
|
|
17
|
+
def load_toggl_users_from_toml():
|
|
18
|
+
config_path = os.environ.get("PAPI_CONFIG_FILE")
|
|
19
|
+
if config_path:
|
|
20
|
+
path = Path(config_path).expanduser()
|
|
21
|
+
else:
|
|
22
|
+
path = Path.home() / ".config" / "papi" / "config.toml"
|
|
23
|
+
if not path.exists():
|
|
24
|
+
return []
|
|
25
|
+
with path.open("rb") as f:
|
|
26
|
+
raw = tomllib.load(f)
|
|
27
|
+
toggl = raw.get("toggl", {})
|
|
28
|
+
users = toggl.get("users", {})
|
|
29
|
+
out = []
|
|
30
|
+
if isinstance(users, dict):
|
|
31
|
+
for _, u in users.items():
|
|
32
|
+
if not isinstance(u, dict):
|
|
33
|
+
continue
|
|
34
|
+
name = u.get("name")
|
|
35
|
+
api_key = u.get("api_key")
|
|
36
|
+
if name and api_key:
|
|
37
|
+
out.append({"name": name, "api_key": api_key})
|
|
38
|
+
return out
|
|
39
|
+
|
|
40
|
+
def initialise_toggl(toggl_api_key, toggl_workspace):
|
|
41
|
+
toggl_api_password = config["TOGGL_TRACK_PASSWORD"]
|
|
42
|
+
toggl = TogglTrackWrapper(toggl_api_key, toggl_api_password)
|
|
43
|
+
toggl.set_default_workspace(toggl_workspace)
|
|
44
|
+
toggl.set_me()
|
|
45
|
+
return toggl
|
|
46
|
+
|
|
47
|
+
def calculate_cost(hours: float, hourly_rate: float) -> float:
|
|
48
|
+
cost = Decimal(hours * hourly_rate)
|
|
49
|
+
cost_rounded = cost.quantize(Decimal("0.01"), rounding=ROUND_UP)
|
|
50
|
+
return cost_rounded
|
|
51
|
+
|
|
52
|
+
def get_toggl_hours(start_time, end_time, toggl):
|
|
53
|
+
tracked_hours = toggl.get_user_hours(start_time=start_time, end_time=end_time)
|
|
54
|
+
if not tracked_hours:
|
|
55
|
+
return {}
|
|
56
|
+
projects = {p["id"]: p["name"] for p in toggl.get_user_projects() if p["id"] in tracked_hours}
|
|
57
|
+
hours_per_project = [(projects[t], tracked_hours[t]) for t in tracked_hours]
|
|
58
|
+
hours_dict = {}
|
|
59
|
+
for i, p in enumerate(hours_per_project):
|
|
60
|
+
project_id = get_project_ids([p[0]])
|
|
61
|
+
if len(project_id):
|
|
62
|
+
hours_dict[project_id[0]] = hours_per_project[i][1]
|
|
63
|
+
else:
|
|
64
|
+
hours_dict[p[0]] = hours_per_project[i][1]
|
|
65
|
+
return hours_dict
|
|
66
|
+
|
|
67
|
+
def main():
|
|
68
|
+
parser = argparse.ArgumentParser()
|
|
69
|
+
parser.add_argument("-s", "--start", type=str, required=True)
|
|
70
|
+
parser.add_argument("-e", "--end", type=str, default=False)
|
|
71
|
+
parser.add_argument("--disable-logging", action="store_true")
|
|
72
|
+
parser.add_argument(
|
|
73
|
+
"--round",
|
|
74
|
+
action="store_true",
|
|
75
|
+
help="round each project's total hours (across all users) up to the nearest 7.5",
|
|
76
|
+
)
|
|
77
|
+
parser.add_argument("-o", "--output", type=str, required=True)
|
|
78
|
+
args = parser.parse_args()
|
|
79
|
+
|
|
80
|
+
if not args.disable_logging:
|
|
81
|
+
logger = setup_logger(True, "WARNING", None)
|
|
82
|
+
|
|
83
|
+
start_time = pendulum.parse(args.start).to_rfc3339_string()
|
|
84
|
+
# Toggl's end_date is exclusive, so add a day to include entries on the end date itself.
|
|
85
|
+
end_time = pendulum.now().to_rfc3339_string() if not args.end else pendulum.parse(args.end).add(days=1).to_rfc3339_string()
|
|
86
|
+
|
|
87
|
+
if pendulum.now() < pendulum.parse(args.start).add(months=3):
|
|
88
|
+
toggl_workspace = config["TOGGL_TRACK_WORKSPACE"]
|
|
89
|
+
toggl_users = load_toggl_users_from_toml()
|
|
90
|
+
if not toggl_users:
|
|
91
|
+
toggl_users = [{"name": "Self", "api_key": config["TOGGL_TRACK_API_KEY"]}]
|
|
92
|
+
|
|
93
|
+
notion = NotionWrapper(config["NOTION_API_SECRET"])
|
|
94
|
+
output = args.output
|
|
95
|
+
|
|
96
|
+
df = pd.DataFrame(
|
|
97
|
+
columns=[
|
|
98
|
+
"workorder",
|
|
99
|
+
"project_id",
|
|
100
|
+
"payment_type",
|
|
101
|
+
"costing_rate",
|
|
102
|
+
"hourly_rate",
|
|
103
|
+
"hours",
|
|
104
|
+
"cost",
|
|
105
|
+
"person",
|
|
106
|
+
"description",
|
|
107
|
+
"agresso_description",
|
|
108
|
+
]
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
for user in toggl_users:
|
|
112
|
+
print(user)
|
|
113
|
+
toggl = initialise_toggl(user["api_key"], toggl_workspace)
|
|
114
|
+
try:
|
|
115
|
+
hours_dict = get_toggl_hours(start_time, end_time, toggl)
|
|
116
|
+
except Exception:
|
|
117
|
+
logger.error(f"[Toggl] Failed fetching hours for user {user['name']!r}")
|
|
118
|
+
raise
|
|
119
|
+
if not hours_dict:
|
|
120
|
+
logger.warning(
|
|
121
|
+
f"[Toggl] No hours tracked for user {user['name']!r} "
|
|
122
|
+
f"in the requested period; skipping."
|
|
123
|
+
)
|
|
124
|
+
continue
|
|
125
|
+
print(hours_dict)
|
|
126
|
+
|
|
127
|
+
for project_id, hours in hours_dict.items():
|
|
128
|
+
hours = float(hours)
|
|
129
|
+
payment_type = ""
|
|
130
|
+
costing_rate = ""
|
|
131
|
+
hourly_rate = ""
|
|
132
|
+
cost = 0.0
|
|
133
|
+
workorder_id = ""
|
|
134
|
+
if check_project_id(project_id):
|
|
135
|
+
try:
|
|
136
|
+
project = notion.get_project(
|
|
137
|
+
config["NOTION_PROJECTS_DB"],
|
|
138
|
+
project_id,
|
|
139
|
+
workorders_db_id=config["NOTION_WORKORDERS_DB"]
|
|
140
|
+
)
|
|
141
|
+
if project is not None:
|
|
142
|
+
project_name = project.name
|
|
143
|
+
workorder_id = project.workorder
|
|
144
|
+
if workorder_id is not None:
|
|
145
|
+
workorder = notion.get_workorder(
|
|
146
|
+
workorders_db_id=config["NOTION_WORKORDERS_DB"],
|
|
147
|
+
workorder_id=workorder_id
|
|
148
|
+
)
|
|
149
|
+
if workorder.is_complete():
|
|
150
|
+
payment_type = workorder.payment_type
|
|
151
|
+
costing_rate = workorder.costing_rate
|
|
152
|
+
hourly_rate = workorder.hourly_rate
|
|
153
|
+
cost = calculate_cost(hours, workorder.hourly_rate)
|
|
154
|
+
else:
|
|
155
|
+
project_name = ""
|
|
156
|
+
workorder_id = ""
|
|
157
|
+
except Exception:
|
|
158
|
+
logger.error(
|
|
159
|
+
f"[Notion] Failed processing project {project_id!r} "
|
|
160
|
+
f"for user {user['name']!r}"
|
|
161
|
+
)
|
|
162
|
+
raise
|
|
163
|
+
else:
|
|
164
|
+
project_name = project_id
|
|
165
|
+
|
|
166
|
+
df.loc[len(df)] = [
|
|
167
|
+
workorder_id,
|
|
168
|
+
project_id,
|
|
169
|
+
payment_type,
|
|
170
|
+
costing_rate,
|
|
171
|
+
hourly_rate,
|
|
172
|
+
hours,
|
|
173
|
+
cost,
|
|
174
|
+
user["name"],
|
|
175
|
+
project_name,
|
|
176
|
+
f"{project_id}: {project_name}",
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
output_path = Path(output).expanduser()
|
|
180
|
+
itemised_path = output_path.with_name(f"{output_path.stem}-itemised{output_path.suffix}")
|
|
181
|
+
totals_path = output_path.with_name(f"{output_path.stem}-totals{output_path.suffix}")
|
|
182
|
+
da_path = output_path.with_name(f"{output_path.stem}-DA{output_path.suffix}")
|
|
183
|
+
di_path = output_path.with_name(f"{output_path.stem}-DI{output_path.suffix}")
|
|
184
|
+
|
|
185
|
+
df.to_csv(itemised_path, sep="\t", index=False)
|
|
186
|
+
|
|
187
|
+
totals_df = df.drop(columns=["person"]).groupby("project_id", as_index=False).agg(
|
|
188
|
+
{
|
|
189
|
+
"workorder": "first",
|
|
190
|
+
"payment_type": "first",
|
|
191
|
+
"costing_rate": "first",
|
|
192
|
+
"hourly_rate": "first",
|
|
193
|
+
"hours": "sum",
|
|
194
|
+
"cost": "sum",
|
|
195
|
+
"description": "first",
|
|
196
|
+
"agresso_description": "first",
|
|
197
|
+
}
|
|
198
|
+
)
|
|
199
|
+
totals_df = totals_df[
|
|
200
|
+
[
|
|
201
|
+
"workorder",
|
|
202
|
+
"project_id",
|
|
203
|
+
"payment_type",
|
|
204
|
+
"costing_rate",
|
|
205
|
+
"hourly_rate",
|
|
206
|
+
"hours",
|
|
207
|
+
"cost",
|
|
208
|
+
"description",
|
|
209
|
+
"agresso_description",
|
|
210
|
+
]
|
|
211
|
+
]
|
|
212
|
+
|
|
213
|
+
if args.round:
|
|
214
|
+
totals_df["hours"] = totals_df["hours"].apply(round_up_to_multiple)
|
|
215
|
+
totals_df["cost"] = totals_df.apply(
|
|
216
|
+
lambda row: calculate_cost(row["hours"], row["hourly_rate"])
|
|
217
|
+
if row["hourly_rate"] not in ("", None)
|
|
218
|
+
else row["cost"],
|
|
219
|
+
axis=1,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
totals_df.to_csv(totals_path, sep="\t", index=False)
|
|
223
|
+
|
|
224
|
+
da_df = totals_df[totals_df["payment_type"] == "DA"]
|
|
225
|
+
if len(da_df):
|
|
226
|
+
da_df.to_csv(da_path, sep="\t", index=False)
|
|
227
|
+
|
|
228
|
+
di_df = totals_df[totals_df["payment_type"] == "DI"]
|
|
229
|
+
if len(di_df):
|
|
230
|
+
di_df.to_csv(di_path, sep="\t", index=False)
|
|
231
|
+
|
|
232
|
+
logger.warning(f"{len(df)} journal entries successfully written to {itemised_path}")
|
|
233
|
+
logger.warning(f"{len(totals_df)} project totals successfully written to {totals_path}")
|
|
234
|
+
if len(da_df):
|
|
235
|
+
logger.warning(f"{len(da_df)} DA totals successfully written to {da_path}")
|
|
236
|
+
if len(di_df):
|
|
237
|
+
logger.warning(f"{len(di_df)} DI totals successfully written to {di_path}")
|
|
238
|
+
else:
|
|
239
|
+
logger.warning("Start time must not be more than 3 months ago!")
|
|
240
|
+
|
|
241
|
+
if __name__ == "__main__":
|
|
242
|
+
main()
|
|
@@ -1,154 +0,0 @@
|
|
|
1
|
-
import argparse
|
|
2
|
-
import pendulum
|
|
3
|
-
import warnings
|
|
4
|
-
import os
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
from papi.wrappers import TogglTrackWrapper, NotionWrapper
|
|
7
|
-
from papi import config, setup_logger
|
|
8
|
-
from papi.project import get_project_ids, check_project_id
|
|
9
|
-
from decimal import Decimal, ROUND_UP
|
|
10
|
-
import pandas as pd
|
|
11
|
-
|
|
12
|
-
try:
|
|
13
|
-
import tomllib
|
|
14
|
-
except ModuleNotFoundError:
|
|
15
|
-
import tomli as tomllib
|
|
16
|
-
|
|
17
|
-
def load_toggl_users_from_toml():
|
|
18
|
-
config_path = os.environ.get("PAPI_CONFIG_FILE")
|
|
19
|
-
if config_path:
|
|
20
|
-
path = Path(config_path).expanduser()
|
|
21
|
-
else:
|
|
22
|
-
path = Path.home() / ".config" / "papi" / "config.toml"
|
|
23
|
-
if not path.exists():
|
|
24
|
-
return []
|
|
25
|
-
with path.open("rb") as f:
|
|
26
|
-
raw = tomllib.load(f)
|
|
27
|
-
toggl = raw.get("toggl", {})
|
|
28
|
-
users = toggl.get("users", {})
|
|
29
|
-
out = []
|
|
30
|
-
if isinstance(users, dict):
|
|
31
|
-
for _, u in users.items():
|
|
32
|
-
if not isinstance(u, dict):
|
|
33
|
-
continue
|
|
34
|
-
name = u.get("name")
|
|
35
|
-
api_key = u.get("api_key")
|
|
36
|
-
if name and api_key:
|
|
37
|
-
out.append({"name": name, "api_key": api_key})
|
|
38
|
-
return out
|
|
39
|
-
|
|
40
|
-
def initialise_toggl(toggl_api_key, toggl_workspace):
|
|
41
|
-
toggl_api_password = config["TOGGL_TRACK_PASSWORD"]
|
|
42
|
-
toggl = TogglTrackWrapper(toggl_api_key, toggl_api_password)
|
|
43
|
-
toggl.set_default_workspace(toggl_workspace)
|
|
44
|
-
toggl.set_me()
|
|
45
|
-
return toggl
|
|
46
|
-
|
|
47
|
-
def calculate_cost(hours: float, hourly_rate: float) -> float:
|
|
48
|
-
cost = Decimal(hours * hourly_rate)
|
|
49
|
-
cost_rounded = cost.quantize(Decimal("0.01"), rounding=ROUND_UP)
|
|
50
|
-
return cost_rounded
|
|
51
|
-
|
|
52
|
-
def get_toggl_hours(start_time, end_time, toggl):
|
|
53
|
-
tracked_hours = toggl.get_user_hours(start_time=start_time, end_time=end_time)
|
|
54
|
-
projects = {p["id"]: p["name"] for p in toggl.get_user_projects() if p["id"] in tracked_hours}
|
|
55
|
-
hours_per_project = [(projects[t], tracked_hours[t]) for t in tracked_hours]
|
|
56
|
-
hours_dict = {}
|
|
57
|
-
for i, p in enumerate(hours_per_project):
|
|
58
|
-
project_id = get_project_ids([p[0]])
|
|
59
|
-
if len(project_id):
|
|
60
|
-
hours_dict[project_id[0]] = hours_per_project[i][1]
|
|
61
|
-
else:
|
|
62
|
-
hours_dict[p[0]] = hours_per_project[i][1]
|
|
63
|
-
return hours_dict
|
|
64
|
-
|
|
65
|
-
def main():
|
|
66
|
-
parser = argparse.ArgumentParser()
|
|
67
|
-
parser.add_argument("-s", "--start", type=str, required=True)
|
|
68
|
-
parser.add_argument("-e", "--end", type=str, default=False)
|
|
69
|
-
parser.add_argument("--disable-logging", action="store_true")
|
|
70
|
-
parser.add_argument("-o", "--output", type=str, required=True)
|
|
71
|
-
args = parser.parse_args()
|
|
72
|
-
|
|
73
|
-
if not args.disable_logging:
|
|
74
|
-
logger = setup_logger(True, "WARNING", None)
|
|
75
|
-
|
|
76
|
-
start_time = pendulum.parse(args.start).to_rfc3339_string()
|
|
77
|
-
end_time = pendulum.now().to_rfc3339_string() if not args.end else pendulum.parse(args.end).to_rfc3339_string()
|
|
78
|
-
|
|
79
|
-
if pendulum.now() < pendulum.parse(args.start).add(months=3):
|
|
80
|
-
toggl_workspace = config["TOGGL_TRACK_WORKSPACE"]
|
|
81
|
-
toggl_users = load_toggl_users_from_toml()
|
|
82
|
-
if not toggl_users:
|
|
83
|
-
toggl_users = [{"name": "Self", "api_key": config["TOGGL_TRACK_API_KEY"]}]
|
|
84
|
-
|
|
85
|
-
notion = NotionWrapper(config["NOTION_API_SECRET"])
|
|
86
|
-
output = args.output
|
|
87
|
-
|
|
88
|
-
df = pd.DataFrame(
|
|
89
|
-
columns=[
|
|
90
|
-
"workorder",
|
|
91
|
-
"project_id",
|
|
92
|
-
"payment_type",
|
|
93
|
-
"costing_rate",
|
|
94
|
-
"hourly_rate",
|
|
95
|
-
"hours",
|
|
96
|
-
"cost",
|
|
97
|
-
"person",
|
|
98
|
-
"description",
|
|
99
|
-
"agresso_description",
|
|
100
|
-
]
|
|
101
|
-
)
|
|
102
|
-
|
|
103
|
-
for user in toggl_users:
|
|
104
|
-
toggl = initialise_toggl(user["api_key"], toggl_workspace)
|
|
105
|
-
hours_dict = get_toggl_hours(start_time, end_time, toggl)
|
|
106
|
-
|
|
107
|
-
for project_id, hours in hours_dict.items():
|
|
108
|
-
hours = float(hours)
|
|
109
|
-
payment_type = ""
|
|
110
|
-
costing_rate = ""
|
|
111
|
-
hourly_rate = ""
|
|
112
|
-
cost = 0.0
|
|
113
|
-
workorder_id = ""
|
|
114
|
-
if check_project_id(project_id):
|
|
115
|
-
project = notion.get_project(
|
|
116
|
-
config["NOTION_PROJECTS_DB"],
|
|
117
|
-
project_id,
|
|
118
|
-
workorders_db_id=config["NOTION_WORKORDERS_DB"]
|
|
119
|
-
)
|
|
120
|
-
project_name = project.name
|
|
121
|
-
workorder_id = project.workorder
|
|
122
|
-
if workorder_id is not None:
|
|
123
|
-
workorder = notion.get_workorder(
|
|
124
|
-
workorders_db_id=config["NOTION_WORKORDERS_DB"],
|
|
125
|
-
workorder_id=workorder_id
|
|
126
|
-
)
|
|
127
|
-
if workorder.is_complete():
|
|
128
|
-
payment_type = workorder.payment_type
|
|
129
|
-
costing_rate = workorder.costing_rate
|
|
130
|
-
hourly_rate = workorder.hourly_rate
|
|
131
|
-
cost = calculate_cost(hours, workorder.hourly_rate)
|
|
132
|
-
else:
|
|
133
|
-
project_name = project_id
|
|
134
|
-
|
|
135
|
-
df.loc[len(df)] = [
|
|
136
|
-
workorder_id,
|
|
137
|
-
project_id,
|
|
138
|
-
payment_type,
|
|
139
|
-
costing_rate,
|
|
140
|
-
hourly_rate,
|
|
141
|
-
hours,
|
|
142
|
-
cost,
|
|
143
|
-
user["name"],
|
|
144
|
-
project_name,
|
|
145
|
-
f"{project_id}: {project_name}",
|
|
146
|
-
]
|
|
147
|
-
|
|
148
|
-
df.to_csv(output, sep="\t", index=False)
|
|
149
|
-
logger.warning(f"{len(df)} journal entries successfully written to {output}")
|
|
150
|
-
else:
|
|
151
|
-
logger.warning("Start time must not be more than 3 months ago!")
|
|
152
|
-
|
|
153
|
-
if __name__ == "__main__":
|
|
154
|
-
main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|