diff --git a/.gitignore b/.gitignore index 187bb2d..000c5cd 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ Testplan.md .venv users/ backup/ +settings/ Archiv/ Docker/ -docker-work/ \ No newline at end of file +docker-work/ +Wiki/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index ff4197a..d892ad0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,13 @@ -FROM debian:latest +FROM python:latest RUN apt update && apt upgrade -y -RUN apt install python3 python3-pip python3.11-venv locales -y +RUN apt install locales -y RUN mkdir /app RUN mkdir /.venv RUN mkdir /backup RUN mkdir /settings -RUN python3 -m venv /.venv -RUN /.venv/bin/pip install nicegui -RUN /.venv/bin/pip install segno -RUN /.venv/bin/pip install python-dateutil +RUN pip install nicegui +RUN pip install segno +RUN pip install python-dateutil RUN sed -i '/de_DE.UTF-8/s/^# //g' /etc/locale.gen && \ locale-gen @@ -19,4 +18,4 @@ ENV LC_ALL de_DE.UTF-8 COPY main.py /app/main.py COPY lib /app/lib/ EXPOSE 8090 -ENTRYPOINT ["/.venv/bin/python", "/app/main.py"] +ENTRYPOINT ["python", "/app/main.py"] diff --git a/create_docker.py b/create_docker.py index e7e6170..d3266a9 100644 --- a/create_docker.py +++ b/create_docker.py @@ -5,13 +5,13 @@ import os server = 'gitea.am-td.de' server_user = 'alexander' -if os.getuid() == 0: - subprocess.run(["docker", "build", "--force-rm", "-t", f"{server}/{server_user}/{app_title.lower()}:{app_version}", "."]) - if input("docker-compose erstellen j=JA ") == "j": - userfolder = input("Pfad für Benutzerdaten /users:") - backupfolder = input("Pfad für Backupdaten /backup:") - settingsfolder = input("Pfad für Einstellungen /settings:") - docker_compose_content = f''' +#if os.getuid() == 0: +subprocess.run(["docker", "build", "--force-rm", "-t", f"{server}/{server_user}/{app_title.lower()}:{app_version}", "."]) +if input("docker-compose erstellen j=JA ") == "j": + userfolder = input("Pfad für Benutzerdaten /users:") + backupfolder = input("Pfad für Backupdaten /backup:") + settingsfolder = input("Pfad für Einstellungen /settings:") + docker_compose_content = f''' services: zeiterfassung: image: {server}/{server_user}/{app_title.lower()}:{app_version.lower()} @@ -25,7 +25,7 @@ services: - {backupfolder}:/backup - {settingsfolder}:/settings''' - with open('docker-compose.yml', 'w') as docker_compose: - docker_compose.write(docker_compose_content) -else: - print("Es werden Root-Rechte benötigt.") + with open('docker-compose.yml', 'w') as docker_compose: + docker_compose.write(docker_compose_content) +#else: +# print("Es werden Root-Rechte benötigt.") diff --git a/docker-compose.yml b/docker-compose.yml index 589fadc..dc3a301 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,13 +1,14 @@ services: zeiterfassung: - image: gitea.am-td.de/alexander/zeiterfassung:beta-2025.0.1 + image: gitea.am-td.de/alexander/zeiterfassung:beta-2025.0.2 restart: always ports: - 8090:8090 environment: - PYTHONUNBUFFERED=1 volumes: - - ./docker-work/users:/users - - ./docker-work/backup:/backup - - ./docker-work/settings:/settings \ No newline at end of file + - ./users:/users + - ./backup:/backup + - ./settings:/settings + - /etc/localtime:/etc/localtime:ro diff --git a/lib/admin.py b/lib/admin.py index bd27e74..4804882 100644 --- a/lib/admin.py +++ b/lib/admin.py @@ -4,6 +4,7 @@ import dateutil.easter from dateutil.easter import * from nicegui import ui, app, events +from nicegui.functions.navigate import navigate from nicegui.html import button from nicegui.events import KeyEventArguments @@ -44,6 +45,10 @@ def page_admin(): updates_available = ValueBinder() updates_available.value = False + delete_binder = ValueBinder() + delete_binder.value = True + delete_info = ValueBinder() + delete_info.value = False enabled_because_not_docker = ValueBinder if is_docker(): @@ -65,6 +70,13 @@ def page_admin(): def update_userlist(): nonlocal userlist userlist = list_users() + # Benutzerliste um No Time Users bereinigen + users_to_remove = [ ] + for entry in userlist: + if entry in get_no_time_users_list(): + users_to_remove.append(entry) + for i in users_to_remove: + userlist.remove(i) update_userlist() @@ -759,17 +771,27 @@ def page_admin(): with ui.tab_panel(settings): with ui.grid(columns='auto auto'): with ui.card(): - ui.label("Administrationsbenutzer:").classes('text-bold') - with ui.grid(columns=2).classes('items-baseline'): - - ui.label("Benutzername des Adminstrators:") - admin_user = ui.input().tooltip("Geben Sie hier den Benutzernamen für den Adminstationsnutzer ein") - admin_user.value = data["admin_user"] - ui.label("Passwort des Administrators:") - admin_password = ui.input(password=True).tooltip("Geben Sie hier das Passwort für den Administationsnutzer ein. Merken Sie sich dieses Passwort gut. Es kann nicht über das Webinterface zurückgesetzt werden.") + ui.label("Benutzereinstellungen:").classes('text-bold') + with ui.grid(columns=2).classes('items-center'): + ui.label("Benutzer mit Administrationsrechten:") + user_switch_list = [] + with ui.grid(columns=2).classes('gap-y-0'): + for i in list_users(): + user_switch_list.append(ui.switch(i)) + for i in user_switch_list: + if i.text in get_admin_list(): + i.value = True secret = data["secret"] - + ui.separator().classes('col-span-2') + ui.label("Benutzer ohne Zeiterfassung:") + no_time_user_switch_list = [ ] + with ui.grid(columns=2).classes('gap-y-0'): + for i in list_users(): + no_time_user_switch_list.append(ui.switch(i)) + for item in no_time_user_switch_list: + if item.text in get_no_time_users_list(): + item.value = True with ui.card(): ui.label("Systemeinstellungen:").classes('text-bold') with ui.grid(columns=2).classes('items-baseline'): @@ -1045,31 +1067,56 @@ def page_admin(): holiday_section() def save_admin_settings(): - write_adminsetting("admin_user", admin_user.value) - if admin_password.value != "": - write_adminsetting("admin_password", hash_password(admin_password.value)) - else: - write_adminsetting("admin_password", data["admin_password"]) - write_adminsetting("port", port.value) - write_adminsetting("secret", secret) - write_adminsetting("touchscreen", touchscreen_switch.value) - write_adminsetting("times_on_touchscreen", timestamp_switch.value) - write_adminsetting("photos_on_touchscreen", photo_switch.value) - write_adminsetting("picture_height", picture_height_input.value) - write_adminsetting("button_height", button_height_input.value) - write_adminsetting("user_notes", notes_switch.value) - write_adminsetting("holidays", data["holidays"]) - write_adminsetting("vacation_application", va_switch.value) - - if int(old_port) != int(port.value): + admin_users = { } + admin_counter = -1 + for i in user_switch_list: + if i.value == True: + admin_counter += 1 + admin_users[str(admin_counter)] = i.text + if len(admin_users) == 0: with ui.dialog() as dialog, ui.card(): - ui.label( - "Damit die Porteinstellungen wirksam werden, muss der Server neu gestartet werden.") - ui.button("OK", on_click=lambda: dialog.close()) + ui.label("Es wurde kein Administrationsbenutzer ausgewählt. Mindestens ein Benutzer muss Administrationsrechte haben.") + ui.button("OK", on_click=dialog.close) dialog.open() - ui.notify("Einstellungen gespeichert") - reset_visibility.value = False - timetable.refresh() + else: + + no_time_users = { } + no_time_users_counter = -1 + for i in no_time_user_switch_list: + if i.value == True: + no_time_users_counter += 1 + no_time_users[str(no_time_users_counter)] = i.text + + old_no_time_users_list = get_no_time_users_list() + + write_adminsetting("admin_user", admin_users) + write_adminsetting("no_time_users", no_time_users) + write_adminsetting("port", port.value) + write_adminsetting("secret", secret) + write_adminsetting("touchscreen", touchscreen_switch.value) + write_adminsetting("times_on_touchscreen", timestamp_switch.value) + write_adminsetting("photos_on_touchscreen", photo_switch.value) + write_adminsetting("picture_height", picture_height_input.value) + write_adminsetting("button_height", button_height_input.value) + write_adminsetting("user_notes", notes_switch.value) + write_adminsetting("holidays", data["holidays"]) + write_adminsetting("vacation_application", va_switch.value) + + if int(old_port) != int(port.value): + with ui.dialog() as dialog, ui.card(): + ui.label( + "Damit die Porteinstellungen wirksam werden, muss der Server neu gestartet werden.") + ui.button("OK", on_click=lambda: dialog.close()) + dialog.open() + ui.notify("Einstellungen gespeichert") + reset_visibility.value = False + timetable.refresh() + + if not set(old_no_time_users_list) == set(get_no_time_users_list()): + with ui.dialog() as infobox, ui.card(): + ui.label("Benutzer ohne Zeiterfassung wurden geändert. Die Seite wird neu geladen.") + ui.button("OK", on_click=ui.navigate.reload) + infobox.open() with ui.button("Speichern", on_click=save_admin_settings): with ui.tooltip(): @@ -1080,7 +1127,6 @@ def page_admin(): workhours = [ ] with ui.row(): - def user_selection_changed(): try: if user_selection.value != None: @@ -1089,7 +1135,12 @@ def page_admin(): fullname_input.value = current_user.fullname #password_input.value = current_user.password usersettingscard.visible = True - + if current_user.username in get_admin_list(): + delete_info.value = True + delete_binder.value = False + else: + delete_info.value = False + delete_binder.value = True api_key_input.value = current_user.api_key api_link_column.clear() @@ -1162,7 +1213,7 @@ def page_admin(): ui.label("Benutzername wurde geändert.").classes('text-bold') ui.label(f"Benutzerdaten werden in den neuen Ordner {username_input.value} verschoben.") ui.label("Sollen die Einstellungen gespeichert werden?") - with ui.row(): + with ui.row().classes('w-full justify-center'): ui.button("Speichern", on_click=save_settings) ui.button("Abbrechen", on_click=dialog.close) dialog.open() @@ -1215,9 +1266,9 @@ def page_admin(): with ui.dialog() as dialog, ui.card(): ui.label("Sollen die Änderungen an den Arbeitsstunden und/oder Urlaubstagen gespeichert werden?") - with ui.row(): + with ui.row().classes('justify-center w-full'): ui.button("Speichern", on_click=save_settings) - ui.button("Abrrechen", on_click=dialog.close) + ui.button("Abbrechen", on_click=dialog.close) dialog.open() def delete_workhour_entry(): @@ -1321,16 +1372,18 @@ def page_admin(): ui.button("Neu", on_click=new_api_key).tooltip("Neuen API-Schlüssel erzeugen. Wird erst beim Klick auf Speichern übernommen und entsprechende Links und QR-Codes aktualisiert") ui.label('Aufruf zum Stempeln:') global api_link_column - with ui.column().classes('gap-0') as api_link_column: - global stamp_link - stamp_link = [ ] - for i in app.urls: - stamp_link.append(ui.link(f'{i}/api/stamp/"API-Schüssel"')) - + with ui.expansion("").classes('w-full'): + with ui.column().classes('gap-0') as api_link_column: + global stamp_link + stamp_link = [ ] + for i in app.urls: + stamp_link.append(ui.link(f'{i}/api/stamp/"API-Schüssel"')) + ui.label("Administratoren können nicht gelöscht werden. Um das Konto zu löschen, müssen Sie ihm zuerst die Administrationsrechte entziehen.").bind_visibility_from(delete_info, 'value').classes('font-bold text-red') with ui.grid(columns=2): ui.button("Speichern", on_click=save_user_settings).tooltip("Klicken Sie hier um die Änderungen zu speichern.") - ui.button("Löschen", on_click=del_user) + ui.button("Löschen", on_click=del_user).bind_enabled_from(delete_binder, 'value') + usersettings_card() diff --git a/lib/definitions.py b/lib/definitions.py index f8a4747..31ab1ee 100644 --- a/lib/definitions.py +++ b/lib/definitions.py @@ -6,7 +6,7 @@ from pathlib import Path import hashlib app_title = "Zeiterfassung" -app_version = "beta-2025.0.1" +app_version = "beta-2025.0.2" # Standardpfade @@ -36,8 +36,9 @@ status_out = "ausgestempelt" # Standardadmin Settings: -standard_adminsettings = { "admin_user": "admin", - "admin_password": "8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918", +standard_adminsettings = { "admin_user": { + 0: "admin"}, + "no_time_users": { }, "port": "8090", "secret": "ftgzuhjikg,mt5jn46uzer8sfi9okrmtzjhndfierko5zltjhdgise", "times_on_touchscreen": True, @@ -55,9 +56,9 @@ standard_adminsettings = { "admin_user": "admin", # Standard User Settings: standard_usersettings = { - "username": "default", - "fullname": "Standardbenutzer", - "password": "37a8eec1ce19687d132fe29051dca629d164e2c4958ba141d5f4133a33f0688f", + "username": "admin", + "fullname": "Administrator", + "password": "8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918", "api_key": "1234567890", "workhours": { } } diff --git a/lib/login.py b/lib/login.py deleted file mode 100644 index 25520ae..0000000 --- a/lib/login.py +++ /dev/null @@ -1,41 +0,0 @@ -from datetime import datetime - -from nicegui import ui, app -from lib.web_ui import * - -from lib.users import * -from lib.definitions import * -from calendar import monthrange - -import hashlib -import calendar -import locale - -@ui.page('/login') -def page_login(): - - # Settingsdatei einlesen - data = load_adminsettings() - - def login(): - nonlocal data - - if username.value == data["admin_user"]: - print(f"Input Hash: {hash_password(password.value)} gespeichert: {data['admin_password']}") - if hash_password(password.value) == data["admin_password"]: - app.storage.user['authenticated'] = True - ui.navigate.to("/admin") - else: - ui.notify("Login fehlgeschlagen") - - #ui.markdown(f"## {app_title} {app_version}") - #ui.markdown("Bitte einloggen") - - pageheader("Bitte einloggen:") - - with ui.grid(columns=2): - ui.markdown("Benutzer:") - username = ui.input('Benutzername') - ui.markdown("Passwort:") - password = ui.input('Passwort', password=True) - ui.button(text="Login", on_click=lambda: login()) \ No newline at end of file diff --git a/lib/touchscreen.py b/lib/touchscreen.py index 9557552..a06beca 100644 --- a/lib/touchscreen.py +++ b/lib/touchscreen.py @@ -41,15 +41,15 @@ def page_touchscreen(): def set_columns(width): nonlocal number_of_columns if width > 1400: - number_of_columns = 6 - elif width > 1200: number_of_columns = 5 - elif width > 900: + elif width > 1200: number_of_columns = 4 - elif width > 750: + elif width > 900: number_of_columns = 3 - else: + elif width > 750: number_of_columns = 2 + else: + number_of_columns = 1 user_buttons.refresh() ui.on('resize', lambda e: set_columns(e.args['width'])) @@ -71,76 +71,77 @@ def page_touchscreen(): ''') - with ui.grid(columns=number_of_columns).classes('w-full center'): + with ui.grid(columns=number_of_columns).classes('w-full'): for name in userlist: current_user = user(name) - current_button = ui.button(on_click=lambda name=name: button_click(name)).classes(f'w-md h-full min-h-[{admin_settings["button_height"]}px]') + if not current_user.username in get_no_time_users_list(): + current_button = ui.button(on_click=lambda name=name: button_click(name)).classes(f'h-full min-h-[{admin_settings["button_height"]}px]') - with current_button: - with ui.grid(columns='1fr 1fr').classes('w-full h-full py-5 items-start'): + with current_button: + with ui.grid(columns='1fr 1fr').classes('w-full h-full py-5 items-start'): - if admin_settings["photos_on_touchscreen"]: - image_size = int(admin_settings["picture_height"]) - try: - with open(current_user.photofile, 'r') as file: - pass - ui.image(current_user.photofile).classes(f'max-h-[{image_size}px]').props('fit=scale-down') - except: - no_photo_svg = f''' - - - - - - - - - - - - - - - - - - ''' - ui.html(no_photo_svg) - with ui.column().classes('' if admin_settings["photos_on_touchscreen"] else 'col-span-2'): - ui.label(current_user.fullname).classes('text-left text-xl text.bold') - if admin_settings["times_on_touchscreen"]: - todays_timestamps = current_user.get_day_timestamps() - # Wenn wir Einträge haben - if len(todays_timestamps) > 0 and admin_settings["times_on_touchscreen"]: - table_string = "" - for i in range(0, len(todays_timestamps), 2): - try: - table_string += f"{datetime.datetime.fromtimestamp(todays_timestamps[i]).strftime('%H:%M')} - {datetime.datetime.fromtimestamp(todays_timestamps[i+1]).strftime('%H:%M')}" - except IndexError: - table_string += f"{datetime.datetime.fromtimestamp(todays_timestamps[i]).strftime('%H:%M')} -" - if i < len(todays_timestamps) - 2: - table_string += "\n" - ui.label(table_string).style('white-space: pre-wrap').classes('text-left') - if current_user.stamp_status() == status_in: - current_button.props('color=green') - else: - current_button.props('color=red') - buttons[name] = current_button + if admin_settings["photos_on_touchscreen"]: + image_size = int(admin_settings["picture_height"]) + try: + with open(current_user.photofile, 'r') as file: + pass + ui.image(current_user.photofile).classes(f'max-h-[{image_size}px]').props('fit=scale-down') + except: + no_photo_svg = f''' + + + + + + + + + + + + + + + + + + ''' + ui.html(no_photo_svg, sanitize=False) + with ui.column().classes('' if admin_settings["photos_on_touchscreen"] else 'col-span-2'): + ui.label(current_user.fullname).classes('text-left text-xl text.bold') + if admin_settings["times_on_touchscreen"]: + todays_timestamps = current_user.get_day_timestamps() + # Wenn wir Einträge haben + if len(todays_timestamps) > 0 and admin_settings["times_on_touchscreen"]: + table_string = "" + for i in range(0, len(todays_timestamps), 2): + try: + table_string += f"{datetime.datetime.fromtimestamp(todays_timestamps[i]).strftime('%H:%M')} - {datetime.datetime.fromtimestamp(todays_timestamps[i+1]).strftime('%H:%M')}" + except IndexError: + table_string += f"{datetime.datetime.fromtimestamp(todays_timestamps[i]).strftime('%H:%M')} -" + if i < len(todays_timestamps) - 2: + table_string += "\n" + ui.label(table_string).style('white-space: pre-wrap').classes('text-left') + if current_user.stamp_status() == status_in: + current_button.props('color=green') + else: + current_button.props('color=red') + buttons[name] = current_button user_buttons() else: diff --git a/lib/users.py b/lib/users.py index eb5c00a..345b440 100644 --- a/lib/users.py +++ b/lib/users.py @@ -578,3 +578,13 @@ def write_adminsetting(key: str, value): except KeyError: print(f"Kein Einstellungsschlüssel {key} vorhanden.") +def get_admin_list(): + adnin_settings = load_adminsettings() + admin_list = load_adminsettings()["admin_user"] + return admin_list.values() + +def get_no_time_users_list(): + adnin_settings = load_adminsettings() + admin_list = load_adminsettings()["no_time_users"] + return admin_list.values() + diff --git a/lib/web_ui.py b/lib/web_ui.py index 919eb4f..d14864a 100644 --- a/lib/web_ui.py +++ b/lib/web_ui.py @@ -38,10 +38,26 @@ class login_mask: def login(): nonlocal data - if username.value == data["admin_user"]: - if hash_password(password.value) == data["admin_password"]: - app.storage.user['admin_authenticated'] = True - ui.navigate.to("/admin") + if username.value in get_admin_list(): + current_user = user(username.value) + if hash_password(password.value) == current_user.password: + if not username.value in get_no_time_users_list(): + with ui.dialog() as forward_dialog, ui.card(): + ui.label("Wollen Sie den Administrationsbereich oder den Datenbereich aufrufen?") + def admin_area(): + app.storage.user['admin_authenticated'] = True + ui.navigate.to('/admin') + def time_area(): + app.storage.user['active_user'] = current_user.username + ui.navigate.to(self.target) + with ui.grid(columns=2).classes('justify-center w-full'): + ui.button("Administrationsbereich", on_click=admin_area) + ui.button("Datenbereich", on_click=time_area) + + forward_dialog.open() + else: + app.storage.user['admin_authenticated'] = True + ui.navigate.to("/admin") else: ui.notify("Login fehlgeschlagen") else: diff --git a/main.py b/main.py index 6566a6c..2e524e8 100644 --- a/main.py +++ b/main.py @@ -4,7 +4,6 @@ import os.path from lib.web_ui import * from lib.admin import * -from lib.login import * from lib.users import * from lib.touchscreen import * from lib.definitions import * @@ -33,7 +32,7 @@ def main(): list_users() - homepage() + #homepage() def startup_message(): @@ -54,7 +53,7 @@ def main(): ui.toggle.default_props('rounded') ui.row.default_classes('items-baseline') - ui.run(favicon='⏲', port=port, storage_secret=secret, language='de-DE', show_welcome_message=False) + ui.run(root=homepage, favicon='⏲', port=port, storage_secret=secret, language='de-DE', show_welcome_message=False) if __name__ in ("__main__", "__mp_main__"): parser = argparse.ArgumentParser(description=f'{app_title} {app_version}') @@ -85,11 +84,34 @@ if __name__ in ("__main__", "__mp_main__"): print("Sollen diese Einstellungen übernommen werden? j=Ja") question = input() if question == "j": - admin_settings["admin_user"] = admin_user - admin_settings["admin_password"] = hash_password(admin_password) - json_dict = json.dumps(admin_settings, indent=4) - with open(os.path.join(scriptpath, usersettingsfilename), "w") as outputfile: - outputfile.write(json_dict) + if not os.path.exists(userfolder): + os.makedirs(userfolder) + print("Kein Ordner mit Benutzerdaten gefunden. Lege ihn an.") + if not os.path.exists(os.path.join(userfolder, admin_user)): + print("Benutzer existiert noch nicht. Lege ihn an.") + os.makedirs(os.path.join(userfolder, admin_user)) + start_date_dt = datetime.datetime.now() + start_date = start_date_dt.strftime("%Y-%m-%d") + settings_to_write = standard_usersettings + settings_to_write["workhours"][start_date] = {} + settings_to_write["fullname"] = "Administrator" + settings_to_write["username"] = admin_user + # API-Key erzeugen + string_to_hash = f'{admin_user}_{datetime.datetime.now().timestamp()}' + hash_string = hashlib.shake_256(bytes(string_to_hash, 'utf-8')).hexdigest(20) + settings_to_write["api_key"] = hash_string + for i in range(1, 8): + settings_to_write["workhours"][start_date][str(i)] = 0 + settings_to_write["workhours"][start_date]["vacation"] = 0 + with open(f"{userfolder}/{admin_user}/{usersettingsfilename}", 'w') as json_file: + json_dict = json.dumps(standard_usersettings, indent=4) + json_file.write(json_dict) + current_user = user(admin_user) + current_user.password = hash_password(admin_password) + current_user.write_settings() + admin_users_list = load_adminsettings()["admin_user"] + admin_users_list[str(len(admin_users_list))] = admin_user + write_adminsetting("admin_user", admin_users_list) print("Daten geschrieben") quit() else: diff --git a/settings.json b/settings.json new file mode 100644 index 0000000..9ac6bba --- /dev/null +++ b/settings.json @@ -0,0 +1,20 @@ +{ + "admin_user": { + "0": "admin" + }, + "no_time_users": { + "0": "admin" + }, + "port": "8090", + "secret": "ftgzuhjikg,mt5jn46uzer8sfi9okrmtzjhndfierko5zltjhdgise", + "times_on_touchscreen": true, + "photos_on_touchscreen": true, + "touchscreen": true, + "picture_height": 200, + "button_height": "300", + "user_notes": true, + "vacation_application": true, + "backup_folder": "/home/alexander/Dokumente/Python/Zeiterfassung/backup", + "backup_api_key": "6fed93dc4a35308b2c073a8a6f3284afe1fb9946", + "holidays": {} +} \ No newline at end of file