From 153da38f894a415e8be93fb9b85f2a02e4b02df7 Mon Sep 17 00:00:00 2001 From: Thastertyn Date: Thu, 16 Jan 2025 15:11:50 +0100 Subject: [PATCH] [main] WIP rework structure --- src/database/author.py | 32 +++ src/database/book.py | 135 +++++++++--- src/database/book_category.py | 39 ++++ src/database/book_overview.py | 24 +++ src/{export/__init__.py => database/utils.py} | 0 src/export/book_exporter.py | 76 ------- src/importer/book/book_importer.py | 77 +------ src/models/author.py | 3 + src/models/book.py | 12 +- src/models/book_category.py | 6 +- src/models/book_category_statistics.py | 7 +- src/models/member.py | 3 + .../main_window_tabs => services}/__init__.py | 0 src/services/book_service.py | 77 +++++++ src/ui/editor/book_editor.py | 20 +- src/ui/main_tabs/__init__.py | 2 + .../main_tabs/book_overview_list/__init__.py | 7 + .../book_overview_list}/book_card.py | 5 +- .../book_overview_list/overview_list.py} | 17 +- src/ui/main_tabs/member_list/__init__.py | 7 + .../member_list/member_card.py | 3 + .../member_list/member_list.py | 3 + .../main_window_tabs/member_list/__init__.py | 0 src/ui/menu_bar.py | 172 +++++++++++++++ src/ui/settings.py | 8 +- src/ui/window.py | 202 +----------------- src/utils/database.py | 0 src/utils/errors/__init__.py | 9 + src/utils/errors/database.py | 3 + src/utils/errors/export_error.py | 16 ++ src/utils/errors/import_error.py | 22 ++ src/utils/errors/import_error/__init__.py | 0 src/utils/errors/import_error/import_error.py | 5 - .../import_error/invalid_contents_error.py | 8 - .../import_error/xsd_scheme_not_found.py | 8 - src/utils/errors/no_export_entity_error.py | 6 - 36 files changed, 591 insertions(+), 423 deletions(-) create mode 100644 src/database/author.py create mode 100644 src/database/book_category.py create mode 100644 src/database/book_overview.py rename src/{export/__init__.py => database/utils.py} (100%) delete mode 100644 src/export/book_exporter.py rename src/{ui/main_window_tabs => services}/__init__.py (100%) create mode 100644 src/services/book_service.py create mode 100644 src/ui/main_tabs/__init__.py create mode 100644 src/ui/main_tabs/book_overview_list/__init__.py rename src/ui/{dashboard => main_tabs/book_overview_list}/book_card.py (98%) rename src/ui/{dashboard/dashboard.py => main_tabs/book_overview_list/overview_list.py} (87%) create mode 100644 src/ui/main_tabs/member_list/__init__.py rename src/ui/{main_window_tabs => main_tabs}/member_list/member_card.py (99%) rename src/ui/{main_window_tabs => main_tabs}/member_list/member_list.py (99%) delete mode 100644 src/ui/main_window_tabs/member_list/__init__.py create mode 100644 src/ui/menu_bar.py delete mode 100644 src/utils/database.py create mode 100644 src/utils/errors/import_error.py delete mode 100644 src/utils/errors/import_error/__init__.py delete mode 100644 src/utils/errors/import_error/import_error.py delete mode 100644 src/utils/errors/import_error/invalid_contents_error.py delete mode 100644 src/utils/errors/import_error/xsd_scheme_not_found.py delete mode 100644 src/utils/errors/no_export_entity_error.py diff --git a/src/database/author.py b/src/database/author.py new file mode 100644 index 0000000..689494e --- /dev/null +++ b/src/database/author.py @@ -0,0 +1,32 @@ +import logging +from typing import Dict + +from sqlalchemy.orm import Session + +from models import Author + +logger = logging.getLogger(__name__) + +def get_or_create_author(session: Session, author_data: Dict[str, str]) -> Author: + """ + Checks if an author exists in the database, creates one if not. + + :param session: SQLAlchemy session object. + :param author_data: Dictionary containing author's first and last name. + :return: An Author instance (either existing or newly created). + """ + existing_author = session.query(Author).filter_by( + first_name=author_data["first_name"], + last_name=author_data["last_name"] + ).one_or_none() + + if existing_author is not None: + logger.debug(f"Author {author_data['first_name']} {author_data['last_name']} already exists. Reusing.") + return existing_author + + logger.debug(f"Creating new author: {author_data['first_name']} {author_data['last_name']}") + author = Author(first_name=author_data["first_name"], last_name=author_data["last_name"]) + session.add(author) + return author + +__all__ = ["get_or_create_author"] diff --git a/src/database/book.py b/src/database/book.py index 19cc76b..e715ab8 100644 --- a/src/database/book.py +++ b/src/database/book.py @@ -1,16 +1,25 @@ -from typing import Dict, List +from typing import Dict, List, Optional 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 +from .author import get_or_create_author +from .book_category import get_or_create_categories + logger = logging.getLogger(__name__) -def fetch_all(): +def fetch_all_books() -> List[Book]: + """ + Fetches all books from the database. + + :return: A list of all books in the database. + :raises DatabaseConnectionError: If the connection to the database is interrupted. + :raises DatabaseError: If any other error occurs while fetching books. + """ with DatabaseManager.get_session() as session: try: return session.query(Book).all() @@ -19,47 +28,117 @@ def fetch_all(): raise DatabaseConnectionError( "Connection with database interrupted") from e except SQLAlchemyError as e: - logger.error(f"An error occured when fetching all books: {e}") + logger.error(f"An error occurred when fetching all books: {e}") raise DatabaseError( - "An error occured when fetching all books") from e + "An error occurred when fetching all books") from e -def create_new_book(book: Book): - pass +def create_book(book: Dict[str, object]) -> None: + """ + Creates a new book in the database. + + :param book: A dictionary containing the book details (title, description, year_published, ISBN, author, and categories). + :raises DuplicateEntryError: If a book with the same ISBN already exists in the database. + :raises DatabaseConnectionError: If the connection to the database is interrupted. + :raises DatabaseError: If any other error occurs while creating the book. + """ + create_books([book]) -def update_book(book: Book): +def create_books(books: List[Dict[str, object]]) -> None: + """ + Creates multiple books in the database. + + :param books: A list of dictionaries, each containing the details of a book. + :raises DuplicateEntryError: If a book with the same ISBN already exists in the database. + :raises DatabaseConnectionError: If the connection to the database is interrupted. + :raises DatabaseError: If any other error occurs while creating the books. + """ try: with DatabaseManager.get_session() as session: - logger.debug(f"Updating book {book.title}") - existing_book = session.query(Book).get(book.id) + for book in books: + logger.debug(f"Attempting to create a new book: {book['title']}") - if not existing_book: - logger.warning(f"Book with id {book.id} not found") - raise DatabaseError("Book not found in the database") + # Check if the book already exists + existing_book = session.query(Book).filter_by(isbn=book["isbn"]).first() - existing_book.title = book.title - existing_book.description = book.description - existing_book.year_published = book.year_published - existing_book.isbn = book.isbn + if existing_book: + logger.warning(f"Book with ISBN {book['isbn']} already exists. Skipping.") + raise DuplicateEntryError(f"Book with ISBN {book['isbn']} already exists.") - session.commit() - logger.info(f"{book.title} successfully updated") + author = get_or_create_author(session, book["author"]) + categories = get_or_create_categories(session, book["categories"]) + + # Create the new book + new_book = Book( + title=book["title"], + description=book["description"], + year_published=book["year_published"], + isbn=book["isbn"], + author=author, + categories=categories + ) + session.add(new_book) + + session.commit() + logger.info(f"Book {book['title']} successfully created.") except IntegrityError as e: logger.warning("Data already exists") - session.rollback() - raise DuplicateEntryError( - "Data already exists in the database") from e + 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 occurred when saving book: {e}") - session.rollback() - raise DatabaseError( - "An error occurred when updating the book") from e + logger.error(f"An error occurred when creating the book: {e}") + raise DatabaseError("An error occurred when creating the book") from e -__all__ = ["create_new_book", "update_book"] +def update_book(book: Dict[str, object]) -> None: + """ + Updates an existing book in the database. Reuses existing authors and categories if they exist. + + :param book: A dictionary containing the updated book details, including the book ID. + :raises DatabaseError: If the book is not found in the database. + :raises DuplicateEntryError: If an attempt is made to update the book with duplicate data. + :raises DatabaseConnectionError: If the connection to the database is interrupted. + """ + try: + with DatabaseManager.get_session() as session: + logger.debug(f"Updating book {book['title']}") + + # Find the existing book + 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") + + # Get or create the author + author = get_or_create_author(session, book["author"]) + + # Get or create the categories + categories = get_or_create_categories(session, book["categories"]) + + # Update the book details + existing_book.title = book["title"] + existing_book.description = book["description"] + existing_book.year_published = book["year_published"] + existing_book.isbn = book["isbn"] + existing_book.author = author + existing_book.categories = categories + + session.commit() + logger.info(f"{book['title']} successfully updated.") + except IntegrityError as e: + logger.warning("Data already exists") + raise DuplicateEntryError("Data already exists in the database") from e + 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 occurred when updating the book: {e}") + raise DatabaseError("An error occurred when updating the book") from e + + +__all__ = ["create_book", "create_books", "update_book", "fetch_all"] diff --git a/src/database/book_category.py b/src/database/book_category.py new file mode 100644 index 0000000..bc5d436 --- /dev/null +++ b/src/database/book_category.py @@ -0,0 +1,39 @@ +from typing import Dict, List, Optional +import logging + +from models import BookCategory +from database.manager import DatabaseManager + +logger = logging.getLogger(__name__) + + +def get_or_create_categories(session, category_names: List[str]) -> List[BookCategory]: + """ + Checks if categories exist in the database, creates ones that don't. + + :param session: SQLAlchemy session object. + :param category_names: List of category names. + :return: List of BookCategory instances (existing or newly created). + """ + processed_categories = {} # Cache for already processed categories + filtered_categories = [] + + for category_name in category_names: + if category_name in processed_categories: + filtered_categories.append(processed_categories[category_name]) + continue + + existing_category = session.query(BookCategory).filter_by(name=category_name).one_or_none() + + if existing_category is not None: + logger.debug(f"Category {category_name} already exists. Reusing.") + processed_categories[category_name] = existing_category + filtered_categories.append(existing_category) + else: + logger.debug(f"Adding new category: {category_name}") + new_category = BookCategory(name=category_name) + session.add(new_category) + processed_categories[category_name] = new_category + filtered_categories.append(new_category) + + return filtered_categories \ No newline at end of file diff --git a/src/database/book_overview.py b/src/database/book_overview.py new file mode 100644 index 0000000..dde5bd5 --- /dev/null +++ b/src/database/book_overview.py @@ -0,0 +1,24 @@ +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 BooksOverview +from database.manager import DatabaseManager + +logger = logging.getLogger(__name__) + + +def fetch_all() -> List[BooksOverview]: + with DatabaseManager.get_session() as session: + try: + return session.query(BooksOverview).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 diff --git a/src/export/__init__.py b/src/database/utils.py similarity index 100% rename from src/export/__init__.py rename to src/database/utils.py diff --git a/src/export/book_exporter.py b/src/export/book_exporter.py deleted file mode 100644 index 9177a13..0000000 --- a/src/export/book_exporter.py +++ /dev/null @@ -1,76 +0,0 @@ -from typing import Optional -import xml.etree.ElementTree as ET -from xml.dom import minidom - -from database.manager import DatabaseManager -from utils.errors.export_error import ExportError -from utils.errors.no_export_entity_error import NoExportEntityError - -from models import Book - - -class BookExporter(): - def save_xml(self, file_path: str): - - xml = self._get_full_xml() - - with open(file_path, "w", encoding="utf-8") as file: - file.write(xml) - - def _get_full_xml(self) -> str: - root = ET.Element("books") - - with DatabaseManager.get_session() as session: - self.books = session.query(Book).all() - - if not self.books: - raise NoExportEntityError("No books found to export") - - for book in self.books: - # Create a element - book_element = ET.SubElement(root, "book") - - # Add - title_element = ET.SubElement(book_element, "title") - title_element.text = book.title - - # Add <author> - author_element = ET.SubElement(book_element, "author") - - # Add <first_name> - author_first_name_element = ET.SubElement( - author_element, "first_name") - author_first_name_element.text = book.author.first_name - - author_last_name_element = ET.SubElement( - author_element, "last_name") - author_last_name_element.text = book.author.last_name - - # Add <description> - description_element = ET.SubElement( - book_element, "description") - description_element.text = book.description - - # Add <year_published> - year_published_element = ET.SubElement( - book_element, "year_published") - year_published_element.text = book.year_published - - # Add <isbn> - isbn_element = ET.SubElement(book_element, "isbn") - isbn_element.text = book.isbn - - # Add <categories> - categories_element = ET.SubElement(book_element, "categories") - for category in book.categories: - category_element = ET.SubElement( - categories_element, "category") - category_element.text = category.name - - # Convert the tree to a string - tree_str = ET.tostring(root, encoding="unicode") - - # Pretty print the XML - pretty_xml = minidom.parseString( - tree_str).toprettyxml(indent=(" " * 4)) - return pretty_xml diff --git a/src/importer/book/book_importer.py b/src/importer/book/book_importer.py index 1e8ff15..49a0f18 100644 --- a/src/importer/book/book_importer.py +++ b/src/importer/book/book_importer.py @@ -9,6 +9,7 @@ from xmlschema import XMLSchema from sqlalchemy.exc import IntegrityError from database.manager import DatabaseManager +from database.utils import get_or_create_categories, get_or_create_author 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 @@ -67,78 +68,4 @@ class BookImporter: return books except ET.ParseError as e: - raise ImportError(f"Failed to parse XML file: {e}") - - def save_books(self, books: List[Dict[str, object]]): - """Saves a list of books to the database.""" - try: - with DatabaseManager.get_session() as session: - processed_categories = {} # Cache for processed categories by name - - for book_dict in books: - self.logger.debug(f"Attempting to save {book_dict['title']}") - - # Check if the book already exists - existing_book = session.query(Book).filter_by(isbn=book_dict["isbn"]).first() - if existing_book: - self.logger.warning(f"ISBN {book_dict['isbn']} already exists. Skipping.") - continue - - # Check or add the author - existing_author = session.query(Author).filter_by( - first_name=book_dict["author"]["first_name"], - last_name=book_dict["author"]["last_name"] - ).one_or_none() - - if existing_author is not None: - self.logger.debug(f"Author {existing_author.first_name} {existing_author.last_name} already exists. Reusing.") - author = existing_author - else: - self.logger.debug(f"Creating new author: {book_dict['author']['first_name']} {book_dict['author']['last_name']}") - author = Author( - first_name=book_dict["author"]["first_name"], - last_name=book_dict["author"]["last_name"] - ) - session.add(author) - - # Handle categories - filtered_categories = [] - for category_name in book_dict["categories"]: - if category_name in processed_categories: - filtered_categories.append(processed_categories[category_name]) - continue - - existing_category = session.query(BookCategory).filter_by(name=category_name).one_or_none() - if existing_category is not None: - self.logger.debug(f"Category {category_name} already exists. Reusing.") - processed_categories[category_name] = existing_category - filtered_categories.append(existing_category) - else: - self.logger.debug(f"Adding new category: {category_name}") - new_category = BookCategory(name=category_name) - session.add(new_category) - processed_categories[category_name] = new_category - filtered_categories.append(new_category) - - book = Book( - title=book_dict["title"], - description=book_dict["description"], - year_published=book_dict["year_published"], - isbn=book_dict["isbn"], - author=author, - categories=filtered_categories - ) - session.add(book) - user_config = UserConfig() - - if user_config.simulate_slowdown: - self.logger.info("Simulating slowdown for 10 seconds") - time.sleep(10) - - # Commit all changes - session.commit() - except e: - session.rollback() - raise ImportError(f"An error occurred when importing books: {e}") from e - finally: - session.close() + raise ImportError(f"Failed to parse XML file: {e}") \ No newline at end of file diff --git a/src/models/author.py b/src/models/author.py index e5a8b5a..a15ec32 100644 --- a/src/models/author.py +++ b/src/models/author.py @@ -16,5 +16,8 @@ class Author(Base): # Reference 'Book' as a string to avoid direct import books = relationship('Book', back_populates='author') + def to_dict(self): + return {col.name: getattr(self, col.name) for col in self.__table__.columns} + __all__ = ["Author"] diff --git a/src/models/book.py b/src/models/book.py index 2a885cd..acdbdbb 100644 --- a/src/models/book.py +++ b/src/models/book.py @@ -28,7 +28,17 @@ class Book(Base): author = relationship('Author', back_populates='books') categories = relationship('BookCategory',secondary='book_category_link',back_populates='books') - book_category_statistics = relationship('BookCategoryStatistics', back_populates='book_category_statistics') + def to_dict(self): + book_dict = {col.name: getattr(self, col.name) for col in self.__table__.columns} + + book_dict['author'] = { + 'first_name': self.author.first_name, + 'last_name': self.author.last_name + } + + book_dict['categories'] = [category.name for category in self.categories] + + return book_dict __all__ = ["Book", "BookStatusEnum"] diff --git a/src/models/book_category.py b/src/models/book_category.py index 619e68d..6f5f4b8 100644 --- a/src/models/book_category.py +++ b/src/models/book_category.py @@ -17,9 +17,11 @@ class BookCategory(Base): books = relationship( 'Book', - secondary='book_category_link', # Junction table - back_populates='categories' # For bidirectional relationship + secondary='book_category_link', + back_populates='categories', ) + book_category_statistics = relationship('BookCategoryStatistics', backref='book_category_statistics') + __all__ = ["BookCategory"] diff --git a/src/models/book_category_statistics.py b/src/models/book_category_statistics.py index 4a9888e..fc655be 100644 --- a/src/models/book_category_statistics.py +++ b/src/models/book_category_statistics.py @@ -1,4 +1,6 @@ from sqlalchemy import Column, Integer, ForeignKey + +from sqlalchemy.dialects.mysql import INTEGER from sqlalchemy.orm import relationship from .base import Base @@ -7,11 +9,12 @@ class BookCategoryStatistics(Base): __tablename__ = 'book_category_statistics' book_category_id = Column(Integer, ForeignKey('book_category.id', ondelete="cascade"), primary_key=True) - count = Column(Integer(unsigned=True), nullable=False, default=0) + count = Column(INTEGER(unsigned=True), nullable=False, default=0) category = relationship( 'BookCategory', - back_populates='book_category_statistics' + back_populates='book_category_statistics', + overlaps="book_category_statistics" ) diff --git a/src/models/member.py b/src/models/member.py index 59616e0..535a15f 100644 --- a/src/models/member.py +++ b/src/models/member.py @@ -24,5 +24,8 @@ class Member(Base): status = Column(Enum(MemberStatusEnum), nullable=True, default=MemberStatusEnum.active) last_updated = Column(TIMESTAMP, nullable=True) + def to_dict(self): + return {col.name: getattr(self, col.name) for col in self.__table__.columns} + __all__ = ["Member", "MemberStatusEnum"] diff --git a/src/ui/main_window_tabs/__init__.py b/src/services/__init__.py similarity index 100% rename from src/ui/main_window_tabs/__init__.py rename to src/services/__init__.py diff --git a/src/services/book_service.py b/src/services/book_service.py new file mode 100644 index 0000000..358168e --- /dev/null +++ b/src/services/book_service.py @@ -0,0 +1,77 @@ +from typing import Optional, List +import xml.etree.ElementTree as ET +from xml.dom import minidom + +from utils.errors import NoExportEntityError, ExportError, ExportFileError + +from database import fetch_all_books + +def export_to_xml(file_path: str): + all_books = fetch_all_books() + + if not self.books: + raise NoExportEntityError("No books found to export") + + xml = books_to_xml(all_books) + + try: + with open(file_path, "w", encoding="utf-8") as file: + file.write(xml) + except OSError as e: + raise ExportFileError("Failed to save to a file") from e + + +def books_to_xml(books: List[Book]) -> str: + root = ET.Element("books") + + for book in self.books: + # Create a <book> element + book_element = ET.SubElement(root, "book") + + # Add <title> + title_element = ET.SubElement(book_element, "title") + title_element.text = book.title + + # Add <author> + author_element = ET.SubElement(book_element, "author") + + # Add <first_name> + author_first_name_element = ET.SubElement( + author_element, "first_name") + author_first_name_element.text = book.author.first_name + + author_last_name_element = ET.SubElement( + author_element, "last_name") + author_last_name_element.text = book.author.last_name + + # Add <description> + description_element = ET.SubElement( + book_element, "description") + description_element.text = book.description + + # Add <year_published> + year_published_element = ET.SubElement( + book_element, "year_published") + year_published_element.text = book.year_published + + # Add <isbn> + isbn_element = ET.SubElement(book_element, "isbn") + isbn_element.text = book.isbn + + # Add <categories> + categories_element = ET.SubElement(book_element, "categories") + for category in book.categories: + category_element = ET.SubElement( + categories_element, "category") + category_element.text = category.name + + + # Convert the tree to a string + tree_str = ET.tostring(root, encoding="unicode") + + # Pretty print the XML + pretty_xml = minidom.parseString( + tree_str).toprettyxml(indent=(" " * 4)) + return pretty_xml + +__all__ = ["export_to_xml"] \ No newline at end of file diff --git a/src/ui/editor/book_editor.py b/src/ui/editor/book_editor.py index f8f1edc..3c2db3b 100644 --- a/src/ui/editor/book_editor.py +++ b/src/ui/editor/book_editor.py @@ -5,7 +5,7 @@ from PySide6.QtWidgets import ( ) from PySide6.QtGui import QRegularExpressionValidator from PySide6.QtCore import QRegularExpression -from models import Book, BookStatusEnum +from models import Book, BookStatusEnum, BookCategory from database import update_book @@ -25,6 +25,7 @@ class BookEditor(QDialog): self.create_new = False self.fill_with_existing_data() else: + self.book = Book() self.logger.debug("Editing a new book") self.create_new = True @@ -43,7 +44,7 @@ class BookEditor(QDialog): form_layout.addRow("Title:", self.title_input) # Author field - self.author_label = QLabel() + self.author_label = QLineEdit() form_layout.addRow("Author: ", self.author_label) # Description field @@ -57,13 +58,12 @@ class BookEditor(QDialog): # ISBN field self.isbn_input = QLineEdit() - self.isbn_expression = QRegularExpression("\d{10}|\d{13}") + self.isbn_expression = QRegularExpression("(\d{13})|(\d{10})") 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) @@ -102,11 +102,21 @@ class BookEditor(QDialog): self.book.year_published = self.year_input.text() self.book.isbn = self.isbn_input.text() + categories_list = self.categories_input.text().split(",") + self.book.categories = [BookCategory(name=category.strip()) for category in categories_list] + try: if self.create_new: pass else: - update_book(self.book) + update_book(self.book.to_dict()) + + QMessageBox.information(None, + "Success", + "Book updated successfully", + QMessageBox.StandardButton.Ok, + QMessageBox.StandardButton.NoButton) + self.accept() except DuplicateEntryError as e: QMessageBox.critical(None, diff --git a/src/ui/main_tabs/__init__.py b/src/ui/main_tabs/__init__.py new file mode 100644 index 0000000..353979f --- /dev/null +++ b/src/ui/main_tabs/__init__.py @@ -0,0 +1,2 @@ +from .book_overview_list import BookOverviewList +from .member_list import MemberList \ No newline at end of file diff --git a/src/ui/main_tabs/book_overview_list/__init__.py b/src/ui/main_tabs/book_overview_list/__init__.py new file mode 100644 index 0000000..df0e249 --- /dev/null +++ b/src/ui/main_tabs/book_overview_list/__init__.py @@ -0,0 +1,7 @@ +from .overview_list import * +from .book_card import * + +__all__ = [ + *overview_list.__all__, + *book_card.__all__ +] \ No newline at end of file diff --git a/src/ui/dashboard/book_card.py b/src/ui/main_tabs/book_overview_list/book_card.py similarity index 98% rename from src/ui/dashboard/book_card.py rename to src/ui/main_tabs/book_overview_list/book_card.py index b93e97b..cfe8775 100644 --- a/src/ui/dashboard/book_card.py +++ b/src/ui/main_tabs/book_overview_list/book_card.py @@ -153,4 +153,7 @@ class BookCard(QWidget): response = are_you_sure_box.exec() # Handle the response - return response == QMessageBox.Yes \ No newline at end of file + return response == QMessageBox.Yes + + +__all__ = ["BookCard"] diff --git a/src/ui/dashboard/dashboard.py b/src/ui/main_tabs/book_overview_list/overview_list.py similarity index 87% rename from src/ui/dashboard/dashboard.py rename to src/ui/main_tabs/book_overview_list/overview_list.py index 364187a..10c39a5 100644 --- a/src/ui/dashboard/dashboard.py +++ b/src/ui/main_tabs/book_overview_list/overview_list.py @@ -9,11 +9,12 @@ from .book_card import BookCard from models import BooksOverview from database.manager import DatabaseManager +from database.book_overview import fetch_all from ui.editor import MemberEditor -class LibraryDashboard(QWidget): +class BookOverviewList(QWidget): def __init__(self): super().__init__() @@ -98,7 +99,7 @@ class LibraryDashboard(QWidget): self.clear_layout(self.scroll_layout) self.book_cards = [] - self.books = self.fetch_books_from_db() + self.books = fetch_all() for book in self.books: card = BookCard(book) @@ -106,13 +107,5 @@ class LibraryDashboard(QWidget): self.scroll_layout.addWidget(card) self.book_cards.append(card) - def fetch_books_from_db(self): - """Fetch all books from the database.""" - try: - with DatabaseManager.get_session() as session: - books = session.query(BooksOverview).all() - return books - except Exception as e: - QMessageBox.critical(self, "Database Error", - f"Failed to fetch books: {e}") - return [] + +__all__ = ["BookOverviewList"] diff --git a/src/ui/main_tabs/member_list/__init__.py b/src/ui/main_tabs/member_list/__init__.py new file mode 100644 index 0000000..c32a474 --- /dev/null +++ b/src/ui/main_tabs/member_list/__init__.py @@ -0,0 +1,7 @@ +from .member_list import * +from .member_card import * + +__all__ = [ + *member_list.__all__, + *member_card.__all__ +] \ No newline at end of file diff --git a/src/ui/main_window_tabs/member_list/member_card.py b/src/ui/main_tabs/member_list/member_card.py similarity index 99% rename from src/ui/main_window_tabs/member_list/member_card.py rename to src/ui/main_tabs/member_list/member_card.py index d13cff5..59cab69 100644 --- a/src/ui/main_window_tabs/member_list/member_card.py +++ b/src/ui/main_tabs/member_list/member_card.py @@ -141,3 +141,6 @@ class MemberCard(QWidget): response = are_you_sure_box.exec() return response == QMessageBox.Yes + + +__all__ = ["MemberCard"] \ No newline at end of file diff --git a/src/ui/main_window_tabs/member_list/member_list.py b/src/ui/main_tabs/member_list/member_list.py similarity index 99% rename from src/ui/main_window_tabs/member_list/member_list.py rename to src/ui/main_tabs/member_list/member_list.py index e8c38a4..6e650ca 100644 --- a/src/ui/main_window_tabs/member_list/member_list.py +++ b/src/ui/main_tabs/member_list/member_list.py @@ -112,3 +112,6 @@ class MemberList(QWidget): QMessageBox.critical(self, "Database Error", f"Failed to fetch members: {e}") return [] + + +__all__ = ["MemberList"] \ No newline at end of file diff --git a/src/ui/main_window_tabs/member_list/__init__.py b/src/ui/main_window_tabs/member_list/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/ui/menu_bar.py b/src/ui/menu_bar.py new file mode 100644 index 0000000..0ddb907 --- /dev/null +++ b/src/ui/menu_bar.py @@ -0,0 +1,172 @@ +from PySide6.QtGui import QAction +from PySide6.QtWidgets import QMessageBox, QFileDialog, QMenuBar, QMenu, QDialog +from PySide6.QtCore import QStandardPaths + +from ui.settings import SettingsDialog + +from utils.errors import ExportError + +class MenuBar(QMenuBar): + def __init__(self, parent): + super().__init__(parent) + + self.parent = parent + self.file_types = { + "XML files (*.xml)": ".xml", + "Any file type (*)": "" + } + + self.create_file_menu() + self.create_edit_menu() + self.create_help_menu() + + def create_file_menu(self): + # File menu + file_menu = self.addMenu("File") + + # New submenu + new_submenu = QMenu("New", self) + file_menu.addMenu(new_submenu) + + # New book action + new_book_action = QAction("New book", self) + new_book_action.triggered.connect(self.new_book) + new_submenu.addAction(new_book_action) + + # New member action + new_member_action = QAction("New member", self) + new_member_action.triggered.connect(self.new_member) + new_submenu.addAction(new_member_action) + + # Import submenu + import_submenu = QMenu("Import", self) + file_menu.addMenu(import_submenu) + + import_books_action = QAction("Import books", self) + import_books_action.triggered.connect(self.import_books) + import_submenu.addAction(import_books_action) + + import_members_action = QAction("Import members", self) + import_members_action.triggered.connect(self.import_data) + import_submenu.addAction(import_members_action) + + # Export submenu + export_submenu = QMenu("Export", self) + file_menu.addMenu(export_submenu) + + export_overview_action = QAction("Export overview", self) + export_overview_action.triggered.connect(self.export_data) + export_submenu.addAction(export_overview_action) + + export_books_action = QAction("Export books", self) + export_books_action.triggered.connect(self.export_books) + export_submenu.addAction(export_books_action) + + export_members_action = QAction("Export members", self) + export_members_action.triggered.connect(self.export_data) + export_submenu.addAction(export_members_action) + + file_menu.addSeparator() + + exit_action = QAction("Exit", self) + exit_action.setShortcut("Ctrl+Q") + exit_action.triggered.connect(self.parent.close) + file_menu.addAction(exit_action) + + def create_edit_menu(self): + # Edit menu + edit_menu = self.addMenu("Edit") + + # Preferences menu + preferences_action = QAction("Preferences", self) + preferences_action.setShortcut("Ctrl+,") + preferences_action.triggered.connect(self.edit_preferences) + edit_menu.addAction(preferences_action) + + def create_help_menu(self): + # Help menu + help_menu = self.addMenu("Help") + + about_action = QAction("About", self) + about_action.triggered.connect(self.about) + help_menu.addAction(about_action) + + def edit_preferences(self): + SettingsDialog(parent=self).exec() + + def export_books(self): + try: + home_dir = QStandardPaths.writableLocation( + QStandardPaths.HomeLocation) + file_path, selected_filter = QFileDialog.getSaveFileName(self, + "Save book export", + home_dir, + ";;".join(self.file_types.keys())) + if file_path: + selected_filetype = self.file_types[selected_filter] + + if file_path.endswith(selected_filetype): + selected_filetype = "" + + book_exporter = BookExporter() + book_exporter.save_xml(file_path + selected_filetype) + + except OSError as e: + QMessageBox.critical(self, + "Error saving file", + f"Error occurred when saving the exported data: {e}", + QMessageBox.StandardButton.Ok, + QMessageBox.StandardButton.NoButton) + except ExportError as e: + QMessageBox.critical(self, + "Error exporting books", + f"An error occurred when exporting books: {e}", + QMessageBox.StandardButton.Ok, + QMessageBox.StandardButton.NoButton) + + def import_books(self): + try: + home_dir = QStandardPaths.writableLocation(QStandardPaths.HomeLocation) + file_path, _ = QFileDialog.getOpenFileName(self, "Choose import file", home_dir, ";;".join(self.file_types.keys())) + + if not file_path: + return # User canceled + + importer = BookImporter() + books = importer.parse_xml(file_path) + + if not books: + QMessageBox.information( + self, "No New Books", "No new books to import.", QMessageBox.Ok) + return + + # Show preview dialog + dialog = PreviewDialog(books, self) + if dialog.exec() == QDialog.Accepted: + # User confirmed, proceed with importing + create_books(books) + QMessageBox.information(self, "Success", "Books imported successfully!", QMessageBox.Ok) + self.dashboard.redraw_cards() + else: + QMessageBox.information(self, "Canceled", "Import was canceled.", QMessageBox.Ok) + except ImportError as e: + QMessageBox.critical(self, + "Error importing books", + f"An error occurred when importing books from the file provided: {e}", + QMessageBox.StandardButton.Ok) + + def new_book(self): + BookEditor().exec() + + def new_member(self): + MemberEditor().exec() + + def import_data(self): + pass + + def export_data(self): + pass + + def about(self): + QMessageBox.information( + self, "About", "Library app demonstrating the phantom read problem") diff --git a/src/ui/settings.py b/src/ui/settings.py index 8fae42e..b4dc50d 100644 --- a/src/ui/settings.py +++ b/src/ui/settings.py @@ -33,16 +33,20 @@ class SettingsDialog(QDialog): data_mode_layout.addWidget(self.data_mode_dropdown) + # Slowdown simulation + self.slowdown_layout = QtWidgets.QHBoxLayout() + self.slowdown_label = QtWidgets.QLabel(UserConfig.get_friendly_name("simulate_slowdown") + ":") - data_mode_layout.addWidget(self.slowdown_label) + self.slowdown_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) + self.slowdown_layout.addWidget(self.slowdown_checkbox) layout.addLayout(data_mode_layout) + layout.addLayout(self.slowdown_layout) # Set the currently selected mode to the mode in UserConfig config = UserConfig() diff --git a/src/ui/window.py b/src/ui/window.py index 8fbb3a2..fe409d2 100644 --- a/src/ui/window.py +++ b/src/ui/window.py @@ -1,25 +1,11 @@ -from PySide6.QtGui import QGuiApplication, QAction, QIcon -from PySide6.QtQml import QQmlApplicationEngine -from PySide6 import QtWidgets, QtCore -from PySide6.QtWidgets import QMessageBox, QFileDialog -from PySide6.QtCore import QStandardPaths +from PySide6.QtGui import QGuiApplication, QIcon +from PySide6.QtWidgets import QMainWindow, QApplication, QTabWidget -from ui.dashboard.dashboard import LibraryDashboard -from ui.main_window_tabs.member_list.member_list import MemberList -from ui.editor import BookEditor, MemberEditor - -from ui.settings import SettingsDialog - -from ui.import_preview import PreviewDialog - -from export.book_exporter import BookExporter -from importer.book.book_importer import BookImporter - -from utils.errors.export_error import ExportError -from utils.errors.import_error.import_error import ImportError +from ui.main_tabs import BookOverviewList, MemberList +from ui.menu_bar import MenuBar -class LibraryWindow(QtWidgets.QMainWindow): +class LibraryWindow(QMainWindow): def __init__(self): super().__init__() @@ -30,26 +16,20 @@ class LibraryWindow(QtWidgets.QMainWindow): self.center_window() - # Set up menu bar - self.create_menu_bar() + self.setMenuBar(MenuBar(self)) # Central widget and layout - central_widget = QtWidgets.QTabWidget() + central_widget = QTabWidget() self.setCentralWidget(central_widget) - - self.dashboard = LibraryDashboard() - self.member_list = MemberList() + self.dashboard = BookOverviewList() + self.member_list = MemberList() central_widget.addTab(self.dashboard, "Dashboard") central_widget.addTab(self.member_list, "Members") - self.file_types = { - "XML files (*.xml)": ".xml", - "Any file type (*)": ""} - def center_window(self): # Get the screen geometry - screen = QtWidgets.QApplication.primaryScreen() + screen = QApplication.primaryScreen() screen_geometry = screen.geometry() # Get the dimensions of the window @@ -63,165 +43,3 @@ class LibraryWindow(QtWidgets.QMainWindow): # Move the window to the calculated geometry self.move(window_geometry.topLeft()) - - def create_menu_bar(self): - # Create the menu bar - menu_bar = self.menuBar() - - # File menu - file_menu = menu_bar.addMenu("File") - - # New submenu - new_submenu = QtWidgets.QMenu(self) - new_submenu.setTitle("New") - file_menu.addMenu(new_submenu) - - # New book action - new_book_action = QAction("New book", self) - new_book_action.triggered.connect(self.new_book) - new_submenu.addAction(new_book_action) - - # New book action - new_member_action = QAction("New member", self) - new_member_action.triggered.connect(self.new_member) - new_submenu.addAction(new_member_action) - - # Import submenu - import_submenu = QtWidgets.QMenu(self) - import_submenu.setTitle("Import") - file_menu.addMenu(import_submenu) - - import_books_action = QAction("Import books", self) - import_books_action.triggered.connect(self.import_books) - import_submenu.addAction(import_books_action) - - import_members_action = QAction("Import members", self) - import_members_action.triggered.connect(self.import_data) - import_submenu.addAction(import_members_action) - - # Export submenu - export_submenu = QtWidgets.QMenu(self) - export_submenu.setTitle("Export") - file_menu.addMenu(export_submenu) - - # Export overview - export_overview_action = QAction("Export overview", self) - export_overview_action.triggered.connect(self.export_data) - export_submenu.addAction(export_overview_action) - - # Export books - export_books_action = QAction("Export books", self) - export_books_action.triggered.connect(self.export_books) - export_submenu.addAction(export_books_action) - - # Export members - export_members_action = QAction("Export members", self) - export_members_action.triggered.connect(self.export_data) - export_submenu.addAction(export_members_action) - - file_menu.addSeparator() - - exit_action = QAction("Exit", self) - exit_action.setShortcut("Ctrl+Q") - exit_action.triggered.connect(self.close) - file_menu.addAction(exit_action) - - # Edit menu - edit_menu = menu_bar.addMenu("Edit") - - # Preferences menu - preferences_action = QAction("Preferences", self) - preferences_action.setShortcut("Ctrl+,") - preferences_action.triggered.connect(self.edit_preferences) - edit_menu.addAction(preferences_action) - - # Help menu - help_menu = menu_bar.addMenu("Help") - about_action = QAction("About", self) - about_action.triggered.connect(self.about) - help_menu.addAction(about_action) - - # Menu action slots - def edit_preferences(self): - SettingsDialog(parent=self).exec() - - # region Menu Actions - - def export_books(self): - try: - home_dir = QStandardPaths.writableLocation( - QStandardPaths.HomeLocation) - file_path, selected_filter = QFileDialog.getSaveFileName(self, - "Save book export", - home_dir, - ";;".join(self.file_types.keys())) - if file_path: - selected_filetype = self.file_types[selected_filter] - - if file_path.endswith(selected_filetype): - selected_filetype = "" - - book_exporter = BookExporter() - book_exporter.save_xml(file_path + selected_filetype) - - except OSError as e: - QMessageBox.critical(self, "Error saving file", f"Error occurred when saving the exported data: { - e}", QMessageBox.StandardButton.Ok, QMessageBox.StandardButton.NoButton) - except ExportError as e: - QMessageBox.critical(self, "Error exporting books", f"An error occurred when exporting books: { - e}", QMessageBox.StandardButton.Ok, QMessageBox.StandardButton.NoButton) - - def import_books(self): - try: - home_dir = QStandardPaths.writableLocation( - QStandardPaths.HomeLocation) - file_path, _ = QFileDialog.getOpenFileName( - self, "Choose import file", home_dir, ";;".join( - self.file_types.keys()) - ) - - if not file_path: - return # User canceled - - importer = BookImporter() - books = importer.parse_xml(file_path) - - if not books: - QMessageBox.information( - self, "No New Books", "No new books to import.", QMessageBox.Ok) - return - - # Show preview dialog - dialog = PreviewDialog(books, self) - if dialog.exec() == QtWidgets.QDialog.Accepted: - # User confirmed, proceed with importing - importer.save_books(books) - QMessageBox.information( - self, "Success", "Books imported successfully!", QMessageBox.Ok) - self.dashboard.redraw_cards() - else: - QMessageBox.information( - self, "Canceled", "Import was canceled.", QMessageBox.Ok) - except ImportError as e: - QMessageBox.critical(self, "Error importing books", f"An error occurred when importing books from the file provided: { - e}", QMessageBox.StandardButton.Ok, QMessageBox.StandardButton.NoButton) - - def new_book(self): - # BookEditor() - pass - - def new_member(self): - MemberEditor().exec() - - - def import_data(self): - pass - - def export_data(self): - pass - - def about(self): - QtWidgets.QMessageBox.information( - self, "About", "Library app demonstrating the phantom read problem") - - # endregion diff --git a/src/utils/database.py b/src/utils/database.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/utils/errors/__init__.py b/src/utils/errors/__init__.py index e69de29..4e6e690 100644 --- a/src/utils/errors/__init__.py +++ b/src/utils/errors/__init__.py @@ -0,0 +1,9 @@ +from .import_error import * +from .export_error import * +from .database import * + +__all__ = [ + *import_error.__all__, + *export_error.__all__, + *database.__all__ +] \ No newline at end of file diff --git a/src/utils/errors/database.py b/src/utils/errors/database.py index fd689c6..82b50f7 100644 --- a/src/utils/errors/database.py +++ b/src/utils/errors/database.py @@ -23,3 +23,6 @@ class DuplicateEntryError(DatabaseError): def __init__(self, message: str): super().__init__(message) self.message = message + + +__all__ = ["DatabaseError", "DatabaseConfigError", "DatabaseConnectionError", "DuplicateEntryError"] diff --git a/src/utils/errors/export_error.py b/src/utils/errors/export_error.py index b3e7421..5cde159 100644 --- a/src/utils/errors/export_error.py +++ b/src/utils/errors/export_error.py @@ -3,3 +3,19 @@ class ExportError(Exception): super().__init__(message) self.message = message + + +class NoExportEntityError(ExportError): + def __init__(self, message: str): + super().__init__(message) + + self.message = message + +class ExportFileError(ExportError): + def __init__(self, message: str): + super().__init__(message) + + self.message = message + + +__all__ = ["ExportError", "NoExportEntityError", "ExportFileError"] \ No newline at end of file diff --git a/src/utils/errors/import_error.py b/src/utils/errors/import_error.py new file mode 100644 index 0000000..19736f5 --- /dev/null +++ b/src/utils/errors/import_error.py @@ -0,0 +1,22 @@ +class ImportError(Exception): + def __init__(self, message: str): + super().__init__(message) + + self.message = message + + +class InvalidContentsError(ImportError): + def __init__(self, message: str): + super().__init__(message) + + self.message = message + + +class XsdSchemeNotFoundError(ImportError): + def __init__(self, message: str): + super().__init__(message) + + self.message = message + + +__all__ = ["ImportError", "InvalidContentsError", "XsdSchemeNotFoundError"] diff --git a/src/utils/errors/import_error/__init__.py b/src/utils/errors/import_error/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/utils/errors/import_error/import_error.py b/src/utils/errors/import_error/import_error.py deleted file mode 100644 index 0d286d3..0000000 --- a/src/utils/errors/import_error/import_error.py +++ /dev/null @@ -1,5 +0,0 @@ -class ImportError(Exception): - def __init__(self, message: str): - super().__init__(message) - - self.message = message \ No newline at end of file diff --git a/src/utils/errors/import_error/invalid_contents_error.py b/src/utils/errors/import_error/invalid_contents_error.py deleted file mode 100644 index 274c865..0000000 --- a/src/utils/errors/import_error/invalid_contents_error.py +++ /dev/null @@ -1,8 +0,0 @@ -from .import_error import ImportError - - -class InvalidContentsError(ImportError): - def __init__(self, message: str): - super().__init__(message) - - self.message = message diff --git a/src/utils/errors/import_error/xsd_scheme_not_found.py b/src/utils/errors/import_error/xsd_scheme_not_found.py deleted file mode 100644 index 25e7f66..0000000 --- a/src/utils/errors/import_error/xsd_scheme_not_found.py +++ /dev/null @@ -1,8 +0,0 @@ -from .import_error import ImportError - - -class XsdSchemeNotFoundError(ImportError): - def __init__(self, message: str): - super().__init__(message) - - self.message = message diff --git a/src/utils/errors/no_export_entity_error.py b/src/utils/errors/no_export_entity_error.py deleted file mode 100644 index f5dbc99..0000000 --- a/src/utils/errors/no_export_entity_error.py +++ /dev/null @@ -1,6 +0,0 @@ -from .export_error import ExportError -class NoExportEntityError(ExportError): - def __init__(self, message: str): - super().__init__(message) - - self.message = message \ No newline at end of file