Files
db_interface/main.py
ske087 704e01669f fix: db update bug, add action log with 30-day purge, rebuild exe
- main.py: _pending_record_id locks resolved DB key at Add/Update time;
  show original barcode in update frame; auto-focus mass field on open;
  clear all fields and return focus to ID input after confirm/reset
- database_manager.py: buffered=True cursors on all SELECTs; no
  fetchall() after DML; replace ON DUPLICATE KEY UPDATE VALUES() with
  explicit UPDATE then INSERT fallback; add app_actions.log with
  structured per-action entries; purge_old_action_logs(30) on startup
- dist/DatabaseApp.exe: rebuilt single-file Windows binary (30.9 MB)
- remove unused files: README, WINDOWS_README, run_app.sh,
  setup_database.sh, setup_user.sql, test_database.py, sept.csv"
2026-04-09 11:00:37 +03:00

639 lines
27 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
self._pending_record_id = None # resolved (trimmed) ID locked at show_update_frame time
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))
# Mass input + last-update label share the 0.75 right side equally
mass_row = BoxLayout(orientation='horizontal', size_hint_x=0.75, spacing=sp)
self.mass_input = TextInput(
multiline=False, size_hint_x=0.5,
hint_text='Mass (read-only)',
readonly=True, font_size=f_normal, padding=[7, 7]
)
mass_row.add_widget(self.mass_input)
self.last_update_label = Label(
text='Last update: never',
size_hint_x=0.5, font_size=max(9, int(13 * s)),
bold=False, color=(0.7, 0.7, 0.7, 1),
halign='left', valign='middle'
)
self.last_update_label.bind(size=self.last_update_label.setter('text_size'))
mass_row.add_widget(self.last_update_label)
search_layout.add_widget(mass_row)
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 = True # ID is always readonly always set from search
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):
record_id = self.id_input.text.strip()
mass_text = self.mass_input.text.strip()
self.set_update_frame_enabled(True)
if not record_id:
self.update_id_input.text = ''
self.update_mass_input.text = ''
self._pending_record_id = None
return
# Lock in the resolved (trimmed) DB id now; display original scan for the operator
self._pending_record_id = self._resolve_id(record_id)
self.update_id_input.text = record_id # show original barcode value
self.update_mass_input.text = mass_text
# Direct numpad and keyboard focus to mass field so operator can immediately enter new mass
self.active_numpad_input = self.update_mass_input
Clock.schedule_once(lambda dt: setattr(self.update_mass_input, 'focus', True), 0.05)
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])
t_update = record[2] if len(record) > 2 else None
if t_update:
self.last_update_label.text = f'Last update: {t_update.strftime("%d/%m/%Y %H:%M")}'
else:
self.last_update_label.text = 'Last update: never'
self.show_status(f"Found: {record[0]} = {record[1]}")
self.highlight_record(resolved_id)
else:
self.mass_input.text = ""
self.last_update_label.text = 'Last update: never'
self.show_status(f"ID '{record_id}' not found in database", error=True)
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._pending_record_id
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}")
# Clear all fields and return focus to ID input
self.update_id_input.text = ""
self.update_mass_input.text = ""
self.id_input.text = ""
self.mass_input.text = ""
self.last_update_label.text = 'Last update: never'
self._pending_record_id = None
self.set_update_frame_enabled(False)
self.active_numpad_input = self.id_input
Clock.schedule_once(lambda dt2: setattr(self.id_input, 'focus', True), 0.05)
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._pending_record_id
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.last_update_label.text = 'Last update: never'
self.update_id_input.text = ""
self.update_mass_input.text = ""
self._pending_record_id = None
self.set_update_frame_enabled(False)
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()