Skip to content

Commit 543a2af

Browse files
authored
Feat: time commands (#19)
2 parents dab9964 + a12e7e7 commit 543a2af

23 files changed

+1169
-47
lines changed

.env.sample

+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
GAMCFGDIR=/home/compiler/.config/compiler-admin/gam
12
HARVEST_CLIENT_NAME=Client1
23
HARVEST_DATA=data/harvest-sample.csv
34
TOGGL_DATA=data/toggl-sample.csv

README.md

+40-7
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,19 @@ Built on top of [GAMADV-XTD3](https://github.com/taers232c/GAMADV-XTD3) and [GYB
1010

1111
```bash
1212
$ compiler-admin -h
13-
usage: compiler-admin [-h] [-v] {info,init,user} ...
13+
usage: compiler-admin [-h] [-v] {info,init,time,user} ...
1414

1515
positional arguments:
16-
{info,init,user} The command to run
17-
info Print configuration and debugging information.
18-
init Initialize a new admin project. This command should be run once before any others.
19-
user Work with users in the Compiler org.
16+
{info,init,time,user}
17+
The command to run
18+
info Print configuration and debugging information.
19+
init Initialize a new admin project. This command should be run once before any others.
20+
time Work with Compiler time entries.
21+
user Work with users in the Compiler org.
2022

2123
options:
22-
-h, --help show this help message and exit
23-
-v, --version show program's version number and exit
24+
-h, --help show this help message and exit
25+
-v, --version show program's version number and exit
2426
```
2527
2628
## Getting started
@@ -54,6 +56,37 @@ The `init` commands follows the steps in the [GAMADV-XTD3 Wiki](https://github.c
5456
5557
Additionally, GYB is used for Gmail backup/restore. See the [GYB Wiki](https://github.com/GAM-team/got-your-back/wiki) for more information.
5658
59+
## Working with time entires
60+
61+
The `time` command provides an interface for working with time entries from Compiler's various systems:
62+
63+
```bash
64+
$ compiler-admin time -h
65+
usage: compiler-admin time [-h] {convert} ...
66+
67+
positional arguments:
68+
{convert} The time command to run.
69+
convert Convert a time report from one format into another.
70+
71+
options:
72+
-h, --help show this help message and exit
73+
```
74+
75+
### Converting an hours report
76+
77+
With a CSV exported from either Harvest or Toggl, use this command to convert to the opposite format:
78+
79+
```bash
80+
$ compiler-admin time convert -h
81+
usage: compiler-admin time convert [-h] [--input INPUT] [--output OUTPUT] [--client CLIENT]
82+
83+
options:
84+
-h, --help show this help message and exit
85+
--input INPUT The path to the source data for conversion. Defaults to stdin.
86+
--output OUTPUT The path to the file where converted data should be written. Defaults to stdout.
87+
--client CLIENT The name of the client to use in converted data.
88+
```
89+
5790
## Working with users
5891

5992
The following commands are available to work with users in the Compiler domain:
+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from argparse import Namespace
2+
3+
from compiler_admin.commands.time.convert import convert # noqa: F401
4+
5+
6+
def time(args: Namespace, *extra):
7+
# try to call the subcommand function directly from global (module) symbols
8+
# if the subcommand function was imported above, it should exist in globals()
9+
global_env = globals()
10+
11+
if args.subcommand in global_env:
12+
cmd_func = global_env[args.subcommand]
13+
cmd_func(args, *extra)
14+
else:
15+
raise NotImplementedError(f"Unknown time subcommand: {args.subcommand}")
+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from argparse import Namespace
2+
3+
import pandas as pd
4+
5+
from compiler_admin import RESULT_SUCCESS
6+
from compiler_admin.services.harvest import INPUT_COLUMNS as TOGGL_COLUMNS, convert_to_toggl
7+
from compiler_admin.services.toggl import INPUT_COLUMNS as HARVEST_COLUMNS, convert_to_harvest
8+
9+
10+
def _get_source_converter(source):
11+
columns = pd.read_csv(source, nrows=0).columns.tolist()
12+
13+
if set(HARVEST_COLUMNS) <= set(columns):
14+
return convert_to_harvest
15+
elif set(TOGGL_COLUMNS) <= set(columns):
16+
return convert_to_toggl
17+
else:
18+
raise NotImplementedError("A converter for the given source data does not exist.")
19+
20+
21+
def convert(args: Namespace, *extras):
22+
converter = _get_source_converter(args.input)
23+
24+
converter(args.input, args.output, args.client)
25+
26+
return RESULT_SUCCESS

compiler_admin/commands/user/__init__.py

+8-5
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,12 @@
1010

1111

1212
def user(args: Namespace, *extra):
13-
# try to call the subcommand function directly from local symbols
14-
# if the subcommand function was imported above, it should exist in locals()
15-
if args.subcommand in locals():
16-
locals()[args.subcommand](args, *extra)
13+
# try to call the subcommand function directly from global (module) symbols
14+
# if the subcommand function was imported above, it should exist in globals()
15+
global_env = globals()
16+
17+
if args.subcommand in global_env:
18+
cmd_func = global_env[args.subcommand]
19+
cmd_func(args, *extra)
1720
else:
18-
raise ValueError(f"Unknown user subcommand: {args.subcommand}")
21+
raise NotImplementedError(f"Unknown user subcommand: {args.subcommand}")

compiler_admin/main.py

+58-30
Original file line numberDiff line numberDiff line change
@@ -4,78 +4,106 @@
44
from compiler_admin import __version__ as version
55
from compiler_admin.commands.info import info
66
from compiler_admin.commands.init import init
7+
from compiler_admin.commands.time import time
78
from compiler_admin.commands.user import user
89
from compiler_admin.commands.user.convert import ACCOUNT_TYPE_OU
910

1011

12+
def add_sub_cmd_parser(parser: ArgumentParser, dest="subcommand", help=None):
13+
"""Helper adds a subparser for the given dest."""
14+
return parser.add_subparsers(dest=dest, help=help)
15+
16+
1117
def add_sub_cmd(cmd: _SubParsersAction, subcmd, help) -> ArgumentParser:
1218
"""Helper creates a new subcommand parser."""
1319
return cmd.add_parser(subcmd, help=help)
1420

1521

16-
def add_sub_cmd_username(cmd: _SubParsersAction, subcmd, help) -> ArgumentParser:
22+
def add_sub_cmd_with_username_arg(cmd: _SubParsersAction, subcmd, help) -> ArgumentParser:
1723
"""Helper creates a new subcommand parser with a required username arg."""
18-
return add_username_arg(add_sub_cmd(cmd, subcmd, help=help))
19-
20-
21-
def add_username_arg(cmd: ArgumentParser) -> ArgumentParser:
22-
cmd.add_argument("username", help="A Compiler user account name, sans domain.")
23-
return cmd
24-
24+
sub_cmd = add_sub_cmd(cmd, subcmd, help=help)
25+
sub_cmd.add_argument("username", help="A Compiler user account name, sans domain.")
26+
return sub_cmd
2527

26-
def main(argv=None):
27-
argv = argv if argv is not None else sys.argv[1:]
28-
parser = ArgumentParser(prog="compiler-admin")
29-
30-
# https://stackoverflow.com/a/8521644/812183
31-
parser.add_argument(
32-
"-v",
33-
"--version",
34-
action="version",
35-
version=f"%(prog)s {version}",
36-
)
37-
38-
cmd_parsers = parser.add_subparsers(dest="command", help="The command to run")
3928

29+
def setup_info_command(cmd_parsers: _SubParsersAction):
4030
info_cmd = add_sub_cmd(cmd_parsers, "info", help="Print configuration and debugging information.")
4131
info_cmd.set_defaults(func=info)
4232

43-
init_cmd = add_sub_cmd_username(
33+
34+
def setup_init_command(cmd_parsers: _SubParsersAction):
35+
init_cmd = add_sub_cmd_with_username_arg(
4436
cmd_parsers, "init", help="Initialize a new admin project. This command should be run once before any others."
4537
)
4638
init_cmd.add_argument("--gam", action="store_true", help="If provided, initialize a new GAM project.")
4739
init_cmd.add_argument("--gyb", action="store_true", help="If provided, initialize a new GYB project.")
4840
init_cmd.set_defaults(func=init)
4941

42+
43+
def setup_time_command(cmd_parsers: _SubParsersAction):
44+
time_cmd = add_sub_cmd(cmd_parsers, "time", help="Work with Compiler time entries.")
45+
time_cmd.set_defaults(func=time)
46+
time_subcmds = add_sub_cmd_parser(time_cmd, help="The time command to run.")
47+
48+
time_convert = add_sub_cmd(time_subcmds, "convert", help="Convert a time report from one format into another.")
49+
time_convert.add_argument(
50+
"--input", default=sys.stdin, help="The path to the source data for conversion. Defaults to stdin."
51+
)
52+
time_convert.add_argument(
53+
"--output", default=sys.stdout, help="The path to the file where converted data should be written. Defaults to stdout."
54+
)
55+
time_convert.add_argument("--client", default=None, help="The name of the client to use in converted data.")
56+
57+
58+
def setup_user_command(cmd_parsers: _SubParsersAction):
5059
user_cmd = add_sub_cmd(cmd_parsers, "user", help="Work with users in the Compiler org.")
5160
user_cmd.set_defaults(func=user)
52-
user_subcmds = user_cmd.add_subparsers(dest="subcommand", help="The user command to run.")
61+
user_subcmds = add_sub_cmd_parser(user_cmd, help="The user command to run.")
5362

54-
user_create = add_sub_cmd_username(user_subcmds, "create", help="Create a new user in the Compiler domain.")
63+
user_create = add_sub_cmd_with_username_arg(user_subcmds, "create", help="Create a new user in the Compiler domain.")
5564
user_create.add_argument("--notify", help="An email address to send the newly created account info.")
5665

57-
user_convert = add_sub_cmd_username(user_subcmds, "convert", help="Convert a user account to a new type.")
66+
user_convert = add_sub_cmd_with_username_arg(user_subcmds, "convert", help="Convert a user account to a new type.")
5867
user_convert.add_argument("account_type", choices=ACCOUNT_TYPE_OU.keys(), help="Target account type for this conversion.")
5968

60-
user_delete = add_sub_cmd_username(user_subcmds, "delete", help="Delete a user account.")
69+
user_delete = add_sub_cmd_with_username_arg(user_subcmds, "delete", help="Delete a user account.")
6170
user_delete.add_argument("--force", action="store_true", default=False, help="Don't ask for confirmation before deletion.")
6271

63-
user_offboard = add_sub_cmd_username(user_subcmds, "offboard", help="Offboard a user account.")
72+
user_offboard = add_sub_cmd_with_username_arg(user_subcmds, "offboard", help="Offboard a user account.")
6473
user_offboard.add_argument("--alias", help="Account to assign username as an alias.")
6574
user_offboard.add_argument(
6675
"--force", action="store_true", default=False, help="Don't ask for confirmation before offboarding."
6776
)
6877

69-
user_reset = add_sub_cmd_username(
78+
user_reset = add_sub_cmd_with_username_arg(
7079
user_subcmds, "reset-password", help="Reset a user's password to a randomly generated string."
7180
)
7281
user_reset.add_argument("--notify", help="An email address to send the newly generated password.")
7382

74-
add_sub_cmd_username(user_subcmds, "restore", help="Restore an email backup from a prior offboarding.")
83+
add_sub_cmd_with_username_arg(user_subcmds, "restore", help="Restore an email backup from a prior offboarding.")
7584

76-
user_signout = add_sub_cmd_username(user_subcmds, "signout", help="Signs a user out from all active sessions.")
85+
user_signout = add_sub_cmd_with_username_arg(user_subcmds, "signout", help="Signs a user out from all active sessions.")
7786
user_signout.add_argument("--force", action="store_true", default=False, help="Don't ask for confirmation before signout.")
7887

88+
89+
def main(argv=None):
90+
argv = argv if argv is not None else sys.argv[1:]
91+
parser = ArgumentParser(prog="compiler-admin")
92+
93+
# https://stackoverflow.com/a/8521644/812183
94+
parser.add_argument(
95+
"-v",
96+
"--version",
97+
action="version",
98+
version=f"%(prog)s {version}",
99+
)
100+
101+
cmd_parsers = add_sub_cmd_parser(parser, dest="command", help="The command to run")
102+
setup_info_command(cmd_parsers)
103+
setup_init_command(cmd_parsers)
104+
setup_time_command(cmd_parsers)
105+
setup_user_command(cmd_parsers)
106+
79107
if len(argv) == 0:
80108
argv = ["info"]
81109

compiler_admin/services/files.py

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import json
2+
3+
import pandas as pd
4+
5+
6+
def read_csv(file_path, **kwargs) -> pd.DataFrame:
7+
"""Read a file path or buffer of CSV data into a pandas.DataFrame."""
8+
return pd.read_csv(file_path, **kwargs)
9+
10+
11+
def read_json(file_path: str):
12+
"""Read a file path of JSON data into a python object."""
13+
with open(file_path, "r") as f:
14+
return json.load(f)
15+
16+
17+
def write_csv(file_path, data: pd.DataFrame, columns: list[str] = None):
18+
"""Write a pandas.DataFrame as CSV to the given path or buffer, with an optional list of columns to write."""
19+
data.to_csv(file_path, columns=columns, index=False)
20+
21+
22+
def write_json(file_path: str, data):
23+
"""Write a python object as JSON to the given path."""
24+
with open(file_path, "w") as f:
25+
json.dump(data, f, indent=2)

compiler_admin/services/harvest.py

+91
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
from datetime import datetime, timedelta
2+
import os
3+
import sys
4+
from typing import TextIO
5+
6+
import pandas as pd
7+
8+
import compiler_admin.services.files as files
9+
10+
# input CSV columns needed for conversion
11+
INPUT_COLUMNS = ["Date", "Client", "Project", "Notes", "Hours", "First name", "Last name"]
12+
13+
# default output CSV columns
14+
OUTPUT_COLUMNS = ["Email", "Start date", "Start time", "Duration", "Project", "Task", "Client", "Billable", "Description"]
15+
16+
17+
def _calc_start_time(group: pd.DataFrame):
18+
"""Start time is offset by the previous record's duration, with a default of 0 offset for the first record."""
19+
group["Start time"] = group["Start time"] + group["Duration"].shift(fill_value=pd.to_timedelta("00:00:00")).cumsum()
20+
return group
21+
22+
23+
def _duration_str(duration: timedelta) -> str:
24+
"""Use total seconds to convert to a datetime and format as a string e.g. 01:30."""
25+
return datetime.fromtimestamp(duration.total_seconds()).strftime("%H:%M")
26+
27+
28+
def _toggl_client_name():
29+
"""Gets the value of the TOGGL_CLIENT_NAME env var."""
30+
return os.environ.get("TOGGL_CLIENT_NAME")
31+
32+
33+
def convert_to_toggl(
34+
source_path: str | TextIO = sys.stdin,
35+
output_path: str | TextIO = sys.stdout,
36+
client_name: str = None,
37+
output_cols: list[str] = OUTPUT_COLUMNS,
38+
):
39+
"""Convert Harvest formatted entries in source_path to equivalent Toggl formatted entries.
40+
41+
Args:
42+
source_path: The path to a readable CSV file of Harvest time entries; or a readable buffer of the same.
43+
44+
output_cols (list[str]): A list of column names for the output
45+
46+
output_path: The path to a CSV file where Toggl time entries will be written; or a writeable buffer for the same.
47+
48+
Returns:
49+
None. Either prints the resulting CSV data or writes to output_path.
50+
"""
51+
if client_name is None:
52+
client_name = _toggl_client_name()
53+
54+
# read CSV file, parsing dates
55+
source = files.read_csv(source_path, usecols=INPUT_COLUMNS, parse_dates=["Date"], cache_dates=True)
56+
57+
# rename columns that can be imported as-is
58+
source.rename(columns={"Project": "Task", "Notes": "Description", "Date": "Start date"}, inplace=True)
59+
60+
# update static calculated columns
61+
source["Client"] = client_name
62+
source["Project"] = client_name
63+
source["Billable"] = "Yes"
64+
65+
# add the Email column
66+
source["Email"] = source["First name"].apply(lambda x: f"{x.lower()}@compiler.la")
67+
68+
# Convert numeric Hours to timedelta Duration
69+
source["Duration"] = source["Hours"].apply(pd.to_timedelta, unit="hours")
70+
71+
# Default start time to 09:00
72+
source["Start time"] = pd.to_timedelta("09:00:00")
73+
74+
user_days = (
75+
source
76+
# sort and group by email and date
77+
.sort_values(["Email", "Start date"]).groupby(["Email", "Start date"], observed=False)
78+
# calculate a start time within each group (excluding the groupby columns)
79+
.apply(_calc_start_time, include_groups=False)
80+
)
81+
82+
# convert timedeltas to duration strings
83+
user_days["Duration"] = user_days["Duration"].apply(_duration_str)
84+
user_days["Start time"] = user_days["Start time"].apply(_duration_str)
85+
86+
# re-sort by start date/time and user
87+
# reset the index to get rid of the group multi index and fold the group columns back down
88+
output_data = pd.DataFrame(data=user_days).reset_index()
89+
output_data.sort_values(["Start date", "Start time", "Email"], inplace=True)
90+
91+
files.write_csv(output_path, output_data, output_cols)

0 commit comments

Comments
 (0)