diff --git a/homepage.py b/homepage.py deleted file mode 100644 index 6002eb6..0000000 --- a/homepage.py +++ /dev/null @@ -1,129 +0,0 @@ -# Zeiterfassung -import datetime - -from nicegui import ui, app - -from users import * -from definitions import * -from calendar import monthrange, month_name - -import hashlib -import calendar -import locale - -from 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.to('/') - pageheader(f"Willkommen, {current_user.fullname}") - - today = datetime.datetime.now() - - @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() - 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') - - def update_timer(): - time_in_total = time_so_far + int((datetime.datetime.now().timestamp() - current_user.get_worked_time(today.year, today.month, today.day)[1])) - working_hours.set_content(convert_seconds_to_hours(time_in_total)) - - working_timer = ui.timer(1.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() - - ui.separator() - - with ui.grid(columns='1fr auto 1fr').classes('w-full justify-center'): - ui.space() - - def activate_vacation(): - binder_vacation.value = True - - with ui.grid(columns='1fr 1fr'): - - ui.markdown("**Monatsübersicht:**").classes('col-span-2') - - 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.markdown("**Urlaubsanspruch**").classes('col-span-2') - 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.markdown("**Fehlzeitenübersicht**").classes('col-span-2') - absences_select = ui.select(list(reversed(available_years))) - absences_button = ui.button("Anzeigen").bind_enabled_from(binder_absence, 'value') - ui.separator().classes('col-span-2') - - def logout(): - app.storage.user.pop("active_user", None) - ui.navigate.to("/") - - ui.button("Logout", on_click=logout).classes('col-span-2') - ui.space() - - else: - login_mask() \ No newline at end of file diff --git a/admin.py b/lib/admin.py similarity index 80% rename from admin.py rename to lib/admin.py index 704d313..974466f 100644 --- a/admin.py +++ b/lib/admin.py @@ -1,16 +1,20 @@ from datetime import datetime import dateutil.easter +from PIL.SpiderImagePlugin import isInt from dateutil.easter import * from nicegui import ui, app, events +from nicegui.html import button -from users import * -from definitions import * +from lib.users import * +from lib.definitions import * from calendar import monthrange -from web_ui import * +from lib.web_ui import * import os.path +import os +from stat import S_IREAD, S_IRWXU import hashlib import calendar import locale @@ -56,7 +60,7 @@ def page_admin(): ui.markdown("##Übersichten") # Tabelle konstruieren - with ui.card(): + with ui.card().classes('w-full'): with ui.row() as timetable_header: year_binder = ValueBinder() @@ -126,12 +130,13 @@ def page_admin(): # Tabelle aufbauen @ui.refreshable def timetable(): - with ui.card() as calendar_card: + 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) + #current_user = user(time_user.value) # Archivstatus - - with ui.grid(columns='auto auto 1fr 1fr 1fr 1fr') as table_grid: + 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]') as table_grid: if int(select_month.value) > 1: archive_status = current_user.get_archive_status(int(select_year.value), int(select_month.value)) @@ -140,7 +145,11 @@ def page_admin(): def revoke_archive_status(): def revoke_status(): - filename = f"{current_user.userfolder}/{int(select_year.value)}-{int(select_month.value)}.json" + 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 @@ -160,7 +169,7 @@ def page_admin(): dialog.open() if archive_status == True: - with ui.row().classes('text-right col-span-6 justify-center'): + 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') @@ -169,6 +178,7 @@ def page_admin(): # Überschriften ui.markdown("**Datum**") ui.markdown("**Buchungen**") + ui.space() ui.markdown("**Ist**") ui.markdown("**Soll**") ui.markdown("**Saldo**") @@ -185,7 +195,7 @@ def page_admin(): # Alle Timestamps durchgehen und sie den Dictionaryeinträgen zuordnen: for stamp in timestamps: - day_of_month_of_timestamp = int(datetime.datetime.fromtimestamp(int(stamp)).strftime("%-d")) + 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 @@ -303,6 +313,26 @@ Dies kann nicht rückgängig gemacht werden!''') 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'): + for username, text in days_notes.items(): + admins_name = load_adminsettings()["admin_user"] + if username == admins_name: + ui.markdown('Administrator:') + else: + ui.markdown(current_user.fullname) + ui.markdown(text) + else: + ui.space() # Arbeitszeit Ist bestimmen @@ -311,7 +341,7 @@ Dies kann nicht rückgängig gemacht werden!''') # Suche mir alle timestamps für diesen Tag for i in timestamps: actual_timestamp = datetime.datetime.fromtimestamp(int(i)) - timestamp_day = actual_timestamp.strftime('%-d') + timestamp_day = actual_timestamp.day if int(timestamp_day) == int(day): timestamps_of_this_day.append(i) @@ -477,9 +507,63 @@ Dies kann nicht rückgängig gemacht werden!''') dialog.open() dialog.move(calendar_card) - with ui.button(icon='menu'): + 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.markdown(f'**Notizen für {day}.{current_month}.{current_year}**') + with ui.grid(columns='auto auto auto'): + admin_settings = load_adminsettings() + # Beschreibungsfeld für Admin + username_labels["admin"] = ui.markdown("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.markdown(current_user.fullname) + note_labels["user"] = ui.markdown(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') as menu_button: with ui.menu() as menu: 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.") + ui.separator() + menu_item = ui.menu_item("Notizen bearbeiten", lambda day=day: edit_notes(day)) if archive_status: menu_item.disable() ui.separator() @@ -489,16 +573,18 @@ Dies kann nicht rückgängig gemacht werden!''') menu_item.disable() if str(day) in list(user_absent): menu_item.disable() + 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-4') + ui.space().classes('col-span-5') ui.markdown(f"{convert_seconds_to_hours(general_saldo)}").classes('text-right') - ui.markdown("Stunden aus Vormonat").classes('col-span-4 text-right') + ui.markdown("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.markdown(f"{convert_seconds_to_hours(last_months_overtime)}").classes('text-right') - ui.markdown("Gesamtsaldo").classes('col-span-4 text-right') + ui.markdown("Gesamtsaldo").classes('col-span-5 text-right') ui.markdown(f"**{convert_seconds_to_hours(general_saldo + last_months_overtime)}**").classes('text-right') table_grid.move(calendar_card) @@ -507,55 +593,109 @@ Dies kann nicht rückgängig gemacht werden!''') 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}**") - button_update = ui.button("Aktualisieren", on_click=clear_card) - button_update.move(timetable_header) + 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(settings): - with ui.card(): - ui.markdown("**Administrationsbenutzer:**") - with ui.grid(columns=2): - def save_admin_settings(): - output_dict = { } - output_dict["admin_user"] = admin_user.value - if admin_password.value != "": - output_dict["admin_password"] = hash_password(admin_password.value) - else: - output_dict["admin_password"] = data["admin_password"] - output_dict["port"] = port.value - output_dict["secret"] = secret - output_dict["holidays"] = data["holidays"] - json_dict = json.dumps(output_dict, indent=4) - with open(f"{scriptpath}/{usersettingsfilename}", "w") as outputfile: - outputfile.write(json_dict) - if int(old_port) != int(port.value): - with ui.dialog() as dialog, ui.card(): - ui.markdown("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") - timetable.refresh() + with ui.grid(columns='auto auto'): + with ui.card(): + ui.markdown("**Administrationsbenutzer:**") + with ui.grid(columns=2): + def save_admin_settings(): + output_dict = { } + output_dict["admin_user"] = admin_user.value + if admin_password.value != "": + output_dict["admin_password"] = hash_password(admin_password.value) + else: + output_dict["admin_password"] = data["admin_password"] + output_dict["port"] = port.value + output_dict["secret"] = secret + output_dict["touchscreen"] = touchscreen_switch.value + output_dict["times_on_touchscreen"] = timestamp_switch.value + output_dict["photos_on_touchscreen"] = photo_switch.value + output_dict["picture_height"] = picture_height_input.value + output_dict["button_height"] = button_height_input.value + output_dict["user_notes"] = notes_switch.value + output_dict["holidays"] = data["holidays"] + json_dict = json.dumps(output_dict, indent=4) + with open(os.path.join(scriptpath, usersettingsfilename), "w") as outputfile: + outputfile.write(json_dict) + if int(old_port) != int(port.value): + with ui.dialog() as dialog, ui.card(): + ui.markdown("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") + timetable.refresh() - ui.markdown("Benutzername des Adminstrators") - admin_user = ui.input() - admin_user.value = data["admin_user"] - ui.markdown("Passwort des Administrators") - admin_password = ui.input(password=True) + ui.markdown("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.markdown("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"] + secret = data["secret"] - with ui.card(): - ui.markdown("**Systemeinstellungen:**") - with ui.grid(columns=2): + with ui.card(): + ui.markdown("**Systemeinstellungen:**") + with ui.grid(columns=2): + def check_is_number(number): + try: + number = int(number) + return True + except: + return False - ui.markdown("Port:") - port = ui.input() - old_port = data["port"] - port.value = old_port + ui.markdown("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') + old_port = data["port"] + port.value = old_port + + with ui.card(): + ui.markdown("**Einstellungen für das Touchscreenterminal:**") + with ui.column(): + touchscreen_switch = ui.switch("Touchscreenterminal aktivieren") + 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.markdown("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.markdown('px') + with ui.row().bind_visibility_from(touchscreen_switch, 'value'): + ui.markdown("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.markdown('px') + + with ui.card(): + ui.markdown("**Einstellungen für Benutzerfrontend**") + notes_switch = ui.switch("Notizfunktion aktiviert", value=data["user_notes"]) def holiday_section(): with ui.card(): @@ -744,9 +884,9 @@ Dies kann nicht rückgängig gemacht werden!''') with ui.grid(columns='auto auto'): ui.space() with ui.row(): - ui.button("Gesetzliche Feiertage eintragen", on_click=defined_holidays) - ui.button("Eigener Eintrag", on_click=new_holiday_entry) - ui.button("Zurücksetzen", icon="undo", on_click=reset_holidays).bind_visibility_from(reset_visibility, 'value').classes('bg-red') + 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().classes('col-span-2') for year_entry in year_list: @@ -754,12 +894,12 @@ Dies kann nicht rückgängig gemacht werden!''') with ui.row(): for entry in year_dict[year_entry]: date_label = entry.strftime("%d.%m.") - ui.button(f"{data['holidays'][entry.strftime('%Y-%m-%d')]} ({date_label})", on_click=lambda entry=entry: del_holiday_entry(entry)).classes('text-sm') + ui.button(f"{data['holidays'][entry.strftime('%Y-%m-%d')]} ({date_label})", color='cyan-300', on_click=lambda entry=entry: del_holiday_entry(entry)).classes('text-sm') holiday_buttons_grid() holiday_section() - ui.button("Speichern", on_click=save_admin_settings) + ui.button("Speichern", on_click=save_admin_settings).tooltip("Hiermit werden sämtliche oben gemachten Einstellungen gespeichert.") with ui.tab_panel(users): ui.markdown("###Benutzerverwaltung") @@ -933,7 +1073,7 @@ Dies kann nicht rückgängig gemacht werden!''') def dialog_new_user(): def create_new_user(): if user_name_input.validate(): - print(user_name_input.value) + new_user(user_name_input.value) update_userlist() time_user.set_options(userlist) @@ -945,7 +1085,8 @@ Dies kann nicht rückgängig gemacht werden!''') with ui.dialog() as dialog, ui.card(): ui.markdown("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}) + '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) @@ -1042,25 +1183,28 @@ Dies kann nicht rückgängig gemacht werden!''') pass workhours_sum.set_content(str(sum)) - with ui.grid(columns=2): + with ui.grid(columns='auto auto auto'): ui.markdown("gültig ab:") - workhours_select = ui.select(options=workhours, on_change=workhours_selection_changed) + 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.markdown(f"{day}:") - days.append(ui.input(on_change=calculate_weekhours)) + days.append(ui.number(on_change=calculate_weekhours).props('size=3')) + ui.markdown('Stunden') counter = counter + 1 ui.separator().classes('col-span-full') ui.markdown("**Summe:**") workhours_sum = ui.markdown() + ui.markdown("Stunden") with ui.card(): - with ui.grid(columns=2): + with ui.grid(columns='auto auto auto'): ui.markdown("Urlaubstage") - vacation_input = ui.input() + vacation_input = ui.number().props('size=3') + ui.markdown("Tage") with ui.row(): ui.button("Speichern", on_click=save_workhours) ui.button("Löschen", on_click=delete_workhour_entry) diff --git a/api.py b/lib/api.py similarity index 77% rename from api.py rename to lib/api.py index b636999..5996283 100644 --- a/api.py +++ b/lib/api.py @@ -4,10 +4,9 @@ from logging import exception from nicegui import * -import ui -from definitions import * -from web_ui import * -from users import * +from lib.definitions import * +from lib.web_ui import * +from lib.users import * from datetime import datetime import calendar @@ -21,6 +20,7 @@ def page_overview_month(username: str, year: int, month: int): 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'): @@ -80,8 +80,8 @@ def page_overview_month(username: str, year: int, month: int): color_day = color_weekend current_day_date = f"{datetime(year, month, day).strftime('%a')}, {day}.{month}.{year}" - day_text_element = ui.markdown(current_day_date).classes(f'border px-{pad_x} py-{pad_y} bg-{color_day}') - + with ui.link_target(day).classes(f'border px-{pad_x} py-{pad_y} bg-{color_day}'): + ui.markdown(current_day_date) # Abwesenheitseinträge booking_color = "inherit" @@ -100,14 +100,29 @@ def page_overview_month(username: str, year: int, month: int): 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')) + "
" + booking_text = booking_text + str(datetime.fromtimestamp(temp_pair[0]).strftime('%H:%M')) + " - " + str(datetime.fromtimestamp(temp_pair[1]).strftime('%H:%M')) + "
" except: if len(timestamps_dict[day]) % 2 != 0: - booking_text += datetime.fromtimestamp(int(timestamps_dict[day][i])).strftime('%H:%M') + booking_text += datetime.fromtimestamp(int(timestamps_dict[day][i])).strftime('%H:%M') + " - ***Buchung fehlt!***" - booking_text_element = ui.markdown(booking_text).classes(f'border px-{pad_x} py-{pad_y} bg-{booking_color} text-{booking_text_color}') + 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.markdown(booking_text) + 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.markdown(f"Administrator:
{notes}") + else: + with ui.element(): + ui.markdown(f"{current_user.fullname}:
{notes}") + if len(day_notes) > 1 and just_once: + ui.separator() + just_once = False # Ist-Zeiten berechnen timestamps_of_this_day = [] @@ -220,9 +235,9 @@ def page_overview_month(username: str, year: int, month: int): if total_absence_days > 0: ui.markdown("###Abwesenheitstage diesen Monat:") - with ui.grid(columns='auto 20%').classes(f'gap-0 border px-0 py-0'): + with ui.grid(columns='auto 25%').classes(f'gap-0 border px-0 py-0'): - for key,value in absence_dict.items(): + for key, value in absence_dict.items(): if value > 0: ui.markdown(absence_entries[key]['name']).classes(f"border px-{pad_x} py-{pad_y}") ui.markdown(str(value)).classes(f'border px-{pad_x} py-{pad_y} text-center') @@ -258,7 +273,15 @@ def page_overview_month(username: str, year: int, month: int): dialog.open() if archivable == True: - ui.button("Archivieren", on_click=archive_dialog) + 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() @@ -364,17 +387,26 @@ def page_overview_absence(username: str, year: int): if str(column) in list(absences): bg_color = absence_entries[absences[str(column)]]['color'] text_color = absence_entries[absences[str(column)]]['text-color'] - ui.markdown(absences[str(column)]).classes(f'border px-{pad_x} py-{pad_y} bg-{bg_color} text-{text_color} text-center') + tooltip_text = absence_entries[absences[str(column)]]['name'] + with ui.element(): + ui.markdown(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' - ui.space().classes(f'border px-{pad_x} py-{pad_y} bg-{bg_color}') + with ui.label("").classes(f'border px-{pad_x} py-{pad_y} bg-{bg_color}'): + if tooltip_text != "": + ui.tooltip(tooltip_text) absence_calender() @@ -396,10 +428,42 @@ def page_overview_absence(username: str, year: int): else: login = login_mask(target=f'/api/absence/{username}/{year}') -@ui.page('/api/stamp/{api_key}') -def page_api_stamp(api_key: str): +@app.get('/api/stamp/{api_key}') +def json_stamp(api_key: str): userlist = list_users() - user_dict = { } + 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] = "" @@ -412,15 +476,41 @@ def page_api_stamp(api_key: str): found_key = False - ui.page_title(f'{app_title} {app_version}') - for user_key, api_value in user_dict.items(): if api_key == api_value: current_user = user(user_key) - current_user.timestamp() - found_key = True - ui.label(f'Zeitstempel {datetime.now().strftime("%H:%M")} für {current_user.fullname} eingetragen') + 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_vacation_days(now_dt.year) + data["vacation"]["remaining"] = data["vacation"]["claim"] - data["vacation"]["used"] + return data break - if found_key == False: - ui.label("Keinen passenden Benutzer gefunden") + if not found_key: + return { "data": "none"} \ No newline at end of file diff --git a/definitions.py b/lib/definitions.py similarity index 82% rename from definitions.py rename to lib/definitions.py index 83260ad..7296d9f 100644 --- a/definitions.py +++ b/lib/definitions.py @@ -2,12 +2,13 @@ # Quasi-Konstanten import os +from pathlib import Path app_title = "Zeiterfassung" app_version = ("0.0.0") # Standardpfade -scriptpath = os.path.dirname(os.path.abspath(__file__)) +scriptpath = str(Path(os.path.dirname(os.path.abspath(__file__))).parent.absolute()) userfolder = "users" # Dateinamen @@ -26,6 +27,12 @@ standard_adminsettings = { "admin_user": "admin", "admin_password": "8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918", "port": "8090", "secret": "ftgzuhjikg,mt5jn46uzer8sfi9okrmtzjhndfierko5zltjhdgise", + "times_on_touchscreen": True, + "photos_on_touchscreen": True, + "touchscreen": True, + "picure_height": 200, + "button_height": 300, + "user_notes": True, "holidays": { } } diff --git a/lib/homepage.py b/lib/homepage.py new file mode 100644 index 0000000..a5f00a3 --- /dev/null +++ b/lib/homepage.py @@ -0,0 +1,239 @@ +# 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 == "total": + additional_time = yesterdays_overtime() + 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.toggle({"day": "Tagesarbeitszeit", "total": "Gesamtzeit"}, value="day", + on_change=update_timer).classes('w-full justify-center col-span-2').tooltip("Hier lässt sich die Anzeige oben zwischen heute geleisteter Arbeitszeit und summierter Arbeitszeit umschalten.") + + working_timer = ui.timer(1.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 + print(f"Last selection from save: {last_selection}") + 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.grid(columns='1fr auto 1fr').classes('w-full justify-center'): + ui.space() + + def activate_vacation(): + binder_vacation.value = True + + def activate_absence(): + binder_absence.value = True + + with ui.grid(columns='1fr 1fr'): + + ui.markdown("**Monatsübersicht:**").classes('col-span-2') + + 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.markdown("**Urlaubsanspruch**").classes('col-span-2') + 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.markdown("**Fehlzeitenübersicht**").classes('col-span-2') + 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') + ui.separator().classes('col-span-2') + + def logout(): + app.storage.user.pop("active_user", None) + ui.navigate.to("/") + + ui.button("Logout", on_click=logout).classes('col-span-2') + 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/login.py b/lib/login.py similarity index 92% rename from login.py rename to lib/login.py index 3a351d7..25520ae 100644 --- a/login.py +++ b/lib/login.py @@ -1,10 +1,10 @@ from datetime import datetime from nicegui import ui, app -from web_ui import * +from lib.web_ui import * -from users import * -from definitions import * +from lib.users import * +from lib.definitions import * from calendar import monthrange import hashlib diff --git a/lib/settings.json b/lib/settings.json new file mode 100644 index 0000000..fe1226a --- /dev/null +++ b/lib/settings.json @@ -0,0 +1,13 @@ +{ + "admin_user": "admin", + "admin_password": "8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918", + "port": "8090", + "secret": "ftgzuhjikg,mt5jn46uzer8sfi9okrmtzjhndfierko5zltjhdgise", + "times_on_touchscreen": true, + "photos_on_touchscreen": true, + "touchscreen": true, + "picure_height": 200, + "button_height": 300, + "user_notes": true, + "holidays": {} +} \ No newline at end of file diff --git a/lib/touchscreen.py b/lib/touchscreen.py new file mode 100644 index 0000000..19ac320 --- /dev/null +++ b/lib/touchscreen.py @@ -0,0 +1,85 @@ +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 = { } + + @ui.refreshable + def user_buttons(): + if number_of_users > 5: + number_of_columns = 5 + else: + number_of_columns = number_of_users + + 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: + if admin_settings["photos_on_touchscreen"]: + try: + with open(current_user.photofile, 'r') as file: + pass + file.close() + ui.image(current_user.photofile).classes(f'max-h-[{admin_settings["picture_height"]}px]').props('fit=scale-down') + except: + pass + column_classes = "w-full items-center" + if admin_settings["times_on_touchscreen"] or admin_settings["photos_on_touchscreen"]: + column_classes += " self-end" + with ui.column().classes(column_classes): + 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 += ", " + ui.markdown(table_string) + ui.label(current_user.fullname).classes('text-center') + 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/users.py b/lib/users.py similarity index 71% rename from users.py rename to lib/users.py index d9800dd..7c7982e 100644 --- a/users.py +++ b/lib/users.py @@ -3,21 +3,23 @@ import hashlib # User bezogene Funktionen import os +from calendar import monthrange +from stat import S_IREAD, S_IWUSR import datetime import time import json import shutil import re -from definitions import userfolder, scriptpath, usersettingsfilename, photofilename, status_in, status_out, standard_adminsettings, standard_usersettings +from lib.definitions import userfolder, scriptpath, usersettingsfilename, photofilename, status_in, status_out, standard_adminsettings, standard_usersettings # Benutzerklasse class user: def __init__(self, name): - self.userfolder = f"{scriptpath}/{userfolder}/{name}" - self.settingsfile = f"{self.userfolder}/{usersettingsfilename}" - self.photofile = f"{self.userfolder}/{photofilename}" + self.userfolder = os.path.join(scriptpath, userfolder, name) + self.settingsfile = os.path.join(self.userfolder, usersettingsfilename) + self.photofile = os.path.join(self.userfolder, photofilename) # Stammdaten einlesen try: @@ -41,7 +43,7 @@ class user: else: year = str(datetime.datetime.fromtimestamp(time_stamp).year) month = str(datetime.datetime.fromtimestamp(time_stamp).month) - completepath = f"{self.userfolder}/{year}-{month}" + completepath = os.path.join(self.userfolder, f"{year}-{month}") return completepath def timestamp(self, stamptime=-1): @@ -85,7 +87,7 @@ class user: # Zähle die Zeilen lines = file.readlines() except FileNotFoundError: - print(f"Die Datei {self.get_stamp_file()} wurde nicht gefunden.") + 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("") @@ -93,7 +95,7 @@ class user: # Zähle die Zeilen lines = file.readlines() if len(lines)== 0: - print(f"Keine Einträge") + pass elif len(lines) % 2 == 0: return status_out else: @@ -130,10 +132,10 @@ class user: outputfile.write(json_dict) pathcheck = self.userfolder - pathcheck = pathcheck.removeprefix(f"{scriptpath}/{userfolder}/") + pathcheck = pathcheck.removeprefix(os.path.join(scriptpath, userfolder)) if pathcheck != self.username: - os.rename(self.userfolder, f"{scriptpath}/{userfolder}/{self.username}") + os.rename(self.userfolder, os.path.join(scriptpath, userfolder, self.username)) def del_user(self): shutil.rmtree(self.userfolder) @@ -202,7 +204,7 @@ class user: def get_timestamps(self, year, month): try: - with open(f"{self.userfolder}/{year}-{month}.txt", "r") as file: + with open(os.path.join(self.userfolder, f"{year}-{month}.txt"), "r") as file: timestamps = file.readlines() timestamps.sort() return timestamps @@ -216,24 +218,48 @@ class user: def get_archive_status(self, year, month): try: - with open(f"{self.userfolder}/{year}-{month}.json", 'r') as json_file: + with open(os.path.join(self.userfolder, f"{year}-{month}.json"), 'r') as json_file: data = json.load(json_file) return data["archived"] 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 = f"{self.userfolder}/{year}-{month}.json" + 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) + 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, month): try: @@ -242,7 +268,7 @@ class user: month = str(12) else: month = str(int(month) - 1) - with open(f"{self.userfolder}/{year}-{month}.json", "r") as json_file: + 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: @@ -255,19 +281,47 @@ class user: def get_absence(self, year, month): try: - with open(f"{self.userfolder}/{year}-{month}.json", "r") as json_file: + 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): + print(os.path.join(self.userfolder, f"{int(year)}-{int(month)}.json")) + with open(os.path.join(self.userfolder, f"{int(year)}-{int(month)}.json"), "r") as json_file: + json_data = json.load(json_file) + print(json_data) + if len(note_dict) == 1: + user_info = list(note_dict)[0] + 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(f"{self.userfolder}/{int(year)}-{int(month)}.json", "r") as json_file: + 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(f"{self.userfolder}/{year}-{month}.json", "w") as json_file: + 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 @@ -279,21 +333,21 @@ class user: json_data.update({ "absence": { str(int(day)): absence_type}}) json_dict = json.dumps(json_data, indent=4) - with open(f"{self.userfolder}/{int(year)}-{int(month)}.json", "w") as json_file: + 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(f"{self.userfolder}/{int(year)}-{int(month)}.json", "r") as json_file: + 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(f"{self.userfolder}/{int(year)}-{int(month)}.json", "w") as json_file: + 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 + #global hours_to_work workhour_entries = list(self.workhours) workhour_entries.sort() day_to_check = datetime.datetime(int(year), int(month), int(day)) @@ -347,15 +401,28 @@ class user: claim = self.workhours[entry]["vacation"] break - return claim + return int(claim) + + def count_vacation_days(self, year): + vacation_used = 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 == "U": + vacation_used += 1 + + except: + pass + return vacation_used def delete_photo(self): os.remove(self.photofile) - def get_worked_time(self, year, month, day): + 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 = [ ] + todays_timestamps = [] for i in timestamps: i_dt = datetime.datetime.fromtimestamp(int(i)) @@ -363,6 +430,13 @@ class user: 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 @@ -375,9 +449,6 @@ class user: time_worked = todays_timestamps[i + 1] - todays_timestamps[i] total_time += time_worked - print(total_time) - print(in_time_stamp) - return [total_time, in_time_stamp] # Benutzer auflisten @@ -399,8 +470,8 @@ def list_users(): def new_user(username: str): if not os.path.exists(userfolder): os.makedirs(userfolder) - if not os.path.exists(f"{userfolder}/{username}"): - os.makedirs(f"{userfolder}/{username}") + 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 @@ -421,7 +492,7 @@ def new_user(username: str): # Admineinstellungen auslesen def load_adminsettings(): # Settingsdatei einlesen - settings_filename = f"{scriptpath}/{usersettingsfilename}" + 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: diff --git a/web_ui.py b/lib/web_ui.py similarity index 98% rename from web_ui.py rename to lib/web_ui.py index 2913f9e..deb9af7 100644 --- a/web_ui.py +++ b/lib/web_ui.py @@ -2,8 +2,8 @@ from datetime import datetime from nicegui import ui, app -from users import * -from definitions import * +from lib.users import * +from lib.definitions import * from calendar import monthrange import hashlib diff --git a/main.py b/main.py deleted file mode 100644 index 937b047..0000000 --- a/main.py +++ /dev/null @@ -1,44 +0,0 @@ -# Zeiterfassung - -from web_ui import * -from admin import * -from login import * -from users import * -from touchscreen import * -from definitions import * -from api import * -from homepage import * - -import json - -def main(): - - # Einstellungen einlesen - data = load_adminsettings() - port = int(data["port"]) - secret = data["secret"] - - list_users() - - homepage() - - def startup_message(): - - message_string = f"{app_title} {app_version}" - underline = "" - for i in range(len(message_string)): - underline += "-" - print(message_string) - print(underline) - - url_string = "" - for i in list(app.urls): - url_string += f"{i}, " - url_string = url_string[0:-2] - print("Weboberfläche erreichbar unter: " + url_string) - - app.on_startup(startup_message) - ui.run(favicon="favicon.svg", port=port, storage_secret=secret, language='de-DE', show_welcome_message=False) - -if __name__ in ("__main__", "__mp_main__"): - main() diff --git a/photo.jpg b/photo.jpg deleted file mode 100644 index dcaa401..0000000 Binary files a/photo.jpg and /dev/null differ diff --git a/playgound.py b/playgound.py index 1800280..8d8184f 100644 --- a/playgound.py +++ b/playgound.py @@ -1,6 +1,17 @@ -from nicegui import ui +import json +import urllib.request + +from nicegui import ui, app + + import segno +@app.get("/data") +async def deliver_data(): + with open("settings.json") as json_file: + data = json.load(json_file) + return data + string = "" for i in range(1000): string += str(i) diff --git a/qr_scanner.py b/qr_scanner.py new file mode 100644 index 0000000..3f4a21e --- /dev/null +++ b/qr_scanner.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +import base64 +import signal +import time +import argparse +import requests + +import cv2 +import numpy as np +from fastapi import Response +from playsound3 import playsound +from definitions import app_title, app_version + +from nicegui import Client, app, core, run, ui + +class Commandline_Header: + message_string = f"{app_title} {app_version}" + underline = "" + for i in range(len(message_string)): + underline += "-" + print(message_string) + print(underline) + +def visual_interface(port=9000): + # In case you don't have a webcam, this will provide a black placeholder image. + black_1px = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAA1JREFUGFdjYGBg+A8AAQQBAHAgZQsAAAAASUVORK5CYII=' + placeholder = Response(content=base64.b64decode(black_1px.encode('ascii')), media_type='image/png') + + global convert + def convert(frame: np.ndarray) -> bytes: + """Converts a frame from OpenCV to a JPEG image. + + This is a free function (not in a class or inner-function), + to allow run.cpu_bound to pickle it and send it to a separate process. + """ + _, imencode_image = cv2.imencode('.jpg', frame) + return imencode_image.tobytes() + + global setup + def setup() -> None: + + url_string = "" + for i in list(app.urls): + url_string += f"{i}, " + url_string = url_string[0:-2] + print("Weboberfläche erreichbar unter: " + url_string) + + # OpenCV is used to access the webcam. + video_capture = cv2.VideoCapture(0) + detector = cv2.QRCodeDetector() + + blocker = False + blockset = 0 + + + @app.get('/video/frame') + # Thanks to FastAPI's `app.get` it is easy to create a web route which always provides the latest image from OpenCV. + async def grab_video_frame() -> Response: + nonlocal blocker + if time.time() - blockset > 5: + blocker = False + + if not video_capture.isOpened(): + return placeholder + # The `video_capture.read` call is a blocking function. + # So we run it in a separate thread (default executor) to avoid blocking the event loop. + _, frame = await run.io_bound(video_capture.read) + if frame is None: + return placeholder + # `convert` is a CPU-intensive function, so we run it in a separate process to avoid blocking the event loop and GIL. + jpeg = await run.cpu_bound(convert, frame) + + # QR-Handling + + def function_call(): + r = requests.get(str(a)) + print(r.content()) + print("Inside Function_call") + #b = webbrowser.open(str(a)) + if r.status_code == 200: + print('Erkannt') + if r.json()["stampstatus"]: + playsound('ui-on.mp3') + elif not r.json()["stampstatus"]: + playsound('ui-off.mp3') + else: + playsound('ui-sound.mp3') + nonlocal blocker + nonlocal blockset + blocker = True + blockset = time.time() + + if not blocker: + _, img = video_capture.read() + # detect and decode + data, bbox, _ = detector.detectAndDecode(img) + # check if there is a QRCode in the image + if data: + a = data + function_call() + # cv2.imshow("QRCODEscanner", img) + if cv2.waitKey(1) == ord("q"): + function_call() + + return Response(content=jpeg, media_type='image/jpeg') + + # For non-flickering image updates and automatic bandwidth adaptation an interactive image is much better than `ui.image()`. + video_image = ui.interactive_image().classes('w-full h-full') + # A timer constantly updates the source of the image. + # Because data from same paths is cached by the browser, + # we must force an update by adding the current timestamp to the source. + + ui.timer(interval=0.1, callback=lambda: video_image.set_source(f'/video/frame?{time.time()}')) + + async def disconnect() -> None: + """Disconnect all clients from current running server.""" + for client_id in Client.instances: + await core.sio.disconnect(client_id) + + def handle_sigint(signum, frame) -> None: + # `disconnect` is async, so it must be called from the event loop; we use `ui.timer` to do so. + ui.timer(0.1, disconnect, once=True) + # Delay the default handler to allow the disconnect to complete. + ui.timer(1, lambda: signal.default_int_handler(signum, frame), once=True) + + async def cleanup() -> None: + # This prevents ugly stack traces when auto-reloading on code change, + # because otherwise disconnected clients try to reconnect to the newly started server. + await disconnect() + # Release the webcam hardware so it can be used by other applications again. + video_capture.release() + + app.on_shutdown(cleanup) + # We also need to disconnect clients when the app is stopped with Ctrl+C, + # because otherwise they will keep requesting images which lead to unfinished subprocesses blocking the shutdown. + signal.signal(signal.SIGINT, handle_sigint) + + + # All the setup is only done when the server starts. This avoids the webcam being accessed + # by the auto-reload main process (see https://github.com/zauberzeug/nicegui/discussions/2321). + app.on_startup(setup) + ui.run(favicon="favicon.svg", port=port, language='de-DE', show_welcome_message=False) + +if __name__ in ("__main__", "__mp_main__"): + parser = argparse.ArgumentParser(description=f'{app_title}-QR-Scanner {app_version}') + parser.add_argument('--webgui', help='Web-GUI starten', action="store_true") + parser.add_argument('-p', help="Port, über den die Weboberfläche erreichbar ist") + args = parser.parse_args() + + Commandline_Header() + print("QR-Scanner") + + if args.webgui: + try: + port = int(args.p) + except: + port = False + if not port == False: + visual_interface(port) + else: + print("Ungültiger Port") + print("Beende") + quit() diff --git a/qr_scanner_example.py b/qr_scanner_example.py new file mode 100644 index 0000000..a641a08 --- /dev/null +++ b/qr_scanner_example.py @@ -0,0 +1,22 @@ +import cv2 +import webbrowser + +cap = cv2.VideoCapture(0) +# initialize the cv2 QRCode detector +detector = cv2.QRCodeDetector() + +while True: + _, img = cap.read() + # detect and decode + data, bbox, _ = detector.detectAndDecode(img) + # check if there is a QRCode in the image + if data: + a = data + break + cv2.imshow("QRCODEscanner", img) + if cv2.waitKey(1) == ord("q"): + break + +b = webbrowser.open(str(a)) +cap.release() +cv2.destroyAllWindows() \ No newline at end of file diff --git a/settings.json b/settings.json index 2add415..2e00471 100644 --- a/settings.json +++ b/settings.json @@ -3,6 +3,12 @@ "admin_password": "8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918", "port": "8090", "secret": "ftgzuhjikg,mt5jn46uzer8sfi9okrmtzjhndfierko5zltjhdgise", + "touchscreen": true, + "times_on_touchscreen": true, + "photos_on_touchscreen": true, + "picture_height": "100", + "button_height": "120", + "user_notes": true, "holidays": { "2025-01-01": "Neujahr", "2025-04-18": "Karfreitag", diff --git a/sounds/3beeps.mp3 b/sounds/3beeps.mp3 new file mode 100644 index 0000000..c14243e Binary files /dev/null and b/sounds/3beeps.mp3 differ diff --git a/sounds/beep.mp3 b/sounds/beep.mp3 new file mode 100644 index 0000000..0d1f255 Binary files /dev/null and b/sounds/beep.mp3 differ diff --git a/sounds/power-on.mp3 b/sounds/power-on.mp3 new file mode 100644 index 0000000..c659616 Binary files /dev/null and b/sounds/power-on.mp3 differ diff --git a/sounds/store_beep.mp3 b/sounds/store_beep.mp3 new file mode 100644 index 0000000..ac50b9c Binary files /dev/null and b/sounds/store_beep.mp3 differ diff --git a/sounds/success.mp3 b/sounds/success.mp3 new file mode 100644 index 0000000..c86f6c6 Binary files /dev/null and b/sounds/success.mp3 differ diff --git a/sounds/ui-off.mp3 b/sounds/ui-off.mp3 new file mode 100644 index 0000000..1b8ccb7 Binary files /dev/null and b/sounds/ui-off.mp3 differ diff --git a/sounds/ui-on.mp3 b/sounds/ui-on.mp3 new file mode 100644 index 0000000..834f291 Binary files /dev/null and b/sounds/ui-on.mp3 differ diff --git a/sounds/ui-sound.mp3 b/sounds/ui-sound.mp3 new file mode 100644 index 0000000..1da7ff3 Binary files /dev/null and b/sounds/ui-sound.mp3 differ diff --git a/touchscreen.py b/touchscreen.py deleted file mode 100644 index f2e73c7..0000000 --- a/touchscreen.py +++ /dev/null @@ -1,60 +0,0 @@ -from datetime import datetime - -from nicegui import ui, app - -from users import * -from definitions import * -from web_ui import * -from calendar import monthrange - -import hashlib -import calendar -import locale - -@ui.page('/touchscreen') -def page_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("Bitte User auswählen:") - - userlist = list_users() - number_of_users = len(userlist) - buttons = { } - - @ui.refreshable - def user_buttons(): - if number_of_users > 5: - number_of_columns = 5 - else: - number_of_columns = number_of_users - - with ui.grid(columns=number_of_columns): - for name in userlist: - current_user = user(name) - current_button = ui.button(on_click=lambda name=name: button_click(name)) - with current_button: - try: - with open(current_user.photofile, 'r') as file: - pass - file.close() - ui.image(current_user.photofile) - except: - pass - ui.label(current_user.fullname) - if current_user.stamp_status() == status_in: - current_button.props('color=green') - else: - current_button.props('color=red') - buttons[name] = current_button - user_buttons() \ No newline at end of file diff --git a/ui.py b/ui.py deleted file mode 100644 index 3255751..0000000 --- a/ui.py +++ /dev/null @@ -1,217 +0,0 @@ -# Zeiterfassung -# UI Definitionen - -import tkinter as tk -import locale -locale.setlocale(locale.LC_ALL, '') - -from time import strftime -from definitions import app_title, app_version, status_in -from users import user as uo -from users import list_users - -# Pinpad - -class win_pinpad(tk.Toplevel): - def __init__(self, parent): - super().__init__(parent) - - def update_time(): - string_time = strftime('%A, der %d.%m.%Y - %H:%M:%S') - nonlocal digital_clock - digital_clock.config(text=string_time) - digital_clock.after(1000, update_time) - - self.title(app_title + " " + app_version) - - # Digital clock label configuration - digital_clock = tk.Label(self) - digital_clock.grid(row=0, column=0, columnspan=3, padx=10, pady=10) - # Initial call to update_time function - update_time() - - # Benutzernummer - def usernr_changed(UserNr): - nonlocal usernr - if len(str(usernr.get())) > 0: - buttons["OK"].configure(state="active") - else: - buttons["OK"].configure(state="disabled") - - - tk.Label(self, text="Benutzernummer:").grid(row=1, column=0) - UserNr = tk.StringVar() - UserNr.trace("w", lambda name, index, mode, UserNr=UserNr: usernr_changed(UserNr)) - usernr = tk.Entry(self, width=10, textvariable=UserNr) - usernr.grid(row=1,column=1) - - # Pinpad - - def buttonPress(key): - - nonlocal usernr - if type(key) is int: - if key < 10: - usernr.insert('end', str(key)) - if key =="OK": - print("OK pressed") - if key == "<-": - usernr.delete(usernr.index("end") - 1 ) - if len(usernr.get()) > 0: - buttons["OK"].configure(state="active") - else: - buttons["OK"].configure(state="disabled") - - # Buttons definieren - button_width = 7 - button_height = 3 - pinframe = tk.Frame(self) - pinframe.grid(row=2, column=0, columnspan=3, padx=10, pady=10) - buttons = { } - - keys = [ - [ 1, 2, 3], - [ 4, 5, 6], - [ 7, 8, 9], - [ "<-", 0, "OK"] - ] - - for y, row in enumerate(keys, 1): - for x, key in enumerate(row): - button = tk.Button(pinframe, width=button_width, height=button_height, text=key, command=lambda key=key: buttonPress(key)) - button.grid(row=y, column=x) - buttons[key] = button - - buttons["OK"].configure(state="disabled") - - usernr.focus_set() - -class win_userlist(tk.Toplevel): - def __init__(self, parent): - super().__init__(parent) - - def update_time(): - string_time = strftime('%A, der %d.%m.%Y - %H:%M:%S') - nonlocal digital_clock - digital_clock.config(text=string_time) - digital_clock.after(1000, update_time) - - self.title(app_title + " " + app_version) - - # Digital clock label configuration - digital_clock = tk.Label(self) - digital_clock.grid(row=0, column=0, columnspan=3, padx=10, pady=10) - # Initial call to update_time function - update_time() - - tk.Label(self, text="Benutzer auswählen").grid(row=1, column=0, columnspan=2) - - # Button Frame - button_frame = tk.Frame(self) - button_frame.grid(row=2, column=0, columnspan=2, padx=0, pady=10) - userlist = list_users() - - - # Button Dictionary - buttons = { } - button_row_index = 0 - - for name in userlist: - button = tk.Button(button_frame, text=name) - button.grid(row=button_row_index, column=0, pady=5, sticky="ew") - buttons[name] = button - button_row_index = button_row_index + 1 - -class win_stamping(tk.Toplevel): - def __init__(self, parent, user): - super().__init__(parent) - def update_time(): - string_time = strftime('%A, der %d.%m.%Y - %H:%M:%S') - nonlocal digital_clock - digital_clock.config(text=string_time) - digital_clock.after(1000, update_time) - - self.title(app_title + " " + app_version) - - # Benutzer feststellen - - current_user = uo(user) - - # Digital clock label configuration - digital_clock = tk.Label(self) - digital_clock.grid(row=0, column=0, columnspan=3, padx=10, pady=10) - # Initial call to update_time function - update_time() - - # Benutzer anzeigen - tk.Label(self, text=current_user.fullname).grid(row=1, column=0, pady=10, columnspan=3) - - todays_hours = tk.Label(self, text="Arbeitsstunden erscheinen hier") - todays_hours.grid(row=2, column=0, pady=10, columnspan=3) - - in_button = tk.Button(self, text="Einstempeln", bg="green") - out_button = tk.Button(self, text="Ausstempeln", bg="red") - - if current_user.stamp_status() == status_in: - in_button.configure(state="disabled") - out_button.configure(state="active") - out_button.focus_set() - else: - in_button.configure(state="active") - out_button.configure(state="disabled") - in_button.focus_set() - in_button.grid(row=3, column = 0) - out_button.grid(row=3, column=2) - - button_frame = tk.Frame(self, relief="groove") - button_frame.grid(row=4, column=0, columnspan=3, pady=10) - - overview_workinghours = tk.Button(button_frame, text="Übersicht Arbeitszeiten") - overview_missed = tk.Button(button_frame, text="Übersicht Fehlzeiten") - overview_data = tk.Button(button_frame, text="Stammdaten") - - overview_workinghours.grid(row=0, column=0, sticky="ew") - overview_missed.grid(row=1, column=0, sticky="ew") - overview_data.grid(row=2, column=0, sticky="ew") - - button_close = tk.Button(self, text="Schließen") - button_close.grid(row=5, column=1) - -#======================================================== - -class mainwindow(tk.Tk): - def __init__(self): - super().__init__() - - self.geometry('300x200') - self.title('Main Window') - - # place a button on the root window - tk.Button(self, - text='PinPad Window', - command=self.open_pinpad).pack(expand=True) - tk.Button(self, - text='Userlist Window', - command=self.open_userlist).pack(expand=True) - tk.Button(self, - text='Stamping Window', - command=self.open_stamping).pack(expand=True) - - def open_pinpad(self): - window = win_pinpad(self) - window.grab_set() - - def open_userlist(self): - window = win_userlist(self) - window.grab_set() - - def open_stamping(self): - window = win_stamping(self, user="testuser") - window.grab_set() - - -if __name__ == "__main__": - app = mainwindow() - app.mainloop() - - diff --git a/users/filler2/2025-5.json b/users/filler2/2025-5.json new file mode 100644 index 0000000..b7881be --- /dev/null +++ b/users/filler2/2025-5.json @@ -0,0 +1,4 @@ +{ + "archived": 0, + "total_hours": 0 +} \ No newline at end of file diff --git a/users/filler2/2025-5.txt b/users/filler2/2025-5.txt new file mode 100644 index 0000000..4d67d1a --- /dev/null +++ b/users/filler2/2025-5.txt @@ -0,0 +1,10 @@ +1747642816 +1747642898 +1747642972 +1747642976 +1747643508 +1747643521 +1747643564 +1747643566 +1747643603 +1747644615 diff --git a/users/filler2/settings.json b/users/filler2/settings.json new file mode 100644 index 0000000..9bf84fb --- /dev/null +++ b/users/filler2/settings.json @@ -0,0 +1,18 @@ +{ + "username": "filler2", + "fullname": "filler2", + "password": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "workhours": { + "2025-05-16": { + "1": 0, + "2": 0, + "3": 0, + "4": 0, + "5": 0, + "6": 0, + "7": 0, + "vacation": 0 + } + }, + "api_key": "43ec918e7d773cb23ab3113d18059a83fee389ac" +} \ No newline at end of file diff --git a/users/filler3/2025-5.json b/users/filler3/2025-5.json new file mode 100644 index 0000000..b7881be --- /dev/null +++ b/users/filler3/2025-5.json @@ -0,0 +1,4 @@ +{ + "archived": 0, + "total_hours": 0 +} \ No newline at end of file diff --git a/users/filler3/2025-5.txt b/users/filler3/2025-5.txt new file mode 100644 index 0000000..e831471 --- /dev/null +++ b/users/filler3/2025-5.txt @@ -0,0 +1,2 @@ +1747391900 +1747391907 diff --git a/users/filler3/settings.json b/users/filler3/settings.json new file mode 100644 index 0000000..07e5ee7 --- /dev/null +++ b/users/filler3/settings.json @@ -0,0 +1,18 @@ +{ + "username": "filler3", + "fullname": "filler3", + "password": "37a8eec1ce19687d132fe29051dca629d164e2c4958ba141d5f4133a33f0688f", + "workhours": { + "2025-05-16": { + "1": "6", + "2": "6", + "3": "6", + "4": "6", + "5": "6", + "6": 0, + "7": 0, + "vacation": 0 + } + }, + "api_key": "9e3f37809cd898a3db340c453df53bd0793a99fa" +} \ No newline at end of file diff --git a/users/filler4/2025-5.txt b/users/filler4/2025-5.txt new file mode 100644 index 0000000..e69de29 diff --git a/users/filler4/settings.json b/users/filler4/settings.json new file mode 100644 index 0000000..a657bde --- /dev/null +++ b/users/filler4/settings.json @@ -0,0 +1,18 @@ +{ + "username": "filler4", + "fullname": "filler4", + "password": "37a8eec1ce19687d132fe29051dca629d164e2c4958ba141d5f4133a33f0688f", + "api_key": "614e31aab9fcf1373558f100cb2c7a9918349eec", + "workhours": { + "2025-05-16": { + "1": 0, + "2": 0, + "3": 0, + "4": 0, + "5": 0, + "6": 0, + "7": 0, + "vacation": 0 + } + } +} \ No newline at end of file diff --git a/users/filler5/2025-5.txt b/users/filler5/2025-5.txt new file mode 100644 index 0000000..e69de29 diff --git a/users/filler5/settings.json b/users/filler5/settings.json new file mode 100644 index 0000000..3b45fe5 --- /dev/null +++ b/users/filler5/settings.json @@ -0,0 +1,18 @@ +{ + "username": "filler5", + "fullname": "filler5", + "password": "37a8eec1ce19687d132fe29051dca629d164e2c4958ba141d5f4133a33f0688f", + "api_key": "ad32682beb4e19f78efc1bdae259aee3ccbf9883", + "workhours": { + "2025-05-16": { + "1": 0, + "2": 0, + "3": 0, + "4": 0, + "5": 0, + "6": 0, + "7": 0, + "vacation": 0 + } + } +} \ No newline at end of file diff --git a/users/filler6/2025-5.txt b/users/filler6/2025-5.txt new file mode 100644 index 0000000..e69de29 diff --git a/users/filler6/settings.json b/users/filler6/settings.json new file mode 100644 index 0000000..50c28c8 --- /dev/null +++ b/users/filler6/settings.json @@ -0,0 +1,18 @@ +{ + "username": "filler6", + "fullname": "filler6", + "password": "37a8eec1ce19687d132fe29051dca629d164e2c4958ba141d5f4133a33f0688f", + "api_key": "68d974e4ed516795d48d5cb8b7dc8b8ca4144a9b", + "workhours": { + "2025-05-16": { + "1": 0, + "2": 0, + "3": 0, + "4": 0, + "5": 0, + "6": 0, + "7": 0, + "vacation": 0 + } + } +} \ No newline at end of file diff --git a/users/testuser1/2025-3.json b/users/testuser1/2025-3.json old mode 100644 new mode 100755 index 27c5b37..d438ef3 --- a/users/testuser1/2025-3.json +++ b/users/testuser1/2025-3.json @@ -1 +1 @@ -{"archived": 1, "overtime": -528928} \ No newline at end of file +{"archived": 0, "overtime": -528928} \ No newline at end of file diff --git a/users/testuser1/2025-3.txt b/users/testuser1/2025-3.txt old mode 100644 new mode 100755 index 7350e03..da7ed21 --- a/users/testuser1/2025-3.txt +++ b/users/testuser1/2025-3.txt @@ -1,30 +1,4 @@ -1743965819 -1743965909 -1743966022 -1743966045 -1743966047 -1743966049 -1743967346 -1744889948 -1744889966 -1744989797 -1744989827 -1744989830 -1744989883 -1744989909 -1744989914 -1744989916 -1744991169 -1744991171 -1744991288 -1744991291 -1744991473 -1744991477 -1744991770 -1744991777 -1745181046 -1745181050 -1745240760 -1745240762 1740996000 -1740997800 +1742460540 +1741038540 +1742464500 diff --git a/users/testuser1/2025-4.json b/users/testuser1/2025-4.json index 1346aed..f5daf68 100644 --- a/users/testuser1/2025-4.json +++ b/users/testuser1/2025-4.json @@ -1,6 +1,6 @@ { - "archived": 0, - "overtime": 0, + "archived": 1, + "overtime": -348226, "absence": { "7": "U", "8": "K", diff --git a/users/testuser1/2025-4.txt b/users/testuser1/2025-4.txt index ad67cca..bd42eec 100644 --- a/users/testuser1/2025-4.txt +++ b/users/testuser1/2025-4.txt @@ -1,21 +1,5 @@ 1744889948 1744890300 -1744989797 -1744989827 -1744989830 -1744989883 -1744989909 -1744989914 -1744989916 -1744991169 -1744991171 -1744991288 -1744991291 -1744991473 -1744991477 -1744991770 -1745215200 -1745229600 1745390818 1745390894 1745390894 diff --git a/users/testuser1/2025-5.json b/users/testuser1/2025-5.json index c970b2a..f343e14 100644 --- a/users/testuser1/2025-5.json +++ b/users/testuser1/2025-5.json @@ -2,7 +2,22 @@ "archived": 0, "overtime": 0, "absence": { - "14": "U", - "2": "SO" + "2": "SO", + "8": "U", + "9": "U", + "10": "U", + "11": "U", + "12": "U", + "13": "U" + }, + "notes": { + "5": {}, + "4": {}, + "2": {}, + "1": {}, + "9": {}, + "12": {}, + "14": {}, + "22": {} } } \ No newline at end of file diff --git a/users/testuser1/2025-5.txt b/users/testuser1/2025-5.txt index 5098bdd..4425bef 100644 --- a/users/testuser1/2025-5.txt +++ b/users/testuser1/2025-5.txt @@ -6,3 +6,27 @@ 1746608922 1746609024 1746609037 +1747206908 +1747207022 +1747213977 +1747214813 +1747216800 +1747220619 +1747301302 +1747301459 +1747302876 +1747302887 +1747302889 +1747302897 +1747386098 +1747386110 +1747387148 +1747387150 +1747387501 +1747387508 +1747387633 +1747387635 +1747387761 +1747388239 +1747388242 +1747388615 diff --git a/users/testuser1/settings.json b/users/testuser1/settings.json index 7f31cc1..f4cb50d 100644 --- a/users/testuser1/settings.json +++ b/users/testuser1/settings.json @@ -1,7 +1,7 @@ { "username": "testuser1", "fullname": "Pia Paulina", - "password": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "password": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "workhours": { "2025-05-13": { "1": "4", diff --git a/users/testuser10/2025-5.json b/users/testuser10/2025-5.json new file mode 100644 index 0000000..b7881be --- /dev/null +++ b/users/testuser10/2025-5.json @@ -0,0 +1,4 @@ +{ + "archived": 0, + "total_hours": 0 +} \ No newline at end of file diff --git a/users/testuser10/2025-5.txt b/users/testuser10/2025-5.txt index e69de29..2ed4771 100644 --- a/users/testuser10/2025-5.txt +++ b/users/testuser10/2025-5.txt @@ -0,0 +1,4 @@ +1747387168 +1747387171 +1747388261 +1747388617 diff --git a/users/testuser3/2025-5.txt b/users/testuser3/2025-5.txt index 4eb306a..4eff463 100644 --- a/users/testuser3/2025-5.txt +++ b/users/testuser3/2025-5.txt @@ -1,2 +1,6 @@ 1746385111 1746385118 +1747388255 +1747388619 +1747391536 +1747391567 diff --git a/webcam_example.py b/webcam_example.py new file mode 100644 index 0000000..b4fbfc4 --- /dev/null +++ b/webcam_example.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +import base64 +import signal +import time +import webbrowser + +import cv2 +import numpy as np +from fastapi import Response + +from nicegui import Client, app, core, run, ui + +# In case you don't have a webcam, this will provide a black placeholder image. +black_1px = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAA1JREFUGFdjYGBg+A8AAQQBAHAgZQsAAAAASUVORK5CYII=' +placeholder = Response(content=base64.b64decode(black_1px.encode('ascii')), media_type='image/png') + + +def convert(frame: np.ndarray) -> bytes: + """Converts a frame from OpenCV to a JPEG image. + + This is a free function (not in a class or inner-function), + to allow run.cpu_bound to pickle it and send it to a separate process. + """ + _, imencode_image = cv2.imencode('.jpg', frame) + return imencode_image.tobytes() + + +def setup() -> None: + # OpenCV is used to access the webcam. + video_capture = cv2.VideoCapture(0) + detector = cv2.QRCodeDetector() + + blocker = False + blockset = 0 + + @app.get('/video/frame') + # Thanks to FastAPI's `app.get` it is easy to create a web route which always provides the latest image from OpenCV. + async def grab_video_frame() -> Response: + nonlocal blocker + if time.time() - blockset > 5: + blocker = False + + if not video_capture.isOpened(): + return placeholder + # The `video_capture.read` call is a blocking function. + # So we run it in a separate thread (default executor) to avoid blocking the event loop. + _, frame = await run.io_bound(video_capture.read) + if frame is None: + return placeholder + # `convert` is a CPU-intensive function, so we run it in a separate process to avoid blocking the event loop and GIL. + jpeg = await run.cpu_bound(convert, frame) + + # QR-Handling + + def function_call(): + b = webbrowser.open(str(a)) + print('\a') + nonlocal blocker + nonlocal blockset + blocker = True + blockset = time.time() + + if not blocker: + _, img = video_capture.read() + # detect and decode + data, bbox, _ = detector.detectAndDecode(img) + # check if there is a QRCode in the image + if data: + a = data + function_call() + # cv2.imshow("QRCODEscanner", img) + if cv2.waitKey(1) == ord("q"): + function_call() + + return Response(content=jpeg, media_type='image/jpeg') + + # For non-flickering image updates and automatic bandwidth adaptation an interactive image is much better than `ui.image()`. + video_image = ui.interactive_image().classes('w-full h-full') + # A timer constantly updates the source of the image. + # Because data from same paths is cached by the browser, + # we must force an update by adding the current timestamp to the source. + + ui.timer(interval=0.1, callback=lambda: video_image.set_source(f'/video/frame?{time.time()}')) + + async def disconnect() -> None: + """Disconnect all clients from current running server.""" + for client_id in Client.instances: + await core.sio.disconnect(client_id) + + def handle_sigint(signum, frame) -> None: + # `disconnect` is async, so it must be called from the event loop; we use `ui.timer` to do so. + ui.timer(0.1, disconnect, once=True) + # Delay the default handler to allow the disconnect to complete. + ui.timer(1, lambda: signal.default_int_handler(signum, frame), once=True) + + async def cleanup() -> None: + # This prevents ugly stack traces when auto-reloading on code change, + # because otherwise disconnected clients try to reconnect to the newly started server. + await disconnect() + # Release the webcam hardware so it can be used by other applications again. + video_capture.release() + + app.on_shutdown(cleanup) + # We also need to disconnect clients when the app is stopped with Ctrl+C, + # because otherwise they will keep requesting images which lead to unfinished subprocesses blocking the shutdown. + signal.signal(signal.SIGINT, handle_sigint) + + +# All the setup is only done when the server starts. This avoids the webcam being accessed +# by the auto-reload main process (see https://github.com/zauberzeug/nicegui/discussions/2321). +app.on_startup(setup) + +ui.run(port=9005) \ No newline at end of file diff --git a/zeiterfassung.py b/zeiterfassung.py new file mode 100644 index 0000000..31632f4 --- /dev/null +++ b/zeiterfassung.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +# Zeiterfassung + +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 + + +class 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("Weboberfläche erreichbar unter: " + url_string) + + app.on_startup(startup_message) + ui.run(favicon="favicon.svg", 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 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()