diff --git a/requirements.txt b/requirements.txt index 1ad4bf2..6c88086 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,5 +9,5 @@ python-dotenv==1.0.1 shiboken2==5.13.2 shiboken6==6.8.1 SQLAlchemy==2.0.36 -sqlalchemy-stubs==0.4 typing_extensions==4.12.2 +xmlschema==3.4.3 \ No newline at end of file diff --git a/src/app.py b/src/app.py index e0e0e51..c3242c3 100644 --- a/src/app.py +++ b/src/app.py @@ -7,7 +7,8 @@ from PySide6.QtWidgets import QMessageBox, QApplication from utils.config import UserConfig from utils.database import DatabaseManager -from utils.errors.database_config_error import DatabaseConfigError +from utils.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 @@ -25,15 +26,18 @@ class LibraryApp(): try: self.database_manger = DatabaseManager() except DatabaseConfigError as e: - detail_text = f"Invalid config: {e.config_name}" - self.show_error(e.message, detail_text=detail_text) - sys.exit(1) + 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.show_error("Configuration not found") - sys.exit(1) - + 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() @@ -53,6 +57,7 @@ class LibraryApp(): def cleanup(self) -> None: self.logger.info("Cleaning up") + self.qt_app.quit() self.database_manger.cleanup() diff --git a/src/export/book_exporter.py b/src/export/book_exporter.py index 126279d..a35690e 100644 --- a/src/export/book_exporter.py +++ b/src/export/book_exporter.py @@ -8,56 +8,69 @@ from utils.errors.no_export_entity_error import NoExportEntityError from models.book import Book + class BookExporter(): def save_xml(self, file_path: str): xml = self._get_full_xml() - if xml is None: - raise NoExportEntityError("No books found to export") - with open(file_path, "w", encoding="utf-8") as file: - file.write(xml) + file.write(xml) - - def _get_full_xml(self) -> Optional[str]: + def _get_full_xml(self) -> str: root = ET.Element("books") - with DatabaseManager().get_session() as session: + with DatabaseManager.get_session() as session: self.books = session.query(Book).all() - + if not self.books: - return None + 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 = 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 = 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 = 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 \ No newline at end of file + pretty_xml = minidom.parseString( + tree_str).toprettyxml(indent=(" " * 4)) + return pretty_xml diff --git a/src/importer/__init__.py b/src/importer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/importer/book/__init__.py b/src/importer/book/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/book_import_scheme.xsd b/src/importer/book/book_import_scheme.xsd similarity index 64% rename from src/book_import_scheme.xsd rename to src/importer/book/book_import_scheme.xsd index 6d1fdaf..5137892 100644 --- a/src/book_import_scheme.xsd +++ b/src/importer/book/book_import_scheme.xsd @@ -15,6 +15,26 @@ </xs:restriction> </xs:simpleType> </xs:element> + <xs:element name="author"> <!-- Author --> + <xs:complexType> + <xs:sequence> + <xs:element name="first_name"> + <xs:simpleType> + <xs:restriction base="xs:string"> + <xs:maxLength value="50"/> + </xs:restriction> + </xs:simpleType> + </xs:element> + <xs:element name="last_name"> + <xs:simpleType> + <xs:restriction base="xs:string"> + <xs:maxLength value="50"/> + </xs:restriction> + </xs:simpleType> + </xs:element> + </xs:sequence> + </xs:complexType> + </xs:element> <xs:element name="description" type="xs:string" /> <!-- Description --> <xs:element name="year_published"> <!-- Year published --> <xs:simpleType> @@ -33,7 +53,8 @@ <xs:element name="categories"> <!-- Categories list --> <xs:complexType> <xs:sequence> - <xs:element name="category" type="xs:string" maxOccurs="unbounded" /> + <xs:element name="category" type="xs:string" + maxOccurs="unbounded" /> </xs:sequence> </xs:complexType> </xs:element> diff --git a/src/importer/book/book_importer.py b/src/importer/book/book_importer.py new file mode 100644 index 0000000..ed22482 --- /dev/null +++ b/src/importer/book/book_importer.py @@ -0,0 +1,119 @@ +from typing import List +import os +import logging +from xml.etree import ElementTree as ET +from xmlschema import XMLSchema +from utils.database 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 sqlalchemy.exc import IntegrityError + +class BookImporter: + def __init__(self): + self.logger = logging.getLogger(__name__) + try: + self.logger.debug("Opening XSD scheme in ./") + scheme_path = os.path.join(os.path.dirname(__file__), "book_import_scheme.xsd") + self.schema = XMLSchema(scheme_path) + except Exception as e: + self.logger.error("Failed to load XSD scheme") + raise XsdSchemeNotFoundError(f"Failed to load XSD schema: {e}") + + def parse_xml(self, file_path: str) -> List[Book]: + """Parses the XML file and validates it against the XSD schema.""" + try: + tree = ET.parse(file_path) + root = tree.getroot() + + if not self.schema.is_valid(file_path): + raise InvalidContentsError("XML file is not valid according to XSD schema.") + + 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") + first_name = author_element.find("first_name").text + last_name = author_element.find("last_name").text + author = Author(first_name=first_name, last_name=last_name) + + # Parse categories + category_elements = book_element.find("categories").findall("category") + categories = [BookCategory(name=category_element.text) for category_element in category_elements] + + # Create a Book object + book = 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 filter_new_books(self, books: List[Book]) -> List[Book]: + """Filters out books that already exist in the database.""" + new_books = [] + with DatabaseManager.get_session() as session: + for book in books: + existing_book = session.query(Book).filter( + Book.isbn == book.isbn, + ).first() + if existing_book is None: + new_books.append(book) + return new_books + + def save_books(self, books: List[Book]): + """Saves a list of books to the database.""" + try: + with DatabaseManager.get_session() as session: + for book in books: + # Check if the author exists, otherwise add + existing_author = session.query(Author).filter_by( + first_name=book.author.first_name, + last_name=book.author.last_name + ).first() + if existing_author: + book.author = existing_author + else: + session.add(book.author) + session.commit() + + # Handle categories + new_categories = [] + for category in book.categories: + existing_category = session.query(BookCategory).filter_by(name=category.name).first() + if existing_category: + new_categories.append(existing_category) + else: + self.logger.debug(f"Adding new category: {category.name}") + session.add(category) + session.commit() + new_categories.append(category) + # Replace book categories with the resolved categories + book.categories = new_categories + + # Check if the book already exists + existing_book = session.query(Book).filter_by(isbn=book.isbn).first() + if not existing_book: + session.add(book) + else: + self.logger.warning(f"Book with ISBN {book.isbn} already exists. Skipping.") + + # Commit all changes + session.commit() + except IntegrityError as e: + raise ImportError(f"An error occurred when importing books: {e}") from e diff --git a/src/models/book_category_link.py b/src/models/book_category_link.py index 4a6ee4e..df29c31 100644 --- a/src/models/book_category_link.py +++ b/src/models/book_category_link.py @@ -11,5 +11,5 @@ class BookCategoryLink(Base): book_id = Column(Integer, ForeignKey('book.id'), primary_key=True) book_category_id = Column(Integer, ForeignKey('book_category.id'), primary_key=True) - book = relationship('Book') - book_category = relationship('BookCategory') \ No newline at end of file + book = relationship('Book', overlaps='categories,books') + book_category = relationship('BookCategory', overlaps='categories,books') \ No newline at end of file diff --git a/src/requirements.txt b/src/requirements.txt new file mode 100644 index 0000000..4cf16d3 --- /dev/null +++ b/src/requirements.txt @@ -0,0 +1,7 @@ +PySide6==6.8.1 +PySide6==6.8.1.1 +PySide6_Addons==6.8.1 +PySide6_Essentials==6.8.1 +python-dotenv==1.0.1 +SQLAlchemy==2.0.36 +xmlschema==3.4.3 diff --git a/src/ui/dashboard/book_card.py b/src/ui/dashboard/book_card.py index 43cf85c..4784657 100644 --- a/src/ui/dashboard/book_card.py +++ b/src/ui/dashboard/book_card.py @@ -89,13 +89,16 @@ class BookCard(QWidget): action_edit_book = context_menu.addAction("Edit Book") action_edit_author = context_menu.addAction("Edit Author") + action_mark_returned = context_menu.addAction("Mark as Returned") + action_remove_reservation = context_menu.addAction("Remove reservation") + context_menu.addSeparator() + delete_book_action = context_menu.addAction("Delete Book") - if self.book_overview.status == BookStatusEnum.borrowed: - action_mark_returned = context_menu.addAction("Mark as Returned") + if self.book_overview.status != BookStatusEnum.borrowed: + action_mark_returned.setVisible(False) - if self.book_overview.status == BookStatusEnum.reserved: - action_remove_reservation = context_menu.addAction( - "Remove reservation") + if self.book_overview.status != BookStatusEnum.reserved: + action_remove_reservation.setVisible(False) action = context_menu.exec_(self.mapToGlobal(event.pos())) @@ -120,3 +123,5 @@ class BookCard(QWidget): print("Mark as Returned selected") elif action == action_remove_reservation: print("Remove reservation selected") + elif action == delete_book_action: + print("Delete book") diff --git a/src/ui/import_preview.py b/src/ui/import_preview.py new file mode 100644 index 0000000..8b60ec2 --- /dev/null +++ b/src/ui/import_preview.py @@ -0,0 +1,34 @@ +from PySide6.QtWidgets import QDialog, QVBoxLayout, QTableWidget, QTableWidgetItem, QPushButton, QHeaderView + +class PreviewDialog(QDialog): + def __init__(self, books, parent=None): + super().__init__(parent) + + self.setWindowTitle("Preview Books to Import") + self.setLayout(QVBoxLayout()) + self.setMinimumWidth(500) + + # Table to display books + table = QTableWidget(self) + table.setRowCount(len(books)) + table.setColumnCount(4) + table.setHorizontalHeaderLabels(["Title", "Author", "Year", "ISBN"]) + + for row, book in enumerate(books): + table.setItem(row, 0, QTableWidgetItem(book.title)) + table.setItem(row, 1, QTableWidgetItem(f"{book.author.first_name} {book.author.last_name}")) + table.setItem(row, 2, QTableWidgetItem(book.year_published)) + table.setItem(row, 3, QTableWidgetItem(book.isbn)) + + header = table.horizontalHeader() + header.setSectionResizeMode(QHeaderView.Stretch) + + self.layout().addWidget(table) + + # Add buttons + self.confirm_button = QPushButton("Confirm", self) + self.cancel_button = QPushButton("Cancel", self) + self.confirm_button.clicked.connect(self.accept) + self.cancel_button.clicked.connect(self.reject) + self.layout().addWidget(self.confirm_button) + self.layout().addWidget(self.cancel_button) diff --git a/src/ui/window.py b/src/ui/window.py index 12dfbdb..db249fb 100644 --- a/src/ui/window.py +++ b/src/ui/window.py @@ -1,7 +1,8 @@ from PySide6.QtGui import QGuiApplication, QAction, QIcon from PySide6.QtQml import QQmlApplicationEngine from PySide6 import QtWidgets, QtCore -from PySide6.QtWidgets import QMessageBox +from PySide6.QtWidgets import QMessageBox, QFileDialog +from PySide6.QtCore import QStandardPaths from ui.dashboard.dashboard import LibraryDashboard from ui.book_editor.book_editor import BookEditor @@ -9,9 +10,14 @@ from ui.member_editor.member_editor import 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 + class LibraryWindow(QtWidgets.QMainWindow): def __init__(self): @@ -19,6 +25,7 @@ class LibraryWindow(QtWidgets.QMainWindow): # Set up main window properties self.setWindowTitle("Library App") + self.setWindowIcon(QIcon.fromTheme("x-content-ebook-reader")) self.setGeometry(0, 0, 800, 600) self.center_window() @@ -31,9 +38,11 @@ class LibraryWindow(QtWidgets.QMainWindow): self.setCentralWidget(central_widget) central_widget.addTab(LibraryDashboard(), "Dashboard") - # central_widget.addTab(BookEditor(), "Books") central_widget.addTab(MemberEditor(), "Members") - + + self.file_types = { + "XML files (*.xml)": ".xml", + "Any file type (*)": ""} def center_window(self): # Get the screen geometry @@ -52,8 +61,6 @@ 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() @@ -82,7 +89,7 @@ class LibraryWindow(QtWidgets.QMainWindow): file_menu.addMenu(import_submenu) import_books_action = QAction("Import books", self) - import_books_action.triggered.connect(self.import_data) + import_books_action.triggered.connect(self.import_books) import_submenu.addAction(import_books_action) import_members_action = QAction("Import members", self) @@ -141,16 +148,62 @@ class LibraryWindow(QtWidgets.QMainWindow): def export_books(self): try: - file_path = QtWidgets.QFileDialog.getSaveFileName(self, "Save book export", "", ".xml;;") + 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 = "" - if file_path[0]: book_exporter = BookExporter() - book_exporter.save_xml(file_path[0] + file_path[1]) + book_exporter.save_xml(file_path + selected_filetype) except OSError as e: - QMessageBox.critical(self, "Error saving file", f"An error occured when saving the exported data: {e}", QMessageBox.StandardButton.Ok, QMessageBox.StandardButton.NoButton) + 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 occured when exporting books: {e}", QMessageBox.StandardButton.Ok, QMessageBox.StandardButton.NoButton) + 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) + new_books = importer.filter_new_books(books) + + if not new_books: + QMessageBox.information( + self, "No New Books", "No new books to import.", QMessageBox.Ok) + return + + # Show preview dialog + dialog = PreviewDialog(new_books, self) + if dialog.exec() == QtWidgets.QDialog.Accepted: + # User confirmed, proceed with importing + importer.save_books(new_books) + QMessageBox.information( + self, "Success", "Books imported successfully!", QMessageBox.Ok) + 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): pass @@ -165,6 +218,7 @@ class LibraryWindow(QtWidgets.QMainWindow): pass def about(self): - QtWidgets.QMessageBox.information(self, "About", "Library app demonstrating the phantom read problem") + QtWidgets.QMessageBox.information( + self, "About", "Library app demonstrating the phantom read problem") - # endregion \ No newline at end of file + # endregion diff --git a/src/utils/config.py b/src/utils/config.py index 8ee6540..6e6324b 100644 --- a/src/utils/config.py +++ b/src/utils/config.py @@ -1,7 +1,7 @@ import enum import os -from utils.errors.database_config_error import DatabaseConfigError +from utils.errors.database.database_config_error import DatabaseConfigError from dotenv import load_dotenv import logging diff --git a/src/utils/database.py b/src/utils/database.py index 2400b4a..2aa5359 100644 --- a/src/utils/database.py +++ b/src/utils/database.py @@ -1,10 +1,12 @@ import logging from sqlalchemy.orm import sessionmaker, Session -from sqlalchemy import create_engine +from sqlalchemy import create_engine, text + +from sqlalchemy.exc import DatabaseError from utils.config import DatabaseConfig -from utils.errors.database_config_error import DatabaseConfigError +from utils.errors.database.database_connection_error import DatabaseConnectionError class DatabaseManager(): @@ -19,15 +21,32 @@ class DatabaseManager(): def __init__(self) -> None: self.logger = logging.getLogger(__name__) self.logger.info("Reading database config") - self.database_config = DatabaseConfig() - self.engine = create_engine(f'mysql+mysqlconnector://{self.database_config.user}:{ - self.database_config.password}@{self.database_config.host}/{self.database_config.name}') - self.session_local = sessionmaker(bind=self.engine) + 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_local = 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_local() diff --git a/src/utils/errors/database/__init__.py b/src/utils/errors/database/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/errors/database_config_error.py b/src/utils/errors/database/database_config_error.py similarity index 100% rename from src/utils/errors/database_config_error.py rename to src/utils/errors/database/database_config_error.py diff --git a/src/utils/errors/database/database_connection_error.py b/src/utils/errors/database/database_connection_error.py new file mode 100644 index 0000000..22f81f0 --- /dev/null +++ b/src/utils/errors/database/database_connection_error.py @@ -0,0 +1,5 @@ +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 new file mode 100644 index 0000000..a78dbdd --- /dev/null +++ b/src/utils/errors/database/database_error.py @@ -0,0 +1,4 @@ +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/import_error/__init__.py b/src/utils/errors/import_error/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/errors/import_error/import_error.py b/src/utils/errors/import_error/import_error.py new file mode 100644 index 0000000..0d286d3 --- /dev/null +++ b/src/utils/errors/import_error/import_error.py @@ -0,0 +1,5 @@ +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 new file mode 100644 index 0000000..274c865 --- /dev/null +++ b/src/utils/errors/import_error/invalid_contents_error.py @@ -0,0 +1,8 @@ +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 new file mode 100644 index 0000000..25e7f66 --- /dev/null +++ b/src/utils/errors/import_error/xsd_scheme_not_found.py @@ -0,0 +1,8 @@ +from .import_error import ImportError + + +class XsdSchemeNotFoundError(ImportError): + def __init__(self, message: str): + super().__init__(message) + + self.message = message