diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..187bb2d --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +**/*.pyc +Testplan.md +.idea +.nicegui +.venv +users/ +backup/ +Archiv/ +Docker/ +docker-work/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ff4197a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM debian:latest +RUN apt update && apt upgrade -y +RUN apt install python3 python3-pip python3.11-venv 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 sed -i '/de_DE.UTF-8/s/^# //g' /etc/locale.gen && \ + locale-gen +ENV LANG de_DE.UTF-8 +ENV LANGUAGE de_DE:de +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"] diff --git a/create_docker.py b/create_docker.py new file mode 100644 index 0000000..c094235 --- /dev/null +++ b/create_docker.py @@ -0,0 +1,31 @@ +from lib.definitions import app_version, app_title +import subprocess +import os + +server = 'gitea.am-td.de' +server_user = 'alexander' + +if os.getuid() == 0: + subprocess.run(["docker", "build", "-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()} + restart: always + ports: + - 8090:8090 + environment: + - PYTHONUNBUFFERED=1 + volumes: + - {userfolder}:/users + - {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.") diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..cf14b60 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ + +services: + zeiterfassung: + image: gitea.am-td.de/alexander/zeiterfassung:0.0.0 + 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 diff --git a/lib/admin.py b/lib/admin.py new file mode 100644 index 0000000..bd27e74 --- /dev/null +++ b/lib/admin.py @@ -0,0 +1,1691 @@ +from datetime import datetime, timedelta + +import dateutil.easter +from dateutil.easter import * + +from nicegui import ui, app, events +from nicegui.html import button +from nicegui.events import KeyEventArguments + +from lib.users import * +from lib.definitions import * +from calendar import monthrange +from lib.web_ui import * + +import os.path +import os +import zipfile +from stat import S_IREAD, S_IRWXU +import hashlib +import calendar +import locale +import segno +import shutil + +@ui.page('/admin') +def page_admin(): + ui.page_title(f"{app_title} {app_version}") + data = load_adminsettings() + + try: + browser_cookie = app.storage.user['admin_authenticated'] + except: + browser_cookie = False + + # Adminseite + if browser_cookie: + pageheader("Administration") + + def admin_logout(): + app.storage.user.pop("admin_authenticated", None) + ui.navigate.to("/") + + ui.button("Logout", on_click=admin_logout) + + updates_available = ValueBinder() + updates_available.value = False + + enabled_because_not_docker = ValueBinder + if is_docker(): + enabled_because_not_docker.value = False + scriptpath = "/app" + backupfolder = "/backup" + else: + enabled_because_not_docker.value = True + + with ui.tabs() as tabs: + + time_overview = ui.tab('Zeitdaten') + settings = ui.tab('Einstellungen') + users = ui.tab('Benutzer') + backups = ui.tab('Backups') + + userlist = [ ] + + def update_userlist(): + nonlocal userlist + userlist = list_users() + + update_userlist() + + with ui.tab_panels(tabs, value=time_overview): + + with ui.tab_panel(time_overview): + with ui.tabs() as overview_tabs: + user_month_overview = ui.tab('Monatsansicht') + user_summary = ui.tab("Zusammenfassung") + vacation_applications = ui.tab("Urlaubsanträge") + vacation_applications.set_visibility(load_adminsettings()["vacation_application"]) + + with ui.tab_panels(overview_tabs, value = user_month_overview): + with ui.tab_panel(user_month_overview).classes('w-full'): + ui.label("Übersichten").classes(h3) + + # Tabelle konstruieren + with ui.card().classes('w-full'): + + with ui.row() as timetable_header: + year_binder = ValueBinder() + month_binder = ValueBinder() + + def update_months(): + current_user = user(time_user.value) + available_months = current_user.get_months(year_binder.value) + available_months_dict = { } + + for element in available_months: + available_months_dict[element] = calendar.month_name[int(element)] + + select_month.clear() + select_month.set_options(available_months_dict) + select_month.value = list(available_months)[0] + + def update_user(): + current_user = user(time_user.value) + available_years = current_user.get_years() + + try: + select_year.clear() + select_year.set_options(available_years) + + try: + select_year.value = str(datetime.datetime.now().year) + except: + select_year.value = list(available_years)[0] + update_months() + try: + select_month.value = datetime.datetime.now().month + except: + select_month.value = list(available_months)[0] + except NameError: + pass + + ui.label("Benutzer:") + + time_user = ui.select(options=userlist, on_change=update_user) + time_user.value = userlist[0] + user_to_select_for_start = userlist[0] + + current_year = datetime.datetime.now().year + current_month = datetime.datetime.now().month + current_user = user(user_to_select_for_start) + available_years = current_user.get_years() + available_months = current_user.get_months(current_year) + available_months_dict = { } + + for element in available_months: + available_months_dict[element] = calendar.month_name[int(element)] + + if current_month in available_months: + set_month = current_month + else: + set_month = available_months[0] + + if str(current_year) in available_years: + set_year = str(current_year) + else: + set_year = (available_years[0]) + + select_month = ui.select(options=available_months_dict, value=set_month).bind_value_to(month_binder, 'value') + select_year = ui.select(options=available_years, value=set_year, on_change=update_months).bind_value_to(year_binder, 'value') + + month_header = ui.markdown(f"###Buchungen für **{current_user.fullname}** für **{calendar.month_name[int(select_month.value)]} {select_year.value}**") + + # Tabelle aufbauen + @ui.refreshable + def timetable(): + current_user = user(time_user.value) + with ui.card().classes('w-full') as calendar_card: + def update_month_and_year(): + #current_user = user(time_user.value) + # Archivstatus + days_with_errors = current_user.archiving_validity_check(int(select_year.value), int(select_month.value)) + with ui.grid(columns='auto auto auto 1fr 1fr 1fr 1fr').classes('w-full md:min-w-[600px] lg:min-w-[800px] items-baseline') as table_grid: + if int(select_month.value) > 1: + archive_status = current_user.get_archive_status(int(select_year.value), + int(select_month.value)) + else: + archive_status = current_user.get_archive_status(int(select_year.value) - 1, 12) + + def revoke_archive_status(): + def revoke_status(): + filestring = f"{current_user.userfolder}/{int(select_year.value)}-{int(select_month.value)}" + filename = f"{filestring}.txt" + os.chmod(filename, S_IRWXU) + filename = f"{filestring}.json" + os.chmod(filename, S_IRWXU) + with open(filename, 'r') as json_file: + data = json.load(json_file) + data["archived"] = 0 + + json_dict = json.dumps(data) + + with open(filename, "w") as outputfile: + outputfile.write(json_dict) + timetable.refresh() + dialog.close() + + with ui.dialog() as dialog, ui.card(): + ui.label("Soll der Archivstatus für den aktuellen Monat aufgehoben werden, damit Änderungen vorgenommen werden können?") + with ui.grid(columns=2): + ui.button("Ja", on_click=revoke_status) + ui.button("Nein", on_click=dialog.close) + dialog.open() + + if archive_status == True: + with ui.row().classes('text-right col-span-7 justify-center'): + ui.button("Archiviert", on_click=revoke_archive_status).classes('bg-transparent text-black') + ui.separator() + calendar_card.classes('bg-yellow') + else: + calendar_card.classes('bg-white') + # Überschriften + ui.label("Datum").classes('font-bold') + ui.label("Buchungen").classes('font-bold') + ui.space() + ui.label("Ist").classes('font-bold') + ui.label("Soll").classes('font-bold') + ui.label("Saldo").classes('font-bold') + ui.space() + + timestamps = current_user.get_timestamps(year=select_year.value, month=select_month.value) + user_absent = current_user.get_absence(year=select_year.value, month=select_month.value) + # Dictionary für sortierte Timestamps + timestamps_dict = { } + # Dictionary mit zunächst leeren Tageinträgen befüllen + for day in range(1, monthrange(int(select_year.value), int(select_month.value))[1] + 1): + # Jeder Tag bekommt eine leere Liste + timestamps_dict[day] = [ ] + + # Alle Timestamps durchgehen und sie den Dictionaryeinträgen zuordnen: + for stamp in timestamps: + day_of_month_of_timestamp = int(datetime.datetime.fromtimestamp(int(stamp)).day) + timestamps_dict[day_of_month_of_timestamp].append(int(stamp)) + + general_saldo = 0 + + for day in list(timestamps_dict): + # Datum für Tabelle konstruieren + day_in_list = datetime.datetime(int(select_year.value), int(select_month.value), day) + class_content = "" + if day_in_list.date() == datetime.datetime.now().date(): + class_content = 'font-bold text-red-700 uppercase' + ui.label(f"{day_in_list.strftime('%a')}., {day}. {calendar.month_name[int(select_month.value)]}").classes(class_content) + + # Buchungen + + with ui.row(): + def delete_absence(day, absence_type): + def execute_deletion(): + current_user.del_absence(select_year.value, select_month.value, day) + calendar_card.clear() + update_month_and_year() + dialog.close() + ui.notify("Abwesenheitseintrag gelöscht") + with ui.dialog() as dialog, ui.card(): + ui.markdown(f'Soll der Eintrag **{absence_type}** für den **{day}. {calendar.month_name[int(select_month.value)]} {select_year.value}** gelöscht werden?') + ui.label('Dies kann nicht rückgängig gemacht werden!') + with ui.grid(columns=3): + ui.button("Ja", on_click=execute_deletion) + ui.space() + ui.button("Nein", on_click=dialog.close) + + def handle_key(e: KeyEventArguments): + if e.key == 'j' or e.key == 'Enter': + execute_deletion() + if e.key == 'n' or e.key == 'Esc': + dialog.close() + + keyboard = ui.keyboard(on_key=handle_key) + + dialog.open() + + try: + for i in list(user_absent): + if int(i) == day: + absence_button = ui.button(absence_entries[user_absent[i]]["name"], on_click=lambda i=i, day=day: delete_absence(day, absence_entries[user_absent[i]]["name"])).props(f'color={absence_entries[user_absent[i]]["color"]} square') + if archive_status: + absence_button.disable() + except: + pass + + day_type = ui.label("Kein Arbeitstag") + day_type.set_visibility(False) + + # Hier werden nur die Tage mit Timestamps behandelt + if len(timestamps_dict[day]) > 0: + timestamps_dict[day].sort() + + def edit_entry(t_stamp, day): + + with ui.dialog() as edit_dialog, ui.card(): + ui.label("Eintrag bearbeiten").classes(h4) + timestamp = datetime.datetime.fromtimestamp(int(t_stamp)) + input_time = ui.time().props('format24h now-btn').classes('w-full justify-center') + + input_time.value = timestamp.strftime('%H:%M') + + def save_entry(day): + nonlocal t_stamp + t_stamp = f"{t_stamp}\n" + position = timestamps.index(t_stamp) + new_time_stamp = datetime.datetime(int(select_year.value), + int(select_month.value), day, + int(input_time.value[:2]), + int(input_time.value[-2:])) + timestamps[position] = str( + int(new_time_stamp.timestamp())) + "\n" + + current_user = user(time_user.value) + current_user.write_edited_timestamps(timestamps, + select_year.value, + select_month.value) + edit_dialog.close() + calendar_card.clear() + update_month_and_year() + month_header.set_content( + f"###Buchungen für {calendar.month_name[int(select_month.value)]} {select_year.value}") + ui.notify("Eintrag gespeichert") + + def del_entry(): + nonlocal t_stamp + t_stamp = f"{t_stamp}\n" + timestamps.remove(t_stamp) + timestamps.sort() + current_user = user(time_user.value) + current_user.write_edited_timestamps(timestamps, + select_year.value, + select_month.value) + edit_dialog.close() + calendar_card.clear() + update_month_and_year() + month_header.set_content( + f"###Buchungen für {calendar.month_name[int(select_month.value)]} {select_year.value}") + ui.notify("Eintrag gelöscht") + + with ui.row(): + ui.button("Speichern", + on_click=lambda day=day: save_entry(day)) + ui.button("Löschen", on_click=del_entry) + ui.button("Abbrechen", on_click=edit_dialog.close) + + edit_dialog.open() + for i in range(0, len(timestamps_dict[day]), 2): + try: + temp_pair = [ timestamps_dict[day][i] , timestamps_dict[day][i+1] ] + with ui.card().classes('bg-inherit'): + with ui.row(): + for j in temp_pair: + timestamp_button = ui.button(datetime.datetime.fromtimestamp(int(j)).strftime('%H:%M'), on_click=lambda t_stamp=j, day=day: edit_entry(t_stamp, day)).props('square') + if archive_status: + timestamp_button.disable() + except Exception as e: + if len(timestamps_dict[day]) % 2 != 0: + with ui.card().classes('bg-inherit'): + timestamp_button = ui.button(datetime.datetime.fromtimestamp(int(timestamps_dict[day][i])).strftime('%H:%M'), on_click=lambda t_stamp=timestamps_dict[day][i], day=day: edit_entry(t_stamp, day)) + if archive_status: + timestamp_button.disable() + with ui.row(): + # Fehlerhinweis + if day in days_with_errors: + ui.icon('warning', color='red').tooltip("Keine Schlussbuchung").classes('text-2xl') + + # Notizen anzeigen + days_notes = current_user.get_day_notes(select_year.value, select_month.value, day) + if days_notes != { }: + with ui.icon('o_description').classes('text-2xl'): + with ui.tooltip(): + with ui.grid(columns='auto auto').classes('items-center'): + for username, text in days_notes.items(): + admins_name = load_adminsettings()["admin_user"] + if username == admins_name: + ui.label('Administrator:') + else: + ui.label(current_user.fullname) + ui.label(text) + else: + ui.space() + + # Arbeitszeit Ist bestimmen + + timestamps_of_this_day = [] + + # Suche mir alle timestamps für diesen Tag + for i in timestamps: + actual_timestamp = datetime.datetime.fromtimestamp(int(i)) + timestamp_day = actual_timestamp.day + + if int(timestamp_day) == int(day): + timestamps_of_this_day.append(i) + + timestamps_of_this_day.sort() + + time_sum = 0 + if len(timestamps_of_this_day) > 1: + + if len(timestamps_of_this_day) % 2 == 0: + for i in range(0, len(timestamps_of_this_day), 2): + time_delta = int( + timestamps_of_this_day[i + 1]) - int( + timestamps_of_this_day[i]) + time_sum = time_sum + time_delta + else: + for i in range(0, len(timestamps_of_this_day) - 1, 2): + time_delta = int( + timestamps_of_this_day[i + 1]) - int( + timestamps_of_this_day[i]) + time_sum = time_sum + time_delta + + ui.label(convert_seconds_to_hours(time_sum)).classes('text-right') + else: + ui.label("Kein") + + # Arbeitszeitsoll bestimmen + + hours_to_work = int(current_user.get_day_workhours(select_year.value, select_month.value, day)) + if hours_to_work < 0: + ui.space() + day_type.text="Kein Arbeitsverhältnis" + day_type.set_visibility(True) + else: + ui.label(f"{convert_seconds_to_hours(int(hours_to_work) * 3600)}").classes('text-right') + if int(hours_to_work) == 0: + day_type.text = "Kein Arbeitstag" + day_type.classes('text-bold') + day_type.set_visibility(True) + + if day_in_list.strftime("%Y-%m-%d") in data["holidays"]: + day_type.text = f'{data["holidays"][day_in_list.strftime("%Y-%m-%d")]}' + day_type.classes('text-bold') + + # Saldo für den Tag berechnen + + if time.time() > day_in_list.timestamp(): + + time_duty = int(current_user.get_day_workhours(select_year.value, select_month.value, day)) * 3600 + if time_duty < 0: + ui.space() + else: + saldo = int(time_sum) - int(time_duty) + # Nach Abwesenheitseinträgen suchen + try: + for i in list(user_absent): + if int(i) == day and user_absent[i] != "UU": + saldo = 0 + except: + pass + + general_saldo = general_saldo + saldo + ui.label(convert_seconds_to_hours(saldo)).classes('text-right') + else: + ui.label("-").classes('text-center') + + def add_entry(day): + with ui.dialog() as add_dialog, ui.card(): + ui.label("Eintrag hinzufügen").classes(h4) + input_time = ui.time().classes('w-full justify-center') + + def add_entry_save(): + if input_time.value == None: + ui.notify("Bitte eine Uhrzeit auswählen.") + return + + new_time_stamp = datetime.datetime(int(year_binder.value), + int(month_binder.value), day, + int(input_time.value[:2]), + int(input_time.value[-2:])).timestamp() + current_user = user(time_user.value) + current_user.timestamp(stamptime=int(new_time_stamp)) + calendar_card.clear() + update_month_and_year() + add_dialog.close() + ui.notify("Eintrag hinzugefügt") + with ui.grid(columns=3): + ui.button("Speichern", on_click=add_entry_save) + ui.space() + ui.button("Abbrechen", on_click=add_dialog.close) + add_dialog.open() + add_dialog.move(calendar_card) + + def add_absence(absence_type, day): + with ui.dialog() as dialog, ui.card().classes('w-[350px]'): + ui.markdown(f'Für welchen Zeitraum soll *{absence_entries[absence_type]["name"]}* eingetragen werden?').classes('w-full') + absence_dates = ui.date().props('range today-btn').classes('w-full justify-center') + absence_dates._props['locale'] = {'daysShort': ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'], + 'months': ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'], + 'monthsShort': ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez']} + absence_dates._props['title'] = absence_entries[absence_type]["name"] + absence_dates._props['minimal'] = True + + if day < 10: + day = f"0{str(day)}" + else: + day = str(day) + if int(select_month.value) < 10: + month = f"0{select_month.value}" + else: + month = select_month.value + absence_dates.value = f"{select_year.value}-{month}-{day}" + + def add_absence_save(): + # Bei nur einem Datum, direkt schreiben + if isinstance(absence_dates.value, str): + absence_date = absence_dates.value.split("-") + current_user.update_absence(absence_date[0], absence_date[1], absence_date[2], absence_type) + current_sel_month = select_month.value + current_sel_year = select_year.value + update_user() + update_months() + select_year.value = current_sel_year + select_month.value = current_sel_month + calendar_card.clear() + update_month_and_year() + + # Bei Zeitbereich, aufteilen + elif isinstance(absence_dates.value, dict): + start_date = absence_dates.value["from"] + end_date = absence_dates.value["to"] + start_date = start_date.split("-") + end_date = end_date.split("-") + + start_year = int(start_date[0]) + end_year = int(end_date[0]) + start_month = int(start_date[1]) + end_month = int(end_date[1]) + start_day = int(start_date[2]) + end_day = int(end_date[2]) + + start_date = datetime.datetime(start_year, start_month, start_day) + end_date = datetime.datetime(end_year, end_month, end_day) + actual_date = start_date + + while actual_date <= end_date: + absences = current_user.get_absence(actual_date.year, actual_date.month) + + if str(actual_date.day) in list(absences): + current_user.del_absence(actual_date.year, actual_date.month, actual_date.day) + ui.notify(f"Eintrag {absence_entries[absences[str(actual_date.day)]]['name']} am {actual_date.day}.{actual_date.month}.{actual_date.year} überschrieben.") + if current_user.get_day_workhours(actual_date.year, actual_date.month, actual_date.day) > 0: + current_user.update_absence(actual_date.year, actual_date.month, actual_date.day, absence_type) + + actual_date = actual_date + datetime.timedelta(days=1) + clear_card() + ui.notify("Einträge vorgenomomen") + dialog.close() + + + with ui.grid(columns=3).classes('w-full justify-center'): + ui.button("Speichern", on_click=add_absence_save) + ui.space() + ui.button("Abbrechen", on_click=dialog.close) + + dialog.open() + dialog.move(calendar_card) + + def edit_notes(day): + notes = current_user.get_day_notes(select_year.value, select_month.value, day) + def del_note_entry(user): + try: + del(notes[user]) + username_labels[user].delete() + note_labels[user].delete() + del_buttons[user].delete() + except KeyError: + ui.notify("Kann nicht gelöscht werden. Eintrag wurde noch nicht gespeichert.") + + def save_notes(): + if not note_labels["admin"].is_deleted: + notes["admin"] = note_labels["admin"].value + current_user.write_notes(select_year.value, select_month.value, day, notes) + timetable.refresh() + dialog.close() + + with ui.dialog() as dialog, ui.card(): + # Notizen + username_labels = { } + note_labels = { } + del_buttons = { } + + ui.label(f'Notizen für {day}.{current_month}.{current_year}').classes('font-bold') + with ui.grid(columns='auto auto auto').classes('items-baseline'): + admin_settings = load_adminsettings() + # Beschreibungsfeld für Admin + username_labels["admin"] = ui.label("Administrator:") + # Textarea für Admin + note_labels["admin"] = ui.textarea() + del_buttons["admin"] = ui.button(icon='remove', on_click=lambda user="admin": del_note_entry(user)) + + for name, text in notes.items(): + if name != "admin": + username_labels["user"] = ui.label(current_user.fullname) + note_labels["user"] = ui.label(text) + del_buttons["user"] = ui.button(icon='remove', on_click=lambda user="user": del_note_entry(user)) + elif name == "admin": + note_labels["admin"].value = text + + with ui.row(): + ui.button("OK", on_click=save_notes) + ui.button("Abbrechen", on_click=dialog.close) + dialog.open() + dialog.move(calendar_card) + + with ui.button(icon='menu').props('square') as menu_button: + with ui.menu() as menu: + no_contract = False + start_of_contract = current_user.get_starting_day() + if datetime.datetime(int(select_year.value), int(select_month.value), day) < datetime.datetime(int(start_of_contract[0]), int(start_of_contract[1]), int(start_of_contract[2])): + no_contract = True + + menu_item = ui.menu_item("Zeiteintrag hinzufügen", lambda day=day: add_entry(day)) + if archive_status: + menu_item.disable() + if datetime.datetime.now().day < day: + menu_item.disable() + menu_item.tooltip("Kann keine Zeiteinträge für die Zukunft vornehmen.") + if no_contract: + menu_item.disable() + menu_item.tooltip("Kann keine Zeiteinträge für Zeit vor der Einstellung vornehmen") + ui.separator() + menu_item = ui.menu_item("Notizen bearbeiten", lambda day=day: edit_notes(day)) + if archive_status: + menu_item.disable() + ui.separator() + for i in list(absence_entries): + menu_item = ui.menu_item(f"{absence_entries[i]['name']} eintragen", lambda absence_type=i, day=day: add_absence(absence_type, day)) + if archive_status: + menu_item.disable() + if str(day) in list(user_absent): + menu_item.disable() + if no_contract: + menu_item.disable() + menu_item.tooltip( + "Kann keine Zeiteinträge für Zeit vor der Einstellung vornehmen") + if archive_status: + menu_button.disable() + + #ui.button("Eintrag hinzufügen", on_click=lambda day=day: add_entry(day)) + + #4x leer und dann Gesamtsaldo + ui.space().classes('col-span-5') + ui.label(convert_seconds_to_hours(general_saldo)).classes('text-right') + ui.label("Stunden aus Vormonat").classes('col-span-5 text-right') + last_months_overtime = current_user.get_last_months_overtime(select_year.value, select_month.value) + ui.label(convert_seconds_to_hours(last_months_overtime)).classes('text-right') + ui.label("Gesamtsaldo").classes('col-span-5 text-right') + ui.label(convert_seconds_to_hours(general_saldo + last_months_overtime)).classes('text-right text-bold text-underline') + + table_grid.move(calendar_card) + + update_month_and_year() + + def clear_card(): + calendar_card.clear() + button_update.delete() + update_month_and_year() + current_user = user(time_user.value) + month_header.set_content(f"###Buchungen für **{current_user.fullname}** für **{calendar.month_name[int(select_month.value)]} {select_year.value}**") + + month_header.set_content(f"###Buchungen für **{current_user.fullname}** für **{calendar.month_name[int(select_month.value)]} {select_year.value}**") + + timetable() + button_update = ui.button("Aktualisieren", on_click=timetable.refresh) + button_update.move(timetable_header) + with ui.tab_panel(user_summary): + + global overview_table + @ui.refreshable + def overview_table(): + ov_columns = [ {'label': 'Benutzername', 'name': 'username', 'field': 'username'}, + {'label': 'Name', 'name': 'name', 'field': 'name'}, + {'label': 'Zeitsaldo', 'name': 'time', 'field': 'time'}, + {'label': 'Urlaub', 'name': 'vacation', 'field': 'vacation'}, + {'label': 'Resturlaub', 'name': 'vacation_remaining', 'field': 'vacation_remaining'}, + {'label': 'Krankheit', 'name': 'illness', 'field': 'illness'}] + ov_rows = [ ] + for username in userlist: + actual_user = user(username) + ov_rows.append( + {'username': username, 'name': actual_user.fullname, 'time': f'{convert_seconds_to_hours(actual_user.get_last_months_overtime() + actual_user.get_worked_time()[0])} h', 'vacation': f'{actual_user.count_absence_days("U")} Tage', + 'vacation_remaining': f'{actual_user.get_vacation_claim() - actual_user.count_absence_days("U")} Tage', 'illness': f'{actual_user.count_absence_days("K")} Tage'}) + ui.table(columns=ov_columns, rows=ov_rows, row_key='username', column_defaults={'align': 'left', 'sortable': True}) + + overview_table() + ui.button("Aktualisieren", on_click=overview_table.refresh) + + with ui.tab_panel(vacation_applications): + date_string = '%d.%m.%Y' + + @ui.refreshable + def va_table(): + va_columns = [ {'label': 'Benutzername', 'name': 'username', 'field': 'username'}, + {'label': 'Name', 'name': 'name', 'field': 'name'}, + {'label': 'key', 'name': 'key', 'field': 'key', 'classes': 'hidden', 'headerClasses': 'hidden'}, + {'label': 'Anfang', 'name': 'start', 'field': 'start'}, + {'label': 'Ende', 'name': 'end', 'field': 'end'}, + {'label': 'Resturlaub', 'name': 'vacation_remaining', 'field': 'vacation_remaining'}, + {'label': 'Resturlaub nach Genehmigung', 'name': 'vacation_remaining_after_submission', 'field': 'vacation_remaining_after_submission'} + ] + va_rows = [ ] + + for username in userlist: + actual_user = user(username) + open_va = actual_user.get_open_vacation_applications() + for i, dates in open_va.items(): + startdate_dt = datetime.datetime.strptime(dates[0], '%Y-%m-%d') + enddate_dt = datetime.datetime.strptime(dates[1], '%Y-%m-%d') + vacation_start_to_end = (enddate_dt-startdate_dt).days + vacation_duration = 0 + for day_counter in range(0, vacation_start_to_end): + new_date = startdate_dt + datetime.timedelta(days=day_counter) + if int(actual_user.get_day_workhours(new_date.year, new_date.month, new_date.day)) > 0: + if not datetime.datetime(new_date.year, new_date.month, new_date.day).strftime('%Y-%m-%d') in list(load_adminsettings()["holidays"]): + vacation_duration += 1 + + va_rows.append({'username': username, + 'name': actual_user.fullname, + 'key': f'{username}-{i}', + 'start': startdate_dt.strftime(date_string), + 'end': enddate_dt.strftime(date_string), + 'vacation_remaining': f'{actual_user.get_vacation_claim() - actual_user.count_absence_days("U", startdate_dt.year)} Tage', + 'vacation_remaining_after_submission': f'{actual_user.get_vacation_claim() - actual_user.count_absence_days("U", startdate_dt.year) - vacation_duration } Tage'}) + global vacation_table + vacation_table = ui.table(columns=va_columns, rows=va_rows, row_key='key', selection='single', column_defaults={'align': 'left', 'sortable': True}) + va_table() + + def approve_vacation(): + global vacation_table + for selection in vacation_table.selected: + key = selection["key"] + username = key.split("-")[0] + index = key.split("-")[1] + actual_user = user(username) + startdate_dt = datetime.datetime.strptime(selection["start"], date_string) + enddate_dt = datetime.datetime.strptime(selection["end"], date_string) + delta = (enddate_dt - startdate_dt).days + for i in range(0, delta): + new_date = startdate_dt + datetime.timedelta(days=i) + if int(actual_user.get_day_workhours(new_date.year, new_date.month, new_date.day)) > 0: + if not datetime.datetime(new_date.year, new_date.month, new_date.day).strftime('%Y-%m-%d') in list(load_adminsettings()["holidays"]): + actual_user.update_absence(new_date.year, new_date.month, new_date.day, "U") + ui.notify(f"Urlaub vom {selection['start']} bis {selection['end']} für {actual_user.fullname} eingetragen") + try: + retract_result = actual_user.revoke_vacation_application(index) + except IndexError: + ui.notify("Fehler beim Entfernen des Urlaubsantrages nach dem Eintragen.") + va_table.refresh() + timetable.refresh() + overview_table.refresh() + + def deny_vacation(): + for selection in vacation_table.selected: + key = selection["key"] + username = key.split("-")[0] + index = key.split("-")[1] + actual_user = user(username) + try: + retract_result = actual_user.revoke_vacation_application(index) + ui.notify(f"Urlaubsantrag vom {selection['start']} bis {selection['end']} für {actual_user.fullname} entfernt.") + except IndexError: + ui.notify("Fehler beim Entfernen des Urlaubsantrages. Ggf. wurde dieser zurückgezogen.") + va_table.refresh() + overview_table.refresh() + + ui.button("Aktualisieren", on_click=va_table.refresh) + ui.separator() + with ui.grid(columns=2): + ui.button("Genehmigen", on_click=approve_vacation) + ui.button("Ablehnen", on_click=deny_vacation) + + 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.") + + secret = data["secret"] + + with ui.card(): + ui.label("Systemeinstellungen:").classes('text-bold') + with ui.grid(columns=2).classes('items-baseline'): + def check_is_number(number): + try: + number = int(number) + return True + except: + return False + + ui.label("Port:") + port = ui.input(validation={"Nur ganzzahlige Portnummern erlaubt": lambda value: check_is_number(value), + "Portnummer zu klein": lambda value: len(value)>=2}).tooltip("Geben Sie hier die Portnummer ein, unter der die Zeiterfassung erreichbar ist.").props('size=5').bind_enabled_from(enabled_because_not_docker, 'value') + if is_docker(): + port.tooltip("Diese Einstellung ist beim Einsatz von Docker deaktiviert.") + old_port = data["port"] + port.value = old_port + + with ui.card(): + ui.label("Einstellungen für das Touchscreenterminal:").classes('text-bold') + with ui.column().classes('items-baseline'): + touchscreen_switch = ui.switch("Touchscreenterminal aktiviert") + touchscreen_switch.value = data["touchscreen"] + timestamp_switch = ui.switch("Stempelzeiten anzeigen").bind_visibility_from(touchscreen_switch, 'value') + photo_switch = ui.switch("Fotos anzeigen").bind_visibility_from(touchscreen_switch, 'value') + timestamp_switch.value = bool(data["times_on_touchscreen"]) + with ui.row().bind_visibility_from(touchscreen_switch, 'value'): + photo_switch.value = bool(data["photos_on_touchscreen"]) + with ui.row().bind_visibility_from(photo_switch, 'value'): + ui.label("Maximale Bilderöhe") + picture_height_input = ui.input(validation={"Größe muss eine Ganzzahl sein.": lambda value: check_is_number(value), + "Größe muss größer 0 sein": lambda value: int(value)>0}).props('size=5') + picture_height_input.value = data["picture_height"] + ui.label('px') + with ui.row().bind_visibility_from(touchscreen_switch, 'value'): + ui.label("Minimale Buttonhöhe:") + def compare_button_height(height): + if not photo_switch.value: + return True + elif int(height) < int(picture_height_input.value): + return False + else: + return True + + button_height_input = ui.input(validation={"Größe muss eine Ganzzahl sein.": lambda value: check_is_number(value), + "Größe muss größer 0 sein": lambda value: int(value)>0, + "Buttons dürfen nicht kleiner als die Fotos sein": lambda value: compare_button_height(value)}).props('size=5') + button_height_input.value = data["button_height"] + photo_switch.on_value_change(button_height_input.validate) + picture_height_input.on_value_change(button_height_input.validate) + ui.label('px') + + with ui.card(): + ui.label("Einstellungen für Benutzerfrontend").classes('text-bold') + notes_switch = ui.switch("Notizfunktion aktiviert", value=data["user_notes"]) + va_switch = ui.switch("Urlaubsanträge", value=data["vacation_application"]) + reset_visibility = ValueBinder() + def holiday_section(): + with ui.card(): + ui.label('Feiertage:').classes('text-bold') + + + reset_visibility.value = False + + def new_holiday_entry(): + def add_holiday_to_dict(): + year_from_picker = int(datepicker.value.split("-")[0]) + month_from_picker = int(datepicker.value.split("-")[1]) + day_from_picker = int(datepicker.value.split("-")[2]) + for i in range(0, int(repetition.value)): + repetition_date_dt = datetime.datetime(year_from_picker + i, month_from_picker, day_from_picker) + date_entry = repetition_date_dt.strftime('%Y-%m-%d') + data["holidays"][date_entry] = description.value + holiday_buttons_grid.refresh() + reset_visibility.value = True + dialog.close() + + with ui.dialog() as dialog, ui.card(): + with ui.grid(columns='auto auto'): + ui.label('Geben Sie den neuen Feiertag ein:').classes('col-span-2') + datepicker = ui.date(value=datetime.datetime.now().strftime('%Y-%m-%d')).classes('col-span-2') + description = ui.input('Beschreibung').classes('col-span-2') + repetition = ui.number('Für Jahre wiederholen', value=1, min=1, precision=0).classes('col-span-2') + ui.button("OK", on_click=add_holiday_to_dict) + ui.button('Abbrechen', on_click=dialog.close) + dialog.open() + + @ui.refreshable + def holiday_buttons_grid(): + + holidays = list(data["holidays"]) + holidays.sort() + + year_list = [] + + # Jahresliste erzeugen + for i in holidays: + i_split = i.split("-") + year = int(i_split[0]) + year_list.append(year) + + year_list = list(set(year_list)) + year_dict = {} + + # Jahresdictionary konstruieren + for i in year_list: + year_dict[i] = [] + + for i in holidays: + i_split = i.split("-") + year = int(i_split[0]) + month = int(i_split[1]) + day = int(i_split[2]) + date_dt = datetime.datetime(year, month, day) + year_dict[year].append(date_dt) + + def del_holiday_entry(entry): + del(data['holidays'][entry.strftime('%Y-%m-%d')]) + reset_visibility.value = True + holiday_buttons_grid.refresh() + + def defined_holidays(): + with ui.dialog() as dialog, ui.card(): + ui.label("Bitte wählen Sie aus, welche Feiertage eingetragen werden sollen. Vom Osterdatum abhängige Feiertage werden für die verschiedenen Jahre berechnet.:") + with ui.grid(columns='auto auto'): + with ui.column().classes('gap-0'): # Auswahlen für Feiertage + + checkbox_classes = 'py-0' + + new_year = ui.checkbox("Neujahr (1. Januar)").classes(checkbox_classes) + heilige_drei_koenige = ui.checkbox("Heilige Drei Könige (6. Januar)").classes(checkbox_classes) + womens_day = ui.checkbox("Internationaler Frauentag (8. März)").classes(checkbox_classes) + gruendonnerstag = ui.checkbox("Gründonnerstag (berechnet)").classes(checkbox_classes) + karfreitag = ui.checkbox("Karfreitag (berechnet").classes(checkbox_classes) + easter_sunday = ui.checkbox("Ostersonntag (berechnet)").classes(checkbox_classes) + easter_monday = ui.checkbox("Ostermontag (berechnet)").classes(checkbox_classes) + first_of_may = ui.checkbox("Tag der Arbeit (1. Mai)").classes(checkbox_classes) + liberation_day = ui.checkbox("Tag der Befreiung (8. Mai)").classes(checkbox_classes) + ascension_day = ui.checkbox("Christi Himmelfahrt (berechnet)").classes(checkbox_classes) + whitsun_sunday = ui.checkbox("Pfingssonntag (berechnet)").classes(checkbox_classes) + whitsun_monday = ui.checkbox("Pfingsmontag (berechnet)").classes(checkbox_classes) + fronleichnam = ui.checkbox("Fronleichnam (berechnet)").classes(checkbox_classes) + peace_party = ui.checkbox("Friedensfest (Augsburg - 8. August)").classes(checkbox_classes) + mary_ascension = ui.checkbox("Mariä Himmelfahrt (15. August)").classes(checkbox_classes) + childrens_day = ui.checkbox("Weltkindertag (20. September)").classes(checkbox_classes) + unity_day = ui.checkbox("Tag der deutschen Einheit (3. Oktober)").classes(checkbox_classes) + reformation_day = ui.checkbox("Reformationstag (30. Oktober)").classes(checkbox_classes) + all_hallows = ui.checkbox("Allerheiligen (1. November)").classes(checkbox_classes) + praying_day = ui.checkbox("Buß- und Bettag (berechnet)").classes(checkbox_classes) + christmas_day = ui.checkbox("1. Weihnachtsfeiertag (25. Dezember)").classes(checkbox_classes) + boxing_day = ui.checkbox("2. Weihnachtsfeiertag (26. Dezember)").classes(checkbox_classes) + + def enter_holidays(): + + for year in range (int(starting_year.value), int(end_year.value) + 1): + ostersonntag = dateutil.easter.easter(year) + if new_year.value: + data["holidays"][f"{year}-01-01"] = f"Neujahr" + if heilige_drei_koenige.value: + data["holidays"][f"{year}-01-06"] = f"Hl. Drei Könige" + if womens_day.value: + data["holidays"][f"{year}-03-08"] = f"Intern. Frauentag" + if gruendonnerstag.value: + datum_dt = ostersonntag - datetime.timedelta(days=3) + datum = datum_dt.strftime("%Y-%m-%d") + data["holidays"][f"{datum}"] = f"Gründonnerstag" + if karfreitag.value: + datum_dt = ostersonntag - datetime.timedelta(days=2) + datum = datum_dt.strftime("%Y-%m-%d") + data["holidays"][f"{datum}"] = f"Karfreitag" + if easter_sunday.value: + datum_dt = ostersonntag + datum = datum_dt.strftime("%Y-%m-%d") + data["holidays"][f"{datum}"] = "Ostersonntag" + if easter_monday.value: + datum_dt = ostersonntag + datetime.timedelta(days=1) + datum = datum_dt.strftime("%Y-%m-%d") + data["holidays"][f"{datum}"] = "Ostermontag" + if first_of_may.value: + data["holidays"][f"{year}-05-01"] = f"Tag der Arbeit" + if liberation_day.value: + data["holidays"][f"{year}-05-08"] = f"Tag der Befreiung" + if ascension_day.value: + datum_dt = ostersonntag + datetime.timedelta(days=39) + datum = datum_dt.strftime("%Y-%m-%d") + data["holidays"][f"{datum}"] = f"Christi Himmelfahrt" + if whitsun_sunday.value: + datum_dt = ostersonntag + datetime.timedelta(days=49) + datum = datum_dt.strftime("%Y-%m-%d") + data["holidays"][f"{datum}"] = f"Pfingssonntag" + if whitsun_monday.value: + datum_dt = ostersonntag + datetime.timedelta(days=49) + datum = datum_dt.strftime("%Y-%m-%d") + data["holidays"][f"{datum}"] = f"Pfingstmontag" + if fronleichnam.value: + datum_dt = ostersonntag + datetime.timedelta(days=60) + datum = datum_dt.strftime("%Y-%m-%d") + data["holidays"][f"{datum}"] = f"Fronleichnam" + if peace_party.value: + data["holidays"][f"{year}-08-08"] = f"Friedensfest" + if mary_ascension.value: + data["holidays"][f"{year}-08-15"] = f"Mariä Himmelfahrt" + if childrens_day.value: + data["holidays"][f"{year}-09-20"] = f"Intern. Kindertag" + if unity_day.value: + data["holidays"][f"{year}-10-03"] = f"Tag der deutschen Einheit" + if reformation_day.value: + data["holidays"][f"{year}-10-30"] = f"Reformationstag" + if all_hallows.value: + data["holidays"][f"{year}-11-01"] = f"Allerheiligen" + if praying_day.value: + starting_day = datetime.datetime(year, 11 ,23) + for i in range(1, 8): + test_day = starting_day - datetime.timedelta(days=-i) + if test_day.weekday() == 2: + datum_dt = test_day + break + datum = datum_dt.strftime("%Y-%m-%d") + data["holidays"][f"{datum}"] = f"Bu0- und Bettag" + if christmas_day.value: + data["holidays"][f"{year}-12-25"] = f"1. Weihnachtsfeiertag" + if boxing_day.value: + data["holidays"][f"{year}-12-26"] = f"2. Weihnachtsfeiertag" + reset_visibility.value = True + dialog.close() + holiday_buttons_grid.refresh() + + with ui.column(): + end_year_binder = ValueBinder() + start_year_binder = ValueBinder() + def correct_end_year(): + if starting_year.value > end_year_binder.value: + end_year_binder.value = starting_year.value + + def correct_start_year(): + if starting_year.value > end_year_binder.value: + starting_year.value = end_year_binder.value + + start_year_binder.value = datetime.datetime.now().year + starting_year = ui.number(value=datetime.datetime.now().year, label="Startjahr", on_change=correct_end_year).bind_value(start_year_binder, 'value') + + end_year_binder.value = starting_year.value + end_year = ui.number(value=starting_year.value, label="Endjahr", on_change=correct_start_year).bind_value(end_year_binder, 'value') + with ui.row(): + ui.button("Anwenden", on_click=enter_holidays) + ui.button("Abbrechen", on_click=dialog.close) + dialog.open() + + def reset_holidays(): + old_data = load_adminsettings() + data["holidays"] = old_data["holidays"] + reset_visibility.value = False + holiday_buttons_grid.refresh() + + with ui.row(): + ui.button("Gesetzliche Feiertage eintragen", on_click=defined_holidays).tooltip("Hier können Sie automatisiert gesetzliche Feiertage in Deutschland eintragen.") + ui.button("Eigener Eintrag", on_click=new_holiday_entry).tooltip("Hier können Sie einen eigenen Feiertag definieren.") + ui.button("Zurücksetzen", icon="undo", on_click=reset_holidays).bind_visibility_from(reset_visibility, 'value').classes('bg-red').tooltip("Hier können Sie ungespeicherte Änderungen zurücknehmen.") + + ui.separator() + + for year_entry in year_list: + with ui.expansion(year_entry): + with ui.column(): + for entry in year_dict[year_entry]: + date_label = entry.strftime("%d.%m.%y") + with ui.button(on_click=lambda entry=entry: del_holiday_entry(entry)).classes('w-full').props('color=light-blue-8').tooltip(f"Klicken Sie hier, um den Feiertag \"{data['holidays'][entry.strftime('%Y-%m-%d')]}\" zu löschen."): + with ui.grid(columns="auto auto").classes('w-full'): + ui.label(f"{data['holidays'][entry.strftime('%Y-%m-%d')]}").props('align="left"') + ui.label(f"{date_label}").props('align="right"') + holiday_buttons_grid() + + 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): + 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() + + with ui.button("Speichern", on_click=save_admin_settings): + with ui.tooltip(): + ui.label("Hiermit werden sämtliche oben gemachten Einstellungen gespeichert.\nGgf. müssen Sie die Seite neu laden um die Auswirkungen sichtbar zu machen.").style('white-space: pre-wrap') + + with ui.tab_panel(users): + ui.label("Benutzerverwaltung").classes(h3) + workhours = [ ] + + with ui.row(): + + def user_selection_changed(): + try: + if user_selection.value != None: + current_user = user(user_selection.value) + username_input.value = current_user.username + fullname_input.value = current_user.fullname + #password_input.value = current_user.password + usersettingscard.visible = True + + api_key_input.value = current_user.api_key + + api_link_column.clear() + for i in app.urls: + with ui.row() as link_row: + link_string = f'{i}/api/stamp/{api_key_input.value}' + link = ui.link(f'{i}/api/stamp/"API-Schlüssel"', link_string).tooltip(("ACHTUNG: Klick auf den Link löst Stempelaktion aus!")) + qr = segno.make_qr(link_string).svg_data_uri() + with ui.image(qr).classes('w-1/3'): + with ui.tooltip().classes('bg-white border'): + ui.image(qr).classes('w-64') + + link_row.move(api_link_column) + + workhours_select.clear() + workhour_list = list(current_user.workhours) + workhour_dict = { } + for i in workhour_list: + workhour_dict[i] = datetime.datetime.strptime(i, "%Y-%m-%d").strftime("%d.%m.%Y") + workhour_list.sort() + workhours_select.set_options(workhour_dict) + workhours_select.value = workhour_list[0] + workinghourscard.visible = True + + user_photo.set_source(current_user.photofile) + user_photo.force_reload() + + user_photo.set_visibility(os.path.exists(current_user.photofile)) + delete_button.set_visibility(os.path.exists(current_user.photofile)) + + except: + pass + + # workhours_selection_changed(list(current_user.workhours)[0]) + + def workhours_selection_changed(): + if workhours_select.value != None: + current_user = user(user_selection.value) + selected_workhours = current_user.workhours[workhours_select.value] + + for key, hours in selected_workhours.items(): + try: + days[int(key)-1].value = hours + except: + if key == 0: + days[6].value = hours + elif key == "vacation": + vacation_input.value = hours + + def save_user_settings(): + def save_settings(): + current_user = user(user_selection.value) + current_user.username = username_input.value + current_user.fullname = fullname_input.value + current_user.password = hash_password(password_input.value) + current_user.api_key = api_key_input.value + current_user.write_settings() + password_input.value = "" + userlist = list_users() + userlist.sort() + user_selection.clear() + user_selection.set_options(userlist) + user_selection.value = current_user.username + user_selection_changed() + dialog.close() + ui.notify("Einstellungen gespeichert") + + with ui.dialog() as dialog, ui.card(): + if user_selection.value != username_input.value: + 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(): + ui.button("Speichern", on_click=save_settings) + ui.button("Abbrechen", on_click=dialog.close) + dialog.open() + + def del_user(): + current_user = user(user_selection.value) + + def del_definitely(): + if current_user.username == time_user.value: + if userlist.index(current_user.username) == 0: + time_user.value = userlist[1] + else: + time_user.value = userlist[0] + current_user.del_user() + #userlist = list_users() + #userlist.sort() + #user_selection.clear() + #user_selection.set_options(userlist) + #user_selection.value = userlist[0] + update_userlist() + time_user.set_options(userlist) + user_ui.refresh() + dialog.close() + ui.notify("Benutzer gelöscht") + + with ui.dialog() as dialog, ui.card(): + ui.markdown(f"Soll der Benutzer *{current_user.username}* gelöscht werden?") + ui.label("Dies kann nicht rückgängig gemacht werden?").classes('text-bold') + with ui.row(): + ui.button("Löschen", on_click=del_definitely) + ui.button("Abbrechen", on_click=dialog.close) + + dialog.open() + + def save_workhours(): + def save_settings(): + current_user = user(user_selection.value) + construct_dict = { } + for i in range(7): + if i < 7: + construct_dict[i+1] = days[i].value + elif i == 7: + construct_dict[0] = days[i].value + + construct_dict["vacation"] = vacation_input.value + current_user.workhours[workhours_select.value] = construct_dict + current_user.write_settings() + dialog.close() + ui.notify("Einstellungen gespeichert") + + with ui.dialog() as dialog, ui.card(): + ui.label("Sollen die Änderungen an den Arbeitsstunden und/oder Urlaubstagen gespeichert werden?") + with ui.row(): + ui.button("Speichern", on_click=save_settings) + ui.button("Abrrechen", on_click=dialog.close) + dialog.open() + + def delete_workhour_entry(): + def delete_entry(): + current_user = user(user_selection.value) + del current_user.workhours[workhours_select.value] + current_user.write_settings() + workhour_list = list(current_user.workhours) + workhours_select.clear() + workhour_dict = {} + for i in workhour_list: + workhour_dict[i] = datetime.datetime.strptime(i, "%Y-%m-%d").strftime( + "%d.%m.%Y") + workhours_select.set_options(workhour_dict) + workhours_select.set_options(workhour_dict) + workhours_select.set_value(workhour_list[-1]) + + #workhours_selection_changed(current_user.workhours[0]) + dialog.close() + ui.notify("Eintrag gelöscht" + "") + with ui.dialog() as dialog, ui.card(): + current_user = user(user_selection.value) + if len(current_user.workhours) > 1: + ui.label(f"Soll der Eintrag {datetime.datetime.strptime(workhours_select.value, '%Y-%m-%d').strftime('%d.%m.%Y')} wirklich gelöscht werden?") + ui.label("Dies kann nicht rückgängig gemacht werden.").classes('text-bold') + with ui.row(): + ui.button("Löschen", on_click=delete_entry) + ui.button("Abbrechen", on_click=dialog.close) + def handle_key(e: KeyEventArguments): + print(e.key) + if e.key == 'j' or e.key == 'Enter': + delete_entry() + if e.key == 'n' or e.key == 'Esc': + dialog.close() + keyboard = ui.keyboard(on_key=handle_key) + else: + ui.label("Es gibt nur einen Eintrag. Dieser kann nicht gelöscht werden.") + ui.button("OK", on_click=dialog.close) + dialog.open() + + def dialog_new_user(): + def create_new_user(): + if user_name_input.validate(): + + new_user(user_name_input.value) + update_userlist() + time_user.set_options(userlist) + user_ui.refresh() + dialog.close() + else: + ui.notify("Ungültiger Benutzername") + + with ui.dialog() as dialog, ui.card(): + ui.label("Geben Sie den Benutzernamen für das neue Konto an:") + user_name_input = ui.input(label="Benutzername", validation={'Leerer Benutzername nicht erlaubt': lambda value: len(value) != 0, + 'Leerzeichen im Benutzername nicht erlaubt': lambda value: " " not in value, + 'Benutzername schon vergeben': lambda value: value not in userlist}).on('keypress.enter', create_new_user) + with ui.row(): + ui.button("OK", on_click=create_new_user) + ui.button("Abbrechen", on_click=dialog.close) + dialog.open() + + @ui.refreshable + def user_ui(): + userlist = list_users() + userlist.sort() + + with ui.column(): + global user_selection + user_selection = ui.select(options=userlist, with_input=True, on_change=user_selection_changed) + user_selection.value = userlist[0] + ui.button("Neu", on_click=dialog_new_user) + user_ui() + + with ui.column(): + @ui.refreshable + def usersettings_card(): + global usersettingscard + with ui.card() as usersettingscard: + ui.label("Benutzereinstellungen").classes('text-bold') + with ui.grid(columns="auto 1fr").classes('items-baseline') as usersettingsgrid: + + ui.label("Benutzername:") + global username_input + username_input = ui.input() + ui.label("Voller Name:") + global fullname_input + fullname_input = ui.input() + ui.label("Passwort:") + global password_input + password_input = ui.input(password=True) + password_input.value = "" + ui.label("API-Schlüssel:") + with ui.row(): + global api_key_input + api_key_input = ui.input().props('size=37') + def new_api_key(): + api_key_input.value = hashlib.shake_256(bytes(f'{username_input.value}_{datetime.datetime.now().timestamp()}', 'utf-8')).hexdigest(20) + + 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.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) + + usersettings_card() + + with ui.card() as photocard: + ui.label('Foto').classes('text-bold') + current_user = user(user_selection.value) + user_photo = ui.image(current_user.photofile) + + def handle_upload(e: events.UploadEventArguments): + picture = e.content.read() + current_user = user(user_selection.value) + with open(current_user.photofile, 'wb') as outoutfile: + outoutfile.write(picture) + uploader.reset() + user_selection_changed() + + + def del_photo(): + current_user = user(user_selection.value) + current_user.delete_photo() + user_selection_changed() + + uploader = ui.upload(label="Foto hochladen", on_upload=handle_upload).props('accept=.jpg|.jpeg').classes('max-w-full') + delete_button = ui.button("Löschen", on_click=del_photo) + + + with ui.card() as workinghourscard: + workhours = [] + ui.label("Arbeitszeiten").classes('text-bold') + + with ui.card(): + + def calculate_weekhours(): + sum = 0 + for i in range(7): + try: + sum = float(days[i].value) + sum + except: + pass + workhours_sum.text = str(sum) + + with ui.grid(columns='auto auto auto').classes('items-baseline'): + ui.label("gültig ab:") + workhours_select = ui.select(options=workhours, on_change=workhours_selection_changed).classes('col-span-2') + + days = [ ] + weekdays = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"] + counter = 0 + for day in weekdays: + ui.label(f"{day}:") + days.append(ui.number(on_change=calculate_weekhours).props('size=3')) + ui.label('Stunden') + counter = counter + 1 + ui.separator().classes('col-span-full') + ui.label("Summe:").classes('text-bold') + workhours_sum = ui.label() + ui.label("Stunden") + + with ui.card(): + with ui.grid(columns='auto auto auto').classes('items-baseline'): + ui.label("Urlaubstage") + vacation_input = ui.number().props('size=3') + ui.label("Tage") + + def new_workhours_entry(): + current_user = user(user_selection.value) + + def add_workhours_entry(): + workhours_dict = { } + if not use_last_entries_chkb.value: + for i in range(1, 8): + workhours_dict[i] = 0 + workhours_dict["vacation"] = 0 + else: + validity_date_dt = datetime.datetime.strptime(date_picker.value, "%Y-%m-%d") + for i in range (1, 8): + check_date_dt = validity_date_dt - datetime.timedelta(days=i) + weekday_of_check_date = check_date_dt.weekday() + 1 + workhours_of_check_date = current_user.get_day_workhours(check_date_dt.year, check_date_dt.month, check_date_dt.day) + workhours_dict[weekday_of_check_date] = workhours_of_check_date + workhours_dict["vacation"] = current_user.get_vacation_claim(validity_date_dt.year, validity_date_dt.month, validity_date_dt.day) + + current_user.workhours[date_picker.value] = workhours_dict + current_user.write_settings() + + workhours_select.clear() + workhours_list = list(current_user.workhours) + workhours_list.sort() + workhour_dict = {} + for i in workhours_list: + workhour_dict[i] = datetime.datetime.strptime(i, "%Y-%m-%d").strftime( + "%d.%m.%Y") + workhours_select.set_options(workhour_dict) + workhours_select.value = date_picker.value + + dialog.close() + ui.notify("Eintrag angelegt") + + with ui.dialog() as dialog, ui.card(): + ui.label("Geben Sie das Gültigkeitsdatum an, ab wann die Einträge gültig sein sollen.") + date_picker = ui.date() + use_last_entries_chkb = ui.checkbox("Werte von letztem gültigen Eintrag übernehmen.") + with ui.row(): + ui.button("OK", on_click=add_workhours_entry) + ui.button("Abbrechen", on_click=dialog.close) + dialog.open() + ui.button("Neu", on_click=new_workhours_entry) + with ui.row(): + ui.button("Speichern", on_click=save_workhours) + ui.button("Löschen", on_click=delete_workhour_entry) + user_selection_changed() + + with ui.tab_panel(backups): + + try: + if is_docker(): + backupfolder = "/backup" + else: + backupfolder = load_adminsettings()["backup_folder"] + except KeyError: + pass + try: + api_key = load_adminsettings()["backup_api_key"] + except: + api_key = "" + + ui.label("Backupeinstellungen").classes('font-bold') + with ui.grid(columns='auto auto auto').classes('items-baseline'): + ui.label("Backupordner:") + backupfolder_input = ui.input(value=backupfolder).props(f"size={len(backupfolder)}").bind_enabled_from(enabled_because_not_docker, 'value') + def save_new_folder_name(): + if os.path.exists(backupfolder_input.value): + write_adminsetting("backup_folder", backupfolder_input.value) + ui.notify("Neuen Pfad gespeichert") + else: + with ui.dialog() as dialog, ui.card(): + ui.label("Der Pfad") + ui.label(backupfolder_input.value) + ui.label("exisitiert nicht und kann daher nicht verwendet werden.") + ui.button("OK", on_click=dialog.close) + dialog.open() + save_backup_folder_button = ui.button("Speichern", on_click=save_new_folder_name).tooltip("Hiermit können Sie das Backupverzeichnis ändeern").bind_enabled_from(enabled_because_not_docker, 'value') + if is_docker(): + save_backup_folder_button.tooltip("Diese Einstellung ist beim Einsatz von Docker deaktiviert.") + + ui.label("API-Schlüssel:") + backup_api_key_input = ui.input(value=api_key).tooltip("Hier den API-Schlüssel eintragen, der für Backuperzeugung mittels API-Aufruf verwendet werden soll.") + def new_backup_api_key(): + backup_api_key_input.value = hashlib.shake_256(bytes(f"{backupfolder}-{datetime.datetime.now().timestamp()}", 'utf-8')).hexdigest(20) + def save_new_api_key(): + write_adminsetting("backup_api_key", backup_api_key_input.value) + with ui.grid(columns=2): + ui.button("Neu", on_click=new_backup_api_key).tooltip("Hiermit können Sie einen neuen zufälligen API-Schlüssel erstellen.") + ui.button("Speichern", on_click=save_new_api_key).tooltip("Neuen API-Schlüssel speichern. Der alte API-Schlüssel ist damit sofort ungültig.") + + ui.label(f"Der Adresse für den API-Aufruf lautet /api/backup/[API-Schlüssel]").classes('col-span-3') + + ui.separator() + + ui.label('Backups').classes('text-bold') + date_format = '%Y-%m-%d_%H-%M' + searchpath = backupfolder + + @ui.refreshable + def backup_list(): + + if not os.path.isdir(searchpath): + os.makedirs(os.path.join(searchpath)) + + backup_files = [] + file_info = [] + with ui.grid(columns='auto auto auto auto auto auto').classes('items-baseline'): + + ui.label("Backupzeitpunkt/Dateiname") + ui.label("Backupgröße") + ui.label("Programmversion") + + for i in range(0,3): + ui.space() + + for file in os.listdir(searchpath): + if file.endswith(".zip"): + with zipfile.ZipFile(os.path.join(searchpath, file)) as current_archive: + try: + current_version = current_archive.read("app_version.txt").decode('utf-8') + except KeyError: + current_version = "-" + file_info = [file, os.path.getsize(os.path.join(searchpath, file)), current_version] + backup_files.append(file_info) + backup_files.sort() + backup_files.reverse() + + if len(backup_files) == 0: + ui.label("Keine Backups vorhanden") + + for file, size, version in backup_files: + date_string = file[0:-4] + try: + date_string_dt = datetime.datetime.strptime(date_string, date_format) + button_string = date_string_dt.strftime('%d.%m.%Y - %H:%M') + except ValueError: + button_string = date_string + ui.label(button_string) + if size > 1_000_000: + ui.label(f'{round(size/1_000_000,2)} MB') + else: + ui.label(f'{round(size / 1_000, 2)} kB') + ui.label(version) + from lib.definitions import scriptpath + ui.button(icon='download', on_click=lambda file=date_string: ui.download.file( + os.path.join(scriptpath, backupfolder, f'{file}.zip'))).tooltip( + "Backup herunterladen") + + def del_backup_dialog(file): + from lib.definitions import scriptpath + def del_backup(): + os.remove(os.path.join(scriptpath, backupfolder, f'{file}.zip')) + dialog.close() + ui.notify(f'Backupdatei {file}.zip gelöscht') + backup_list.refresh() + + with ui.dialog() as dialog, ui.card(): + ui.label(f"Soll das Backup {file}.zip wirklich gelöscht werden?") + ui.label("Dies kann nicht rückgänig gemacht werden!").classes('font-bold') + check = ui.checkbox("Ich habe verstanden.") + with ui.grid(columns=2): + ui.button("Löschen", on_click=del_backup).bind_enabled_from(check, 'value') + ui.button("Abbrechen", on_click=dialog.close) + dialog.open() + + def restore_backup_dialog(file): + from lib.definitions import scriptpath + def restore_backup(): + if is_docker(): + folder_to_delete = "/users" + else: + folder_to_delete = userfolder + for file_path in os.listdir(folder_to_delete): + delete_item = os.path.join(folder_to_delete, file_path) + if os.path.isfile(delete_item) or os.path.islink(delete_item): + os.unlink(delete_item) + elif os.path.isdir(delete_item): + shutil.rmtree(delete_item) + + with zipfile.ZipFile(os.path.join(backupfolder, f'{file}.zip'), 'r') as source: + user_target = userfolder.strip("users") + filelist = source.namelist() + for file_list_item in filelist: + if file_list_item.startswith("users"): + source.extract(file_list_item, user_target) + source.extract("settings.json", scriptpath) + with ui.dialog() as confirm_dialog, ui.card(): + ui.label("Das Backup wurde wiederhergestellt. Um Änderungen anzuzeigen, muss die Seite neu geladen werden.") + with ui.grid(columns=2): + ui.button("Neu laden", on_click=ui.navigate.reload) + ui.button("Später selbst neu laden", on_click=dialog.close) + confirm_dialog.open() + + with ui.dialog() as dialog, ui.card(): + ui.label(f"Soll das Backup {file}.zip wiederhergestellt werden?") + ui.label("Es werden dabei alle Dateien überschrieben!").classes('font-bold') + ui.label("Dies kann nicht rückgängig gemacht werden!").classes('font-bold fg-red') + check = ui.checkbox("Ich habe verstanden.") + with ui.grid(columns=2): + ui.button("Wiederherstellen", on_click=restore_backup).bind_enabled_from(check, 'value') + ui.button("Abbrechen", on_click=dialog.close) + + dialog.open() + + ui.button(icon='delete', on_click=lambda file=date_string: del_backup_dialog(file)).tooltip("Backup löschen") + ui.button(icon='undo', on_click=lambda file=date_string: restore_backup_dialog(file)).tooltip("Backup wiederherstellen") + + backup_list() + + ui.separator() + + async def make_backup(): + from lib.definitions import scriptpath + n = ui.notification("Backup wird erzeugt...") + compress = zipfile.ZIP_DEFLATED + filename = os.path.join(searchpath, datetime.datetime.now().strftime(date_format) + '.zip') + folder = userfolder.replace(f'{scriptpath}/', '') + with zipfile.ZipFile(filename, 'w', compress) as target: + for root, dirs, files in os.walk(folder): + for file in files: + add = os.path.join(root, file) + target.write(add) + target.write(os.path.join(scriptpath, usersettingsfilename), arcname=usersettingsfilename) + target.writestr("app_version.txt", data=app_version) + backup_list.refresh() + n.dismiss() + ui.notify("Backup erstellt") + + ui.button("Neues Backup erstellen", on_click=make_backup) + + def handle_upload(e: events.UploadEventArguments): + filename = e.name + upload = True + if os.path.exists(os.path.join(searchpath, filename )): + with ui.dialog() as dialog, ui.card(): + ui.label("Datei mit diesem Namen existiert bereits. Die Datei kann nicht hochgeladen werden.") + ui.button("OK", on_click=dialog.close) + dialog.open() + upload = False + + if upload: + + content = e.content.read() + temp_file = os.path.join(searchpath, f"temp-{filename}") + with open(temp_file, 'wb') as output: + output.write(content) + with zipfile.ZipFile(temp_file) as temporary_file: + try: + version_in_file = temporary_file.read("app_version.txt").decode('utf-8') + except KeyError: + version_in_file = "" + if version_in_file == app_version: + os.rename(temp_file, os.path.join(searchpath, filename)) + ui.notify("Datei hochgeladen") + backup_list.refresh() + zip_upload.reset() + else: + with ui.dialog() as dialog, ui.card(): + if version_in_file == "": + ui.label("Es wurden keine gültigen Versionsdaten in der Datei gefunden.") + ui.label("Sind sie sicher, dass Sie diese Datei verwenden möchten?").classes('font-bold') + else: + ui.label(f"Die Versionsdaten des Backups zeigen an, dass diese mit der Version {version_in_file} erstellt wurden. Die aktuell verwendete Version ist {app_version}. Ggf. sind die Backupdaten inkompatibel.") + ui.label("Sind Sie sicher, dass Sie diese Daten verwenden möchten?").classes('font-bold') + go_check = ui.checkbox("Ich bin mir sicher.") + with ui.grid(columns=2): + def go_action(): + os.rename(temp_file, os.path.join(searchpath, filename)) + ui.notify("Datei hochgeladen") + backup_list.refresh() + zip_upload.reset() + dialog.close() + def abort_action(): + os.remove(temp_file) + ui.notify("Temporäre Datei gelöscht") + zip_upload.reset() + dialog.close() + ui.button("Ja", on_click=go_action).bind_enabled_from(go_check, 'value') + ui.button("Nein", on_click=abort_action) + dialog.open() + + ui.separator() + ui.label("Backup hochladen").classes('font-bold') + ui.label(f"Stellen Sie sicher, dass Sie zur aktuellen Programmversion ({app_version}) passende Backups hochladen.") + zip_upload = ui.upload(on_upload=handle_upload).props('accept=.zip') + + ui.separator() + + + # Alternativ zur Loginseite navigieren + else: + login = login_mask() + #ui.navigate.to("/login") \ No newline at end of file diff --git a/lib/api.py b/lib/api.py new file mode 100644 index 0000000..66421cc --- /dev/null +++ b/lib/api.py @@ -0,0 +1,558 @@ +import sys +import os +import zipfile +from calendar import month_name +from logging import exception + +from nicegui import * + +from lib.definitions import * +from lib.web_ui import * +from lib.users import * +from datetime import datetime + +import calendar + + +# Überblicksseite zum Ausdrucken oder als PDF speichern +@ui.page('/api/month/{username}/{year}-{month}') +def page_overview_month(username: str, year: int, month: int): + + try: + admin_auth = app.storage.user['admin_authenticated'] + except: + admin_auth = False + + if login_is_valid(username) or admin_auth: + data = load_adminsettings() + + try: + current_user = user(username) + days_with_errors = current_user.archiving_validity_check(year, month) + ui.page_title(f"Bericht für {current_user.fullname} für {calendar.month_name[month]} {year}") + if current_user.get_archive_status(year, month): + with ui.column().classes('w-full items-end gap-0'): + ui.label(f"Bericht erstellt am {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}") + ui.label('Archiviert').classes('italic').classes('text-red text-bold text-xl') + #ui.add_head_html('') + else: + with ui.column().classes('w-full items-end gap-0'): + ui.label(f"Bericht erstellt am {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}") + ui.label(f'Bericht für {current_user.fullname} für {calendar.month_name[month]} {year}').classes(h1) + + pad_x = 4 + pad_y = 2 + + color_weekend = "gray-100" + color_holiday = "gray-100" + + def overview_table(): + # Timestamp in ein Array schreiben + timestamps = current_user.get_timestamps(year, month) + timestamps.sort() + + # Abwesenheitsdaten in ein Dict schreiben + user_absent = current_user.get_absence(year, month) + + # Dictionary für sortierte Timestamps + timestamps_dict = { } + + # Dictionary mit zunächst leeren Tageinträgen befüllen + for day in range(1, monthrange(year, month)[1] + 1): + # Jeder Tag bekommt eine leere Liste + timestamps_dict[day] = [ ] + + # Timestamps den Monatstagen zuordnen + for stamp in timestamps: + day_of_month_of_timestamp = datetime.fromtimestamp(int(stamp)).day + timestamps_dict[day_of_month_of_timestamp].append(int(stamp)) + + general_saldo = 0 + + bg_color = '' + if current_user.get_archive_status(year, month): + bg_color = ' bg-yellow-100' + + with ui.grid(columns='auto auto 1fr 1fr 1fr').classes(f'gap-0 border px-0 py-0 {bg_color}'): + ui.label("Datum").classes(f'border px-{pad_x} py-{pad_y} text-bold') + ui.label("Buchungen").classes(f'border px-{pad_x} py-{pad_y} text-bold') + ui.label("Ist").classes(f'border px-{pad_x} py-{pad_y} text-bold') + ui.label("Soll").classes(f'border px-{pad_x} py-{pad_y} text-bold') + ui.label("Saldo").classes(f'border px-{pad_x} py-{pad_y} text-bold') + + # Gehe jeden einzelnen Tag des Dictionaries für die Timestamps durch + for day in list(timestamps_dict): + booking_text = "" + color_day = 'inherit' + if datetime(year, month, day).strftime('%w') in ["0", "6"]: + color_day = color_weekend + + current_day_date = f"{datetime(year, month, day).strftime('%a')}, {day}.{month}.{year}" + with ui.link_target(day).classes(f'border px-{pad_x} py-{pad_y} bg-{color_day}'): + ui.label(current_day_date) + + # Abwesenheitseinträge + booking_color = "inherit" + booking_text_color = "inherit" + bold = '' + + try: + # Abwesenheitszeiten behandeln + for i in list(user_absent): + if int(i) == day: + booking_text += absence_entries[user_absent[i]]["name"] + "\n" + booking_color = absence_entries[user_absent[i]]["color"] + booking_text_color = absence_entries[user_absent[i]]["text-color"] + bold = 'text-bold' + except: + pass + + # Buchungen behandeln + for i in range(0, len(timestamps_dict[day]), 2): + try: + temp_pair = [timestamps_dict[day][i], timestamps_dict[day][i + 1]] + booking_text = booking_text + str(datetime.fromtimestamp(temp_pair[0]).strftime('%H:%M')) + " - " + str(datetime.fromtimestamp(temp_pair[1]).strftime('%H:%M')) + "\n" + + except: + if len(timestamps_dict[day]) % 2 != 0: + booking_text += datetime.fromtimestamp(int(timestamps_dict[day][i])).strftime('%H:%M') + " - Buchung fehlt!" + + day_notes = current_user.get_day_notes(year, month, day) + just_once = True + + with ui.column().classes(f'border px-{pad_x} py-{pad_y} bg-{booking_color} text-{booking_text_color}'): + booking_text_element = ui.label(booking_text).style('white-space: pre-wrap').classes(bold) + if len(day_notes) > 0: + if len(timestamps_dict[day]) > 0 or day in list(map(int, list(user_absent))): + ui.separator() + for user_key, notes in day_notes.items(): + if user_key == "admin": + ui.label(f"Administrator:\n{notes}").style('white-space: pre-wrap') + else: + with ui.element(): + ui.label(f"{current_user.fullname}:\n{notes}").style('white-space: pre-wrap') + if len(day_notes) > 1 and just_once: + ui.separator() + just_once = False + + # Ist-Zeiten berechnen + timestamps_of_this_day = [] + + # Suche mir alle timestamps für diesen Tag + for i in timestamps: + actual_timestamp = datetime.fromtimestamp(int(i)) + timestamp_day = actual_timestamp.strftime('%-d') + + if int(timestamp_day) == int(day): + timestamps_of_this_day.append(i) + + timestamps_of_this_day.sort() + time_sum = 0 + if len(timestamps_of_this_day) > 1: + + if len(timestamps_of_this_day) % 2 == 0: + for i in range(0, len(timestamps_of_this_day), 2): + time_delta = int( + timestamps_of_this_day[i + 1]) - int( + timestamps_of_this_day[i]) + time_sum = time_sum + time_delta + else: + for i in range(0, len(timestamps_of_this_day) - 1, 2): + time_delta = int( + timestamps_of_this_day[i + 1]) - int( + timestamps_of_this_day[i]) + time_sum = time_sum + time_delta + + is_time = convert_seconds_to_hours(time_sum) + " h" + else: + is_time = "Kein" + + ui.label(is_time).classes(f'border px-{pad_x} py-{pad_y} text-center') + # Sollzeit bestimmen + + hours_to_work = int(current_user.get_day_workhours(year, month, day)) + if hours_to_work < 0: + target_time = "" + else: + target_time = f"{convert_seconds_to_hours(int(hours_to_work) * 3600)} h" + if int(hours_to_work) == 0: + booking_text = "Kein Arbeitstag" + date_dt = datetime(year, month, day) + if date_dt.strftime("%Y-%m-%d") in data["holidays"]: + booking_text = f'{data["holidays"][date_dt.strftime("%Y-%m-%d")]}' + booking_text_element.classes('text-bold') + booking_text_element.text = booking_text + + ui.label(target_time).classes(f'border px-{pad_x} py-{pad_y} text-center') + + # Saldo für den Tag berechnen + day_in_list = datetime(year, month, day) + if time.time() > day_in_list.timestamp(): + + time_duty = int(current_user.get_day_workhours(year, month, day)) * 3600 + if time_duty < 0: + saldo = 0 + total = "" + booking_text = "Kein Arbeitsverhältnis" + booking_text_element.value = booking_text + else: + saldo = int(time_sum) - int(time_duty) + # Nach Abwesenheitseinträgen suchen + try: + for i in list(user_absent): + if int(i) == day and user_absent[i] != "UU": + saldo = 0 + except: + pass + + general_saldo = general_saldo + saldo + total = f"{convert_seconds_to_hours(saldo)} h" + + else: + total = "-" + if total == "-": + total_class = 'text-center' + else: + total_class = 'text-right' + ui.label(total).classes(total_class).classes(f'border px-{pad_x} py-{pad_y}') + + # Überstundenzusammenfassung + ui.label("Überstunden aus Vormonat:").classes(f'col-span-4 text-right border px-{pad_x} py-{pad_y}') + last_months_overtime = current_user.get_last_months_overtime(year, month) + ui.label(f"{convert_seconds_to_hours(last_months_overtime)} h").classes(f'text-right border px-{pad_x} py-{pad_y}') + ui.label("Überstunden diesen Monat:").classes(f'col-span-4 text-right border px-{pad_x} py-{pad_y}') + ui.label(f"{convert_seconds_to_hours(general_saldo)} h").classes(f'text-right border px-{pad_x} py-{pad_y}') + ui.label("Überstunden Gesamt:").classes(f'col-span-4 text-right border px-{pad_x} py-{pad_y} text-bold') + global overtime_overall + overtime_overall = last_months_overtime + general_saldo + ui.label(f"{convert_seconds_to_hours(overtime_overall)} h").classes(f'text-right border px-{pad_x} py-{pad_y} text-bold') + + overview_table() + + def absence_table(): + absences_this_month = current_user.get_absence(year, month) + absence_dict = { } + + for abbr in list(absence_entries): + absence_dict[abbr] = 0 + + for key, value in absences_this_month.items(): + if value in list(absence_dict): + absence_dict[value] += 1 + + total_absence_days = 0 + for key, value in absence_dict.items(): + total_absence_days += absence_dict[key] + + if total_absence_days > 0: + ui.label("Abwesenheitstage diesen Monat:").classes(h3) + + with ui.grid(columns='auto 25%').classes(f'gap-0 border px-0 py-0'): + + for key, value in absence_dict.items(): + if value > 0: + ui.label(absence_entries[key]['name']).classes(f"border px-{pad_x} py-{pad_y}") + ui.label(str(value)).classes(f'border px-{pad_x} py-{pad_y} text-center') + + absence_table() + + def archive(): + current_year = datetime.now().year + current_month = datetime.now().month + archivable = False + + if current_year > year: + if current_user.get_archive_status(year, month) == False: + archivable = True + if current_year == year: + if current_month > month: + if current_user.get_archive_status(year, month) == False: + archivable = True + + def archive_dialog(): + def do_archiving(): + global overtime_overall + current_user.archive_hours(year, month, overtime_overall) + dialog.close() + ui.navigate.to(f'/api/month/{username}/{year}-{month}') + + with ui.dialog() as dialog, ui.card(): + with ui.grid(columns='1fr 1fr'): + ui.label("Hiermit bestätigen Sie, dass die Zeitbuchungen im Montagsjournal korrekt sind.\nSollte dies nicht der Fall sein, wenden Sie sich für eine Korrektur an den Administrator.").classes('col-span-2').style('white-space: pre-wrap') + ui.button("Archivieren", on_click=do_archiving) + ui.button("Abbrechen", on_click=dialog.close) + + dialog.open() + + if archivable == True: + if len(days_with_errors) > 0: + ui.label("Es gibt Inkonsistenzen in den Buchungen. Folgende Tage müssen überprüft werden:") + with ui.grid(columns=len(days_with_errors)): + for i in days_with_errors: + ui.link(f"{i}.", f'#{i}') + + archive_button = ui.button("Archivieren", on_click=archive_dialog) + if len(days_with_errors) > 0: + archive_button.disable() + + archive() + + except Exception as e: + print(str(type(e).__name__) + " " + str(e)) + if type(e) == UnboundLocalError: + ui.label('Fehler').classes(h1) + ui.label('Benutzer existiert nicht') + else: + ui.label('Fehler').classes(h1) + ui.label(str(type(e))) + ui.label(str(e)) + else: + login_mask(target=f'/api/month/{username}/{year}-{month}') +@ui.page('/api/vacation/{username}/{year}') +def page_overview_vacation(username: str, year: int): + + try: + admin_auth = app.storage.user['admin_authenticated'] + except: + admin_auth = False + + if login_is_valid(username) or admin_auth: + + try: + current_user = user(username) + + month = datetime.now().month + day = datetime.now().day + + ui.page_title(f"Urlaubsanspruch für {current_user.fullname} für {year}") + ui.label(datetime.now().strftime('%d.%m.%Y')).classes('w-full text-right') + ui.label(f'Urlaubsanspruch für {current_user.fullname} für {year}').classes(h1) + + pad_x = 4 + pad_y = 2 + + vacationclaim = int(current_user.get_vacation_claim(year, month, day)) + if vacationclaim == -1: + ui.label(f"Kein Urlaubsanspruch für {year}").classes(h3) + else: + + with ui.grid(columns='auto auto').classes(f'gap-0 border px-0 py-0'): + ui.label(f"Urlaubsanspruch für {year}:").classes(f'border px-{pad_x} py-{pad_y}') + ui.label(f"{vacationclaim} Tage").classes(f'text-right border px-{pad_x} py-{pad_y}') + ui.label("Registrierte Urlaubstage").classes(f'border px-{pad_x} py-{pad_y} col-span-2') + vacation_counter = 0 + try: + for i in range(1, 13): + absence_entries = current_user.get_absence(year, i) + for day, absence_type in absence_entries.items(): + # print(day + "." + str(i) + " " + absence_type) + if absence_type == "U": + day_in_list = datetime(int(year), int(i), int(day)).strftime("%d.%m.%Y") + ui.label(day_in_list).classes(f'border px-{pad_x} py-{pad_y}') + ui.label("-1 Tag").classes(f'border px-{pad_x} py-{pad_y} text-center') + vacation_counter += 1 + except Exception as e: + print(str(type(e).__name__) + " " + str(e)) + ui.label("Resturlaub:").classes(f'border px-{pad_x} py-{pad_y} text-bold') + ui.label(f'{str(vacationclaim - vacation_counter)} Tage').classes(f'border px-{pad_x} py-{pad_y} text-center text-bold') + + + except Exception as e: + print(str(type(e).__name__) + " " + str(e)) + if type(e) == UnboundLocalError: + ui.label('Fehler').classes(h1) + ui.label('Benutzer existiert nicht') + else: + ui.label('Fehler').classes(h1) + ui.label(str(type(e))) + ui.label(str(e)) + else: + login = login_mask(target=f'/api/vacation/{username}/{year}') + +@ui.page('/api/absence/{username}/{year}') +def page_overview_absence(username: str, year: int): + try: + admin_auth = app.storage.user['admin_authenticated'] + except: + admin_auth = False + + if login_is_valid(username) or admin_auth: + current_user = user(username) + ui.page_title(f"Abwesenheitsübersicht für {current_user.fullname} für {year}") + ui.label(datetime.now().strftime('%d.%m.%Y')).classes('w-full text-right') + pageheader(f"Abwesenheitsübersicht für {current_user.fullname} für {year}") + + pad_x = 2 + pad_y = 1 + + def absence_calender(): + + column_constructor = 'auto ' + for j in range(1, 31): + column_constructor += "1fr " + column_constructor += 'auto' + + with ui.grid(columns=column_constructor).classes(f'gap-0 border px-0 py-0') as calendar_grid: + # Erste Zeile + ui.space() + for i in range(1, 32): + ui.label(str(i)).classes(f'border px-{pad_x} py-{pad_y} text-center') + # Monate durchgehen + for month in range(1, 13): + for column in range(0, 32): + if column == 0: + ui.label(month_name[month]).classes(f'border px-{pad_x} py-{pad_y} text.center') + else: + absences = current_user.get_absence(year, month) + if str(column) in list(absences): + bg_color = absence_entries[absences[str(column)]]['color'] + text_color = absence_entries[absences[str(column)]]['text-color'] + tooltip_text = absence_entries[absences[str(column)]]['name'] + with ui.element(): + ui.label(absences[str(column)]).classes(f'border px-{pad_x} py-{pad_y} bg-{bg_color} text-{text_color} align-middle text-center') + ui.tooltip(tooltip_text) + else: + tooltip_text = "" + if column > monthrange(year, month)[1]: + bg_color = 'gray-500' + tooltip_text="Tag exisitiert nicht" + elif int(current_user.get_day_workhours(year, month, column)) == 0: + bg_color = 'gray-300' + tooltip_text = "Kein Arbeitstag" + elif int(current_user.get_day_workhours(year, month, column)) == -1: + bg_color = 'gray-400' + tooltip_text = "Kein Arbeitsverhältnis" + else: + bg_color = 'inherit' + with ui.label("").classes(f'border px-{pad_x} py-{pad_y} bg-{bg_color}'): + if tooltip_text != "": + ui.tooltip(tooltip_text) + + absence_calender() + + def absence_table(): + + with ui.grid(columns='auto auto').classes(f'gap-0 px-0 py-0 items-baseline'): + ui.label('Summen').classes('col-span-2 px-2 text-bold') + for type in list(absence_entries): + number_of_days = 0 + ui.label(absence_entries[type]["name"]).classes(f'border px-{pad_x} py-{pad_y}') + for month in range(1, 13): + absences_of_month = current_user.get_absence(year, month) + for i in list(absences_of_month): + if absences_of_month[i] == type: + number_of_days += 1 + ui.label(str(number_of_days)).classes(f'border px-{pad_x} py-{pad_y} text-center') + absence_table() + + else: + login = login_mask(target=f'/api/absence/{username}/{year}') + +@app.get('/api/stamp/{api_key}') +def json_stamp(api_key: str): + userlist = list_users() + user_dict = {} + # Dictionary mit Usernamen befüllen + for i in userlist: + user_dict[i] = "" + for entry in list(user_dict): + try: + temp_user = user(entry) + user_dict[entry] = temp_user.api_key + except: + pass + + returndata = {} + for user_key, api_value in user_dict.items(): + if api_key == api_value: + current_user = user(user_key) + current_user.timestamp() + + + returndata["username"] = current_user.username + if current_user.stamp_status() == status_in: + returndata["stampstatus"] = True + else: + returndata["stampstatus"] = False + break + else: + returndata["username"] = None + + return returndata + +@app.get("/api/json/{api_key}") +def json_info(api_key: str): + userlist = list_users() + user_dict = {} + # Dictionary mit Usernamen befüllen + for i in userlist: + user_dict[i] = "" + for entry in list(user_dict): + try: + temp_user = user(entry) + user_dict[entry] = temp_user.api_key + except: + pass + + found_key = False + + for user_key, api_value in user_dict.items(): + if api_key == api_value: + current_user = user(user_key) + now_dt = datetime.now() + year = now_dt.year + month = now_dt.month + day = now_dt.day + + data = { } + data["user"] = current_user.username + if current_user.stamp_status() == status_in: + data["status"] = 1 + else: + data["status"] = 0 + absences = current_user.get_absence(now_dt.year, now_dt.month) + data["absence"] = 0 + if str(now_dt.day) in list(absences): + data["absence"] = absences[str(now_dt.day)] + data["time"] = { } + data["time"]["today"] = current_user.get_worked_time(now_dt.year, now_dt.month, now_dt.day)[0] + + # Arbeitszeit berechnen + months_time_sum = 0 + for checkday in range(1, day + 1): + months_time_sum += (int(current_user.get_worked_time(year, month, checkday)[0]) - int(current_user.get_day_workhours(year, month, checkday))*3600) + + time_saldo = months_time_sum + current_user.get_last_months_overtime(year, month) + + data["time"]["overall"] = time_saldo + data["vacation"] = { } + data["vacation"]["claim"] = current_user.get_vacation_claim(now_dt.year, now_dt.month, now_dt.day) + data["vacation"]["used"] = current_user.count_absence_days("U", now_dt.year) + data["vacation"]["remaining"] = data["vacation"]["claim"] - data["vacation"]["used"] + return data + break + + if not found_key: + return { "data": "none"} + +@app.get('/api/backup/{api_key}') +def backup_api(api_key: str): + date_format = '%Y-%m-%d_%H-%M' + searchpath = backupfolder + + def make_backup(): + compress = zipfile.ZIP_DEFLATED + filename = os.path.join(searchpath, datetime.now().strftime(date_format) + '.zip') + folder = userfolder.replace(f"{scriptpath}/") + with zipfile.ZipFile(filename, 'w', compress) as target: + for root, dirs, files in os.walk(folder): + for file in files: + add = os.path.join(root, file) + target.write(add) + target.write(usersettingsfilename) + target.writestr("app_version.txt", data=app_version) + if api_key == load_adminsettings()["backup_api_key"]: + make_backup() + return {"backup": datetime.now().strftime(date_format), "success": True} + else: + return {"backup": datetime.now().strftime(date_format), "success": False} \ No newline at end of file diff --git a/lib/definitions.py b/lib/definitions.py new file mode 100644 index 0000000..f8a4747 --- /dev/null +++ b/lib/definitions.py @@ -0,0 +1,130 @@ +# Zeiterfassung +# Quasi-Konstanten + +import os +from pathlib import Path +import hashlib + +app_title = "Zeiterfassung" +app_version = "beta-2025.0.1" + +# Standardpfade + +def is_docker(): + cgroup = Path('/proc/self/cgroup') + return Path('/.dockerenv').is_file() or (cgroup.is_file() and 'docker' in cgroup.read_text()) + +if is_docker(): + scriptpath = "/settings" + backupfolder = "/backup" + userfolder = "/users" +else: + scriptpath = str(Path(os.path.dirname(os.path.abspath(__file__))).parent.absolute()) + backupfolder = str(os.path.join(scriptpath, "backup")) + userfolder = os.path.join(scriptpath, "users") + +# Dateinamen + +usersettingsfilename = "settings.json" +photofilename = "photo.jpg" +va_file = "vacation_application.json" + +# Status + +status_in = "eingestempelt" +status_out = "ausgestempelt" + +# Standardadmin Settings: + +standard_adminsettings = { "admin_user": "admin", + "admin_password": "8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918", + "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": backupfolder, + "backup_api_key": hashlib.shake_256(bytes(backupfolder, 'utf-8')).hexdigest(20), + "holidays": { } + } + +# Standard User Settings: + +standard_usersettings = { + "username": "default", + "fullname": "Standardbenutzer", + "password": "37a8eec1ce19687d132fe29051dca629d164e2c4958ba141d5f4133a33f0688f", + "api_key": "1234567890", + "workhours": { } +} + +# Abesenheiten + +absence_entries = {"U": { "name": "Urlaub", + "color": "green", + "text-color": "black"}, + "K": { "name": "Krankheit", + "color": "red", + "text-color": "white"}, + "KK": { "name": "Krankheit Kind", + "color": "orange", + "text-color": "black"}, + "UU": { "name": "Urlaub aus Überstunden", + "color": "green", + "text-color": "black"}, + "F": { "name": "Fortbildung", + "color": "black", + "text-color": "white"}, + "EZ": { "name": "Elternzeit", + "color": "purple", + "text-color": "white"}, + "SO": { "name": "Sonstiges", + "color": "pink", + "text-color": "white"} + } + +# Styling +h1 = "text-5xl font-bold" +h2 = "text-4xl font-medium" +h3 = "text-3xl font-light" +h4 = "text-2xl" + +# SVGs: + +no_photo_svg = f''' + + + + + + + + + + + + + + + + + +''' \ No newline at end of file diff --git a/lib/homepage.py b/lib/homepage.py new file mode 100644 index 0000000..1f9a8f3 --- /dev/null +++ b/lib/homepage.py @@ -0,0 +1,330 @@ +# Zeiterfassung +import datetime + +from nicegui import ui, app, Client +from nicegui.page import page + + +from lib.users import * +from lib.definitions import * +from calendar import monthrange, month_name + +import hashlib +import calendar +import locale + +from lib.web_ui import * + +@ui.page('/') +def homepage(): + ui.page_title(f'{app_title} {app_version}') + if login_is_valid(): + + try: + current_user = user(app.storage.user["active_user"]) + except: + del(app.storage.user["active_user"]) + ui.navigate.reload() + pageheader(f"Willkommen, {current_user.fullname}") + + today = datetime.datetime.now() + def yesterdays_overtime(): + last_months_overtime = current_user.get_last_months_overtime(today.year, today.month) + overtime_this_month = 0 + for i in range(1, today.day): + overtime_this_month += (int(current_user.get_worked_time(today.year, today.month, i)[0]) - int(current_user.get_day_workhours(today.year, today.month, i))) + return last_months_overtime + overtime_this_month + + @ui.refreshable + def stamp_interface(): + + time_so_far = current_user.get_worked_time(today.year, today.month, today.day)[0] + + def stamp_and_refresh(): + current_user.timestamp() + stamp_interface.refresh() + + with ui.grid(columns='20% auto 20%').classes('w-full justify-center'): + ui.space() + + def update_timer(): + additional_time = 0 + if time_toggle.value: + additional_time = yesterdays_overtime() + time_toggle.set_text("Gesamtzeit") + if not time_toggle.value: + time_toggle.set_text("Tageszeit") + if current_user.get_worked_time(today.year, today.month, today.day)[1] > 0: + time_in_total = additional_time + time_so_far + int((datetime.datetime.now().timestamp() - current_user.get_worked_time(today.year, today.month, today.day)[1])) + else: + time_in_total = additional_time + time_so_far + working_hours.set_content(convert_seconds_to_hours(time_in_total)) + + with ui.grid(columns='1fr 1fr'): + if current_user.stamp_status() == status_in: + bg_color = 'green' + else: + bg_color = 'red' + working_hours = ui.markdown(convert_seconds_to_hours(time_so_far)).classes(f'col-span-2 rounded-3xl text-center text-white text-bold text-2xl border-4 border-gray-600 bg-{bg_color}') + in_button = ui.button("Einstempeln", on_click=stamp_and_refresh).classes('bg-green') + out_button = ui.button("Ausstempeln", on_click=stamp_and_refresh).classes('bg-red') + + time_toggle = ui.switch("Tageszeit",on_change=update_timer).classes('w-full justify-center col-span-2 normal-case') + + #time_toggle = ui.toggle({"day": "Tagesarbeitszeit", "total": "Gesamtzeit"}, value="day", + # on_change=update_timer).classes('w-full justify-center col-span-2 normal-case').tooltip("Hier lässt sich die Anzeige oben zwischen heute geleisteter Arbeitszeit und summierter Arbeitszeit umschalten.") + + working_timer = ui.timer(30.0, update_timer) + working_timer.active = False + + if current_user.stamp_status() == status_in: + in_button.set_enabled(False) + out_button.set_enabled(True) + working_timer.active = True + + else: + in_button.set_enabled(True) + out_button.set_enabled(False) + working_timer.active = False + + stamp_interface() + + available_years = current_user.get_years() + + + available_months = [ ] + binder_month_button = ValueBinder() + binder_month_button.value = False + + binder_available_years = ValueBinder() + + binder_vacation = ValueBinder() + binder_vacation.value = False + + binder_absence = ValueBinder() + binder_absence.value = False + + def enable_month(): + binder_month_button.value = True + + def update_month(): + month_dict = { } + for i in current_user.get_months(month_year_select.value): + month_dict[i] = month_name[i] + + month_month_select.set_options(month_dict) + month_month_select.enable() + + if load_adminsettings()["user_notes"]: + with ui.grid(columns='1fr auto 1fr').classes('w-full justify-center'): + ui.space() + + with ui.expansion("Tagesnotizen", icon='o_description'): + with ui.grid(columns=2): + + last_selection = 0 + @ui.refreshable + def day_note_ui(): + + day_notes = { } + options = { } + options[0] = "Heute" + for i in range(1, monthrange(today.year, today.month)[1] + 1): + notes_of_i = current_user.get_day_notes(today.year, today.month, i) + if len(notes_of_i) > 0: + try: + day_notes[i] = notes_of_i["user"] + options[i] = f"{i}.{today.month}.{today.year}" + except KeyError: + pass + + select_value = last_selection + try: + day_notes[today.day] + del(options[0]) + select_value = today.day + except KeyError: + select_value = 0 + day_selector = ui.select(options=options, value=select_value).classes('col-span-2') + #except ValueError: + # day_selector = ui.select(options=options, value=0).classes('col-span-2') + daynote = ui.textarea().classes('col-span-2') + + try: + if last_selection == 0: + daynote.value = current_user.get_day_notes(today.year, today.month, today.day)["user"] + else: + daynote.value = day_notes[day_selector.value] + except: + daynote.value = "" + + def call_note(): + if day_selector.value == 0: + daynote.value = current_user.get_day_notes(today.year, today.month, today.day)["user"] + else: + daynote.value = day_notes[day_selector.value] + day_selector.on_value_change(call_note) + + def save_note(): + note_dict = { } + note_dict["user"] = daynote.value + nonlocal last_selection + last_selection = day_selector.value + if day_selector.value == 0: + day_to_write = today.day + else: + day_to_write = day_selector.value + current_user.write_notes(today.year, today.month, day_to_write, note_dict) + day_note_ui.refresh() + + save_button = ui.button("Speichern", on_click=save_note) + + def del_text(): + daynote.value = "" + delete_button = ui.button("Löschen", on_click=del_text) + + + notes = current_user.get_day_notes(today.year, today.month, today.day) + try: + daynote.value = notes[current_user.username] + except: + pass + day_note_ui() + + ui.separator() + + with ui.tabs().classes('w-full items-center') as tabs: + + overviews = ui.tab('Übersichten') + absence = ui.tab('Urlaubsantrag') + absence.set_visibility(load_adminsettings()["vacation_application"]) + pw_change = ui.tab("Passwort") + + with ui.grid(columns='1fr auto 1fr').classes('w-full items-center'): + ui.space() + with ui.tab_panels(tabs, value=overviews): + with ui.tab_panel(overviews): + + def activate_vacation(): + binder_vacation.value = True + + def activate_absence(): + binder_absence.value = True + + with ui.grid(columns='1fr 1fr').classes('items-end'): + + ui.label("Monatsübersicht:").classes('col-span-2 font-bold') + + month_year_select = ui.select(list(reversed(available_years)), label="Jahr", on_change=update_month).bind_value_to(binder_available_years, 'value') + month_month_select = ui.select(available_months, label="Monat", on_change=enable_month) + month_month_select.disable() + + ui.space() + month_button = ui.button("Anzeigen", on_click=lambda: ui.navigate.to(f"/api/month/{current_user.username}/{month_year_select.value}-{month_month_select.value}", new_tab=True)).bind_enabled_from(binder_month_button, 'value') + ui.label("Urlaubsanspruch").classes('col-span-2 font-bold') + vacation_select = ui.select(list(reversed(available_years)), on_change=activate_vacation) + vacation_button = ui.button("Anzeigen", on_click=lambda: ui.navigate.to(f"/api/vacation/{current_user.username}/{vacation_select.value}", new_tab=True)).bind_enabled_from(binder_vacation, 'value') + ui.label("Fehlzeitenübersicht").classes('col-span-2 font-bold') + absences_select = ui.select(list(reversed(available_years)), on_change=activate_absence) + absences_button = ui.button("Anzeigen", on_click=lambda: ui.navigate.to(f"api/absence/{current_user.username}/{absences_select.value}", new_tab=True)).bind_enabled_from(binder_absence, 'value') + + with ui.tab_panel(absence): + ui.label("Urlaub für folgenden Zeitraum beantragen:") + vacation_date = ui.date().props('range today-btn') + def vacation_submission(): + if vacation_date.value == None: + return None + try: + current_user.vacation_application(vacation_date.value["from"], vacation_date.value["to"]) + except TypeError: + current_user.vacation_application(vacation_date.value, vacation_date.value) + vacation_date.value = "" + with ui.dialog() as dialog, ui.card(): + ui.label("Urlaubsantrag wurde abgeschickt") + ui.button("OK", on_click=dialog.close) + open_vacation_applications.refresh() + dialog.open() + + ui.button("Einreichen", on_click=vacation_submission).classes('w-full items-center').tooltip("Hiermit reichen Sie einen Urlaubsantrag für den oben markierten Zeitraum ein.") + + @ui.refreshable + def open_vacation_applications(): + open_applications = current_user.get_open_vacation_applications() + if len(list(open_applications)) > 0: + ui.separator() + ui.label("Offene Urlaubsanträge:").classes('font-bold') + va_columns = [ {'label': 'Index', 'name': 'index', 'field': 'index', 'classes': 'hidden', 'headerClasses': 'hidden'}, + {'label': 'Start', 'name': 'start', 'field': 'start'}, + {'label': 'Ende', 'name': 'end', 'field': 'end'}] + va_rows = [ ] + date_string = '%d.%m.%Y' + for i, dates in open_applications.items(): + startdate_dt = datetime.datetime.strptime(dates[0], '%Y-%m-%d') + enddate_dt = datetime.datetime.strptime(dates[1], '%Y-%m-%d') + va_rows.append({'index': i, 'start': startdate_dt.strftime(date_string), 'end': enddate_dt.strftime(date_string)}) + + va_table = ui.table(columns=va_columns, rows=va_rows, selection="single", row_key="index").classes('w-full') + def retract_va(): + try: + retract_result = current_user.revoke_vacation_application(va_table.selected[0]["index"]) + open_vacation_applications.refresh() + if retract_result == 0: + ui.notify("Urlaubsantrag zurückgezogen") + except IndexError: + ui.notify("Kein Urlaubsanstrag ausgewählt") + ui.button("Zurückziehen", on_click=retract_va).tooltip("Hiermit wird der oben gewählte Urlaubsantrag zurückgezogen.").classes('w-full') + + open_vacation_applications() + with ui.tab_panel(pw_change): + ui.label("Passwort ändern").classes('font-bold') + with ui.grid(columns='auto auto').classes('items-end'): + ui.label("Altes Passwort:") + old_pw_input = ui.input(password=True) + ui.label("Neues Passwort:") + new_pw_input = ui.input(password=True) + ui.label("Neues Passwort bestätigen:") + new_pw_confirm_input = ui.input(password=True) + def revert_pw_inputs(): + old_pw_input.value = "" + new_pw_input.value = "" + new_pw_confirm_input.value = "" + def save_new_password(): + if hash_password(old_pw_input.value) == current_user.password: + if new_pw_input.value == new_pw_confirm_input.value: + current_user.password = hash_password(new_pw_input.value) + current_user.write_settings() + ui.notify("Neues Passwort gespeichert") + else: + ui.notify("Passwortbestätigung stimmt nicht überein") + else: + ui.notify("Altes Passwort nicht korrekt") + ui.button("Speichern", on_click=save_new_password) + ui.button("Zurücksetzen", on_click=revert_pw_inputs) + ui.space() + ui.space() + with ui.column(): + ui.separator() + + def logout(): + app.storage.user.pop("active_user", None) + ui.navigate.to("/") + + ui.button("Logout", on_click=logout).classes('w-full') + ui.space() + + else: + login_mask() + +# 404 Fehlerseite +@app.exception_handler(404) +async def exception_handler_404(request, exception: Exception): + with Client(page(''), request=request) as client: + pageheader("Fehler 404") + ui.label("Diese Seite existiert nicht.") + ui.label("Was möchten Sie tun?") + with ui.list().props('dense'): + with ui.item(): + ui.link("zur Startseite", "/") + with ui.item(): + ui.link("zum Administratrionsbereich", "/admin") + return client.build_response(request, 404) \ No newline at end of file diff --git a/lib/login.py b/lib/login.py new file mode 100644 index 0000000..25520ae --- /dev/null +++ b/lib/login.py @@ -0,0 +1,41 @@ +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 new file mode 100644 index 0000000..9557552 --- /dev/null +++ b/lib/touchscreen.py @@ -0,0 +1,147 @@ +from datetime import datetime + +from nicegui import ui, app + +from lib.users import * +from lib.definitions import * +from lib.web_ui import * +from calendar import monthrange + +import hashlib +import calendar +import locale + +@ui.page('/touchscreen') +def page_touchscreen(): + + if load_adminsettings()["touchscreen"]: + + def button_click(name): + #nonlocal buttons + current_user = user(name) + current_user.timestamp() + #if current_user.stamp_status() == status_in: + # buttons[name].props('color=green') + # ui.notify(status_in) + #else: + # buttons[name].props('color=red') + # ui.notify(status_out) + user_buttons.refresh() + + pageheader("Stempeluhr") + ui.page_title("Stempeluhr") + + admin_settings = load_adminsettings() + userlist = list_users() + number_of_users = len(userlist) + buttons = { } + + number_of_columns = 5 + + 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: + number_of_columns = 4 + elif width > 750: + number_of_columns = 3 + else: + number_of_columns = 2 + user_buttons.refresh() + + ui.on('resize', lambda e: set_columns(e.args['width'])) + + @ui.refreshable + def user_buttons(): + + # Fenstergröße bestimmen und dann Spalten anpassen + ui.add_head_html(''' + + ''') + + with ui.grid(columns=number_of_columns).classes('w-full center'): + + 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]') + + 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 + user_buttons() + + else: + pageheader("Interface deaktiviert") \ No newline at end of file diff --git a/lib/users.py b/lib/users.py new file mode 100644 index 0000000..eb5c00a --- /dev/null +++ b/lib/users.py @@ -0,0 +1,580 @@ +# Zeiterfassung +import hashlib +# User bezogene Funktionen + +import os +from calendar import monthrange +from stat import S_IREAD, S_IWUSR +from nicegui import ui + +import datetime +import time +import json +import shutil +import re + +from lib.definitions import userfolder, scriptpath, usersettingsfilename, photofilename, status_in, status_out, \ + standard_adminsettings, standard_usersettings, va_file, is_docker + +# Benutzerklasse + +class user: + def __init__(self, name): + if not is_docker(): + self.userfolder = os.path.join(userfolder, name) + else: + self.userfolder = os.path.join("/users", name) + self.settingsfile = os.path.join(self.userfolder, usersettingsfilename) + self.photofile = os.path.join(self.userfolder, photofilename) + + # Stammdaten einlesen + + with open(self.settingsfile) as json_file: + data = json.load(json_file) + + self.password = data["password"] + self.workhours = data["workhours"] + self.username = data["username"] + self.fullname = data["fullname"] + self.api_key = data["api_key"] + + def get_stamp_file(self, time_stamp=None): + if time_stamp == None: + year = str(datetime.datetime.now().year) + month = str(datetime.datetime.now().month) + else: + year = str(datetime.datetime.fromtimestamp(time_stamp).year) + month = str(datetime.datetime.fromtimestamp(time_stamp).month) + completepath = os.path.join(self.userfolder, f"{year}-{month}") + return completepath + + def timestamp(self, stamptime=-1): + + if stamptime == -1: + stamptime = time.time() + timestamp = int(stamptime) + + filename = f"{self.get_stamp_file(time_stamp=stamptime)}.txt" + + try: + # Öffne die Datei im Anhang-Modus ('a') + with open(filename, 'a') as file: + # Schreibe den Timestamp in die Datei und füge einen Zeilenumbruch hinzu + file.write(f"{timestamp}\n") + except FileNotFoundError: + # Fehlende Verzeichnisse anlegen + folder_path = os.path.dirname(filename) + os.makedirs(folder_path, exist_ok=True) + self.timestamp() + + # Nach zugehörigem JSON-File suchen und bei Bedarf anlegen + json_filename = f"{self.get_stamp_file()}.json" + try: + with open(json_filename, 'r') as json_file: + pass + except: + dict = { } + dict["archived"] = 0 + dict["total_hours"] = 0 + + json_dict = json.dumps(dict, indent=4) + with open(json_filename, 'w') as json_file: + json_file.write(json_dict) + + def stamp_status(self): + try: + # Öffne die Datei im Lese-Modus ('r') + with open(f"{self.get_stamp_file()}.txt", 'r') as file: + # Zähle die Zeilen + lines = file.readlines() + except FileNotFoundError: + print(f"Die Datei {self.get_stamp_file()}.txt wurde nicht gefunden.") + print("Lege die Datei an.") + with open(f'{self.get_stamp_file()}.txt', 'w') as file: + file.write("") + with open(f"{self.get_stamp_file()}.txt", 'r') as file: + # Zähle die Zeilen + lines = file.readlines() + if len(lines)== 0: + pass + elif len(lines) % 2 == 0: + return status_out + else: + return status_in + + def last_2_timestmaps(self): + + with open(f"{self.get_stamp_file()}.txt", 'r') as file: + lines = file.readlines() + file.close() + + if len(lines) > 2: + second_last_line = int(lines[-2]) + last_line = int(lines[-1]) + last_2_timestamps = [second_last_line, last_line] + return last_2_timestamps + + elif len(lines) == 1: + return int(lines[0]) + else: + return -1 + + def write_settings(self): + dict = { } + dict["username"] = self.username + dict["fullname"] = self.fullname + dict["password"] = self.password + dict["workhours"] = self.workhours + dict["api_key"] = self.api_key + + json_dict = json.dumps(dict, indent=4) + + with open(self.settingsfile, "w") as outputfile: + outputfile.write(json_dict) + + pathcheck = self.userfolder + if not is_docker(): + pathcheck = pathcheck.removeprefix(os.path.join(userfolder)) + if pathcheck != self.username: + os.rename(self.userfolder, os.path.join(userfolder, self.username)) + else: + pathcheck = pathcheck.removeprefix("/users") + if pathcheck != self.username: + os.rename(self.userfolder, os.path.join(userfolder, self.username)) + + + def del_user(self): + shutil.rmtree(self.userfolder) + + def get_starting_day(self): + starting_date = list(self.workhours) + starting_date.sort() + year = str(starting_date[0])[:4] + month = str(starting_date[0])[5:7] + day = str(starting_date[0])[8:10] + + return [year, month, day] + + def get_years(self): + years = [ ] + + # Aktuelles Jahr bestimmen + year_now = int(datetime.datetime.fromtimestamp(time.time()).strftime('%Y')) + + for i in range(int(self.get_starting_day()[0]), year_now + 1): + years.append(str(i)) + + for file in os.listdir(self.userfolder): + if re.match(r"\d{4}-\d{1,2}\.json", file): + year = file.split("-")[0] + if year not in years: + years.append(year) + + years.sort() + return years + + def get_months(self, year): + available_months = [ ] + + # Anfangsdatum bestimmen + start_year = int(self.get_starting_day()[0]) + start_month = int(self.get_starting_day()[1]) + year_now = int(datetime.datetime.now().year) + month_now = int(datetime.datetime.now().month) + + if start_year == int(year): + + if start_year == year_now: + for i in range(start_month, month_now + 1): + available_months.append(i) + elif start_year < year_now: + for i in range(start_month, 13): + available_months.append(i) + else: + if int(year) == year_now: + for i in range(1, month_now + 1): + available_months.append(i) + elif int(year) < year_now: + for i in range(1, 13): + available_months.append(i) + + for file in os.listdir(self.userfolder): + + if re.match(r"\d{4}-\d{1,2}\.json", file): + if file.split("-")[0] == str(year): + month = int(file.split("-")[1].split(".")[0]) + if month not in available_months: + available_months.append(month) + available_months.sort() + return available_months + + def get_timestamps(self, year, month): + try: + with open(os.path.join(self.userfolder, f"{year}-{month}.txt"), "r") as file: + timestamps = file.readlines() + timestamps.sort() + return timestamps + except: + timestamps = [ ] + return timestamps + + def write_edited_timestamps(self, timestamps, year, month): + with open(f"{self.userfolder}/{year}-{month}.txt", "w") as file: + file.write(''.join(timestamps)) + + def get_archive_status(self, year, month): + try: + with open(os.path.join(self.userfolder, f"{year}-{month}.json"), 'r') as json_file: + data = json.load(json_file) + return data["archived"] + except FileNotFoundError: + return False + except: + return -1 + + def archiving_validity_check(self, year: int, month: int): + timestampfilename = os.path.join(self.userfolder, f"{year}-{month}.txt") + try: + with open(timestampfilename) as timestampfile: + timestamps = timestampfile.readlines() + timestamps.sort() + days_with_errors = [ ] + for day in range(1, monthrange(year, month)[1] + 1): + day_dt = datetime.datetime(year, month, day) + timestamps_of_this_day = [ ] + for i in timestamps: + i_dt = datetime.datetime.fromtimestamp(int(i)) + if day_dt.year == i_dt.year and day_dt.month == i_dt.month and day_dt.day == i_dt.day: + timestamps_of_this_day.append(i) + if len(timestamps_of_this_day) % 2 != 0: + days_with_errors.append(day) + return days_with_errors + except: + return [ ] + + def archive_hours(self, year, month, overtime: int): + + filename = os.path.join(self.userfolder, f"{year}-{month}.json") + with open(filename, 'r') as json_file: + data = json.load(json_file) + data["archived"] = 1 + data["overtime"] = overtime + + json_dict = json.dumps(data, indent=4) + + with open(filename, "w") as outputfile: + outputfile.write(json_dict) + # Dateien auf readonly setzen + os.chmod(filename, S_IREAD) + filename_txt = os.path.join(self.userfolder, f"{year}-{month}.txt") + os.chmod(filename_txt, S_IREAD) + + def get_last_months_overtime(self, year=datetime.datetime.now().year, month=datetime.datetime.now().month): + try: + if int(month) == 1: + year = str(int(year) - 1) + month = str(12) + else: + month = str(int(month) - 1) + with open(os.path.join(self.userfolder, f"{year}-{month}.json"), "r") as json_file: + json_data = json.load(json_file) + + if json_data["archived"] == 1: + overtime = int(json_data["overtime"]) + return overtime + else: + return 0 + except: + return 0 + + def get_absence(self, year, month): + try: + with open(os.path.join(self.userfolder, f"{int(year)}-{int(month)}.json"), "r") as json_file: + json_data = json.load(json_file) + absence = json_data["absence"] + return absence + except: + return { } + + def get_day_notes(self, year, month, day): + try: + with open(os.path.join(self.userfolder, f"{int(year)}-{int(month)}.json"), "r") as json_file: + json_data = json.load(json_file) + day_note = json_data["notes"][str(day)] + return day_note + except: + return { } + + def write_notes(self, year, month, day, note_dict): + try: + with open(os.path.join(self.userfolder, f"{int(year)}-{int(month)}.json"), "r") as json_file: + json_data = json.load(json_file) + except FileNotFoundError: + dict = {} + dict["archived"] = 0 + dict["total_hours"] = 0 + dict["notes"] = { } + + json_dict = json.dumps(dict, indent=4) + with open(os.path.join(self.userfolder, f"{int(year)}-{int(month)}.json"), 'w') as json_file: + json_file.write(json_dict) + + json_data = dict + + if len(note_dict) == 1: + user_info = list(note_dict)[0] + try: + json_data["notes"] + except KeyError: + json_data["notes"] = { } + json_data["notes"][str(day)] = { } + json_data["notes"][str(day)][user_info] = note_dict[user_info] + if json_data["notes"][str(day)][user_info] == "": + del json_data["notes"][str(day)][user_info] + else: + json_data["notes"][str(day)] = note_dict + + json_output = json.dumps(json_data, indent=4) + with open(os.path.join(self.userfolder, f"{int(year)}-{int(month)}.json"), "w") as json_file: + json_file.write(json_output) + + + def update_absence(self, year, month, day, absence_type): + try: + with open(os.path.join(self.userfolder, f"{int(year)}-{int(month)}.json"), "r") as json_file: + json_data = json.load(json_file) + except: + with open(os.path.join(self.userfolder, f"{int(year)}-{int(month)}.json"), "w") as json_file: + json_data = { } + json_data["archived"] = 0 + json_data["overtime"] = 0 + json_dict = json.dumps(json_data, indent=4) + json_file.write(json_dict) + try: + json_data["absence"][str(int(day))] = absence_type + except: + json_data.update({ "absence": { str(int(day)): absence_type}}) + json_dict = json.dumps(json_data, indent=4) + + with open(os.path.join(self.userfolder, f"{int(year)}-{int(month)}.json"), "w") as json_file: + json_file.write(json_dict) + + def del_absence(self, year, month, day): + with open(os.path.join(self.userfolder, f"{int(year)}-{int(month)}.json"), "r") as json_file: + json_data = json.load(json_file) + + del json_data["absence"][str(day)] + json_dict = json.dumps(json_data, indent=4) + + with open(os.path.join(self.userfolder, f"{int(year)}-{int(month)}.json"), "w") as json_file: + json_file.write(json_dict) + + def get_day_workhours(self, year, month, day): + #global hours_to_work + workhour_entries = list(self.workhours) + workhour_entries.sort() + day_to_check = datetime.datetime(int(year), int(month), int(day)) + + # Fertage prüfen + settings = load_adminsettings() + holidays = list(settings["holidays"]) + + today_dt = datetime.datetime(int(year), int(month), int(day)) + check_date_list = [ ] + for i in holidays: + i_split = i.split("-") + check_year = int(i_split[0]) + check_month = int(i_split[1]) + check_day = int(i_split[2]) + check_dt = datetime.datetime(check_year, check_month, check_day) + check_date_list.append(check_dt) + if today_dt in check_date_list: + return 0 + + # Wochenarbeitszeit durchsuchen + for entry in reversed(workhour_entries): + + entry_split = entry.split("-") + entry_dt = datetime.datetime(int(entry_split[0]), int(entry_split[1]), int(entry_split[2])) + + if entry_dt <= day_to_check: + weekday = day_to_check.strftime("%w") + if int(weekday) == 0: + weekday = str(7) + hours_to_work = self.workhours[entry][weekday] + break + else: + # Wenn vor Einstellungsdatum -1 ausgeben + hours_to_work = -1 + return hours_to_work + + def get_vacation_claim(self, year=datetime.datetime.now().year, month=datetime.datetime.now().month, day=datetime.datetime.now().day): + workhour_entries = list(self.workhours) + workhour_entries.sort() + day_to_check = datetime.datetime(int(year), int(month), int(day)) + + claim = -1 + + for entry in reversed(workhour_entries): + + entry_split = entry.split("-") + entry_dt = datetime.datetime(int(entry_split[0]), int(entry_split[1]), int(entry_split[2])) + + if entry_dt <= day_to_check: + claim = self.workhours[entry]["vacation"] + break + + return int(claim) + + def count_absence_days(self, absence_code: str, year=datetime.datetime.now().year): + absence_days = 0 + for month in range(0, 13): + try: + absence_dict = self.get_absence(year, month) + for entry, absence_type in absence_dict.items(): + if absence_type == absence_code: + absence_days += 1 + + except: + pass + return absence_days + + def delete_photo(self): + os.remove(self.photofile) + + def get_day_timestamps(self, year=datetime.datetime.now().year, month=datetime.datetime.now().month, day=datetime.datetime.now().day): + timestamps = self.get_timestamps(year, month) + check_day_dt = datetime.datetime(year, month, day) + todays_timestamps = [] + + for i in timestamps: + i_dt = datetime.datetime.fromtimestamp(int(i)) + if i_dt.date() == check_day_dt.date(): + todays_timestamps.append(int(i)) + + todays_timestamps.sort() + + return todays_timestamps + + def get_worked_time(self, year=datetime.datetime.now().year, month=datetime.datetime.now().month, day=datetime.datetime.now().day): + + todays_timestamps = self.get_day_timestamps(year, month, day) + + if len(todays_timestamps) % 2 == 0: + workrange = len(todays_timestamps) + in_time_stamp = -1 + else: + workrange = len(todays_timestamps) - 1 + in_time_stamp = int(todays_timestamps[-1]) + total_time = 0 + + for i in range(0, workrange, 2): + time_worked = todays_timestamps[i + 1] - todays_timestamps[i] + total_time += time_worked + + return [total_time, in_time_stamp] + + def vacation_application(self, startdate, enddate): + application_file = os.path.join(self.userfolder, va_file) + try: + with open(application_file, 'r') as json_file: + applications = json.load(json_file) + except FileNotFoundError: + applications = { } + applications[str(len(list(applications)))] = (startdate, enddate) + with open(application_file, 'w') as json_file: + json_file.write(json.dumps(applications, indent=4)) + + def get_open_vacation_applications(self): + application_file = os.path.join(self.userfolder, va_file) + try: + with open(application_file, 'r') as json_file: + applications = json.load(json_file) + except FileNotFoundError: + applications = { } + return applications + + def revoke_vacation_application(self, index): + application_file = os.path.join(self.userfolder, va_file) + with open(application_file, 'r') as json_file: + applications = json.load(json_file) + try: + del(applications[index]) + new_applications = { } + new_index = 0 + for index, dates in applications.items(): + new_applications[new_index] = dates + new_index += 1 + with open(application_file, 'w') as json_file: + json_file.write(json.dumps(new_applications, indent=4)) + return 0 + except KeyError: + ui.notify("Urlaubsantrag wurde schon bearbeitet") + return -1 + +# Benutzer auflisten +def list_users(): + + if not os.path.exists(userfolder): + print("Kein Benutzerverzeichnis gefunden. Lege es an.") + os.makedirs(userfolder) + + users = [d for d in os.listdir(userfolder) if os.path.isdir(os.path.join(userfolder, d))] + if len(users) == 0: + print("Keine Benutzer gefunden. Lege Standardbenutzer an.") + new_user("default") + users = [d for d in os.listdir(userfolder) if os.path.isdir(os.path.join(userfolder, d))] + + users.sort() + return users + +def new_user(username: str): + if not os.path.exists(userfolder): + os.makedirs(userfolder) + if not os.path.exists(os.path.join(userfolder, username)): + os.makedirs(os.path.join(userfolder, username)) + 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"] = username + settings_to_write["username"] = username + # API-Key erzeugen + string_to_hash = f'{username}_{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}/{username}/{usersettingsfilename}", 'w') as json_file: + json_dict = json.dumps(standard_usersettings, indent=4) + json_file.write(json_dict) + +# Admineinstellungen auslesen +def load_adminsettings(): + # Settingsdatei einlesen + settings_filename = os.path.join(scriptpath, usersettingsfilename) + if not os.path.exists(settings_filename): + print("Keine Einstellungsdatei gefunden. Lege Standarddatei an.") + with open(settings_filename, 'w') as json_file: + json_dict = json.dumps(standard_adminsettings, indent=4) + json_file.write(json_dict) + try: + with open(settings_filename) as json_file: + data = json.load(json_file) + return data + except: + return -1 + +# bestimmte Admineinstellungen speichern +def write_adminsetting(key: str, value): + settings_filename = os.path.join(scriptpath, usersettingsfilename) + admin_settings = load_adminsettings() + try: + admin_settings[key] = value + json_data = json.dumps(admin_settings, indent=4) + with open(settings_filename, 'w') as output_file: + output_file.write(json_data) + except KeyError: + print(f"Kein Einstellungsschlüssel {key} vorhanden.") + diff --git a/lib/web_ui.py b/lib/web_ui.py new file mode 100644 index 0000000..919eb4f --- /dev/null +++ b/lib/web_ui.py @@ -0,0 +1,121 @@ +from datetime import datetime + +from nicegui import ui, app, events + +from lib.users import * +from lib.definitions import * +from calendar import monthrange + +import hashlib +import calendar +import locale + +import platform +from pathlib import Path +from typing import Optional + +locale.setlocale(locale.LC_ALL, '') + +class pageheader: + def __init__(self, heading): + self.heading = heading + + ui.label(f"{app_title} {app_version}").classes(h2) + ui.label(self.heading).classes(h3) + +class ValueBinder: + def __init__(self): + self.value = "" + +def hash_password(password): + return hashlib.sha256(bytes(password, 'utf-8')).hexdigest() + +class login_mask: + def __init__(self, target="/"): + data = load_adminsettings() + self.target = target + + 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") + else: + ui.notify("Login fehlgeschlagen") + else: + userlist = list_users() + + if username.value in userlist: + current_user = user(username.value) + + if hash_password(password.value) == current_user.password: + app.storage.user['active_user'] = current_user.username + ui.navigate.to(self.target) + else: + ui.notify("Login fehlgeschlagen") + else: + ui.notify("Login fehlgeschlagen") + + # ui.markdown(f"## {app_title} {app_version}") + # ui.markdown("Bitte einloggen") + + pageheader("Bitte einloggen:") + + with ui.grid(columns='1fr auto 1fr').classes('w-full justify-center'): + + ui.space() + with ui.grid(columns=2): + ui.markdown("Benutzer:") + username = ui.input('Benutzername') + ui.markdown("Passwort:") + password = ui.input('Passwort', password=True).on('keypress.enter', login) + ui.button(text="Login", on_click=lambda: login()) + ui.space() + +def convert_seconds_to_hours(seconds): + if seconds < 0: + sign = "-" + seconds = seconds * (-1) + else: + sign = "" + hours = seconds // 3600 + remaining_seconds = seconds - hours * 3600 + minutes = remaining_seconds // 60 + remaining_seconds = remaining_seconds - minutes * 60 + if remaining_seconds > 0 and sign != "-": + minutes = minutes + 1 + if minutes == 60: + hours = hours + 1 + minutes = 0 + if hours < 10: + hours = "0" + str(hours) + else: + hours = str(hours) + if minutes < 10: + minutes = "0" + str(minutes) + else: + minutes = str(minutes) + + if sign == "-": + return f"-{hours}:{minutes}" + else: + return f"{hours}:{minutes}" + +def login_is_valid(user = -1): + + if user == -1: + try: + app.storage.user['active_user'] + return True + except: + return False + else: + try: + if app.storage.user['active_user'] == user: + return True + else: + return False + except: + return False \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..6566a6c --- /dev/null +++ b/main.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +# Zeiterfassung +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 * +from lib.api import * +from lib.homepage import * + +import json +import argparse + +from lib.web_ui import hash_password + +def commandline_header(): + message_string = f"{app_title} {app_version}" + underline = "" + for i in range(len(message_string)): + underline += "-" + print(message_string) + print(underline) + +def main(): + + # Einstellungen einlesen + data = load_adminsettings() + port = int(data["port"]) + secret = data["secret"] + + list_users() + + homepage() + + def startup_message(): + + commandline_header() + url_string = "" + for i in list(app.urls): + url_string += f"{i}, " + url_string = url_string[0:-2] + print("Oberfläche erreichbar unter: " + url_string) + + app.on_startup(startup_message) + + # Styling: + ui.button.default_classes('normal-case') + ui.button.default_props('rounded') + ui.tab.default_classes('normal-case') + ui.toggle.default_classes('normal-case') + 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) + +if __name__ in ("__main__", "__mp_main__"): + parser = argparse.ArgumentParser(description=f'{app_title} {app_version}') + parser.add_argument('--admin-access', help='Zugangsdaten für Administrator einstellen', action="store_true") + args = parser.parse_args() + + if is_docker(): + scriptpath = "/app" + backupfolder = "/backup" + + if args.admin_access: + commandline_header() + print("Lade Administrationseinstellungen") + admin_settings = load_adminsettings() + print("Geben Sie den neuen Benutzernamen für den Administrationsbenutzer an:") + admin_user = input() + if admin_user == "": + print("Ungültiger Benutzername. Breche ab.") + quit() + print("Geben Sie das neue Passwort für den Administrationsbenutzer ein:") + admin_password = input() + if admin_password == "": + print("Ungültiges Passwort. Breche ab.") + quit() + print("Sie haben folgende Informationen eingegeben.") + print(f"Benutzername: {admin_user}") + print(f"Passwort: {admin_password}") + 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) + print("Daten geschrieben") + quit() + else: + print("Breche ab.") + quit() + + main() diff --git a/users/testuser/2025-4.txt b/users/testuser/2025-4.txt deleted file mode 100644 index b06d023..0000000 --- a/users/testuser/2025-4.txt +++ /dev/null @@ -1,7 +0,0 @@ -1743965819 -1743965909 -1743966022 -1743966045 -1743966047 -1743966049 -1743967346 diff --git a/users/testuser2/2025-4.txt b/users/testuser2/2025-4.txt deleted file mode 100644 index 9189aba..0000000 --- a/users/testuser2/2025-4.txt +++ /dev/null @@ -1,2 +0,0 @@ -1743966330 -1743966416 diff --git a/zeiterfassung.py b/zeiterfassung.py deleted file mode 100644 index 52a504d..0000000 --- a/zeiterfassung.py +++ /dev/null @@ -1,113 +0,0 @@ -# -# -# Zeiterfassung - -# Bibliotheksimports -import time -import datetime -import os - -# Statische Definitionen -# Pfade: -userfolder = "users" -settingsfolder = "settings" -program_name = "Zeiterfassung" -program_version = "0.0.0" - -# Funktionen - -# Zeitstempel schreiben -def append_timestamp(filename): - # Hole den aktuellen Timestamp in Epoch-Zeit - timestamp = int(time.time()) - - try: - # Öffne die Datei im Anhang-Modus ('a') - with open(filename, 'a') as file: - # Schreibe den Timestamp in die Datei und füge einen Zeilenumbruch hinzu - file.write(f"{timestamp}\n") - except FileNotFoundError: - # Fehlende Verzeichnisse anlegen - folder_path = os.path.dirname(filename) - os.makedirs(folder_path, exist_ok=True) - append_timestamp(filename) - -# Anzahl der Zeilen zählen -def len_timestamps(filename): - try: - # Öffne die Datei im Lese-Modus ('r') - with open(filename, 'r') as file: - # Zähle die Zeilen - lines = file.readlines() - return len(lines) - except FileNotFoundError: - print(f"Die Datei {filename} wurde nicht gefunden.") - return 0 - -# Stempelzustand auslesen -def stempel_zustand(filename): - lines = len_timestamps(filename) - if lines == 0: - print(f"Keine Einträge") - elif lines % 2 == 0: - return("in") - else: - return("out") - -# Stempelübersicht zusammenstellen -def overview(filename): - - # Öffne die Datei im Lese-Modus ('r') - with open(filename, 'r') as file: - lines = file.readlines() - - in_times = [] - out_times = [] - - for i in range(0, len(lines)-1): - if (i + 1) % 2 == 0: - out_times.append(lines[i]) - else: - in_times.append(lines[i]) - for i in range(0, len(in_times) - 1): - print(str(in_times[i]) + " - " + str(out_times[i])) - -# Pfade bestimmen -def scriptpath(): - return os.path.dirname(os.path.abspath(__file__)) - -def determine_filename(user): - year = str(datetime.datetime.now().year) - month = str(datetime.datetime.now().month) - completepath = scriptpath() + "/" + userfolder +"/" + user + "/" + year + "-" + month + ".txt" - return completepath - -# Benutzer anhand Verzeichnisse auflisten -def list_users(directory): - users = [d for d in os.listdir(directory) if os.path.isdir(os.path.join(directory, d))] - return users - - -# Hauptfunktion -def main(): - print(program_name + " " + str(program_version)) - print("Welche Funktion soll ausgeführt werden?") - print("1: Stempeln") - print("2: Stempelübersicht anzeigen") - question = int(input("Geben Sie Ihre Antwort ein: ")) - - if question == 1: - which_user = input("Für welchen User soll gestempelt werden? ") - append_timestamp(determine_filename(which_user)) - print("Stempeleintrag vorgenommen") - elif question == 2: - which_user = input("Für welchen User sollen die Stempelzeiten angezeigt werden?" ) - print("Zustand: " + stempel_zustand(determine_filename(which_user))) - overview(determine_filename(which_user)) - else: - print("Keine Eingabe erkannt.") - - -# Programmstart -if __name__ == "__main__": - main() \ No newline at end of file