Skip to content

Commit 3a381b0

Browse files
add a script to merge records of multiple databases (#455)
* add a script to merge records of multiple databases The timetagger-mergedatabases.py is intended to merge TimeTagger record tables of multiple user databases into one user database, replacing the existing records. Extends the records by user information. This can be used as a workaround to create a unified view of the records of all or a group of users. * copy settings * improve records arguments handling * fix handling * cleanup * pkg: add timetagger_multiuser_tweaks.py as script * add replace parameter ... to be able to replace text in the descriptions during merging. * Update setup.py * Apply suggestions from code review --------- Co-authored-by: Almar Klein <[email protected]>
1 parent 5a33a3b commit 3a381b0

File tree

2 files changed

+346
-0
lines changed

2 files changed

+346
-0
lines changed
Lines changed: 345 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,345 @@
1+
#!/usr/bin/env python3
2+
3+
"""
4+
Some utilities to help running TimeTagger in a multi-user environment.
5+
6+
Examples:
7+
* multiuser_tweaks.py records --merge --dest newuser
8+
* merges TimeTagger record tables of multiple users into one user database,
9+
replacing the existing records. Extends the records by user information.
10+
This can be used to create a timetagger view of the records of multiple users.
11+
* multiuser_tweaks.py settings --settings settings.json
12+
* copy settings from a JSON file to the users settings database tables.
13+
14+
15+
"""
16+
17+
import argparse
18+
import binascii
19+
import json
20+
import logging
21+
import pathlib
22+
from pprint import pprint, pformat # noqa
23+
import sys
24+
import time
25+
26+
from itemdb import ItemDB
27+
from timetagger.server._utils import user2filename, filename2user, ROOT_USER_DIR
28+
29+
30+
def setup_parser():
31+
"""setup argument parsing"""
32+
epilog = """%(prog)s is a helper tool for TimeTagger in multi-user environments.
33+
TimeTagger uses a separate Sqlite database per user.
34+
This tool directly accesses the databases and offers to manipulate them.
35+
Especially it can merge the records of multiple users
36+
into one user database, replacing the existing records.
37+
Every newly generated record is extended by the tag '#user/<username>'.
38+
This way each record still show to which user it belongs.
39+
"""
40+
41+
argparser = argparse.ArgumentParser(
42+
description="Perform actions on TimeTagger databases.",
43+
epilog=epilog,
44+
)
45+
argparser.add_argument(
46+
"-d", "--debug", action="store_true", help="enable debugging output"
47+
)
48+
49+
subparsers = argparser.add_subparsers()
50+
51+
# users
52+
parser_users = subparsers.add_parser("users")
53+
parser_users.add_argument(
54+
"--list", action="store_true", help="show all available user databases"
55+
)
56+
parser_users.set_defaults(parser=parser_users, func=handle_users_command)
57+
58+
# records
59+
parser_records = subparsers.add_parser(
60+
"records",
61+
description="Perform action on the records tables of TimeTagger databases.",
62+
)
63+
parser_records.add_argument(
64+
"--dump", action="store_true", help="dump records of user databases"
65+
)
66+
records_merge_group = parser_records.add_argument_group("merging")
67+
records_merge_group.add_argument(
68+
"--merge",
69+
action="store_true",
70+
help="merge source user database records into dest_user records, overwriting all existing records",
71+
)
72+
records_merge_group.add_argument(
73+
"--dest",
74+
metavar="dest_user",
75+
default="tt_all",
76+
help="destination user database (default: '%(default)s')",
77+
)
78+
records_merge_group.add_argument(
79+
"--replace",
80+
action="append",
81+
help="Replace text entries. The format is '/original_text/replacement_text/'. You can use other separators instead of '/'. This parameter can be given multiple times.",
82+
)
83+
parser_records.add_argument(
84+
"source_user", nargs="*", help="source user (default: <all>)"
85+
)
86+
parser_records.set_defaults(parser=parser_records, func=handle_records_command)
87+
88+
# settings
89+
parser_settings = subparsers.add_parser(
90+
"settings",
91+
description="Perform action on the settings tables of TimeTagger databases.",
92+
)
93+
parser_settings.add_argument(
94+
"--dump", action="store_true", help="dump settings from user databases"
95+
)
96+
parser_settings.add_argument(
97+
"--source",
98+
help='copy settings from JSON file into user settings databases. Format: { "key1": "value1", "key2": "value2" [, ...] }',
99+
type=argparse.FileType("r"),
100+
metavar="<filename.json>",
101+
)
102+
parser_settings.add_argument(
103+
"--force", action="store_true", help="overwrite existing user settings"
104+
)
105+
parser_settings.add_argument(
106+
"dest",
107+
metavar="dest_user",
108+
nargs="*",
109+
help="destination user database (default: '<all_users>')",
110+
)
111+
parser_settings.set_defaults(parser=parser_settings, func=handle_settings_command)
112+
113+
return argparser
114+
115+
116+
def itemdb_exists(db, table):
117+
if db.mtime < 0:
118+
return False
119+
if table not in db.get_table_names():
120+
return False
121+
return True
122+
123+
124+
def get_translation_table(replace):
125+
if replace is None:
126+
return None
127+
table = {}
128+
for i in replace:
129+
sep = i[0]
130+
strings = i.split(sep)
131+
if len(strings) != 4 or len(strings[0]) != 0 or len(strings[3]) != 0:
132+
raise ValueError(
133+
f"Replacement string ('{i}') has an invalid form. Use '/original_text/replacement_text/'. You can use other separators instead of '|'."
134+
)
135+
table[strings[1]] = strings[2]
136+
return table
137+
138+
139+
class TimeTaggerDB:
140+
def get_timetagger_usernames(self, exclude_users=None):
141+
logger = logging.getLogger()
142+
logger.debug("ROOT_USER_DIR: %s", ROOT_USER_DIR)
143+
ignore_usernames = ["defaultuser"]
144+
if exclude_users:
145+
ignore_usernames += exclude_users
146+
for filename in pathlib.Path(ROOT_USER_DIR).glob("*.db"):
147+
try:
148+
username = filename2user(filename)
149+
if username in ignore_usernames:
150+
logger.debug("skipping username '%s'", username)
151+
else:
152+
logger.debug("db: %s, user: %s", filename, username)
153+
yield username
154+
except binascii.Error:
155+
logger.warning(
156+
"failed to extract username from filename '%s', skipped.", filename
157+
)
158+
159+
def dump_db_by_usernames(self, usernames, table):
160+
for username in usernames:
161+
self.dump_db_by_username(username, table)
162+
163+
def dump_db_by_username(self, username, table):
164+
print()
165+
print(f"username: {username}")
166+
filename = user2filename(username)
167+
return self.dump_db_by_filename(filename, table)
168+
169+
def dump_db_by_filename(self, filename, table):
170+
print(f"filename: {filename}")
171+
db = ItemDB(filename)
172+
if not itemdb_exists(db, table):
173+
print("database: not existant")
174+
return
175+
self.dump_db(db, table)
176+
177+
def dump_db(self, db, table):
178+
with db:
179+
for i in db.select_all(table):
180+
print(i)
181+
182+
183+
class Settings(TimeTaggerDB):
184+
TABLE = "settings"
185+
186+
def __init__(self, dest_usernames=None):
187+
self.logger = logging.getLogger()
188+
if dest_usernames:
189+
self.dest_usernames = dest_usernames
190+
else:
191+
self.dest_usernames = self.get_timetagger_usernames()
192+
193+
def dump(self):
194+
self.dump_db_by_usernames(self.dest_usernames, self.TABLE)
195+
196+
def distribute_to_user(self, username, settings, force):
197+
db_filename = user2filename(username)
198+
db = ItemDB(db_filename)
199+
if not itemdb_exists(db, self.TABLE):
200+
print(" skipped, database (table) does not exists")
201+
return
202+
now = int(time.time())
203+
with db:
204+
for key, value in settings.items():
205+
print(f" '{key}': ", end="")
206+
result = db.select_one(self.TABLE, "key = ?", key)
207+
if key == "tag_presets":
208+
current_set = set()
209+
if result:
210+
current_set = set(result.get("value"))
211+
new_set = set(value)
212+
if new_set <= current_set:
213+
print("skipped (values already set)")
214+
continue
215+
print("adding: ", list(new_set - current_set))
216+
value = list(current_set | new_set)
217+
db.put_one(self.TABLE, key=key, value=value, st=now, mt=now)
218+
elif result and not force:
219+
print("skipped (already set)")
220+
else:
221+
print(value)
222+
db.put_one(self.TABLE, key=key, value=value, st=now, mt=now)
223+
224+
def distribute(self, settings, force):
225+
# for key, value in settings.items():
226+
# print(f"key: {key}, value: {value}")
227+
for username in self.dest_usernames:
228+
print(f"distribute settings to user '{username}'")
229+
self.distribute_to_user(username, settings, force)
230+
231+
232+
class Records(TimeTaggerDB):
233+
"""Merge multiple ItemDB records tables"""
234+
235+
TABLE = "records"
236+
TMP_TABLE = "records_new"
237+
# INDICES = ("!key", "st", "t1", "t2")
238+
# add user column. Not used by timetagger, but simply ignored.
239+
INDICES = ("!key", "st", "t1", "t2", "user")
240+
241+
def __init__(self, target_username=None):
242+
self.logger = logging.getLogger()
243+
self.target_username = target_username
244+
if self.target_username:
245+
self.target_db_filename = user2filename(target_username)
246+
self.target_db = ItemDB(self.target_db_filename)
247+
self.target_db.ensure_table(self.TMP_TABLE, *self.INDICES)
248+
249+
def dump_db_by_usernames(self, users):
250+
if not users:
251+
users = list(self.get_timetagger_usernames())
252+
super().dump_db_by_usernames(users, self.TABLE)
253+
254+
def clear(self):
255+
with self.target_db:
256+
self.target_db.delete_table(self.TABLE)
257+
self.target_db.ensure_table(self.TABLE, *self.INDICES)
258+
259+
def merge_user_db(self, username, replace_dict):
260+
if self.target_username is None:
261+
raise RuntimeError("Target database is not initialized")
262+
filename = user2filename(username)
263+
db = ItemDB(filename)
264+
if not itemdb_exists(db, self.TABLE):
265+
raise RuntimeError(
266+
f"Accessing database {filename} failed: no such file or table ({self.TABLE})"
267+
)
268+
with self.target_db:
269+
with db:
270+
for row in db.select_all(self.TABLE):
271+
try:
272+
if "#user/" not in row["ds"]:
273+
row["ds"] += f" #user/{username}"
274+
except KeyError:
275+
row["ds"] = f"#user/{username}"
276+
row["user"] = username
277+
if replace_dict:
278+
for orig, replacement in replace_dict.items():
279+
row["ds"] = row["ds"].replace(orig, replacement)
280+
self.target_db.put(self.TMP_TABLE, row)
281+
282+
def merge(self, users=None, replace=None):
283+
if not users:
284+
users = list(self.get_timetagger_usernames([self.target_username]))
285+
replace_dict = get_translation_table(replace)
286+
if replace_dict:
287+
print("replacements: {}".format(pformat(replace_dict)))
288+
for username in users:
289+
print(f"merging user records of '{username}'")
290+
self.merge_user_db(username, replace_dict)
291+
with self.target_db:
292+
# delete running timers
293+
self.target_db.delete(self.TMP_TABLE, "t1 = t2")
294+
with self.target_db:
295+
if itemdb_exists(self.target_db, self.TABLE):
296+
self.target_db.delete_table(self.TABLE)
297+
self.target_db.rename_table(self.TMP_TABLE, self.TABLE)
298+
299+
300+
def handle_users_command(args):
301+
# currently, there is only the list subcommand:
302+
print(list(TimeTaggerDB().get_timetagger_usernames()))
303+
304+
305+
def handle_settings_command(args):
306+
settings = Settings(args.dest)
307+
if args.source:
308+
settings_data = json.load(args.source)
309+
# pprint(settings_data)
310+
settings.distribute(settings_data, args.force)
311+
elif args.dump:
312+
settings.dump()
313+
else:
314+
args.parser.print_help()
315+
316+
317+
def handle_records_command(args):
318+
if args.dump:
319+
Records().dump_db_by_usernames(args.source_user)
320+
sys.exit(0)
321+
elif args.merge:
322+
print(f"creating database for user '{args.dest}'")
323+
print(f"filename: {user2filename(args.dest)}")
324+
records = Records(args.dest)
325+
records.merge(args.source_user, args.replace)
326+
else:
327+
args.parser.print_help()
328+
329+
330+
if __name__ == "__main__":
331+
logging.basicConfig(
332+
format="%(levelname)s %(module)s.%(funcName)s: %(message)s", level=logging.INFO
333+
)
334+
logger = logging.getLogger()
335+
336+
parser = setup_parser()
337+
args = parser.parse_args()
338+
if args.debug:
339+
logger.setLevel(logging.DEBUG)
340+
logger.debug(args)
341+
342+
if hasattr(args, "func"):
343+
args.func(args)
344+
else:
345+
parser.print_help()

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
package_data={
3434
f"timetagger.{x}": ["*"] for x in ["common", "images", "app", "pages"]
3535
},
36+
scripts=["contrib/multiuser_tweaks/timetagger_multiuser_tweaks.py"],
3637
python_requires=">=3.6.0",
3738
install_requires=runtime_deps,
3839
license="GPL-3.0",

0 commit comments

Comments
 (0)