From d7ee79f7e93e9752b46e733b35dd797d843d710e Mon Sep 17 00:00:00 2001 From: Thastertyn Date: Thu, 16 Jan 2025 17:18:20 +0100 Subject: [PATCH] [main] Fixed imports and exports after structure rework --- src/assets/__init__.py | 5 + src/assets/asset_manager.py | 13 +++ .../book => assets}/book_import_scheme.xsd | 0 src/database/book.py | 24 +++-- src/database/book_overview.py | 11 +- src/services/book_service.py | 101 ++++++++++++++---- src/ui/editor/book_editor.py | 96 ++++++++++++----- .../book_overview_list/overview_list.py | 4 +- src/ui/menu_bar.py | 23 ++-- src/ui/window.py | 3 + 10 files changed, 205 insertions(+), 75 deletions(-) create mode 100644 src/assets/__init__.py create mode 100644 src/assets/asset_manager.py rename src/{importer/book => assets}/book_import_scheme.xsd (100%) diff --git a/src/assets/__init__.py b/src/assets/__init__.py new file mode 100644 index 0000000..27a91da --- /dev/null +++ b/src/assets/__init__.py @@ -0,0 +1,5 @@ +from .asset_manager import * + +__all__ = [ + *asset_manager.__all__ +] \ No newline at end of file diff --git a/src/assets/asset_manager.py b/src/assets/asset_manager.py new file mode 100644 index 0000000..b230c2d --- /dev/null +++ b/src/assets/asset_manager.py @@ -0,0 +1,13 @@ +import os + +ASSETS_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "assets") + + +def get_asset(name: str) -> str: + absolute_path = os.path.join(ASSETS_DIR, name) + if not os.path.exists(absolute_path): + raise FileNotFoundError(f"Asset not found: {absolute_path}") + return os.path.abspath(absolute_path) + + +__all__ = ["get_asset"] diff --git a/src/importer/book/book_import_scheme.xsd b/src/assets/book_import_scheme.xsd similarity index 100% rename from src/importer/book/book_import_scheme.xsd rename to src/assets/book_import_scheme.xsd diff --git a/src/database/book.py b/src/database/book.py index e715ab8..439cc01 100644 --- a/src/database/book.py +++ b/src/database/book.py @@ -2,6 +2,7 @@ from typing import Dict, List, Optional import logging from sqlalchemy.exc import IntegrityError, SQLAlchemyError, DatabaseError as SqlAlchemyDatabaseError +from sqlalchemy.orm import joinedload from utils.errors.database import DatabaseError, DuplicateEntryError, DatabaseConnectionError from models import Book from database.manager import DatabaseManager @@ -22,15 +23,18 @@ def fetch_all_books() -> List[Book]: """ with DatabaseManager.get_session() as session: try: - return session.query(Book).all() + return session.query(Book) \ + .options( + joinedload(Book.author), + joinedload(Book.categories) + ) \ + .all() except SqlAlchemyDatabaseError as e: logger.critical("Connection with database interrupted") - raise DatabaseConnectionError( - "Connection with database interrupted") from e + raise DatabaseConnectionError("Connection with database interrupted") from e except SQLAlchemyError as e: logger.error(f"An error occurred when fetching all books: {e}") - raise DatabaseError( - "An error occurred when fetching all books") from e + raise DatabaseError("An error occurred when fetching all books") from e def create_book(book: Dict[str, object]) -> None: @@ -64,7 +68,7 @@ def create_books(books: List[Dict[str, object]]) -> None: if existing_book: logger.warning(f"Book with ISBN {book['isbn']} already exists. Skipping.") - raise DuplicateEntryError(f"Book with ISBN {book['isbn']} already exists.") + continue author = get_or_create_author(session, book["author"]) categories = get_or_create_categories(session, book["categories"]) @@ -87,8 +91,7 @@ def create_books(books: List[Dict[str, object]]) -> None: 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 + raise DatabaseConnectionError("Connection with database interrupted") from e except SQLAlchemyError as e: logger.error(f"An error occurred when creating the book: {e}") raise DatabaseError("An error occurred when creating the book") from e @@ -134,11 +137,10 @@ def update_book(book: Dict[str, object]) -> None: 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 + 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"] +__all__ = ["create_book", "create_books", "update_book", "fetch_all_books"] diff --git a/src/database/book_overview.py b/src/database/book_overview.py index dde5bd5..4cf9b2b 100644 --- a/src/database/book_overview.py +++ b/src/database/book_overview.py @@ -10,15 +10,16 @@ from database.manager import DatabaseManager logger = logging.getLogger(__name__) -def fetch_all() -> List[BooksOverview]: +def fetch_all_book_overviews() -> 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 + 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 + raise DatabaseError("An error occured when fetching all books") from e + + +__all__ = ["fetch_all_book_overviews"] diff --git a/src/services/book_service.py b/src/services/book_service.py index 358168e..1867741 100644 --- a/src/services/book_service.py +++ b/src/services/book_service.py @@ -1,15 +1,38 @@ -from typing import Optional, List +import os +import logging +from typing import Optional, List, Dict import xml.etree.ElementTree as ET from xml.dom import minidom +from xmlschema import XMLSchema -from utils.errors import NoExportEntityError, ExportError, ExportFileError +from utils.errors import ( + NoExportEntityError, + ExportError, + ExportFileError, + InvalidContentsError, + XsdSchemeNotFoundError, + ImportError, +) +from models import Book +from database import fetch_all_books, create_books +from assets import asset_manager -from database import fetch_all_books +# Initialize logger and XML Schema +logger = logging.getLogger(__name__) -def export_to_xml(file_path: str): + +try: + logger.debug("Loading XSD schema") + SCHEMA = XMLSchema(asset_manager.get_asset("book_import_scheme.xsd")) +except Exception as e: + logger.error("Failed to load XSD schema") + raise XsdSchemeNotFoundError(f"Failed to load XSD schema: {e}") + + +def export_to_xml(file_path: str) -> None: all_books = fetch_all_books() - if not self.books: + if not all_books: raise NoExportEntityError("No books found to export") xml = books_to_xml(all_books) @@ -20,11 +43,55 @@ def export_to_xml(file_path: str): except OSError as e: raise ExportFileError("Failed to save to a file") from e +def save_books(books: List[Dict[str, object]]): + create_books(books) + +def parse_books_from_xml(file_path: str) -> List[Dict[str, object]]: + if not SCHEMA.is_valid(file_path): + raise InvalidContentsError("XML file is not valid according to XSD schema.") + + try: + tree = ET.parse(file_path) + root = tree.getroot() + + books = [] + for book_element in root.findall("book"): + title = book_element.find("title").text + year_published = book_element.find("year_published").text + description = book_element.find("description").text + isbn = book_element.find("isbn").text + + # Parse author + author_element = book_element.find("author") + author = { + "first_name": author_element.find("first_name").text, + "last_name": author_element.find("last_name").text, + } + + # Parse categories + category_elements = book_element.find("categories").findall("category") + categories = [category_element.text for category_element in category_elements] + + # Create a book dictionary + book = { + "title" : title, + "description" : description, + "year_published" : year_published, + "isbn" : isbn, + "author" : author, + "categories" : categories, + } + books.append(book) + + return books + except ET.ParseError as e: + raise ImportError(f"Failed to parse XML file: {e}") + def books_to_xml(books: List[Book]) -> str: root = ET.Element("books") - for book in self.books: + for book in books: # Create a element book_element = ET.SubElement(root, "book") @@ -36,22 +103,18 @@ def books_to_xml(books: List[Book]) -> str: author_element = ET.SubElement(book_element, "author") # Add - author_first_name_element = ET.SubElement( - author_element, "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 = ET.SubElement(author_element, "last_name") author_last_name_element.text = book.author.last_name # Add - description_element = ET.SubElement( - book_element, "description") + description_element = ET.SubElement(book_element, "description") description_element.text = book.description # Add - year_published_element = ET.SubElement( - book_element, "year_published") + year_published_element = ET.SubElement(book_element, "year_published") year_published_element.text = book.year_published # Add @@ -61,17 +124,15 @@ def books_to_xml(books: List[Book]) -> str: # Add categories_element = ET.SubElement(book_element, "categories") for category in book.categories: - category_element = ET.SubElement( - categories_element, "category") + 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)) + pretty_xml = minidom.parseString(tree_str).toprettyxml(indent=(" " * 4)) return pretty_xml -__all__ = ["export_to_xml"] \ No newline at end of file + +__all__ = ["export_to_xml", "parse_books_from_xml"] diff --git a/src/ui/editor/book_editor.py b/src/ui/editor/book_editor.py index 3c2db3b..1bd37e9 100644 --- a/src/ui/editor/book_editor.py +++ b/src/ui/editor/book_editor.py @@ -1,3 +1,5 @@ +from typing import Dict + import logging from PySide6.QtWidgets import ( @@ -7,7 +9,7 @@ from PySide6.QtGui import QRegularExpressionValidator from PySide6.QtCore import QRegularExpression from models import Book, BookStatusEnum, BookCategory -from database import update_book +from database import update_book, create_book from utils.errors.database import DatabaseError, DatabaseConnectionError, DuplicateEntryError @@ -21,11 +23,9 @@ class BookEditor(QDialog): if book: self.logger.debug(f"Editing existing book {book.title}") - self.book = book self.create_new = False - self.fill_with_existing_data() + self.fill_with_existing_data(book) else: - self.book = Book() self.logger.debug("Editing a new book") self.create_new = True @@ -81,35 +81,26 @@ class BookEditor(QDialog): 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 fill_with_existing_data(self, book: Book): + self.title_input.setText(book.title) + self.description_input.setText(book.description) + self.year_input.setText(book.year_published) + self.isbn_input.setText(book.isbn) - full_author_name = f"{self.book.author.first_name} { - self.book.author.last_name}" + full_author_name = f"{book.author.first_name} {book.author.last_name}" self.author_label.setText(full_author_name) - all_categories = ", ".join( - category.name for category in self.book.categories) + all_categories = ", ".join(category.name for category in book.categories) self.categories_input.setText(all_categories) 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() - - categories_list = self.categories_input.text().split(",") - self.book.categories = [BookCategory(name=category.strip()) for category in categories_list] - try: + book_object = self.parse_inputs() + if self.create_new: - pass + create_book(book_object) else: - update_book(self.book.to_dict()) + update_book(book_object) QMessageBox.information(None, "Success", @@ -118,6 +109,12 @@ class BookEditor(QDialog): QMessageBox.StandardButton.NoButton) self.accept() + except ValueError as e: + QMessageBox.critical(None, + "Invalid Input", + f"Input validation failed: {e}", + QMessageBox.StandardButton.Ok, + QMessageBox.StandardButton.NoButton) except DuplicateEntryError as e: QMessageBox.critical(None, "ISBN is already in use", @@ -132,10 +129,59 @@ class BookEditor(QDialog): QMessageBox.StandardButton.NoButton) except DatabaseError as e: QMessageBox.critical(self.parent, - "An error occured", + "An error occurred", f"Could not save the book because of the following error: {e}", QMessageBox.StandardButton.Ok, QMessageBox.StandardButton.NoButton) + def parse_inputs(self) -> Dict[str, object]: + # Title validation + title = self.title_input.text().strip() + if not title or len(title) > 100: + raise ValueError("Title must be non-empty and at most 100 characters long.") + + # Author validation + author_name = self.author_label.text().strip() + if not author_name or len(author_name.split()) < 2: + raise ValueError("Author must include at least a first and last name.") + + # Split author name into first and last names + author_parts = author_name.split() + first_name = author_parts[0] + last_name = " ".join(author_parts[1:]) + + # Description validation + description = self.description_input.toPlainText().strip() + if not description: + raise ValueError("Description cannot be empty.") + + # Year published validation + year_published = self.year_input.text().strip() + if not year_published.isdigit() or len(year_published) != 4 or int(year_published) < 0: + raise ValueError("Year published must be a 4-digit positive number.") + + # ISBN validation + isbn = self.isbn_input.text().strip() + if not isbn or len(isbn) not in (10, 13): + raise ValueError("ISBN must be either 10 or 13 characters long.") + + # Categories validation + category_text = self.categories_input.text().strip() + categories = [category.strip() for category in category_text.split(",") if category.strip()] + if not categories: + raise ValueError("At least one category must be specified.") + + # Map parsed values to dictionary format for saving + return { + "title": title, + "author": { + "first_name": first_name, + "last_name": last_name + }, + "description": description, + "year_published": year_published, + "isbn": isbn, + "categories": categories + } __all__ = ["BookEditor"] diff --git a/src/ui/main_tabs/book_overview_list/overview_list.py b/src/ui/main_tabs/book_overview_list/overview_list.py index 10c39a5..26a74f1 100644 --- a/src/ui/main_tabs/book_overview_list/overview_list.py +++ b/src/ui/main_tabs/book_overview_list/overview_list.py @@ -9,7 +9,7 @@ from .book_card import BookCard from models import BooksOverview from database.manager import DatabaseManager -from database.book_overview import fetch_all +from database.book_overview import fetch_all_book_overviews from ui.editor import MemberEditor @@ -99,7 +99,7 @@ class BookOverviewList(QWidget): self.clear_layout(self.scroll_layout) self.book_cards = [] - self.books = fetch_all() + self.books = fetch_all_book_overviews() for book in self.books: card = BookCard(book) diff --git a/src/ui/menu_bar.py b/src/ui/menu_bar.py index 0ddb907..4d11173 100644 --- a/src/ui/menu_bar.py +++ b/src/ui/menu_bar.py @@ -3,8 +3,11 @@ from PySide6.QtWidgets import QMessageBox, QFileDialog, QMenuBar, QMenu, QDialog from PySide6.QtCore import QStandardPaths from ui.settings import SettingsDialog +from ui.import_preview import PreviewDialog +from ui.editor import BookEditor, MemberEditor -from utils.errors import ExportError +from utils.errors import ExportError, ExportFileError +from services import book_service class MenuBar(QMenuBar): def __init__(self, parent): @@ -107,11 +110,10 @@ class MenuBar(QMenuBar): if file_path.endswith(selected_filetype): selected_filetype = "" + + book_service.export_to_xml(file_path + selected_filetype) - book_exporter = BookExporter() - book_exporter.save_xml(file_path + selected_filetype) - - except OSError as e: + except ExportFileError as e: QMessageBox.critical(self, "Error saving file", f"Error occurred when saving the exported data: {e}", @@ -132,21 +134,18 @@ class MenuBar(QMenuBar): if not file_path: return # User canceled - importer = BookImporter() - books = importer.parse_xml(file_path) - + books = book_service.parse_books_from_xml(file_path) if not books: - QMessageBox.information( - self, "No New Books", "No new books to import.", QMessageBox.Ok) + 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) + book_service.create_books(books) QMessageBox.information(self, "Success", "Books imported successfully!", QMessageBox.Ok) - self.dashboard.redraw_cards() + self.parent.redraw_book_cards() else: QMessageBox.information(self, "Canceled", "Import was canceled.", QMessageBox.Ok) except ImportError as e: diff --git a/src/ui/window.py b/src/ui/window.py index fe409d2..b5eb852 100644 --- a/src/ui/window.py +++ b/src/ui/window.py @@ -43,3 +43,6 @@ class LibraryWindow(QMainWindow): # Move the window to the calculated geometry self.move(window_geometry.topLeft()) + + def redraw_book_cards(self): + self.dashboard.redraw_cards() \ No newline at end of file