From 516639ef9d155003675cf7fbe72fca4d665847b3 Mon Sep 17 00:00:00 2001 From: Thastertyn Date: Wed, 15 Jan 2025 14:08:18 +0100 Subject: [PATCH] [main] Major rework of structure --- src/app.py | 61 +------- src/app/__init__.py | 5 + src/app/library_app.py | 62 +++++++++ src/database/__init__.py | 9 ++ src/database/book.py | 66 +++++++++ src/database/manager.py | 55 ++++++++ src/database/member.py | 28 ++-- src/export/book_exporter.py | 4 +- src/importer/book/book_importer.py | 6 +- src/models/__init__.py | 28 ++-- src/models/author.py | 3 + src/models/base.py | 2 + src/models/book.py | 3 + src/models/book_category.py | 3 + src/models/book_category_link.py | 14 +- src/models/book_overview.py | 5 +- src/models/librarian.py | 3 + src/models/loan.py | 5 +- src/models/member.py | 5 +- src/ui/book_editor/book_editor.py | 88 ------------ src/ui/dashboard/book_card.py | 7 +- src/ui/dashboard/dashboard.py | 8 +- src/ui/editor/__init__.py | 7 + src/ui/editor/book_editor.py | 130 ++++++++++++++++++ .../member_editor.py | 17 ++- .../member_list/member_card.py | 4 +- .../member_list/member_list.py | 6 +- src/ui/settings.py | 48 +++++-- src/ui/window.py | 8 +- src/utils/config.py | 52 ++++++- src/utils/database.py | 52 ------- src/utils/errors/database.py | 25 ++++ src/utils/errors/database/__init__.py | 0 .../errors/database/database_config_error.py | 7 - .../database/database_connection_error.py | 5 - src/utils/errors/database/database_error.py | 4 - .../errors/database/duplicate_entry_error.py | 6 - src/utils/setup_logger.py | 2 +- 38 files changed, 546 insertions(+), 297 deletions(-) create mode 100644 src/app/__init__.py create mode 100644 src/app/library_app.py create mode 100644 src/database/book.py create mode 100644 src/database/manager.py delete mode 100644 src/ui/book_editor/book_editor.py create mode 100644 src/ui/editor/__init__.py create mode 100644 src/ui/editor/book_editor.py rename src/ui/{member_editor => editor}/member_editor.py (87%) create mode 100644 src/utils/errors/database.py delete mode 100644 src/utils/errors/database/__init__.py delete mode 100644 src/utils/errors/database/database_config_error.py delete mode 100644 src/utils/errors/database/database_connection_error.py delete mode 100644 src/utils/errors/database/database_error.py delete mode 100644 src/utils/errors/database/duplicate_entry_error.py diff --git a/src/app.py b/src/app.py index c3242c3..c1001f6 100644 --- a/src/app.py +++ b/src/app.py @@ -1,65 +1,6 @@ import sys -import os -import logging -from typing import Optional - -from PySide6.QtWidgets import QMessageBox, QApplication - -from utils.config import UserConfig -from utils.database import DatabaseManager -from utils.errors.database.database_config_error import DatabaseConfigError -from utils.errors.database.database_connection_error import DatabaseConnectionError -from utils.setup_logger import setup_logger - -from ui.window import LibraryWindow - - -class LibraryApp(): - def __init__(self, user_config: Optional[UserConfig] = 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: - self.exit_with_error(f"Invalid config: {e.config_name}", e.message) - except DatabaseConnectionError as e: - self.exit_with_error(f"Could not connect to database: {e}") - except FileNotFoundError: - self.exit_with_error("Configuration not found") - self.window = LibraryWindow() - - def exit_with_error(self, error: str, additional_text: str = ""): - self.show_error(error, additional_text) - self.qt_app.quit() - sys.exit(1) - - def run(self) -> int: - self.window.show() - status = self.qt_app.exec() - self.cleanup() - self.logger.info("Exiting") - 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.qt_app.quit() - self.database_manger.cleanup() +from app.library_app import LibraryApp if __name__ == "__main__": library_app = LibraryApp() diff --git a/src/app/__init__.py b/src/app/__init__.py new file mode 100644 index 0000000..58c7460 --- /dev/null +++ b/src/app/__init__.py @@ -0,0 +1,5 @@ +from .library_app import * + +__all__ = [ + *library_app.__all__ +] \ No newline at end of file diff --git a/src/app/library_app.py b/src/app/library_app.py new file mode 100644 index 0000000..819f009 --- /dev/null +++ b/src/app/library_app.py @@ -0,0 +1,62 @@ +import sys +import os +import logging +from typing import Optional + +from PySide6.QtWidgets import QMessageBox, QApplication + +from utils.config import UserConfig +from database.manager import DatabaseManager +from utils.errors.database import DatabaseConfigError, DatabaseConnectionError +from utils.setup_logger import setup_logger + +from ui.window import LibraryWindow + + +class LibraryApp(): + def __init__(self, user_config: Optional[UserConfig] = 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: + self.exit_with_error(f"Invalid config: {e.config_name}", e.message) + except DatabaseConnectionError as e: + self.exit_with_error(f"Could not connect to database.") + except FileNotFoundError: + self.exit_with_error("Configuration not found.") + self.window = LibraryWindow() + + def exit_with_error(self, error: str, additional_text: str = ""): + self.show_error(error, additional_text) + self.qt_app.quit() + sys.exit(1) + + def run(self) -> int: + self.window.show() + status = self.qt_app.exec() + self.cleanup() + self.logger.info("Exiting") + 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.qt_app.quit() + self.database_manger.cleanup() + +__all__ = ["LibraryApp"] \ No newline at end of file diff --git a/src/database/__init__.py b/src/database/__init__.py index e69de29..fd3d1ba 100644 --- a/src/database/__init__.py +++ b/src/database/__init__.py @@ -0,0 +1,9 @@ +from .manager import * +from .book import * +from .member import * + +__all__ = [ + *manager.__all__, + *book.__all__, + *member.__all__, +] \ No newline at end of file diff --git a/src/database/book.py b/src/database/book.py new file mode 100644 index 0000000..afe2319 --- /dev/null +++ b/src/database/book.py @@ -0,0 +1,66 @@ +from typing import Dict, List +import logging + +from sqlalchemy.exc import IntegrityError, SQLAlchemyError, DatabaseError as SqlAlchemyDatabaseError + +from utils.errors.database import DatabaseError, DuplicateEntryError, DatabaseConnectionError +from models import Book +from database.manager import DatabaseManager + +logger = logging.getLogger(__name__) + + +def fetch_all(): + with DatabaseManager.get_session() as session: + try: + return session.query(Book).all() + except SqlAlchemyDatabaseError as e: + logger.critical("Connection with database interrupted") + raise DatabaseConnectionError( + "Connection with database interrupted") from e + except SQLAlchemyError as e: + logger.error(f"An error occured when fetching all books: {e}") + raise DatabaseError( + "An error occured when fetching all books") from e + + +def create_new_book(book: Book): + pass + + +def update_book(book: Book): + session = DatabaseManager.get_session() + try: + with session: + logger.debug(f"Updating book {book.title}") + existing_book = session.query(Book).get(book.id) + + if not existing_book: + logger.warning(f"Book with id {book.id} not found") + raise DatabaseError("Book not found in the database") + + existing_book.title = book.title + existing_book.description = book.description + existing_book.year_published = book.year_published + existing_book.isbn = book.isbn + + session.commit() + logger.info("Book successfully updated") + except IntegrityError as e: + logger.warning("Data already exists") + session.rollback() + raise DuplicateEntryError( + "Data already exists in the database") from e + except SqlAlchemyDatabaseError as e: + session.rollback() + logger.critical("Connection with database interrupted") + raise DatabaseConnectionError( + "Connection with database interrupted") from e + except SQLAlchemyError as e: + logger.error(f"An error occured when saving book: {e}") + session.rollback() + raise DatabaseError( + "An error occured when updating the book") from e + + +__all__ = ["create_new_book", "update_book"] diff --git a/src/database/manager.py b/src/database/manager.py new file mode 100644 index 0000000..55ac10a --- /dev/null +++ b/src/database/manager.py @@ -0,0 +1,55 @@ +import logging + +from sqlalchemy.orm import sessionmaker, Session +from sqlalchemy import create_engine, text + +from sqlalchemy.exc import DatabaseError + +from utils.config import DatabaseConfig +from utils.errors.database import DatabaseConnectionError + + +class DatabaseManager(): + + _instance: 'DatabaseManager' = None + + def __new__(cls, *args, **kwargs): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self) -> None: + self.logger = logging.getLogger(__name__) + self.logger.info("Reading database config") + database_config = DatabaseConfig() + self.engine = create_engine('mysql+mysqlconnector://%s:%s@%s:%s/%s' % ( + database_config.user, + database_config.password, + database_config.host, + database_config.port, + database_config.name), + pool_pre_ping=True, + echo=True) + if self.test_connection(): + self.Session = sessionmaker(bind=self.engine) + + def cleanup(self) -> None: + self.logger.debug("Closing connection") + self.engine.dispose() + + def test_connection(self) -> bool: + self.logger.debug("Testing database connection") + try: + with self.engine.connect() as connection: + connection.execute(text("select 1")) + self.logger.debug("Database connection successful") + return True + except DatabaseError as e: + self.logger.critical(f"Database connection failed: {e}") + raise DatabaseConnectionError("Database connection failed") from e + + @classmethod + def get_session(cls) -> Session: + return DatabaseManager._instance.Session() + +__all__ = ["DatabaseManager"] \ No newline at end of file diff --git a/src/database/member.py b/src/database/member.py index 19cd104..3ccac6e 100644 --- a/src/database/member.py +++ b/src/database/member.py @@ -1,11 +1,13 @@ -from sqlalchemy.exc import IntegrityError, SQLAlchemyError +import logging -from models.member import Member +from sqlalchemy.exc import IntegrityError, SQLAlchemyError, DatabaseError -from utils.database import DatabaseManager +from utils.errors.database import DatabaseError, DuplicateEntryError, DatabaseConnectionError +from models import Member +from database.manager import DatabaseManager + +logger = logging.getLogger(__name__) -from utils.errors.database.database_error import DatabaseError -from utils.errors.database.duplicate_entry_error import DuplicateEntryError def create_new_member(new_member: Member): try: @@ -13,11 +15,19 @@ def create_new_member(new_member: Member): session.add(new_member) session.commit() except IntegrityError as e: - print(e) + logger.warning("Data already exists") session.rollback() - raise DuplicateEntryError from e + raise DuplicateEntryError("Data already exists in the database") from e + except DatabaseError as e: + session.rollback() + logger.critical("Connection with database interrupted") + raise DatabaseConnectionError( + "Connection with database interrupted") from e except SQLAlchemyError as e: - print(e) + logger.error(f"An error occured when saving member: {e}") session.rollback() - raise DatabaseError from e + raise DatabaseError( + "An error occured when creating a new member") from e + +__all__ = ["create_new_member"] diff --git a/src/export/book_exporter.py b/src/export/book_exporter.py index a35690e..9177a13 100644 --- a/src/export/book_exporter.py +++ b/src/export/book_exporter.py @@ -2,11 +2,11 @@ from typing import Optional import xml.etree.ElementTree as ET from xml.dom import minidom -from utils.database import DatabaseManager +from database.manager import DatabaseManager from utils.errors.export_error import ExportError from utils.errors.no_export_entity_error import NoExportEntityError -from models.book import Book +from models import Book class BookExporter(): diff --git a/src/importer/book/book_importer.py b/src/importer/book/book_importer.py index bfce985..9e93ef8 100644 --- a/src/importer/book/book_importer.py +++ b/src/importer/book/book_importer.py @@ -3,13 +3,11 @@ import os import logging from xml.etree import ElementTree as ET from xmlschema import XMLSchema -from utils.database import DatabaseManager +from database.manager import DatabaseManager from utils.errors.import_error.xsd_scheme_not_found import XsdSchemeNotFoundError from utils.errors.import_error.invalid_contents_error import InvalidContentsError from utils.errors.import_error.import_error import ImportError -from models.book import Book -from models.author import Author -from models.book_category import BookCategory +from models import Book, Author, BookCategory from sqlalchemy.exc import IntegrityError class BookImporter: diff --git a/src/models/__init__.py b/src/models/__init__.py index dc362da..75a6878 100644 --- a/src/models/__init__.py +++ b/src/models/__init__.py @@ -1,11 +1,19 @@ -from .base import Base -from .author import Author -from .book import Book -from .book_category import BookCategory -from .book_overview import BooksOverview -from .book_category_link import BookCategoryLink -from .member import Member -from .librarian import Librarian -from .loan import Loan +from .author import * +from .book import * +from .book_category import * +from .book_category_link import * +from .book_overview import * +from .member import * +from .librarian import * +from .loan import * -__all__ = ["Author", "Book", "BookCategory", "BookCategoryLink", "Member", "Librarian", "Loan", "BooksOverview"] +__all__ = [ + *author.__all__, + *book.__all__, + *book_category.__all__, + *book_category_link.__all__, + *book_overview.__all__, + *member.__all__, + *librarian.__all__, + *loan.__all__ +] diff --git a/src/models/author.py b/src/models/author.py index 93fdd45..e5a8b5a 100644 --- a/src/models/author.py +++ b/src/models/author.py @@ -15,3 +15,6 @@ class Author(Base): # Reference 'Book' as a string to avoid direct import books = relationship('Book', back_populates='author') + + +__all__ = ["Author"] diff --git a/src/models/base.py b/src/models/base.py index 860e542..c1ba9fd 100644 --- a/src/models/base.py +++ b/src/models/base.py @@ -1,3 +1,5 @@ from sqlalchemy.ext.declarative import declarative_base Base = declarative_base() + +__all__ = ["Base"] diff --git a/src/models/book.py b/src/models/book.py index a8e2448..caf6c5a 100644 --- a/src/models/book.py +++ b/src/models/book.py @@ -28,3 +28,6 @@ class Book(Base): author = relationship('Author', back_populates='books') categories = relationship('BookCategory',secondary='book_category_link',back_populates='books') + + +__all__ = ["Book", "BookStatusEnum"] diff --git a/src/models/book_category.py b/src/models/book_category.py index 873453d..619e68d 100644 --- a/src/models/book_category.py +++ b/src/models/book_category.py @@ -20,3 +20,6 @@ class BookCategory(Base): secondary='book_category_link', # Junction table back_populates='categories' # For bidirectional relationship ) + + +__all__ = ["BookCategory"] diff --git a/src/models/book_category_link.py b/src/models/book_category_link.py index e653602..27be8b8 100644 --- a/src/models/book_category_link.py +++ b/src/models/book_category_link.py @@ -5,11 +5,17 @@ from .book import Book from .book_category import BookCategory from .base import Base + class BookCategoryLink(Base): __tablename__ = 'book_category_link' - book_id = Column(Integer, ForeignKey('book.id', ondelete="cascade"), primary_key=True) - book_category_id = Column(Integer, ForeignKey('book_category.id', ondelete="cascade"), primary_key=True) + book_id = Column(Integer, ForeignKey( + 'book.id', ondelete="cascade"), primary_key=True) + book_category_id = Column(Integer, ForeignKey( + 'book_category.id', ondelete="cascade"), primary_key=True) - book = relationship('Book', overlaps='categories,books') - book_category = relationship('BookCategory', overlaps='categories,books') \ No newline at end of file + book = relationship('Book', overlaps='categories,books') + book_category = relationship('BookCategory', overlaps='categories,books') + + +__all__ = ["BookCategoryLink"] diff --git a/src/models/book_overview.py b/src/models/book_overview.py index 59bade6..3248dd5 100644 --- a/src/models/book_overview.py +++ b/src/models/book_overview.py @@ -28,4 +28,7 @@ class BooksOverview(Base): return (f"") \ No newline at end of file + f"librarian_name={self.librarian_name})>") + + +__all__ = ["BooksOverview"] diff --git a/src/models/librarian.py b/src/models/librarian.py index 019e067..05179d8 100644 --- a/src/models/librarian.py +++ b/src/models/librarian.py @@ -28,3 +28,6 @@ class Librarian(Base): status = Column(Enum(LibrarianStatusEnum), nullable=False, default=LibrarianStatusEnum.active) role = Column(Enum(LibrarianRoleEnum), nullable=False) last_updated = Column(TIMESTAMP, nullable=False, server_default=func.now()) + + +__all__ = ["Librarian", "LibrarianRoleEnum", "LibrarianStatusEnum"] diff --git a/src/models/loan.py b/src/models/loan.py index 2e34451..5fdb59e 100644 --- a/src/models/loan.py +++ b/src/models/loan.py @@ -33,4 +33,7 @@ class Loan(Base): book = relationship('Book', backref='loans') member = relationship('Member', backref='loans') - librarian = relationship('Librarian', backref='loans') \ No newline at end of file + librarian = relationship('Librarian', backref='loans') + + +__all__ = ["Loan", "LoanStatusEnum"] diff --git a/src/models/member.py b/src/models/member.py index 4054c1f..59616e0 100644 --- a/src/models/member.py +++ b/src/models/member.py @@ -22,4 +22,7 @@ class Member(Base): phone = Column(String(20), nullable=True, unique=True) register_date = Column(TIMESTAMP, nullable=True, server_default=func.now()) status = Column(Enum(MemberStatusEnum), nullable=True, default=MemberStatusEnum.active) - last_updated = Column(TIMESTAMP, nullable=True) \ No newline at end of file + last_updated = Column(TIMESTAMP, nullable=True) + + +__all__ = ["Member", "MemberStatusEnum"] diff --git a/src/ui/book_editor/book_editor.py b/src/ui/book_editor/book_editor.py deleted file mode 100644 index 180bad5..0000000 --- a/src/ui/book_editor/book_editor.py +++ /dev/null @@ -1,88 +0,0 @@ -from PySide6.QtWidgets import ( - QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QTextEdit, QPushButton, QComboBox, QFormLayout -) -from PySide6.QtGui import QRegularExpressionValidator -from PySide6.QtCore import QRegularExpression -from models.book import Book, BookStatusEnum - -from utils.database import DatabaseManager - -from sqlalchemy import update - -class BookEditor(QDialog): - def __init__(self, book: Book, parent=None): - super().__init__(parent) - - self.book = book - self.setWindowTitle("Edit Book") - self.setMinimumSize(400, 300) - - # Create main layout - layout = QVBoxLayout(self) - - # Form layout for book fields - form_layout = QFormLayout() - - # Title field - self.title_input = QLineEdit(self.book.title) - form_layout.addRow("Title:", self.title_input) - - full_author_name = f"{self.book.author.first_name} {self.book.author.last_name}" - self.author_input = QLineEdit(full_author_name) - form_layout.addRow("Author: ", self.author_input) - - # Description field - self.description_input = QTextEdit(self.book.description) - form_layout.addRow("Description:", self.description_input) - - # Year published field - self.year_input = QLineEdit(self.book.year_published) - # self.year_input.setValidator - form_layout.addRow("Year Published:", self.year_input) - - # ISBN field - self.isbn_input = QLineEdit(self.book.isbn) - self.isbn_expression = QRegularExpression("\d{10}|\d{13}") - self.isbn_validator = QRegularExpressionValidator(self.isbn_expression) - self.isbn_input.setValidator(self.isbn_validator) - form_layout.addRow("ISBN:", self.isbn_input) - - all_categories = ", ".join(category.name for category in self.book.categories) - self.categories_input = QLineEdit(all_categories) - form_layout.addRow("Categories: ", self.categories_input) - - layout.addLayout(form_layout) - - # Buttons - button_layout = QHBoxLayout() - - self.save_button = QPushButton("Save") - self.save_button.clicked.connect(self.save_book) - button_layout.addWidget(self.save_button) - - self.cancel_button = QPushButton("Discard") - self.cancel_button.clicked.connect(self.reject) - button_layout.addWidget(self.cancel_button) - - layout.addLayout(button_layout) - - def save_book(self): - # Update book object with input values - self.book.title = self.title_input.text() - self.book.description = self.description_input.toPlainText() - self.book.year_published = self.year_input.text() - self.book.isbn = self.isbn_input.text() - self.book.status = BookStatusEnum(self.status_input.currentText()) - - - with DatabaseManager.get_session() as session: - existing_book = session.query(Book).get(self.book.id) - - existing_book.title = self.book.title - existing_book.description = self.book.description - existing_book.year_published = self.book.year_published - existing_book.isbn = self.book.isbn - - session.commit() - # Accept the dialog and close - self.accept() diff --git a/src/ui/dashboard/book_card.py b/src/ui/dashboard/book_card.py index 579b110..b93e97b 100644 --- a/src/ui/dashboard/book_card.py +++ b/src/ui/dashboard/book_card.py @@ -3,12 +3,11 @@ from PySide6.QtQml import QQmlApplicationEngine from PySide6.QtWidgets import QHBoxLayout, QVBoxLayout, QLabel, QWidget, QMenu, QSizePolicy, QLayout, QMessageBox from PySide6.QtCore import qDebug -from ui.book_editor.book_editor import BookEditor +from ui.editor import BookEditor -from models.book import BookStatusEnum, Book -from models.book_overview import BooksOverview +from models import BooksOverview, Book, BookStatusEnum -from utils.database import DatabaseManager +from database.manager import DatabaseManager from sqlalchemy import delete diff --git a/src/ui/dashboard/dashboard.py b/src/ui/dashboard/dashboard.py index 1631b0e..364187a 100644 --- a/src/ui/dashboard/dashboard.py +++ b/src/ui/dashboard/dashboard.py @@ -6,11 +6,11 @@ from PySide6.QtWidgets import ( from PySide6.QtCore import Qt from .book_card import BookCard -from models.book_overview import BooksOverview +from models import BooksOverview -from utils.database import DatabaseManager +from database.manager import DatabaseManager -from ui.member_editor.member_editor import MemberEditor +from ui.editor import MemberEditor class LibraryDashboard(QWidget): @@ -29,7 +29,7 @@ class LibraryDashboard(QWidget): # Search bar self.search_input = QLineEdit() - self.search_input.setPlaceholderText("Type to search...") + self.search_input.setPlaceholderText("Search in books...") self.search_input.textChanged.connect(self.filter_books) main_layout.addWidget(self.search_input) diff --git a/src/ui/editor/__init__.py b/src/ui/editor/__init__.py new file mode 100644 index 0000000..c02a3b5 --- /dev/null +++ b/src/ui/editor/__init__.py @@ -0,0 +1,7 @@ +from .book_editor import * +from .member_editor import * + +__all__ = [ + *book_editor.__all__, + *member_editor.__all__ +] \ No newline at end of file diff --git a/src/ui/editor/book_editor.py b/src/ui/editor/book_editor.py new file mode 100644 index 0000000..e59ec45 --- /dev/null +++ b/src/ui/editor/book_editor.py @@ -0,0 +1,130 @@ +import logging + +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QTextEdit, QPushButton, QComboBox, QFormLayout, QMessageBox +) +from PySide6.QtGui import QRegularExpressionValidator +from PySide6.QtCore import QRegularExpression +from models import Book, BookStatusEnum + +from database import update_book + +from utils.errors.database import DatabaseError, DatabaseConnectionError, DuplicateEntryError + + +class BookEditor(QDialog): + def __init__(self, book: Book = None, parent=None): + super().__init__(parent) + + self.logger = logging.getLogger(__name__) + self.create_layout() + + if book: + self.logger.debug(f"Editing existing book {book.title}") + self.book = book + self.create_new = False + self.fill_with_existing_data() + else: + self.logger.debug("Editing a new book") + self.create_new = True + + def create_layout(self): + self.setWindowTitle("Books") + self.setMinimumWidth(400) + + # Create main layout + layout = QVBoxLayout(self) + + # Form layout for book fields + form_layout = QFormLayout() + + # Title field + self.title_input = QLineEdit() + form_layout.addRow("Title:", self.title_input) + + # Author field + self.author_label = QLabel() + form_layout.addRow("Author: ", self.author_label) + + # Description field + self.description_input = QTextEdit() + form_layout.addRow("Description:", self.description_input) + + # Year published field + self.year_input = QLineEdit() + # self.year_input.setValidator + form_layout.addRow("Year Published:", self.year_input) + + # ISBN field + self.isbn_input = QLineEdit() + self.isbn_expression = QRegularExpression("\d{10}|\d{13}") + self.isbn_validator = QRegularExpressionValidator(self.isbn_expression) + self.isbn_input.setValidator(self.isbn_validator) + form_layout.addRow("ISBN:", self.isbn_input) + + self.categories_input = QLineEdit() + self.categories_input.setEnabled(False) + form_layout.addRow("Categories: ", self.categories_input) + + layout.addLayout(form_layout) + + # Buttons + button_layout = QHBoxLayout() + + self.save_button = QPushButton("Save") + self.save_button.clicked.connect(self.save_book) + button_layout.addWidget(self.save_button) + + self.cancel_button = QPushButton("Discard") + self.cancel_button.clicked.connect(self.reject) + button_layout.addWidget(self.cancel_button) + + layout.addLayout(button_layout) + + def fill_with_existing_data(self): + self.title_input.setText(self.book.title) + self.description_input.setText(self.book.description) + self.year_input.setText(self.book.year_published) + self.isbn_input.setText(self.book.isbn) + + def save_book(self): + # Update book object with input values + self.book.title = self.title_input.text() + full_author_name = f"{self.book.author.first_name} { + self.book.author.last_name}" + self.author_label.setText(full_author_name) + self.book.description = self.description_input.toPlainText() + self.book.year_published = self.year_input.text() + self.book.isbn = self.isbn_input.text() + + all_categories = ", ".join( + category.name for category in self.book.categories) + self.categories_input.setText(all_categories) + + try: + if self.create_new: + pass + else: + update_book(self.book) + self.accept() + except DuplicateEntryError as e: + QMessageBox.critical(None, + "ISBN is already in use", + "The ISBN provided is already in use", + QMessageBox.StandardButton.Ok, + QMessageBox.StandardButton.NoButton) + except DatabaseConnectionError as e: + QMessageBox.critical(None, + "Failed to save", + "Could not connect to the database", + QMessageBox.StandardButton.Ok, + QMessageBox.StandardButton.NoButton) + except DatabaseError as e: + QMessageBox.critical(self.parent, + "An error occured", + f"Could not save the book because of the following error: {e}", + QMessageBox.StandardButton.Ok, + QMessageBox.StandardButton.NoButton) + + +__all__ = ["BookEditor"] diff --git a/src/ui/member_editor/member_editor.py b/src/ui/editor/member_editor.py similarity index 87% rename from src/ui/member_editor/member_editor.py rename to src/ui/editor/member_editor.py index 11d3bba..c31e268 100644 --- a/src/ui/member_editor/member_editor.py +++ b/src/ui/editor/member_editor.py @@ -5,19 +5,18 @@ from PySide6.QtQml import QQmlApplicationEngine from PySide6 import QtWidgets, QtCore from PySide6.QtWidgets import QVBoxLayout, QFormLayout, QLineEdit, QHBoxLayout, QPushButton, QDialog, QMessageBox -from models.member import Member +from models import Member from database.member import create_new_member -from utils.errors.database.database_error import DatabaseError -from utils.errors.database.duplicate_entry_error import DuplicateEntryError +from utils.errors.database import DatabaseError, DatabaseConnectionError, DuplicateEntryError class MemberEditor(QDialog): def __init__(self, member: Member = None): super().__init__() - self.logger = logging.getLogger(__name__) + self.logger = logging.getLogger(__name__) self.create_layout() if member: @@ -30,7 +29,7 @@ class MemberEditor(QDialog): def create_layout(self): self.setWindowTitle("Members") - self.setMinimumSize(400, 300) + self.setMinimumWidth(400) # Create main layout self.layout = QVBoxLayout(self) @@ -86,6 +85,10 @@ class MemberEditor(QDialog): self.logger.debug("Creating new member") create_new_member(self.member) except DuplicateEntryError: - QMessageBox.critical(None, "Details already in use", "Cannot create a new user", QMessageBox.StandardButton.Ok, QMessageBox.StandardButtons.NoButton) + QMessageBox.critical(None, "Details already in use", "Cannot create a new user", + QMessageBox.StandardButton.Ok, QMessageBox.StandardButtons.NoButton) - self.accept() \ No newline at end of file + self.accept() + + +__all__ = ["MemberEditor"] diff --git a/src/ui/main_window_tabs/member_list/member_card.py b/src/ui/main_window_tabs/member_list/member_card.py index 9c32fbc..d13cff5 100644 --- a/src/ui/main_window_tabs/member_list/member_card.py +++ b/src/ui/main_window_tabs/member_list/member_card.py @@ -4,8 +4,8 @@ from PySide6.QtWidgets import ( from PySide6.QtGui import Qt from PySide6.QtCore import qDebug -from models.member import Member, MemberStatusEnum -from utils.database import DatabaseManager +from models import Member, MemberStatusEnum +from database.manager import DatabaseManager from sqlalchemy import delete STATUS_TO_COLOR_MAP = { diff --git a/src/ui/main_window_tabs/member_list/member_list.py b/src/ui/main_window_tabs/member_list/member_list.py index 739b163..e8c38a4 100644 --- a/src/ui/main_window_tabs/member_list/member_list.py +++ b/src/ui/main_window_tabs/member_list/member_list.py @@ -5,10 +5,10 @@ from PySide6.QtWidgets import ( from PySide6.QtCore import Qt from .member_card import MemberCard -from models.member import Member -from utils.database import DatabaseManager +from models import Member +from database.manager import DatabaseManager -from ui.member_editor.member_editor import MemberEditor +from ui.editor import MemberEditor class MemberList(QWidget): diff --git a/src/ui/settings.py b/src/ui/settings.py index 1396aa7..8fae42e 100644 --- a/src/ui/settings.py +++ b/src/ui/settings.py @@ -1,12 +1,16 @@ +import logging import sys from PySide6 import QtWidgets +from PySide6.QtWidgets import QDialog, QMessageBox from utils.config import UserConfig, TransactionLevel -class SettingsDialog(QtWidgets.QDialog): +class SettingsDialog(QDialog): def __init__(self, parent=None): super().__init__(parent) + self.logger = logging.getLogger(__name__) + self.user_config = UserConfig() self.setWindowTitle("Settings") self.setMinimumSize(400, 100) @@ -16,23 +20,43 @@ class SettingsDialog(QtWidgets.QDialog): data_mode_layout = QtWidgets.QHBoxLayout() - self.data_mode_label = QtWidgets.QLabel("Data mode:") + # Transaction isolation level + self.data_mode_label = QtWidgets.QLabel(UserConfig.get_friendly_name("transaction_level") + ":") data_mode_layout.addWidget(self.data_mode_label) self.data_mode_dropdown = QtWidgets.QComboBox() for tl in TransactionLevel: - self.data_mode_dropdown.addItem(tl.name.capitalize(), tl.value) + self.data_mode_dropdown.addItem(tl.name.capitalize(), tl) + self.data_mode_dropdown.setCurrentIndex( + list(TransactionLevel).index(self.user_config.transaction_level) + ) data_mode_layout.addWidget(self.data_mode_dropdown) + + # Slowdown simulation + self.slowdown_label = QtWidgets.QLabel(UserConfig.get_friendly_name("simulate_slowdown") + ":") + data_mode_layout.addWidget(self.slowdown_label) + + self.slowdown_checkbox = QtWidgets.QCheckBox() + self.slowdown_checkbox.setChecked(self.user_config.simulate_slowdown) + + data_mode_layout.addWidget(self.slowdown_checkbox) + layout.addLayout(data_mode_layout) # Set the currently selected mode to the mode in UserConfig config = UserConfig() + + # Transaction level current_level = config.transaction_level index = self.data_mode_dropdown.findData(current_level) if index != -1: self.data_mode_dropdown.setCurrentIndex(index) + # Slowdown simulation + simulate_slowdown = config.simulate_slowdown + self.slowdown_checkbox.setChecked(simulate_slowdown) + # Buttons button_layout = QtWidgets.QHBoxLayout() @@ -48,12 +72,14 @@ class SettingsDialog(QtWidgets.QDialog): def save_settings(self): data_mode = self.data_mode_dropdown.currentData() + simulate_slowdown = self.slowdown_checkbox.isChecked() + try: + self.logger.debug("Saving user configuration") + config = UserConfig() + config.transaction_level = data_mode + config.simulate_slowdown = simulate_slowdown + self.accept() + except TypeError as e: + self.logger.error("Invalid user configuration found") + QMessageBox.critical(None, "Invalid config detected", "Double check your configuration", QMessageBox.StandardButton.Ok, QMessageBox.StandardBUttons.NoButton) - config = UserConfig() - config.transaction_level = data_mode - - print("Settings Saved:") - print(f"Data Mode: {config.transaction_level}") - - - self.accept() \ No newline at end of file diff --git a/src/ui/window.py b/src/ui/window.py index 1ce6715..8fbb3a2 100644 --- a/src/ui/window.py +++ b/src/ui/window.py @@ -6,8 +6,7 @@ from PySide6.QtCore import QStandardPaths from ui.dashboard.dashboard import LibraryDashboard from ui.main_window_tabs.member_list.member_list import MemberList -from ui.book_editor.book_editor import BookEditor -from ui.member_editor.member_editor import MemberEditor +from ui.editor import BookEditor, MemberEditor from ui.settings import SettingsDialog @@ -144,9 +143,7 @@ class LibraryWindow(QtWidgets.QMainWindow): # Menu action slots def edit_preferences(self): - dialog = SettingsDialog(parent=self) - if dialog.exec() == QtWidgets.QDialog.Accepted: - print("Settings were saved.") + SettingsDialog(parent=self).exec() # region Menu Actions @@ -210,6 +207,7 @@ class LibraryWindow(QtWidgets.QMainWindow): e}", QMessageBox.StandardButton.Ok, QMessageBox.StandardButton.NoButton) def new_book(self): + # BookEditor() pass def new_member(self): diff --git a/src/utils/config.py b/src/utils/config.py index 6e6324b..2bcbdce 100644 --- a/src/utils/config.py +++ b/src/utils/config.py @@ -1,9 +1,10 @@ import enum import os -from utils.errors.database.database_config_error import DatabaseConfigError +from utils.errors.database import DatabaseConfigError from dotenv import load_dotenv import logging +from typing import Any class DatabaseConfig(): @@ -59,18 +60,57 @@ class DatabaseConfig(): class TransactionLevel(enum.Enum): - insecure = 'READ UNCOMMITED' - secure = 'SERIALIZABLE' + insecure = "READ UNCOMMITTED" + secure = "SERIALIZABLE" -class UserConfig(): +class UserConfig: _instance = None + _metadata = { + "transaction_level": {"friendly_name": "Transaction Level"}, + "simulate_slowdown": {"friendly_name": "Simulate Slowdown"}, + } + 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 + self._transaction_level = TransactionLevel.insecure + self._simulate_slowdown = False + + @property + def transaction_level(self) -> TransactionLevel: + return self._transaction_level + + @transaction_level.setter + def transaction_level(self, value: Any): + if not isinstance(value, TransactionLevel): + raise TypeError( + f"Invalid value for 'transaction_level'. Must be a TransactionLevel enum, got {type(value).__name__}." + ) + self._transaction_level = value + + @property + def simulate_slowdown(self) -> bool: + return self._simulate_slowdown + + @simulate_slowdown.setter + def simulate_slowdown(self, value: Any): + if not isinstance(value, bool): + raise TypeError( + f"Invalid value for 'simulate_slowdown'. Must be a boolean, got {type(value).__name__}." + ) + self._simulate_slowdown = value + + @classmethod + def get_friendly_name(cls, option: str) -> str: + return cls._metadata.get(option, {}).get("friendly_name", option) + + def __dict__(self) -> dict: + return { + "transaction_level": self.transaction_level, + "simulate_slowdown": self.simulate_slowdown, + } diff --git a/src/utils/database.py b/src/utils/database.py index 18d98d9..e69de29 100644 --- a/src/utils/database.py +++ b/src/utils/database.py @@ -1,52 +0,0 @@ -import logging - -from sqlalchemy.orm import sessionmaker, Session -from sqlalchemy import create_engine, text - -from sqlalchemy.exc import DatabaseError - -from utils.config import DatabaseConfig -from utils.errors.database.database_connection_error import DatabaseConnectionError - - -class DatabaseManager(): - - _instance: 'DatabaseManager' = None - - def __new__(cls, *args, **kwargs): - if cls._instance is None: - cls._instance = super().__new__(cls) - return cls._instance - - def __init__(self) -> None: - self.logger = logging.getLogger(__name__) - self.logger.info("Reading database config") - database_config = DatabaseConfig() - self.engine = create_engine('mysql+mysqlconnector://%s:%s@%s:%s/%s' % ( - database_config.user, - database_config.password, - database_config.host, - database_config.port, - database_config.name), - pool_pre_ping=True) - if self.test_connection(): - self.Session = sessionmaker(bind=self.engine) - - def cleanup(self) -> None: - self.logger.debug("Closing connection") - self.engine.dispose() - - def test_connection(self) -> bool: - self.logger.debug("Testing database connection") - try: - with self.engine.connect() as connection: - connection.execute(text("select 1")) - self.logger.debug("Database connection successful") - return True - except DatabaseError as e: - self.logger.error(f"Database connection failed: {e}") - raise DatabaseConnectionError("Database connection failed") from e - - @classmethod - def get_session(cls) -> Session: - return DatabaseManager._instance.Session() diff --git a/src/utils/errors/database.py b/src/utils/errors/database.py new file mode 100644 index 0000000..fd689c6 --- /dev/null +++ b/src/utils/errors/database.py @@ -0,0 +1,25 @@ +class DatabaseError(Exception): + def __init__(self, message: str): + super().__init__(message) + self.message = message + + +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 + + +class DatabaseConnectionError(DatabaseError): + def __init__(self, message: str): + super().__init__(message) + self.message = message + + +class DuplicateEntryError(DatabaseError): + def __init__(self, message: str): + super().__init__(message) + self.message = message diff --git a/src/utils/errors/database/__init__.py b/src/utils/errors/database/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/utils/errors/database/database_config_error.py b/src/utils/errors/database/database_config_error.py deleted file mode 100644 index 6a9ea99..0000000 --- a/src/utils/errors/database/database_config_error.py +++ /dev/null @@ -1,7 +0,0 @@ -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 diff --git a/src/utils/errors/database/database_connection_error.py b/src/utils/errors/database/database_connection_error.py deleted file mode 100644 index 22f81f0..0000000 --- a/src/utils/errors/database/database_connection_error.py +++ /dev/null @@ -1,5 +0,0 @@ -from .database_error import DatabaseError -class DatabaseConnectionError(DatabaseError): - def __init__(self, message: str): - super().__init__(message) - self.message = message \ No newline at end of file diff --git a/src/utils/errors/database/database_error.py b/src/utils/errors/database/database_error.py deleted file mode 100644 index a78dbdd..0000000 --- a/src/utils/errors/database/database_error.py +++ /dev/null @@ -1,4 +0,0 @@ -class DatabaseError(Exception): - def __init__(self, message: str): - super().__init__(message) - self.message = message \ No newline at end of file diff --git a/src/utils/errors/database/duplicate_entry_error.py b/src/utils/errors/database/duplicate_entry_error.py deleted file mode 100644 index 1264108..0000000 --- a/src/utils/errors/database/duplicate_entry_error.py +++ /dev/null @@ -1,6 +0,0 @@ -from .database_error import DatabaseError - -class DuplicateEntryError(DatabaseError): - def __init__(self, message: str): - super().__init__(message) - self.message = message \ No newline at end of file diff --git a/src/utils/setup_logger.py b/src/utils/setup_logger.py index 827400a..078824f 100644 --- a/src/utils/setup_logger.py +++ b/src/utils/setup_logger.py @@ -8,7 +8,7 @@ def setup_logger(): handler = logging.StreamHandler(sys.stdout) handler.setLevel(logging.DEBUG) - formatter = logging.Formatter("[%(levelname)s] - %(name)s - %(message)s") + formatter = logging.Formatter("[%(levelname)s] - %(name)s:%(lineno)d - %(message)s") handler.setFormatter(formatter) logger.addHandler(handler)