feat: persistent DB host config, async DB ops, Exit button, test connection
- Save/load DB host from config.json (works both in dev and built .exe) - All DB operations moved to background threads to prevent UI blocking - Connection timeout set to 5s to avoid long hangs on unreachable host - Added Test Connection button to Settings popup - Added red Exit button fixed to bottom-right corner of main screen - Updated DatabaseApp.spec with full Kivy deps (sdl2, glew, angle) and hidden imports - PyInstaller-aware base path using sys.frozen for config.json persistence
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,7 +8,6 @@ venv/
|
|||||||
env/
|
env/
|
||||||
ENV/
|
ENV/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
dist/
|
|
||||||
build/
|
build/
|
||||||
*.egg
|
*.egg
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,36 @@
|
|||||||
# -*- mode: python ; coding: utf-8 -*-
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
|
from kivy_deps import sdl2, glew, angle
|
||||||
|
|
||||||
a = Analysis(
|
a = Analysis(
|
||||||
['main.py'],
|
['main.py'],
|
||||||
pathex=[],
|
pathex=[],
|
||||||
binaries=[],
|
binaries=[],
|
||||||
datas=[],
|
datas=[],
|
||||||
hiddenimports=['mysql.connector', 'kivy.core.window.window_sdl2', 'win32timezone'],
|
hiddenimports=[
|
||||||
|
'mysql.connector',
|
||||||
|
'mysql.connector.locales',
|
||||||
|
'mysql.connector.locales.eng',
|
||||||
|
'kivy',
|
||||||
|
'kivy.core.window',
|
||||||
|
'kivy.core.window.window_sdl2',
|
||||||
|
'kivy.core.text',
|
||||||
|
'kivy.core.text.text_sdl2',
|
||||||
|
'kivy.core.image',
|
||||||
|
'kivy.core.image.img_sdl2',
|
||||||
|
'kivy.core.image.img_pil',
|
||||||
|
'kivy.core.audio',
|
||||||
|
'kivy.core.clipboard',
|
||||||
|
'kivy.core.clipboard.clipboard_sdl2',
|
||||||
|
'kivy.core.spelling',
|
||||||
|
'kivy.graphics',
|
||||||
|
'kivy.graphics.cgl_backend',
|
||||||
|
'kivy.graphics.cgl_backend.cgl_glew',
|
||||||
|
'kivy.input.providers',
|
||||||
|
'kivy.input.providers.wm_touch',
|
||||||
|
'kivy.input.providers.wm_pen',
|
||||||
|
'win32timezone',
|
||||||
|
'pkg_resources.py2_compat',
|
||||||
|
],
|
||||||
hookspath=[],
|
hookspath=[],
|
||||||
hooksconfig={},
|
hooksconfig={},
|
||||||
runtime_hooks=[],
|
runtime_hooks=[],
|
||||||
@@ -21,6 +45,7 @@ exe = EXE(
|
|||||||
a.scripts,
|
a.scripts,
|
||||||
a.binaries,
|
a.binaries,
|
||||||
a.datas,
|
a.datas,
|
||||||
|
*[Tree(p) for p in (sdl2.dep_bins + glew.dep_bins + angle.dep_bins)],
|
||||||
[],
|
[],
|
||||||
name='DatabaseApp',
|
name='DatabaseApp',
|
||||||
debug=False,
|
debug=False,
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
import mysql.connector
|
import mysql.connector
|
||||||
from mysql.connector import Error
|
from mysql.connector import Error
|
||||||
from typing import List, Tuple, Optional
|
from typing import List, Tuple, Optional
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# When frozen by PyInstaller, __file__ points to a temp folder that is deleted on exit.
|
||||||
|
# sys.executable points to the .exe location, which is persistent.
|
||||||
|
if getattr(sys, 'frozen', False):
|
||||||
|
_BASE_DIR = os.path.dirname(sys.executable)
|
||||||
|
else:
|
||||||
|
_BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
CONFIG_FILE = os.path.join(_BASE_DIR, 'config.json')
|
||||||
|
|
||||||
class DatabaseManager:
|
class DatabaseManager:
|
||||||
"""
|
"""
|
||||||
@@ -9,12 +21,53 @@ class DatabaseManager:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.host = "localhost"
|
self.host = self._load_host()
|
||||||
self.database = "cantare_injectie"
|
self.database = "cantare_injectie"
|
||||||
self.user = "omron"
|
self.user = "omron"
|
||||||
self.password = "Initial01!"
|
self.password = "Initial01!"
|
||||||
self.connection = None
|
self.connection = None
|
||||||
self.init_database()
|
# init_database() is called asynchronously from the UI layer to avoid blocking
|
||||||
|
|
||||||
|
def _load_host(self) -> str:
|
||||||
|
"""Load the database host from the config file, falling back to localhost."""
|
||||||
|
try:
|
||||||
|
if os.path.exists(CONFIG_FILE):
|
||||||
|
with open(CONFIG_FILE, 'r') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return data.get('host', 'localhost')
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Could not read config file: {e}")
|
||||||
|
return 'localhost'
|
||||||
|
|
||||||
|
def save_host(self, host: str):
|
||||||
|
"""Persist the database host to the config file."""
|
||||||
|
try:
|
||||||
|
data = {}
|
||||||
|
if os.path.exists(CONFIG_FILE):
|
||||||
|
with open(CONFIG_FILE, 'r') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
data['host'] = host
|
||||||
|
with open(CONFIG_FILE, 'w') as f:
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Could not save config file: {e}")
|
||||||
|
|
||||||
|
def test_connection(self, host: str) -> tuple:
|
||||||
|
"""Test connectivity to the database using the given host. Returns (success: bool, message: str)."""
|
||||||
|
try:
|
||||||
|
test_conn = mysql.connector.connect(
|
||||||
|
host=host,
|
||||||
|
database=self.database,
|
||||||
|
user=self.user,
|
||||||
|
password=self.password,
|
||||||
|
connection_timeout=5
|
||||||
|
)
|
||||||
|
if test_conn.is_connected():
|
||||||
|
test_conn.close()
|
||||||
|
return True, f"Connected successfully to '{host}'"
|
||||||
|
except Error as e:
|
||||||
|
return False, str(e)
|
||||||
|
return False, "Connection failed"
|
||||||
|
|
||||||
def get_connection(self):
|
def get_connection(self):
|
||||||
"""Get a database connection."""
|
"""Get a database connection."""
|
||||||
@@ -24,7 +77,8 @@ class DatabaseManager:
|
|||||||
host=self.host,
|
host=self.host,
|
||||||
database=self.database,
|
database=self.database,
|
||||||
user=self.user,
|
user=self.user,
|
||||||
password=self.password
|
password=self.password,
|
||||||
|
connection_timeout=5
|
||||||
)
|
)
|
||||||
return self.connection
|
return self.connection
|
||||||
except Error as e:
|
except Error as e:
|
||||||
|
|||||||
125
main.py
125
main.py
@@ -1,5 +1,8 @@
|
|||||||
|
import threading
|
||||||
from kivy.app import App
|
from kivy.app import App
|
||||||
from kivy.uix.boxlayout import BoxLayout
|
from kivy.uix.boxlayout import BoxLayout
|
||||||
|
from kivy.uix.anchorlayout import AnchorLayout
|
||||||
|
from kivy.uix.floatlayout import FloatLayout
|
||||||
from kivy.uix.gridlayout import GridLayout
|
from kivy.uix.gridlayout import GridLayout
|
||||||
from kivy.uix.label import Label
|
from kivy.uix.label import Label
|
||||||
from kivy.uix.textinput import TextInput
|
from kivy.uix.textinput import TextInput
|
||||||
@@ -18,8 +21,13 @@ class DatabaseApp(App):
|
|||||||
def build(self):
|
def build(self):
|
||||||
# Set window to fullscreen
|
# Set window to fullscreen
|
||||||
Window.fullscreen = 'auto'
|
Window.fullscreen = 'auto'
|
||||||
|
|
||||||
|
# Root float layout so we can overlay the exit button
|
||||||
|
root = FloatLayout()
|
||||||
|
|
||||||
# Main layout with better spacing for fullscreen
|
# Main layout with better spacing for fullscreen
|
||||||
main_layout = BoxLayout(orientation='vertical', padding=40, spacing=20)
|
main_layout = BoxLayout(orientation='vertical', padding=40, spacing=20,
|
||||||
|
size_hint=(1, 1), pos_hint={'x': 0, 'y': 0})
|
||||||
|
|
||||||
# Top spacer for vertical centering
|
# Top spacer for vertical centering
|
||||||
main_layout.add_widget(Label(size_hint_y=0.15))
|
main_layout.add_widget(Label(size_hint_y=0.15))
|
||||||
@@ -115,11 +123,31 @@ class DatabaseApp(App):
|
|||||||
# Bottom spacer for vertical centering
|
# Bottom spacer for vertical centering
|
||||||
main_layout.add_widget(Label(size_hint_y=0.15))
|
main_layout.add_widget(Label(size_hint_y=0.15))
|
||||||
|
|
||||||
# Removed database contents frame
|
root.add_widget(main_layout)
|
||||||
# Load initial data
|
|
||||||
Clock.schedule_once(self.refresh_data, 0.1)
|
|
||||||
|
|
||||||
return main_layout
|
# Exit button — fixed to the bottom-right corner
|
||||||
|
exit_btn = Button(
|
||||||
|
text='Exit',
|
||||||
|
font_size=20,
|
||||||
|
bold=True,
|
||||||
|
background_color=(0.85, 0.1, 0.1, 1),
|
||||||
|
color=(1, 1, 1, 1),
|
||||||
|
size_hint=(None, None),
|
||||||
|
size=(120, 55),
|
||||||
|
pos_hint={'right': 1, 'y': 0}
|
||||||
|
)
|
||||||
|
exit_btn.bind(on_press=lambda x: self.stop())
|
||||||
|
root.add_widget(exit_btn)
|
||||||
|
|
||||||
|
# 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 set_update_frame_enabled(self, enabled):
|
def set_update_frame_enabled(self, enabled):
|
||||||
self.update_id_input.readonly = not enabled
|
self.update_id_input.readonly = not enabled
|
||||||
self.update_mass_input.readonly = not enabled
|
self.update_mass_input.readonly = not enabled
|
||||||
@@ -149,20 +177,24 @@ class DatabaseApp(App):
|
|||||||
self.show_status("ID must be 20 characters or less", error=True)
|
self.show_status("ID must be 20 characters or less", error=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Show searching status
|
|
||||||
self.show_status("Searching...", error=False)
|
self.show_status("Searching...", error=False)
|
||||||
|
|
||||||
|
def _do():
|
||||||
try:
|
try:
|
||||||
record = self.db_manager.search_by_id(record_id)
|
record = self.db_manager.search_by_id(record_id)
|
||||||
|
def _update(dt):
|
||||||
if record:
|
if record:
|
||||||
self.mass_input.text = str(record[1]) # Set the mass field
|
self.mass_input.text = str(record[1])
|
||||||
self.show_status(f"Found: {record[0]} = {record[1]}")
|
self.show_status(f"Found: {record[0]} = {record[1]}")
|
||||||
self.highlight_record(record_id)
|
self.highlight_record(record_id)
|
||||||
else:
|
else:
|
||||||
self.show_status(f"ID '{record_id}' not found in database", error=True)
|
self.show_status(f"ID '{record_id}' not found in database", error=True)
|
||||||
self.mass_input.text = ""
|
self.mass_input.text = ""
|
||||||
|
Clock.schedule_once(_update)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.show_status(f"Search error: {str(e)}", error=True)
|
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):
|
def add_update_record(self, instance):
|
||||||
"""Add or update a record from the update frame."""
|
"""Add or update a record from the update frame."""
|
||||||
@@ -179,20 +211,25 @@ class DatabaseApp(App):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
self.show_status("Mass must be a valid number", error=True)
|
self.show_status("Mass must be a valid number", error=True)
|
||||||
return
|
return
|
||||||
|
self.show_status("Saving...", error=False)
|
||||||
|
|
||||||
|
def _do():
|
||||||
|
try:
|
||||||
success = self.db_manager.add_or_update_record(record_id, mass)
|
success = self.db_manager.add_or_update_record(record_id, mass)
|
||||||
|
def _update(dt):
|
||||||
if success:
|
if success:
|
||||||
existing = self.db_manager.search_by_id(record_id)
|
|
||||||
if existing:
|
|
||||||
self.show_status(f"Successfully added/updated: {record_id} = {mass}")
|
self.show_status(f"Successfully added/updated: {record_id} = {mass}")
|
||||||
self.refresh_data(None)
|
|
||||||
# Clear update frame after successful operation
|
|
||||||
self.update_id_input.text = ""
|
self.update_id_input.text = ""
|
||||||
self.update_mass_input.text = ""
|
self.update_mass_input.text = ""
|
||||||
self.set_update_frame_enabled(False)
|
self.set_update_frame_enabled(False)
|
||||||
else:
|
self.refresh_data(None)
|
||||||
self.show_status("Operation completed but record not found", error=True)
|
|
||||||
else:
|
else:
|
||||||
self.show_status("Failed to add/update record", error=True)
|
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):
|
def delete_record(self, instance):
|
||||||
"""Delete a record using the update frame fields."""
|
"""Delete a record using the update frame fields."""
|
||||||
@@ -209,17 +246,26 @@ class DatabaseApp(App):
|
|||||||
|
|
||||||
def confirm_delete(self, record_id):
|
def confirm_delete(self, record_id):
|
||||||
"""Confirm and execute deletion."""
|
"""Confirm and execute deletion."""
|
||||||
|
self.show_status("Deleting...", error=False)
|
||||||
|
|
||||||
|
def _do():
|
||||||
|
try:
|
||||||
success = self.db_manager.delete_record(record_id)
|
success = self.db_manager.delete_record(record_id)
|
||||||
|
def _update(dt):
|
||||||
if success:
|
if success:
|
||||||
self.show_status(f"Successfully deleted: {record_id}")
|
self.show_status(f"Successfully deleted: {record_id}")
|
||||||
self.refresh_data(None)
|
|
||||||
self.clear_fields()
|
self.clear_fields()
|
||||||
# Clear update frame fields
|
|
||||||
self.update_id_input.text = ""
|
self.update_id_input.text = ""
|
||||||
self.update_mass_input.text = ""
|
self.update_mass_input.text = ""
|
||||||
self.set_update_frame_enabled(False)
|
self.set_update_frame_enabled(False)
|
||||||
|
self.refresh_data(None)
|
||||||
else:
|
else:
|
||||||
self.show_status(f"Failed to delete ID '{record_id}' (not found)", error=True)
|
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):
|
def clear_fields(self):
|
||||||
"""Clear the ID and mass fields."""
|
"""Clear the ID and mass fields."""
|
||||||
@@ -234,13 +280,16 @@ class DatabaseApp(App):
|
|||||||
self.show_status("Fields cleared", error=False)
|
self.show_status("Fields cleared", error=False)
|
||||||
|
|
||||||
def refresh_data(self, instance):
|
def refresh_data(self, instance):
|
||||||
"""Refresh the data display."""
|
"""Refresh the data display (runs in a background thread)."""
|
||||||
|
def _do():
|
||||||
try:
|
try:
|
||||||
records = self.db_manager.read_all_data()
|
records = self.db_manager.read_all_data()
|
||||||
count = len(records)
|
count = len(records)
|
||||||
self.show_status(f"Data refreshed - {count} records found")
|
Clock.schedule_once(lambda dt: self.show_status(f"Data refreshed - {count} records found"))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.show_status(f"Error refreshing data: {str(e)}", error=True)
|
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):
|
def highlight_record(self, record_id):
|
||||||
"""Highlight a specific record in the display."""
|
"""Highlight a specific record in the display."""
|
||||||
@@ -293,6 +342,19 @@ class DatabaseApp(App):
|
|||||||
)
|
)
|
||||||
content.add_widget(info_label)
|
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
|
||||||
buttons = BoxLayout(size_hint_y=None, height=60, spacing=15)
|
buttons = BoxLayout(size_hint_y=None, height=60, spacing=15)
|
||||||
|
|
||||||
@@ -306,13 +368,14 @@ class DatabaseApp(App):
|
|||||||
popup = Popup(
|
popup = Popup(
|
||||||
title='Settings',
|
title='Settings',
|
||||||
content=content,
|
content=content,
|
||||||
size_hint=(0.6, 0.5)
|
size_hint=(0.6, 0.65)
|
||||||
)
|
)
|
||||||
|
|
||||||
def save_settings():
|
def save_settings():
|
||||||
new_host = ip_input.text.strip()
|
new_host = ip_input.text.strip()
|
||||||
if new_host:
|
if new_host:
|
||||||
self.db_manager.host = new_host
|
self.db_manager.host = new_host
|
||||||
|
self.db_manager.save_host(new_host)
|
||||||
# Reconnect with new settings
|
# Reconnect with new settings
|
||||||
if self.db_manager.connection:
|
if self.db_manager.connection:
|
||||||
try:
|
try:
|
||||||
@@ -325,6 +388,28 @@ class DatabaseApp(App):
|
|||||||
else:
|
else:
|
||||||
self.show_status("Please enter a valid IP address", error=True)
|
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())
|
save_btn.bind(on_press=lambda x: save_settings())
|
||||||
cancel_btn.bind(on_press=popup.dismiss)
|
cancel_btn.bind(on_press=popup.dismiss)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user