[main] Added imports as well a whole bunch of tweaks elsewhere

This commit is contained in:
Thastertyn 2025-01-12 20:08:25 +01:00
parent 142b2d449d
commit 48163325d8
22 changed files with 364 additions and 57 deletions

View File

@ -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

View File

@ -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()

View File

@ -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 <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 = 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
pretty_xml = minidom.parseString(
tree_str).toprettyxml(indent=(" " * 4))
return pretty_xml

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

View File

View File

@ -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>

View File

@ -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

View File

@ -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')
book = relationship('Book', overlaps='categories,books')
book_category = relationship('BookCategory', overlaps='categories,books')

7
src/requirements.txt Normal file
View File

@ -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

View File

@ -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")

34
src/ui/import_preview.py Normal file
View File

@ -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)

View File

@ -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
# endregion

View File

@ -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

View File

@ -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()

View File

View File

@ -0,0 +1,5 @@
from .database_error import DatabaseError
class DatabaseConnectionError(DatabaseError):
def __init__(self, message: str):
super().__init__(message)
self.message = message

View File

@ -0,0 +1,4 @@
class DatabaseError(Exception):
def __init__(self, message: str):
super().__init__(message)
self.message = message

View File

@ -0,0 +1,5 @@
class ImportError(Exception):
def __init__(self, message: str):
super().__init__(message)
self.message = message

View File

@ -0,0 +1,8 @@
from .import_error import ImportError
class InvalidContentsError(ImportError):
def __init__(self, message: str):
super().__init__(message)
self.message = message

View File

@ -0,0 +1,8 @@
from .import_error import ImportError
class XsdSchemeNotFoundError(ImportError):
def __init__(self, message: str):
super().__init__(message)
self.message = message