Files
db_interface/main.py

606 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import threading
from kivy.config import Config
Config.set('kivy', 'keyboard_mode', 'system')
from kivy.app import App
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.floatlayout import FloatLayout
from kivy.uix.gridlayout import GridLayout
from kivy.uix.label import Label
from kivy.uix.textinput import TextInput
from kivy.uix.button import Button
from kivy.uix.scrollview import ScrollView
from kivy.uix.popup import Popup
from kivy.clock import Clock
from kivy.core.window import Window
from database_manager import DatabaseManager
class DatabaseApp(App):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.db_manager = DatabaseManager()
self.active_numpad_input = None
def build(self):
# Set window to fullscreen first so Window.height reflects the screen
Window.fullscreen = 'auto'
# ------------------------------------------------------------------
# Responsive sizing: derive dimensions from the actual screen height
# ------------------------------------------------------------------
wh = Window.height
# Scale factor (1.0 at 1080p, down to 0.65 on small screens)
s = max(0.65, min(1.0, wh / 1080.0))
# Padding / spacing (all scaled)
pad_v = max(8, int(20 * s))
pad_h = max(8, int(30 * s))
m_spacing = max(4, int(10 * s))
c_spacing = max(4, int(12 * s))
sp = max(6, int(10 * s))
upd_pad = max(4, int(8 * s))
upd_spc = max(4, int(8 * s))
np_pad_v = max(3, int(6 * s))
np_spc = max(3, int(6 * s))
# Numpad (fixed height at bottom 29 % of screen)
h_numpad_wr = int(wh * 0.29)
h_enter_btn = max(34, int(h_numpad_wr * 0.24))
h_numpad_gr = h_numpad_wr - h_enter_btn - 2 * np_pad_v - np_spc
# Font sizes (scaled)
f_title = max(14, int(26 * s))
f_normal = max(11, int(18 * s))
f_btn = max(12, int(20 * s))
f_numpad = max(15, int(26 * s))
f_enter = max(14, int(24 * s))
f_mode = max(10, int(16 * s))
f_override = max(10, int(14 * s))
f_status = max(12, int(20 * s))
# Proportional size_hint_y weights for the 6 content rows
# title | search | mode | buttons | update-frame | status
_w = [50, 100, 38, 65, 187, 40]
_t = sum(_w)
# ------------------------------------------------------------------
# Build UI
# ------------------------------------------------------------------
root = FloatLayout()
main_layout = BoxLayout(
orientation='vertical',
padding=[pad_h, pad_v, pad_h, pad_v],
spacing=m_spacing,
size_hint=(1, 1),
pos_hint={'x': 0, 'y': 0}
)
# --- Content container: size_hint_y=1 so it fills all space above numpad ---
content_layout = BoxLayout(orientation='vertical', spacing=c_spacing, size_hint_y=1)
# Title row: title + Exit button
title_row = BoxLayout(orientation='horizontal', size_hint_y=_w[0]/_t, spacing=sp)
title = Label(text='Database Search & Update', font_size=f_title, bold=True)
title_row.add_widget(title)
exit_btn = Button(
text='Exit',
font_size=max(11, int(16 * s)),
bold=True,
background_color=(0.85, 0.1, 0.1, 1),
color=(1, 1, 1, 1),
size_hint_x=None,
width=max(70, int(100 * s))
)
exit_btn.bind(on_press=lambda x: self.stop())
title_row.add_widget(exit_btn)
content_layout.add_widget(title_row)
# Search section (ID + Mass)
search_layout = GridLayout(
cols=2, size_hint_y=_w[1]/_t,
spacing=sp
)
search_layout.add_widget(Label(text='ID:', size_hint_x=0.25, font_size=f_normal, bold=True))
self.id_input = TextInput(
multiline=False, size_hint_x=0.75,
hint_text='Enter ID (max 20 chars)',
readonly=False, font_size=f_normal, padding=[7, 7]
)
self.id_input.bind(on_text_validate=self.search_record)
self.id_input.bind(on_text=self.update_mode_indicator)
self.id_input.bind(focus=self.on_id_input_focus)
search_layout.add_widget(self.id_input)
search_layout.add_widget(Label(text='Mass:', size_hint_x=0.25, font_size=f_normal, bold=True))
self.mass_input = TextInput(
multiline=False, size_hint_x=0.75,
hint_text='Mass (read-only)',
readonly=True, font_size=f_normal, padding=[7, 7]
)
search_layout.add_widget(self.mass_input)
content_layout.add_widget(search_layout)
# Mode indicator row
self.manual_override = None
mode_row = BoxLayout(orientation='horizontal', size_hint_y=_w[2]/_t, spacing=sp)
self.mode_label = Label(
text='Article type detected: PRODUCT',
size_hint_x=0.75, font_size=f_mode, bold=True, color=(0.4, 0.8, 1, 1)
)
mode_row.add_widget(self.mode_label)
self.override_btn = Button(
text='Override type', size_hint_x=0.25,
font_size=f_override, bold=True, background_color=(0.3, 0.3, 0.3, 1)
)
self.override_btn.bind(on_press=self.toggle_override)
mode_row.add_widget(self.override_btn)
content_layout.add_widget(mode_row)
# Action buttons (Add/Update, Reset, Settings)
button_layout = GridLayout(cols=3, size_hint_y=_w[3]/_t, spacing=sp)
add_update_btn = Button(text='Add/Update', font_size=f_btn, bold=True)
add_update_btn.bind(on_press=self.show_update_frame)
button_layout.add_widget(add_update_btn)
reset_btn = Button(text='Reset Values', font_size=f_btn, bold=True)
reset_btn.bind(on_press=self.reset_values)
button_layout.add_widget(reset_btn)
settings_btn = Button(text='Settings', font_size=f_btn, bold=True)
settings_btn.bind(on_press=self.show_settings)
button_layout.add_widget(settings_btn)
content_layout.add_widget(button_layout)
# Update frame
self.update_frame = BoxLayout(
orientation='vertical', padding=upd_pad, spacing=upd_spc,
size_hint_y=_w[4]/_t
)
self.update_frame_label = Label(
text='Update Values', size_hint_y=0.20,
font_size=f_btn, bold=True
)
self.update_frame.add_widget(self.update_frame_label)
update_inputs = GridLayout(
cols=2, spacing=sp, size_hint_y=0.52
)
update_inputs.add_widget(Label(text='ID:', size_hint_x=0.25, font_size=f_normal, bold=True))
self.update_id_input = TextInput(
multiline=False, size_hint_x=0.75, readonly=True, font_size=f_normal, padding=[7, 7]
)
update_inputs.add_widget(self.update_id_input)
update_inputs.add_widget(Label(text='Mass:', size_hint_x=0.25, font_size=f_normal, bold=True))
self.update_mass_input = TextInput(
multiline=False, size_hint_x=0.75, readonly=True, font_size=f_normal, padding=[7, 7]
)
self.update_mass_input.bind(focus=self.on_mass_input_focus)
update_inputs.add_widget(self.update_mass_input)
self.update_frame.add_widget(update_inputs)
update_buttons = GridLayout(cols=2, size_hint_y=0.28, spacing=sp)
self.update_confirm_btn = Button(text='Confirm Add/Update', disabled=True, font_size=f_btn, bold=True)
self.update_confirm_btn.bind(on_press=self.add_update_record)
update_buttons.add_widget(self.update_confirm_btn)
self.delete_btn = Button(text='Delete', disabled=True, font_size=f_btn, bold=True)
self.delete_btn.bind(on_press=self.delete_record)
update_buttons.add_widget(self.delete_btn)
self.update_frame.add_widget(update_buttons)
content_layout.add_widget(self.update_frame)
self.set_update_frame_enabled(False)
# Status label
self.status_label = Label(
text='Ready', size_hint_y=_w[5]/_t,
color=(0, 0.8, 0, 1), font_size=f_status, bold=True
)
content_layout.add_widget(self.status_label)
main_layout.add_widget(content_layout)
# --- Numeric keypad ---
numpad_wrapper = BoxLayout(
orientation='vertical', size_hint_y=None, height=h_numpad_wr,
spacing=np_spc, padding=[pad_h, np_pad_v, pad_h, np_pad_v]
)
numpad = GridLayout(cols=3, size_hint_y=None, height=h_numpad_gr, spacing=max(3, int(6 * s)))
for digit in ['1', '2', '3', '4', '5', '6', '7', '8', '9']:
btn = Button(text=digit, font_size=f_numpad, bold=True)
btn.bind(on_press=self.numpad_press)
numpad.add_widget(btn)
dot_btn = Button(text='.', font_size=f_numpad, bold=True, background_color=(0.25, 0.25, 0.45, 1))
dot_btn.bind(on_press=self.numpad_press)
numpad.add_widget(dot_btn)
zero_btn = Button(text='0', font_size=f_numpad, bold=True)
zero_btn.bind(on_press=self.numpad_press)
numpad.add_widget(zero_btn)
back_btn = Button(text='', font_size=f_numpad, bold=True, background_color=(0.5, 0.3, 0.1, 1))
back_btn.bind(on_press=self.numpad_backspace)
numpad.add_widget(back_btn)
numpad_wrapper.add_widget(numpad)
enter_btn = Button(
text='Enter', font_size=f_enter, bold=True,
background_color=(0.1, 0.55, 0.1, 1), size_hint_y=None, height=h_enter_btn
)
enter_btn.bind(on_press=self.numpad_enter)
numpad_wrapper.add_widget(enter_btn)
main_layout.add_widget(numpad_wrapper)
root.add_widget(main_layout)
# Init DB and load data in background so the UI appears immediately
def _init_db(dt):
def _do():
self.db_manager.init_database()
Clock.schedule_once(lambda dt2: self.refresh_data(None))
threading.Thread(target=_do, daemon=True).start()
Clock.schedule_once(_init_db, 0.1)
return root
def on_id_input_focus(self, instance, focused):
"""Track active numpad target when ID field gains focus."""
if focused:
self.active_numpad_input = self.id_input
def on_mass_input_focus(self, instance, focused):
"""Track active numpad target when mass field gains focus."""
if focused:
self.active_numpad_input = self.update_mass_input
def _refocus_active(self):
"""Return keyboard focus to the active field so scanner input keeps working."""
target = self.active_numpad_input if self.active_numpad_input else self.id_input
if not target.readonly:
Clock.schedule_once(lambda dt: setattr(target, 'focus', True), 0.05)
def numpad_enter(self, instance):
"""Enter key: trigger search if ID field is active, then refocus."""
if self.active_numpad_input is self.id_input or self.active_numpad_input is None:
self.search_record(instance)
self._refocus_active()
def numpad_press(self, instance):
"""Append a digit/dot to the active input field, then refocus for scanner."""
target = self.active_numpad_input if self.active_numpad_input else self.id_input
if not target.readonly:
target.text += instance.text
self._refocus_active()
def numpad_backspace(self, instance):
"""Remove the last character from the active input field, then refocus for scanner."""
target = self.active_numpad_input if self.active_numpad_input else self.id_input
if not target.readonly:
target.text = target.text[:-1]
self._refocus_active()
def set_update_frame_enabled(self, enabled):
self.update_id_input.readonly = not enabled
self.update_mass_input.readonly = not enabled
self.update_confirm_btn.disabled = not enabled
self.delete_btn.disabled = not enabled
def _resolve_id(self, raw_id: str) -> str:
"""Resolve ID using manual override if set, otherwise auto-detect."""
mode = self.manual_override if self.manual_override else (
'BOX' if (len(raw_id) == 8 and raw_id.isdigit()) else 'PRODUCT'
)
if mode == 'BOX':
return raw_id.lstrip('0') or '0'
return raw_id
def toggle_override(self, instance):
"""Manually flip the detected type between BOX and PRODUCT."""
current_auto = 'BOX' if (len(self.id_input.text.strip()) == 8 and self.id_input.text.strip().isdigit()) else 'PRODUCT'
# Determine current effective mode
effective = self.manual_override if self.manual_override else current_auto
# Flip it
self.manual_override = 'PRODUCT' if effective == 'BOX' else 'BOX'
self._apply_mode_label(self.manual_override, is_override=True)
def update_mode_indicator(self, instance, value):
"""Auto-detect mode on each keystroke; resets any manual override."""
self.manual_override = None
text = value.strip()
mode = 'BOX' if (len(text) == 8 and text.isdigit()) else 'PRODUCT'
self._apply_mode_label(mode, is_override=False)
def _apply_mode_label(self, mode, is_override):
"""Update the mode label and override button appearance."""
prefix = '[Manual] ' if is_override else ''
if mode == 'BOX':
self.mode_label.text = f'{prefix}Article type detected: BOX'
self.mode_label.color = (1, 0.75, 0, 1)
else:
self.mode_label.text = f'{prefix}Article type detected: PRODUCT'
self.mode_label.color = (0.4, 0.8, 1, 1)
self.override_btn.background_color = (0.6, 0.2, 0.6, 1) if is_override else (0.3, 0.3, 0.3, 1)
def show_update_frame(self, instance):
# If no value in search, copy from search fields
record_id = self.id_input.text.strip()
mass_text = self.mass_input.text.strip()
self.set_update_frame_enabled(True)
# If mass field is empty, just clear update frame
if not record_id:
self.update_id_input.text = ''
self.update_mass_input.text = ''
return
self.update_id_input.text = record_id
self.update_mass_input.text = mass_text
def search_record(self, instance):
record_id = self.id_input.text.strip()
if not record_id:
self.show_status("Please enter an ID to search", error=True)
return
if len(record_id) > 20:
self.show_status("ID must be 20 characters or less", error=True)
return
self.show_status("Searching...", error=False)
resolved_id = self._resolve_id(record_id)
def _do():
try:
record = self.db_manager.search_by_id(resolved_id)
def _update(dt):
if record:
self.mass_input.text = str(record[1])
self.show_status(f"Found: {record[0]} = {record[1]}")
self.highlight_record(resolved_id)
else:
self.show_status(f"ID '{record_id}' not found in database", error=True)
self.mass_input.text = ""
Clock.schedule_once(_update)
except Exception as e:
err = str(e)
Clock.schedule_once(lambda dt: self.show_status(f"Search error: {err}", error=True))
threading.Thread(target=_do, daemon=True).start()
def add_update_record(self, instance):
"""Add or update a record from the update frame."""
record_id = self.update_id_input.text.strip()
mass_text = self.update_mass_input.text.strip()
if not record_id or not mass_text:
self.show_status("Please enter both ID and mass in update frame", error=True)
return
if len(record_id) > 20:
self.show_status("ID must be 20 characters or less", error=True)
return
try:
mass = float(mass_text)
except ValueError:
self.show_status("Mass must be a valid number", error=True)
return
self.show_status("Saving...", error=False)
def _do():
try:
success = self.db_manager.add_or_update_record(record_id, mass)
def _update(dt):
if success:
self.show_status(f"Successfully added/updated: {record_id} = {mass}")
self.update_id_input.text = ""
self.update_mass_input.text = ""
self.set_update_frame_enabled(False)
self.refresh_data(None)
else:
self.show_status("Failed to add/update record", error=True)
Clock.schedule_once(_update)
except Exception as e:
err = str(e)
Clock.schedule_once(lambda dt: self.show_status(f"Save error: {err}", error=True))
threading.Thread(target=_do, daemon=True).start()
def delete_record(self, instance):
"""Delete a record using the update frame fields."""
record_id = self.update_id_input.text.strip()
if not record_id:
self.show_status("Please enter an ID in the update fields to delete", error=True)
return
# Confirm deletion
self.show_confirmation_popup(
f"Are you sure you want to delete ID '{record_id}'?",
lambda: self.confirm_delete(record_id)
)
def confirm_delete(self, record_id):
"""Confirm and execute deletion."""
self.show_status("Deleting...", error=False)
def _do():
try:
success = self.db_manager.delete_record(record_id)
def _update(dt):
if success:
self.show_status(f"Successfully deleted: {record_id}")
self.clear_fields()
self.update_id_input.text = ""
self.update_mass_input.text = ""
self.set_update_frame_enabled(False)
self.refresh_data(None)
else:
self.show_status(f"Failed to delete ID '{record_id}' (not found)", error=True)
Clock.schedule_once(_update)
except Exception as e:
err = str(e)
Clock.schedule_once(lambda dt: self.show_status(f"Delete error: {err}", error=True))
threading.Thread(target=_do, daemon=True).start()
def clear_fields(self):
"""Clear the ID and mass fields."""
self.id_input.text = ""
self.mass_input.text = ""
def reset_values(self, instance):
"""Reset/clear the first ID and mass fields and set focus on ID field."""
self.id_input.text = ""
self.mass_input.text = ""
self.active_numpad_input = self.id_input
self.id_input.focus = True
self.show_status("Fields cleared", error=False)
def refresh_data(self, instance):
"""Refresh the data display (runs in a background thread)."""
def _do():
try:
records = self.db_manager.read_all_data()
count = len(records)
Clock.schedule_once(lambda dt: self.show_status(f"Data refreshed - {count} records found"))
except Exception as e:
err = str(e)
Clock.schedule_once(lambda dt: self.show_status(f"Error refreshing data: {err}", error=True))
threading.Thread(target=_do, daemon=True).start()
def highlight_record(self, record_id):
"""Highlight a specific record in the display."""
# Since we removed the data display, just show a status message
pass
def show_status(self, message, error=False):
"""Show status message."""
self.status_label.text = message
if error:
self.status_label.color = (1, 0.2, 0.2, 1) # Red for errors
else:
self.status_label.color = (0, 0.8, 0, 1) # Green for success
# Clear status after 5 seconds
Clock.schedule_once(lambda dt: self.clear_status(), 5)
def clear_status(self):
"""Clear the status message."""
self.status_label.text = "Ready"
self.status_label.color = (0, 0.8, 0, 1)
def show_settings(self, instance):
"""Show settings popup for database configuration."""
content = BoxLayout(orientation='vertical', spacing=15, padding=20)
# Title
title_label = Label(text='Database Server Settings', font_size=24, bold=True, size_hint_y=None, height=40)
content.add_widget(title_label)
# IP address input
ip_layout = BoxLayout(orientation='horizontal', size_hint_y=None, height=60, spacing=10)
ip_layout.add_widget(Label(text='Server IP Address:', font_size=20, bold=True, size_hint_x=0.4))
ip_input = TextInput(
text=self.db_manager.host,
multiline=False,
font_size=20,
size_hint_x=0.6,
padding=[10, 10]
)
ip_layout.add_widget(ip_input)
content.add_widget(ip_layout)
# Info label
info_label = Label(
text='Enter the IP address or hostname of the database server.\nOther connection settings remain unchanged.',
font_size=16,
size_hint_y=None,
height=60
)
content.add_widget(info_label)
# Test connection button + result label
test_btn = Button(text='Test Connection', font_size=20, bold=True, size_hint_y=None, height=55)
content.add_widget(test_btn)
test_result_label = Label(
text='',
font_size=17,
size_hint_y=None,
height=40,
bold=True
)
content.add_widget(test_result_label)
# Buttons
buttons = BoxLayout(size_hint_y=None, height=60, spacing=15)
save_btn = Button(text='Save', font_size=20, bold=True)
cancel_btn = Button(text='Cancel', font_size=20, bold=True)
buttons.add_widget(save_btn)
buttons.add_widget(cancel_btn)
content.add_widget(buttons)
popup = Popup(
title='Settings',
content=content,
size_hint=(0.6, 0.65)
)
def save_settings():
new_host = ip_input.text.strip()
if new_host:
self.db_manager.host = new_host
self.db_manager.save_host(new_host)
# Reconnect with new settings
if self.db_manager.connection:
try:
self.db_manager.connection.close()
except:
pass
self.db_manager.connection = None
self.show_status(f"Database server updated to: {new_host}")
popup.dismiss()
else:
self.show_status("Please enter a valid IP address", error=True)
def test_connection(instance):
host = ip_input.text.strip()
if not host:
test_result_label.text = 'Please enter a host first.'
test_result_label.color = (1, 0.6, 0, 1)
return
test_result_label.text = 'Testing...'
test_result_label.color = (1, 1, 1, 1)
def _do():
success, message = self.db_manager.test_connection(host)
def _update(dt):
if success:
test_result_label.text = f'OK: {message}'
test_result_label.color = (0, 0.85, 0, 1)
else:
test_result_label.text = f'Failed: {message}'
test_result_label.color = (1, 0.2, 0.2, 1)
Clock.schedule_once(_update)
threading.Thread(target=_do, daemon=True).start()
test_btn.bind(on_press=test_connection)
save_btn.bind(on_press=lambda x: save_settings())
cancel_btn.bind(on_press=popup.dismiss)
popup.open()
def show_confirmation_popup(self, message, confirm_callback):
"""Show a confirmation popup."""
content = BoxLayout(orientation='vertical', spacing=10, padding=10)
label = Label(text=message, text_size=(300, None), halign='center')
content.add_widget(label)
buttons = BoxLayout(size_hint_y=None, height=50, spacing=10)
yes_btn = Button(text='Yes')
no_btn = Button(text='No')
buttons.add_widget(yes_btn)
buttons.add_widget(no_btn)
content.add_widget(buttons)
popup = Popup(
title='Confirm Action',
content=content,
size_hint=(0.8, 0.4)
)
yes_btn.bind(on_press=lambda x: (confirm_callback(), popup.dismiss()))
no_btn.bind(on_press=popup.dismiss)
popup.open()
if __name__ == '__main__':
DatabaseApp().run()