[main] WIP major rework of current design, database management and other things

This commit is contained in:
Thastertyn 2025-01-09 14:14:27 +01:00
parent 4bf43721e3
commit a2ffd8aa3b
15 changed files with 360 additions and 168 deletions

View File

@ -1,38 +1,60 @@
import sys import sys
from PySide6 import QtWidgets, QtCore import os
import logging
from PySide6.QtWidgets import QMessageBox, QApplication
from utils.config import UserConfig
from utils.database import DatabaseManager
from utils.errors.database_config_error import DatabaseConfigError
from utils.setup_logger import setup_logger
from ui.window import LibraryWindow from ui.window import LibraryWindow
class LibraryApp():
def __init__(self, user_config: UserConfig | None = None):
setup_logger()
self.logger = logging.getLogger(__name__)
self.logger.info("Starting")
self.qt_app = QApplication([])
self.user_config = user_config
try:
self.database_manger = DatabaseManager()
except DatabaseConfigError as e:
detail_text = f"Invalid config: {e.config_name}"
self.show_error(e.message, detail_text=detail_text)
sys.exit(1)
except FileNotFoundError:
self.show_error("Configuration not found")
sys.exit(1)
self.window = LibraryWindow()
def run(self) -> int:
self.window.show()
status = self.qt_app.exec()
self.cleanup()
self.logger.info("Exitting")
return status
def show_error(self, text: str, detail_text: str = ""):
error_dialog = QMessageBox()
error_dialog.setIcon(QMessageBox.Icon.Critical)
error_dialog.setWindowTitle("Error")
error_dialog.setText(text)
if detail_text:
error_dialog.setInformativeText(detail_text)
error_dialog.setStandardButtons(QMessageBox.StandardButton.Ok)
error_dialog.exec()
def cleanup(self) -> None:
self.logger.info("Cleaning up")
self.database_manger.cleanup()
if __name__ == "__main__": if __name__ == "__main__":
app = QtWidgets.QApplication([]) library_app = LibraryApp()
window = LibraryWindow() sys.exit(library_app.run())
window.show()
sys.exit(app.exec())
# from sqlalchemy import create_engine
# from sqlalchemy.orm import sessionmaker
# from models.book_overview_view import BookOverviewView
# # Replace with your MySQL database credentials
# DATABASE_URI = 'mysql+mysqlconnector://username:password@localhost:3306/library'
# # Create the engine
# engine = create_engine(DATABASE_URI)
# from models.base import Base
# Base.metadata.create_all(engine)
# # Create a configured session class
# SessionLocal = sessionmaker(bind=engine)
# # Create a session instance
# session = SessionLocal()
# books = session.query(BookOverviewView).all()
# for book in books:
# print(book.title, book.author_name, book.categories)
# session.close()

View File

@ -7,6 +7,6 @@ from .member import Member
from .librarian import Librarian from .librarian import Librarian
from .loan import Loan from .loan import Loan
from .book_overview_view import BookOverviewView from .book_overview import BooksOverview
__all__ = ["Author", "Book", "BookCategory", "BookCategoryLink", "Member", "Librarian", "Loan", "BookOverviewView"] __all__ = ["Author", "Book", "BookCategory", "BookCategoryLink", "Member", "Librarian", "Loan", "BookOverviewView"]

View File

@ -0,0 +1,31 @@
from sqlalchemy import Column, String, TIMESTAMP, Integer, Text, Enum
from .base import Base
from models.book import BookStatusEnum
class BooksOverview(Base):
__tablename__ = 'books_overview'
__table_args__ = {'extend_existing': True}
id = Column(Integer, primary_key=True)
title = Column(String, nullable=False)
author_name = Column(String, nullable=False)
author_id = Column(Integer, nullable=False)
categories = Column(Text, nullable=True)
year_published = Column(Integer, nullable=True)
isbn = Column(String, nullable=True)
status = Column(Enum(BookStatusEnum), nullable=False)
created_at = Column(TIMESTAMP, nullable=False)
borrower_name = Column(String, nullable=True)
member_id = Column(Integer, nullable=True)
librarian_name = Column(String, nullable=True)
librarian_id = Column(Integer, nullable=True)
# This prevents accidental updates/deletes as it's a view
def __repr__(self):
return (f"<BooksOverview(id={self.id}, title={self.title}, author_name={self.author_name}, "
f"categories={self.categories}, year_published={self.year_published}, isbn={self.isbn}, "
f"status={self.status}, created_at={self.created_at}, borrower_name={self.borrower_name}, "
f"librarian_name={self.librarian_name})>")

View File

@ -1,16 +0,0 @@
from sqlalchemy import Column, String, TIMESTAMP, Integer
from .base import Base
class BookOverviewView(Base):
__tablename__ = 'books_overview'
id = Column(Integer, primary_key=True, autoincrement=True)
title = Column(String())
author_name = Column(String())
categories = Column(String())
year_published = Column(String())
isbn = Column(String())
created_at = Column(TIMESTAMP())
borrower_name = Column(String())
librarian_name = Column(String())

View File

@ -0,0 +1,68 @@
from PySide6.QtGui import QGuiApplication, QAction
from PySide6.QtQml import QQmlApplicationEngine
from PySide6.QtWidgets import QHBoxLayout, QVBoxLayout, QLabel, QWidget, QMenu, QSizePolicy, QLayout
from models.book import BookStatusEnum
from models.book_overview import BooksOverview
STATUS_TO_COLOR_MAP = {
BookStatusEnum.available: "#3c702e",
BookStatusEnum.borrowed: "#702525",
BookStatusEnum.reserved: "#bc7613"
}
class BookCard(QWidget):
def __init__(self, book_overview: BooksOverview):
super().__init__()
# Create the layout for the card
layout = QHBoxLayout(self)
layout.setSizeConstraint(QLayout.SizeConstraint.SetMinimumSize) # Ensure minimum size is respected
self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) # Avoid vertical stretching
layout.setContentsMargins(10, 10, 10, 10) # Optional: Add margins for spacing
layout.setSpacing(10)
# Left-side content
left_side = QVBoxLayout()
layout.addLayout(left_side)
title_label = QLabel(book_overview.title)
author_label = QLabel("By: " + book_overview.author_name)
isbn_label = QLabel("ISBN: " + (book_overview.isbn or "Not Available"))
left_side.addWidget(title_label)
left_side.addWidget(author_label)
left_side.addWidget(isbn_label)
# Right-side content
right_side = QVBoxLayout()
layout.addLayout(right_side)
status_label = QLabel(str(book_overview.status))
status_label.setStyleSheet(f"color: {STATUS_TO_COLOR_MAP[book_overview.status]}")
right_side.addWidget(status_label)
if book_overview.librarian_name and book_overview.borrower_name:
borrower_label = QLabel("Borrowed: " + book_overview.borrower_name)
librarian_label = QLabel("By: " + book_overview.librarian_name)
right_side.addWidget(borrower_label)
right_side.addWidget(librarian_label)
self.setLayout(layout)
def contextMenuEvent(self, event):
"""Override to create a custom right-click menu."""
context_menu = QMenu(self)
# Add actions to the menu
action_edit_book = context_menu.addAction("Edit Book")
action_edit_author = context_menu.addAction("Edit Author")
action_mark_returned = context_menu.addAction("Mark as Returned")
# Execute the menu and get the selected action
action = context_menu.exec_(self.mapToGlobal(event.pos()))
if action == action_edit_book:
self.label.setText("Edit Book selected")
elif action == action_edit_author:
self.label.setText("Edit Author selected")
elif action == action_mark_returned:
self.label.setText("Mark as Returned selected")

View File

@ -1,27 +0,0 @@
from PySide6.QtGui import QGuiApplication, QAction
from PySide6.QtQml import QQmlApplicationEngine
from PySide6 import QtWidgets, QtCore
from models.book import BookStatusEnum
STATUS_TO_COLOR_MAP = {
BookStatusEnum.available: "highlight",
BookStatusEnum.borrowed: "highlighted-text",
BookStatusEnum.reserved: "base"
}
class BookListEntry(QtWidgets.QWidget):
def __init__(self, title, status):
super().__init__()
layout = QtWidgets.QHBoxLayout(self)
book_label = QtWidgets.QLabel(title)
layout.addWidget(book_label)
status_label = QtWidgets.QLabel(status)
status_label.setStyleSheet(f"color: palette({STATUS_TO_COLOR_MAP[status]});")
layout.addWidget(status_label)
self.setLayout(layout)

View File

@ -1,92 +1,87 @@
from PySide6.QtGui import QGuiApplication, QAction from PySide6.QtGui import QAction
from PySide6.QtQml import QQmlApplicationEngine from PySide6.QtWidgets import (
from PySide6 import QtWidgets, QtCore QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QScrollArea,
QFrame, QPushButton, QMessageBox, QVBoxLayout, QScrollArea
)
from PySide6.QtCore import Qt
from ui.settings import SettingsDialog from .book_card import BookCard
class LibraryDashboard(QtWidgets.QWidget): from models.book_overview import BooksOverview
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
class LibraryDashboard(QWidget):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
# Central widget and layout # Central widget and layout
main_layout = QtWidgets.QVBoxLayout(self) main_layout = QVBoxLayout(self)
# Title label # Title label
title_label = QtWidgets.QLabel("Dashboard", self) title_label = QLabel("Dashboard", self)
title_label.setAlignment(QtCore.Qt.AlignCenter) title_label.setAlignment(Qt.AlignCenter)
title_label.setStyleSheet("font-size: 20px; font-weight: bold; color: #0078D4;") title_label.setStyleSheet("font-size: 20px; font-weight: bold; color: #0078D4;")
main_layout.addWidget(title_label) main_layout.addWidget(title_label)
# Available books list
available_label = QtWidgets.QLabel("Available Books")
available_label.setStyleSheet("font-size: 16px;")
main_layout.addWidget(available_label)
# Search bar # Search bar
self.search_input = QtWidgets.QLineEdit() self.search_input = QLineEdit()
self.search_input.setPlaceholderText("Type to search...") self.search_input.setPlaceholderText("Type to search...")
self.search_input.textChanged.connect(self.filter_books) self.search_input.textChanged.connect(self.filter_books)
main_layout.addWidget(self.search_input) main_layout.addWidget(self.search_input)
# Scrollable area for cards
self.scroll_area = QScrollArea()
self.scroll_area.setWidgetResizable(True)
self.available_books_list = QtWidgets.QListWidget() # Container widget for the scroll area
self.books = ["Book One", "Book Two", "Book Three", "Book Four", self.scroll_widget = QWidget()
"Book Five", "Book Six", "Book Seven", "Book Eight"] self.scroll_layout = QVBoxLayout(self.scroll_widget)
self.scroll_layout.setSpacing(5) # Optional: Adjust spacing between cards
self.scroll_layout.setContentsMargins(0, 0, 0, 0)
self.available_books_list.addItems(self.books) # Example cards
self.available_books_list.itemClicked.connect(self.edit_book) # self.books = self.fetch_books_from_db()
self.available_books_list.setStyleSheet(""" # self.book_cards = []
QListWidget {
font-size: 14px;
}
QListWidget::item {
padding: 5px;
}
QListWidget::item:hover {
background-color: palette(highlight);
color: palette(highlighted-text);
}
""")
main_layout.addWidget(self.available_books_list)
# Borrowed books list # for book in self.books:
borrowed_label = QtWidgets.QLabel("Currently Borrowed Books") # card = BookCard(book)
borrowed_label.setStyleSheet("font-size: 16px;") # self.scroll_layout.addWidget(card)
main_layout.addWidget(borrowed_label) # self.book_cards.append(card)
self.borrowed_books_list = QtWidgets.QListWidget() self.scroll_widget.setLayout(self.scroll_layout)
self.borrowed_books_list.addItems(["Book Two", "Book Four"]) self.scroll_area.setWidget(self.scroll_widget)
self.borrowed_books_list.itemClicked.connect(self.return_book) main_layout.addWidget(self.scroll_area)
main_layout.addWidget(self.borrowed_books_list)
# Buttons for actions # Buttons for actions
button_layout = QtWidgets.QHBoxLayout() button_layout = QHBoxLayout()
register_member_button = QtWidgets.QPushButton("Add New Member") register_member_button = QPushButton("Add New Member")
register_member_button.clicked.connect(self.register_member) register_member_button.clicked.connect(self.register_member)
button_layout.addWidget(register_member_button) button_layout.addWidget(register_member_button)
add_borrow_record_button = QtWidgets.QPushButton("Add Borrow Record") add_borrow_record_button = QPushButton("Add Borrow Record")
add_borrow_record_button.clicked.connect(self.add_borrow_record) add_borrow_record_button.clicked.connect(self.add_borrow_record)
button_layout.addWidget(add_borrow_record_button) button_layout.addWidget(add_borrow_record_button)
main_layout.addLayout(button_layout) main_layout.addLayout(button_layout)
def filter_books(self, text): def filter_books(self, text):
"""Filter the available books list based on the search input.""" """Filter the cards based on the search input."""
self.available_books_list.clear() for card, book in zip(self.book_cards, self.books):
filtered_books = [book for book in self.books if text.lower() in book.lower()] card.setVisible(text.lower() in book["title"].lower() or text.lower() in book["description"].lower())
self.available_books_list.addItems(filtered_books)
# Slots for button actions
def edit_book(self, item):
QtWidgets.QMessageBox.information(self, "Edit Book", f"Edit details for '{item.text()}'.")
def return_book(self, item):
QtWidgets.QMessageBox.information(self, "Return Book", f"Mark '{item.text()}' as returned.")
def register_member(self): def register_member(self):
QtWidgets.QMessageBox.information(self, "Add Member", "Open dialog to register a new member.") QMessageBox.information(self, "Add Member", "Open dialog to register a new member.")
def add_borrow_record(self): def add_borrow_record(self):
QtWidgets.QMessageBox.information(self, "Add Borrow Record", "Open dialog to add a borrow record.") QMessageBox.information(self, "Add Borrow Record", "Open dialog to add a borrow record.")
def fetch_books_from_db(self):
"""Fetch all books from the database."""
try:
session = SessionLocal()
books = session.query(BooksOverview).all()
return books
except Exception as e:
QMessageBox.critical(self, "Database Error", f"Failed to fetch books: {e}")
return []

View File

@ -2,12 +2,14 @@ import sys
from PySide6 import QtWidgets from PySide6 import QtWidgets
from utils.config import UserConfig, TransactionLevel
class SettingsDialog(QtWidgets.QDialog): class SettingsDialog(QtWidgets.QDialog):
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
self.setWindowTitle("Settings") self.setWindowTitle("Settings")
self.setMinimumSize(400, 300) self.setMinimumSize(400, 100)
# Position the dialog relative to the parent (main window) # Position the dialog relative to the parent (main window)
if parent: if parent:
@ -18,29 +20,24 @@ class SettingsDialog(QtWidgets.QDialog):
# Create layout # Create layout
layout = QtWidgets.QVBoxLayout(self) layout = QtWidgets.QVBoxLayout(self)
# Checkbox: Enable Notifications data_mode_layout = QtWidgets.QHBoxLayout()
self.notifications_checkbox = QtWidgets.QCheckBox("Enable Notifications")
layout.addWidget(self.notifications_checkbox)
# Checkbox: Dark Mode self.data_mode_label = QtWidgets.QLabel("Data mode:")
self.dark_mode_checkbox = QtWidgets.QCheckBox("Enable Dark Mode") data_mode_layout.addWidget(self.data_mode_label)
layout.addWidget(self.dark_mode_checkbox)
# Dropdown: Language Selection self.data_mode_dropdown = QtWidgets.QComboBox()
self.language_label = QtWidgets.QLabel("Language:") for tl in TransactionLevel:
layout.addWidget(self.language_label) self.data_mode_dropdown.addItem(tl.name.capitalize(), tl.value)
self.language_dropdown = QtWidgets.QComboBox() data_mode_layout.addWidget(self.data_mode_dropdown)
self.language_dropdown.addItems(["English", "Spanish", "French", "German"]) layout.addLayout(data_mode_layout)
layout.addWidget(self.language_dropdown)
# Dropdown: Theme Selection # Set the currently selected mode to the mode in UserConfig
self.theme_label = QtWidgets.QLabel("Theme:") config = UserConfig()
layout.addWidget(self.theme_label) current_level = config.transaction_level
index = self.data_mode_dropdown.findData(current_level)
self.theme_dropdown = QtWidgets.QComboBox() if index != -1:
self.theme_dropdown.addItems(["Light", "Dark", "System Default"]) self.data_mode_dropdown.setCurrentIndex(index)
layout.addWidget(self.theme_dropdown)
# Buttons # Buttons
button_layout = QtWidgets.QHBoxLayout() button_layout = QtWidgets.QHBoxLayout()
@ -56,16 +53,13 @@ class SettingsDialog(QtWidgets.QDialog):
layout.addLayout(button_layout) layout.addLayout(button_layout)
def save_settings(self): def save_settings(self):
# Example of how to fetch settings data_mode = self.data_mode_dropdown.currentData()
notifications = self.notifications_checkbox.isChecked()
dark_mode = self.dark_mode_checkbox.isChecked() config = UserConfig()
language = self.language_dropdown.currentText() config.transaction_level = data_mode
theme = self.theme_dropdown.currentText()
print("Settings Saved:") print("Settings Saved:")
print(f"Notifications: {notifications}") print(f"Data Mode: {config.transaction_level}")
print(f"Dark Mode: {dark_mode}")
print(f"Language: {language}")
print(f"Theme: {theme}")
self.accept() self.accept()

View File

@ -56,11 +56,11 @@ class LibraryWindow(QtWidgets.QMainWindow):
# File menu # File menu
file_menu = menu_bar.addMenu("File") file_menu = menu_bar.addMenu("File")
import_action = QAction("Import", self) import_action = QAction("Import books", self)
import_action.triggered.connect(self.import_data) import_action.triggered.connect(self.import_data)
file_menu.addAction(import_action) file_menu.addAction(import_action)
export_action = QAction("Export", self) export_action = QAction("Export overview", self)
export_action.triggered.connect(self.export_data) export_action.triggered.connect(self.export_data)
file_menu.addAction(export_action) file_menu.addAction(export_action)
@ -74,9 +74,10 @@ class LibraryWindow(QtWidgets.QMainWindow):
# Edit menu # Edit menu
edit_menu = menu_bar.addMenu("Edit") edit_menu = menu_bar.addMenu("Edit")
preferences = QAction("Preferences", self) preferences_action = QAction("Preferences", self)
preferences.triggered.connect(self.edit_preferences) preferences_action.setShortcut("Ctrl+,")
edit_menu.addAction(preferences) preferences_action.triggered.connect(self.edit_preferences)
edit_menu.addAction(preferences_action)
# Help menu # Help menu
help_menu = menu_bar.addMenu("Help") help_menu = menu_bar.addMenu("Help")
@ -100,4 +101,4 @@ class LibraryWindow(QtWidgets.QMainWindow):
pass pass
def about(self): def about(self):
QtWidgets.QMessageBox.information(self, "About", "Library Dashboard v1.0\nDeveloped by You.") QtWidgets.QMessageBox.information(self, "About", "Library app demonstrating the phantom read problem")

0
src/utils/__init__.py Normal file
View File

View File

@ -0,0 +1,76 @@
import enum
import os
from utils.errors.database_config_error import DatabaseConfigError
from dotenv import load_dotenv
import logging
class DatabaseConfig():
def __init__(self):
self.logger = logging.getLogger(__name__)
self.logger.debug("Parsing .env")
anything_set = load_dotenv()
if anything_set:
self.logger.debug(".env found")
else:
self.logger.error(".env not found")
raise FileNotFoundError(".env not found.")
host = os.getenv("DATABASE_HOST")
port = os.getenv("DATABASE_PORT", "3306")
name = os.getenv("DATABASE_NAME")
user = os.getenv("DATABASE_USER")
password = os.getenv("DATABASE_PASSWORD")
self.logger.debug("Validating fetched values from .env")
if host is None or host == "":
self.logger.error("DATABASE_HOST is empty")
raise DatabaseConfigError(
"Config is invalid.", "DATABASE_HOST", "(Empty)")
if name is None or name == "":
self.logger.error("DATABASE_NAME is empty")
raise DatabaseConfigError(
"Config is invalid.", "DATABASE_NAME", "(Empty)")
if user is None or user == "":
self.logger.error("DATABASE_USER is empty")
raise DatabaseConfigError(
"Config is invalid.", "DATABASE_USER", "(Empty)")
if password is None or password == "":
self.logger.error("DATABASE_PASSWORD is empty")
raise DatabaseConfigError(
"Config is invalid.", "DATABASE_PASSWORD", "(Empty)")
if not port.isdigit():
self.logger.error("DATABASE_PORT is invalid. Not a number")
raise DatabaseConfigError(
"Config is invalid", "DATABASE_PORT", port)
self.logger.debug("All config validated")
self.host = host
self.port = port
self.name = name
self.user = user
self.password = password
class TransactionLevel(enum.Enum):
insecure = 'READ UNCOMMITED'
secure = 'SERIALIZABLE'
class UserConfig():
_instance = None
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
if not hasattr(self, "transaction_level"):
self.transaction_level = TransactionLevel.insecure

27
src/utils/database.py Normal file
View File

@ -0,0 +1,27 @@
import logging
from mysql.connector import connect, cursor
from utils.config import DatabaseConfig
from utils.errors.database_config_error import DatabaseConfigError
class DatabaseManager():
def __init__(self) -> None:
self.logger = logging.getLogger(__name__)
self.logger.info("Reading database config")
self.database_config = DatabaseConfig()
self.connection = connect(
host=self.database_config.host,
port=self.database_config.port,
database=self.database_config.name,
user=self.database_config.user,
password=self.database_config.password
)
def get_cursor(self) -> cursor.MySQLCursorAbstract:
return self.connection.cursor(dictionary=True)
def cleanup(self) -> None:
self.logger.debug("Closing connection")
self.connection.close()

View File

View File

@ -0,0 +1,7 @@
class DatabaseConfigError(Exception):
def __init__(self, message: str, config_name: str, config_value: str):
super().__init__(message)
self.message = message
self.config_name = config_name
self.config_value = config_value

14
src/utils/setup_logger.py Normal file
View File

@ -0,0 +1,14 @@
import sys
import logging
def setup_logger():
logger = logging.getLogger()
logger.setLevel(logging.DEBUG)
handler = logging.StreamHandler(sys.stdout)
handler.setLevel(logging.DEBUG)
formatter = logging.Formatter("[%(levelname)s] - %(name)s - %(message)s")
handler.setFormatter(formatter)
logger.addHandler(handler)