diff --git a/src/database/__init__.py b/src/database/__init__.py index fd3d1ba..f873297 100644 --- a/src/database/__init__.py +++ b/src/database/__init__.py @@ -1,9 +1,11 @@ from .manager import * from .book import * from .member import * +from .book_overview import * __all__ = [ *manager.__all__, *book.__all__, + *book_overview.__all__, *member.__all__, ] \ No newline at end of file diff --git a/src/database/book_category_statistics.py b/src/database/book_category_statistics.py index 6c67bcd..4428495 100644 --- a/src/database/book_category_statistics.py +++ b/src/database/book_category_statistics.py @@ -3,7 +3,22 @@ from sqlalchemy import update from models import BookCategoryStatistics, Book -def update_category_statistics(session: Session, book_id: int): - statistics = session.query(BookCategoryStatistics).get(book_id) - \ No newline at end of file +def update_category_statistics(session: Session, book_id: int): + book = session.query(Book).filter_by(id=book_id).first() + + if not book: + raise ValueError(f"Book with ID {book_id} does not exist.") + + for category in book.categories: + statistics = session.query(BookCategoryStatistics).filter_by(category_id=category.id).one_or_none() + + if statistics: + statistics.book_count += 1 + session.add(statistics) + else: + new_statistics = BookCategoryStatistics(category_id=category.id, book_count=1) + session.add(new_statistics) + + +__all__ = ["update_category_statistics"] diff --git a/src/database/member.py b/src/database/member.py index 3ccac6e..f1a6cb3 100644 --- a/src/database/member.py +++ b/src/database/member.py @@ -1,6 +1,7 @@ import logging +from typing import List, Dict -from sqlalchemy.exc import IntegrityError, SQLAlchemyError, DatabaseError +from sqlalchemy.exc import IntegrityError, SQLAlchemyError, DatabaseError as SqlAlchemyDatabaseError from utils.errors.database import DatabaseError, DuplicateEntryError, DatabaseConnectionError from models import Member @@ -8,26 +9,46 @@ from database.manager import DatabaseManager logger = logging.getLogger(__name__) +def fetch_all_members() -> List[Member]: + """ + Fetches all members from the database. -def create_new_member(new_member: Member): + :return: A list of all members in the database. + :raises DatabaseConnectionError: If the connection to the database is interrupted. + :raises DatabaseError: If any other error occurs while fetching members. + """ + with DatabaseManager.get_session() as session: + try: + return session.query(Member).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 occurred when fetching all members: {e}") + raise DatabaseError("An error occurred when fetching all members") from e + +def create_member(new_member: Dict[str, str]): + create_members([new_member]) + +def create_members(members: List[Dict[str, str]]): try: with DatabaseManager.get_session() as session: - session.add(new_member) + session.add_all(members) session.commit() except IntegrityError as e: - logger.warning("Data already exists") session.rollback() + logger.warning("Data already exists") raise DuplicateEntryError("Data already exists in the database") from e except DatabaseError as e: session.rollback() logger.critical("Connection with database interrupted") - raise DatabaseConnectionError( - "Connection with database interrupted") from e + raise DatabaseConnectionError("Connection with database interrupted") from e except SQLAlchemyError as e: - logger.error(f"An error occured when saving member: {e}") session.rollback() - raise DatabaseError( - "An error occured when creating a new member") from e + logger.error(f"An error occurred when saving member: {e}") + raise DatabaseError("An error occurred when creating a new member") from e +def update_member(member: Dict[str, str]): + pass -__all__ = ["create_new_member"] +__all__ = ["create_member", "create_members", "fetch_all_members"] diff --git a/src/services/book_overview_service.py b/src/services/book_overview_service.py new file mode 100644 index 0000000..8d003cd --- /dev/null +++ b/src/services/book_overview_service.py @@ -0,0 +1,78 @@ +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, + InvalidContentsError, + XsdSchemeNotFoundError, + ImportError, +) + +# Initialize logger and XML Schema +logger = logging.getLogger(__name__) + +from models import BooksOverview + +from database import fetch_all_book_overviews + +def export_to_xml(file_path: str) -> None: + logger.debug("Attempting to export book overview") + all_books = fetch_all_book_overviews() + + if not all_books: + logger.warning("No books found to export") + raise NoExportEntityError("No books found to export") + + xml = overviews_to_xml(all_books) + + try: + with open(file_path, "w", encoding="utf-8") as file: + file.write(xml) + logger.info("Successfully saved book overview export") + except OSError as e: + raise ExportFileError("Failed to save to a file") from e + + +def overviews_to_xml(overview_list: List[BooksOverview]) -> str: + root = ET.Element("book_overview") + + for book_overview in overview_list: + # Create a element + book_element = ET.SubElement(root, "book_entry") + + # Add + title_element = ET.SubElement(book_element, "title") + title_element.text = book_overview.title + + # Add <author> + author_element = ET.SubElement(book_element, "author") + author_element.text = book_overview.author_name + + # Add <year_published> + year_published_element = ET.SubElement(book_element, "year_published") + year_published_element.text = book_overview.year_published + + # Add <isbn> + isbn_element = ET.SubElement(book_element, "isbn") + isbn_element.text = book_overview.isbn + + # Add <borrower_name> + borrower_name = ET.SubElement(book_element, "borrower_name") + borrower_name.text = book_overview.borrower_name + + # Add <librarian_name> + librarian_name = ET.SubElement(book_element, "librarian_name") + librarian_name.text = book_overview.librarian_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/services/book_service.py b/src/services/book_service.py index 1867741..efbf580 100644 --- a/src/services/book_service.py +++ b/src/services/book_service.py @@ -46,7 +46,7 @@ def export_to_xml(file_path: str) -> None: def save_books(books: List[Dict[str, object]]): create_books(books) -def parse_books_from_xml(file_path: str) -> List[Dict[str, object]]: +def parse_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.") diff --git a/src/services/member_service.py b/src/services/member_service.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ui/editor/book_editor.py b/src/ui/editor/book_editor.py index db367f4..27b2395 100644 --- a/src/ui/editor/book_editor.py +++ b/src/ui/editor/book_editor.py @@ -23,7 +23,7 @@ class BookEditor(QDialog): if book: self.book_id = book.id - self.logger.debug(f"Editing existing book {book.title}") + self.logger.debug(f"Editing book {book.title}") self.create_new = False self.fill_with_existing_data(book) else: @@ -107,34 +107,29 @@ class BookEditor(QDialog): QMessageBox.information(None, "Success", "Book updated successfully", - QMessageBox.StandardButton.Ok, - QMessageBox.StandardButton.NoButton) + QMessageBox.StandardButton.Ok) self.accept() except ValueError as e: QMessageBox.critical(None, "Invalid Input", f"Input validation failed: {e}", - QMessageBox.StandardButton.Ok, - QMessageBox.StandardButton.NoButton) + QMessageBox.StandardButton.Ok) except DuplicateEntryError as e: QMessageBox.critical(None, "ISBN is already in use", "The ISBN provided is already in use", - QMessageBox.StandardButton.Ok, - QMessageBox.StandardButton.NoButton) + QMessageBox.StandardButton.Ok) except DatabaseConnectionError as e: QMessageBox.critical(None, "Failed to save", "Could not connect to the database", - QMessageBox.StandardButton.Ok, - QMessageBox.StandardButton.NoButton) + QMessageBox.StandardButton.Ok) except DatabaseError as e: QMessageBox.critical(self.parent, "An error occurred", f"Could not save the book because of the following error: {e}", - QMessageBox.StandardButton.Ok, - QMessageBox.StandardButton.NoButton) + QMessageBox.StandardButton.Ok) def parse_inputs(self) -> Dict[str, object]: # Title validation diff --git a/src/ui/editor/member_editor.py b/src/ui/editor/member_editor.py index c31e268..bc13b52 100644 --- a/src/ui/editor/member_editor.py +++ b/src/ui/editor/member_editor.py @@ -1,4 +1,6 @@ import logging +import re +from typing import Dict from PySide6.QtGui import QGuiApplication, QAction from PySide6.QtQml import QQmlApplicationEngine @@ -7,7 +9,7 @@ from PySide6.QtWidgets import QVBoxLayout, QFormLayout, QLineEdit, QHBoxLayout, from models import Member -from database.member import create_new_member +from database.member import create_new_member, update_member from utils.errors.database import DatabaseError, DatabaseConnectionError, DuplicateEntryError @@ -20,11 +22,12 @@ class MemberEditor(QDialog): self.create_layout() if member: - self.member = member + self.member_id = member.id + self.logger.debug(f"Editing member {member.first_name} {member.last_name}") self.fill_with_existing_data() self.create_new = False else: - self.member = Member() + self.logger.debug("Editing a new member") self.create_new = True def create_layout(self): @@ -68,27 +71,79 @@ class MemberEditor(QDialog): self.layout.addLayout(self.button_layout) - def fill_with_existing_data(self): - self.first_name_input.setText(self.member.first_name) - self.last_name_input.setText(self.member.last_name) - self.email_input.setText(self.member.email) - self.phone_number_input.setText(self.member.phone) + def fill_with_existing_data(self, member: Member): + self.first_name_input.setText(member.first_name) + self.last_name_input.setText(member.last_name) + self.email_input.setText(member.email) + self.phone_number_input.setText(member.phone) def save_member(self): - self.member.first_name = self.first_name_input.text() - self.member.last_name = self.last_name_input.text() - self.member.email = self.email_input.text() - self.member.phone = self.phone_number_input.text() - try: + member_object = self.parse_inputs() + if self.create_new: - self.logger.debug("Creating new member") - create_new_member(self.member) - except DuplicateEntryError: - QMessageBox.critical(None, "Details already in use", "Cannot create a new user", - QMessageBox.StandardButton.Ok, QMessageBox.StandardButtons.NoButton) + create_member(member_object) + QMessageBox.information(None, + "Success", + "Member created successfully", + QMessageBox.StandardButton.Ok) + else: + member_object["id"] = self.member_id + update_member(book_object) + QMessageBox.information(None, + "Success", + "Member updated successfully", + QMessageBox.StandardButton.Ok) + + + self.accept() + except ValueError as e: + QMessageBox.critical(None, + "Invalid Input", + f"Input validation failed: {e}", + QMessageBox.StandardButton.Ok) + except DuplicateEntryError as e: + QMessageBox.critical(None, + "ISBN is already in use", + "The ISBN provided is already in use", + QMessageBox.StandardButton.Ok) + except DatabaseConnectionError as e: + QMessageBox.critical(None, + "Failed to save", + "Could not connect to the database", + QMessageBox.StandardButton.Ok) + except DatabaseError as e: + QMessageBox.critical(self.parent, + "An error occurred", + f"Could not save the book because of the following error: {e}", + QMessageBox.StandardButton.Ok) self.accept() + def parse_inputs(self) -> Dict: + first_name = self.first_name_input.text().strip() + if not first_name or len(first_name) > 50: + raise ValueError("First name must be non-empty and at most 50 characters long.") + + last_name = self.last_name_input.text().strip() + if not last_name or len(last_name) > 50: + raise ValueError("Last name must be non-empty and at most 50 characters long.") + + email = self.email_input.text().strip() + email_regex = r"^[\w\-\.]+@([\w\-]+\.)+[\w\-]{2,}$" + if not re.match(email_regex, email): + raise ValueError("E-mail address is not in a valid format.") + + phone_number = self.phone_number_input.text().strip() + phone_number_regex = r"(\+\d{1,3})?\d{9}" + if not re.match(phone_number_regex, phone_number): + raise ValueError("Phone number is not in valid format.") + + return { + "first_name": first_name, + "last_name": last_name, + "email": email, + "phone_number": phone_number + } __all__ = ["MemberEditor"] diff --git a/src/ui/menu_bar.py b/src/ui/menu_bar.py index 4d11173..151b9f1 100644 --- a/src/ui/menu_bar.py +++ b/src/ui/menu_bar.py @@ -7,7 +7,8 @@ from ui.import_preview import PreviewDialog from ui.editor import BookEditor, MemberEditor from utils.errors import ExportError, ExportFileError -from services import book_service +from services import book_service, book_overview_service + class MenuBar(QMenuBar): def __init__(self, parent): @@ -50,23 +51,23 @@ class MenuBar(QMenuBar): import_submenu.addAction(import_books_action) import_members_action = QAction("Import members", self) - import_members_action.triggered.connect(self.import_data) + import_members_action.triggered.connect(self.import_members) 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_overview_action = QAction("Export overview", self) + export_overview_action.triggered.connect(self.export_overviews) + export_submenu.addAction(export_overview_action) + export_members_action = QAction("Export members", self) - export_members_action.triggered.connect(self.export_data) + export_members_action.triggered.connect(self.export_members) export_submenu.addAction(export_members_action) file_menu.addSeparator() @@ -97,36 +98,34 @@ class MenuBar(QMenuBar): 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] + def new_book(self): + BookEditor().exec() + self.parent.refresh_book_cards() - if file_path.endswith(selected_filetype): - selected_filetype = "" - - book_service.export_to_xml(file_path + selected_filetype) - - except ExportFileError 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 new_member(self): + MemberEditor().exec() + self.parent.refresh_member_cards() def import_books(self): + self.import_data("Book", book_service) + + def import_members(self): + self.import_data("Member", memb) + + def export_books(self): + self.export_data("Book", book_service) + + def export_overviews(self): + self.export_data("Book overview", book_overview_service) + + def export_members(self): + pass + + def about(self): + QMessageBox.information( + self, "About", "Library app demonstrating the phantom read problem") + + def import_data(self, import_name: str, preview_dialog, service): try: home_dir = QStandardPaths.writableLocation(QStandardPaths.HomeLocation) file_path, _ = QFileDialog.getOpenFileName(self, "Choose import file", home_dir, ";;".join(self.file_types.keys())) @@ -134,18 +133,18 @@ class MenuBar(QMenuBar): if not file_path: return # User canceled - 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) + parsed_data = service.parse_from_xml(file_path) + if not parsed_data: + QMessageBox.warning(self, f"No New {import_name}s", f"No new {import_name}s to import.", QMessageBox.Ok) return # Show preview dialog - dialog = PreviewDialog(books, self) + dialog = PreviewDialog(parsed_data, self) if dialog.exec() == QDialog.Accepted: # User confirmed, proceed with importing - book_service.create_books(books) + book_service.create_books(parsed_data) QMessageBox.information(self, "Success", "Books imported successfully!", QMessageBox.Ok) - self.parent.redraw_book_cards() + self.parent.refresh_book_cards() else: QMessageBox.information(self, "Canceled", "Import was canceled.", QMessageBox.Ok) except ImportError as e: @@ -154,18 +153,28 @@ class MenuBar(QMenuBar): f"An error occurred when importing books from the file provided: {e}", QMessageBox.StandardButton.Ok) - def new_book(self): - BookEditor().exec() + def export_data(self, export_name: str, service): + try: + home_dir = QStandardPaths.writableLocation(QStandardPaths.HomeLocation) + file_path, selected_filter = QFileDialog.getSaveFileName(self, + f"Save {export_name} export", + home_dir, + ";;".join(self.file_types.keys())) + if file_path: + selected_filetype = self.file_types[selected_filter] - def new_member(self): - MemberEditor().exec() + if file_path.endswith(selected_filetype): + selected_filetype = "" - def import_data(self): - pass + service.export_to_xml(file_path + selected_filetype) - def export_data(self): - pass - - def about(self): - QMessageBox.information( - self, "About", "Library app demonstrating the phantom read problem") + except ExportFileError as e: + QMessageBox.critical(self, + "Error saving file", + f"Error occurred when saving the exported data: {e}", + QMessageBox.StandardButton.Ok) + except ExportError as e: + QMessageBox.critical(self, + f"Error exporting {export_name}s", + f"An error occurred when exporting {export_name}s: {e}", + QMessageBox.StandardButton.Ok) diff --git a/src/ui/window.py b/src/ui/window.py index b5eb852..1370b05 100644 --- a/src/ui/window.py +++ b/src/ui/window.py @@ -44,5 +44,8 @@ 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 + def refresh_book_cards(self): + self.dashboard.redraw_cards() + + def refresh_member_cards(self): + self.member_list.redraw_cards() \ No newline at end of file