[main] Major changes for submit

This commit is contained in:
Thastertyn 2025-01-20 06:49:32 +01:00
parent e452822ffc
commit 104d42201d
40 changed files with 511 additions and 484 deletions

View File

@ -3,3 +3,6 @@ DATABASE_PORT=
DATABASE_NAME= DATABASE_NAME=
DATABASE_USER= DATABASE_USER=
DATABASE_PASSWORD= DATABASE_PASSWORD=
TRANSACTION_LEVEL=
VERBOSITY=

Binary file not shown.

View File

@ -6,9 +6,8 @@
<xs:element name="book" maxOccurs="unbounded"> <xs:element name="book" maxOccurs="unbounded">
<xs:complexType> <xs:complexType>
<xs:sequence> <xs:sequence>
<xs:element name="title"> <!-- Book title--> <xs:element name="title"> <!-- Book title -->
<xs:simpleType> <xs:simpleType>
<!-- Allow for a string 2-100 characters long -->
<xs:restriction base="xs:string"> <xs:restriction base="xs:string">
<xs:minLength value="2" /> <xs:minLength value="2" />
<xs:maxLength value="100" /> <xs:maxLength value="100" />
@ -21,14 +20,14 @@
<xs:element name="first_name"> <xs:element name="first_name">
<xs:simpleType> <xs:simpleType>
<xs:restriction base="xs:string"> <xs:restriction base="xs:string">
<xs:maxLength value="50"/> <xs:maxLength value="50" />
</xs:restriction> </xs:restriction>
</xs:simpleType> </xs:simpleType>
</xs:element> </xs:element>
<xs:element name="last_name"> <xs:element name="last_name">
<xs:simpleType> <xs:simpleType>
<xs:restriction base="xs:string"> <xs:restriction base="xs:string">
<xs:maxLength value="50"/> <xs:maxLength value="50" />
</xs:restriction> </xs:restriction>
</xs:simpleType> </xs:simpleType>
</xs:element> </xs:element>
@ -50,6 +49,16 @@
</xs:restriction> </xs:restriction>
</xs:simpleType> </xs:simpleType>
</xs:element> </xs:element>
<xs:element name="price"> <!-- Price -->
<xs:simpleType>
<xs:restriction base="xs:decimal">
<xs:totalDigits value="7" /> <!-- Max 99999.99 -->
<xs:fractionDigits value="2" />
<xs:minInclusive value="0.00" />
</xs:restriction>
</xs:simpleType>
</xs:element>
<xs:element name="is_damaged" type="xs:boolean" /> <!-- Is damaged -->
<xs:element name="categories"> <!-- Categories list --> <xs:element name="categories"> <!-- Categories list -->
<xs:complexType> <xs:complexType>
<xs:sequence> <xs:sequence>
@ -64,5 +73,4 @@
</xs:sequence> </xs:sequence>
</xs:complexType> </xs:complexType>
</xs:element> </xs:element>
</xs:schema> </xs:schema>

View File

@ -1,13 +1,17 @@
from .manager import * from .manager import *
from .book import * from .book import *
from .book_category import *
from .book_category_statistics import * from .book_category_statistics import *
from .book_category_statistics_overview import *
from .member import * from .member import *
from .book_overview import * from .book_overview import *
__all__ = [ __all__ = [
*manager.__all__, *manager.__all__,
*book.__all__, *book.__all__,
*book_category.__all__,
*book_category_statistics.__all__, *book_category_statistics.__all__,
*book_category_statistics_overview.__all__,
*book_overview.__all__, *book_overview.__all__,
*member.__all__, *member.__all__,
] ]

View File

@ -1,31 +1,18 @@
from typing import Dict, List, Optional from typing import Dict, List
import logging
import time
from sqlalchemy.exc import IntegrityError, SQLAlchemyError, DatabaseError as SqlAlchemyDatabaseError from sqlalchemy.exc import IntegrityError, SQLAlchemyError, DatabaseError as SqlAlchemyDatabaseError
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from sqlalchemy import delete from sqlalchemy import delete
from utils.errors.database import DatabaseError, DuplicateEntryError, DatabaseConnectionError from utils.errors.database import DatabaseError, DuplicateEntryError, DatabaseConnectionError
from models import Book from models import Book
from database.manager import DatabaseManager from database.manager import DatabaseManager
from .author import get_or_create_author from .author import get_or_create_author
from .book_category import get_or_create_categories from .book_category import get_or_create_categories
from .book_category_statistics import update_category_statistics from .book_category_statistics import update_category_statistics
from utils.config import UserConfig import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def fetch_all_books() -> List[Book]: 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: with DatabaseManager.get_session() as session:
try: try:
return session.query(Book) \ return session.query(Book) \
@ -41,68 +28,41 @@ def fetch_all_books() -> List[Book]:
logger.error(f"An error occurred when fetching all books: {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], skip_existing: bool = True) -> None:
create_books([book], skip_existing)
def create_book(book: Dict[str, object]) -> None: def create_books(books: List[Dict[str, object]], skip_existing: bool = True) -> 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 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: try:
with DatabaseManager.get_session() as session: with DatabaseManager.get_session() as session:
for book in books: for book in books:
logger.debug(f"Attempting to create a new book: {book['title']}") logger.debug(f"Attempting to create a new book: {book['title']}")
# Check if the book already exists
existing_book = session.query(Book).filter_by(isbn=book["isbn"]).first() existing_book = session.query(Book).filter_by(isbn=book["isbn"]).first()
if existing_book: if existing_book:
if skip_existing:
logger.warning(f"Book with ISBN {book['isbn']} already exists. Skipping.") logger.warning(f"Book with ISBN {book['isbn']} already exists. Skipping.")
continue continue
else:
logger.error(f"Book with ISBN {book['isbn']} already exists.")
raise DuplicateEntryError(f"Book with ISBN {book['isbn']} already exists.")
author = get_or_create_author(session, book["author"]) author = get_or_create_author(session, book["author"])
categories = get_or_create_categories(session, book["categories"]) categories = get_or_create_categories(session, book["categories"])
# Create the new book
new_book = Book( new_book = Book(
title=book["title"], title=book["title"],
description=book["description"], description=book["description"],
year_published=book["year_published"], year_published=book["year_published"],
isbn=book["isbn"], isbn=book["isbn"],
price=book["price"],
is_damaged=book["is_damaged"],
author=author, author=author,
categories=categories categories=categories
) )
session.add(new_book) session.add(new_book)
user_config = UserConfig()
if user_config.simulate_slowdown:
logger.debug("Simulating slowdown before updating statistics for 10 seconds")
time.sleep(10)
else:
logger.debug("Performing category statistics update normally")
update_category_statistics(session) update_category_statistics(session)
session.commit()
# logger.info(f"Book {book['title']} successfully created.")
logger.debug("Committing all changes")
session.commit() session.commit()
except IntegrityError as e: except IntegrityError as e:
logger.warning("Data already exists") logger.warning("Data already exists")
@ -114,41 +74,34 @@ def create_books(books: List[Dict[str, object]]) -> None:
logger.error(f"An error occurred when creating the book: {e}") logger.error(f"An error occurred when creating the book: {e}")
raise DatabaseError("An error occurred when creating the book") from e raise DatabaseError("An error occurred when creating the book") from e
def update_book(book: Dict[str, object]) -> None: 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: try:
with DatabaseManager.get_session() as session: with DatabaseManager.get_session() as session:
logger.debug(f"Updating book {book['title']}") logger.debug(f"Updating book {book['title']}")
# Find the existing book existing_book = session.query(Book).filter_by(isbn=book["isbn"]).first()
existing_book = session.query(Book).get(book["id"])
if not existing_book: if not existing_book:
logger.warning(f"Book with ID {book['id']} not found") logger.warning(f"Book with ISBN {book['isbn']} not found")
raise DatabaseError("Book not found in the database") raise DatabaseError("Book not found in the database")
# Get or create the author
author = get_or_create_author(session, book["author"]) author = get_or_create_author(session, book["author"])
# Get or create the categories
categories = get_or_create_categories(session, book["categories"]) categories = get_or_create_categories(session, book["categories"])
session.commit()
# Update the book details
existing_book.title = book["title"] existing_book.title = book["title"]
existing_book.description = book["description"] existing_book.description = book["description"]
existing_book.year_published = book["year_published"] existing_book.year_published = book["year_published"]
existing_book.isbn = book["isbn"] existing_book.isbn = book["isbn"]
existing_book.price = book["price"]
existing_book.is_damaged = book["is_damaged"]
existing_book.status = book["status"]
existing_book.author = author existing_book.author = author
existing_book.categories = categories existing_book.categories = categories
update_category_statistics(session, ignore_config=True)
session.commit() session.commit()
logger.info(f"{book['title']} successfully updated.") logger.info(f"{book['title']} successfully updated.")
except IntegrityError as e: except IntegrityError as e:
logger.warning("Data already exists") logger.warning("Data already exists")
@ -163,9 +116,12 @@ def update_book(book: Dict[str, object]) -> None:
def delete_book(book_id: int) -> None: def delete_book(book_id: int) -> None:
try: try:
with DatabaseManager.get_session() as session: with DatabaseManager.get_session() as session:
logger.debug(f"Deleting book id {book_id}")
stmt = delete(Book).where(Book.id == book_id) stmt = delete(Book).where(Book.id == book_id)
session.execute(stmt) session.execute(stmt)
update_category_statistics(session, ignore_config=True)
session.commit() session.commit()
logger.info(f"Successfully deleted book with id {book_id}")
except SqlAlchemyDatabaseError as e: except SqlAlchemyDatabaseError as e:
logger.critical("Connection with database interrupted") logger.critical("Connection with database interrupted")
raise DatabaseConnectionError("Connection with database interrupted") from e raise DatabaseConnectionError("Connection with database interrupted") from e
@ -173,4 +129,4 @@ def delete_book(book_id: int) -> None:
logger.error(f"An error occurred when updating the book: {e}") logger.error(f"An error occurred when updating the book: {e}")
raise DatabaseError("An error occurred when updating the book") from e raise DatabaseError("An error occurred when updating the book") from e
__all__ = ["create_book", "create_books", "update_book", "fetch_all_books"] __all__ = ["create_book", "create_books", "update_book", "fetch_all_books", "delete_book"]

View File

@ -4,6 +4,9 @@ import logging
from models import BookCategory from models import BookCategory
from database.manager import DatabaseManager from database.manager import DatabaseManager
from sqlalchemy.orm import Session
from sqlalchemy import func
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -37,3 +40,9 @@ def get_or_create_categories(session, category_names: List[str]) -> List[BookCat
filtered_categories.append(new_category) filtered_categories.append(new_category)
return filtered_categories return filtered_categories
def get_total_count(session: Session) -> int:
return session.query(func.count(BookCategory.id)).scalar()
__all__ = ["get_total_count", "get_or_create_categories"]

View File

@ -1,43 +1,56 @@
from sqlalchemy import func, insert
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import func from models import BookCategory, BookCategoryStatistics, BookCategoryLink
import logging
from models import BookCategoryStatistics, BookCategoryLink logger = logging.getLogger(__name__)
def update_category_statistics(session: Session, ignore_config: bool = False) -> None:
def update_category_statistics(session: Session) -> None:
""" """
Updates category statistics by calculating the count of books in each category. Updates category statistics by calculating the count of books in each category.
:param session: SQLAlchemy session object. :param session: SQLAlchemy session object.
""" """
# Calculate the book count for each category using a query # Fetch book counts per category using a join between book_category and book_category_link
category_counts = ( category_counts = (
session.query( session.query(
BookCategoryLink.book_category_id, BookCategory.id.label('book_category_id'),
func.count(BookCategoryLink.book_id).label('book_count') func.count(BookCategoryLink.book_id).label('book_count')
) )
.group_by(BookCategoryLink.book_category_id) .join(BookCategoryLink, BookCategoryLink.book_category_id == BookCategory.id, isouter=True)
.group_by(BookCategory.id)
.all() .all()
) )
# Update or create statistics based on the query results # Iterate over the results and update or insert the category statistics
for category_id, book_count in category_counts: for category_id, book_count in category_counts:
# Try to get the existing statistics or create a new one if it doesn't exist
existing_statistics = ( existing_statistics = (
session.query(BookCategoryStatistics) session.query(BookCategoryStatistics)
.filter_by(book_category_id=category_id) .filter(BookCategoryStatistics.book_category_id == category_id)
.one_or_none() .one_or_none()
) )
if existing_statistics: if existing_statistics:
# Update the existing count # If statistics exist, update the count
existing_statistics.book_count = book_count existing_statistics.book_count = book_count
logger.debug(f"Updated category {category_id} with count {book_count}")
else: else:
# Create new statistics for the category # If statistics don't exist, create a new one
new_statistics = BookCategoryStatistics( new_statistics = BookCategoryStatistics(
book_category_id=category_id, book_category_id=category_id,
book_count=book_count book_count=book_count
) )
session.add(new_statistics) session.add(new_statistics)
logger.debug(f"Inserted new statistics for category {category_id} with count {book_count}")
try:
# Commit the transaction
session.commit()
logger.debug("Category statistics updated successfully")
except Exception as e:
# In case of error, rollback the transaction
logger.error(f"An error occurred while updating category statistics: {e}")
session.rollback()
__all__ = ["update_category_statistics"] __all__ = ["update_category_statistics"]

View File

@ -0,0 +1,55 @@
from typing import List, Tuple
import time
import logging
from sqlalchemy.exc import IntegrityError, SQLAlchemyError, DatabaseError as SqlAlchemyDatabaseError
from utils.errors.database import DatabaseError, DuplicateEntryError, DatabaseConnectionError
from models import BookCategoryStatisticsOverview
from database.manager import DatabaseManager
from database import get_total_count
from utils.config import UserConfig
logger = logging.getLogger(__name__)
def fetch_all_book_category_statistics_overviews() -> List[BookCategoryStatisticsOverview]:
with DatabaseManager.get_session() as session:
try:
return session.query(BookCategoryStatisticsOverview).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 category overviews: {e}")
raise DatabaseError("An error occurred when fetching all category overviews") from e
def fetch_all_book_category_statistics_overviews_with_count() -> Tuple[List[BookCategoryStatisticsOverview], int]:
with DatabaseManager.get_session() as session:
try:
category_list = session.query(BookCategoryStatisticsOverview).all()
user_config = UserConfig()
if user_config.simulate_slowdown:
logger.debug("Simulating slowdown after fetching statistics for 10 seconds")
time.sleep(10)
else:
logger.debug("Performing category statistics update normally")
count = get_total_count(session)
return (category_list, count)
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 category overviews: {e}")
raise DatabaseError("An error occurred when fetching all category overviews") from e
__all__ = ["fetch_all_book_category_statistics_overviews", "fetch_all_book_category_statistics_overviews_with_count"]

View File

@ -18,8 +18,8 @@ def fetch_all_book_overviews() -> List[BooksOverview]:
logger.critical("Connection with database interrupted") 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: 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 raise DatabaseError("An error occurred when fetching all books") from e
__all__ = ["fetch_all_book_overviews"] __all__ = ["fetch_all_book_overviews"]

View File

@ -22,27 +22,28 @@ class DatabaseManager():
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
self.logger.info("Reading database config") self.logger.info("Reading database config")
database_config = DatabaseConfig() database_config = DatabaseConfig()
user_config = UserConfig()
self.engine = create_engine('mysql+mysqlconnector://%s:%s@%s:%s/%s' % ( self.engine = create_engine('mysql+mysqlconnector://%s:%s@%s:%s/%s' % (
database_config.user, database_config.user,
database_config.password, database_config.password,
database_config.host, database_config.host,
database_config.port, database_config.port,
database_config.name), database_config.name),
pool_pre_ping=True) pool_pre_ping=True,
if self.test_connection(): isolation_level=user_config.transaction_level.value)
self.test_connection()
self.Session = sessionmaker(bind=self.engine) self.Session = sessionmaker(bind=self.engine)
def cleanup(self) -> None: def cleanup(self) -> None:
self.logger.debug("Closing connection") self.logger.debug("Closing connection")
self.engine.dispose() self.engine.dispose()
def test_connection(self) -> bool: def test_connection(self):
self.logger.debug("Testing database connection") self.logger.debug("Testing database connection")
try: try:
with self.engine.connect() as connection: with self.engine.connect() as connection:
connection.execute(text("select 1")) connection.execute(text("select 1"))
self.logger.debug("Database connection successful") self.logger.debug("Database connection successful")
return True
except DatabaseError as e: except DatabaseError as e:
self.logger.critical(f"Database connection failed: {e}") self.logger.critical(f"Database connection failed: {e}")
raise DatabaseConnectionError("Database connection failed") from e raise DatabaseConnectionError("Database connection failed") from e

View File

@ -67,7 +67,44 @@ def create_members(members: List[Dict[str, str]]):
raise DatabaseError("An error occurred when creating a new member") from e raise DatabaseError("An error occurred when creating a new member") from e
def update_member(member: Dict[str, str]): def update_member(member: Dict[str, str]):
pass try:
with DatabaseManager.get_session() as session:
logger.debug(f"Editing member {member['first_name']} {member['last_name']}")
existing_member = session.query(Member).get(member["id"])
if not existing_member:
logger.warning(f"Member with ID {member['id']} not found")
raise DatabaseError("Member not found in database")
existing_member.first_name = member["first_name"]
existing_member.last_name = member["last_name"]
existing_member.email = member["email"]
existing_member.phone = member["phone"]
session.commit()
except IntegrityError as e:
session.rollback()
if "email" in str(e.orig):
logger.warning("Email is already in use")
raise DuplicateEntryError("Email", "Email is already in use") from e
elif "phone" in str(e.orig):
logger.warning("Phone number is already in use")
raise DuplicateEntryError("Phone number", "Phone number is already in use") from e
else:
logger.error("An error occurred when updating member")
raise DatabaseError("An error occurred when updating member") from e
except DatabaseError as e:
session.rollback()
logger.critical("Connection with database interrupted")
raise DatabaseConnectionError("Connection with database interrupted") from e
except SQLAlchemyError as e:
session.rollback()
logger.error(f"An error occurred when saving member: {e}")
raise DatabaseError("An error occurred when creating a new member") from e
def delete_member(member_id: int) -> None: def delete_member(member_id: int) -> None:
try: try:

View File

@ -1,71 +0,0 @@
import os
import logging
import time
from typing import List, Dict
from xml.etree import ElementTree as ET
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
from utils.config import UserConfig
from models import Book, Author, BookCategory
class BookImporter:
def __init__(self):
# Initialize the logger and schema
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[Dict[str, object]]:
"""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")
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 with explicit types
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}")

View File

@ -6,8 +6,6 @@ from .book_category_statistics_model import *
from .book_category_statistics_overview_model import * from .book_category_statistics_overview_model import *
from .book_overview_model import * from .book_overview_model import *
from .member_model import * from .member_model import *
from .librarian_model import *
from .loan_model import *
__all__ = [ __all__ = [
*author_model.__all__, *author_model.__all__,
@ -18,6 +16,4 @@ __all__ = [
*book_category_statistics_overview_model.__all__, *book_category_statistics_overview_model.__all__,
*book_overview_model.__all__, *book_overview_model.__all__,
*member_model.__all__, *member_model.__all__,
*librarian_model.__all__,
*loan_model.__all__
] ]

View File

@ -8,13 +8,9 @@ class BookCategory(Base):
__table_args__ = (UniqueConstraint('name'),) __table_args__ = (UniqueConstraint('name'),)
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
parent_category_id = Column(Integer, ForeignKey('book_category.id'), nullable=True)
name = Column(String(100), nullable=False) name = Column(String(100), nullable=False)
mature_content = Column(Integer, nullable=False, default=0)
last_updated = Column(TIMESTAMP, nullable=False, server_default=func.now()) last_updated = Column(TIMESTAMP, nullable=False, server_default=func.now())
parent_category = relationship('BookCategory', remote_side=[id])
books = relationship( books = relationship(
'Book', 'Book',
secondary='book_category_link', secondary='book_category_link',

View File

@ -8,11 +8,12 @@ class BookCategoryStatisticsOverview(Base):
__tablename__ = 'book_category_statistics_overview' __tablename__ = 'book_category_statistics_overview'
__table_args__ = {'extend_existing': True} __table_args__ = {'extend_existing': True}
id = Column(Integer, primary_key=True)
name = Column(String) name = Column(String)
book_count = Column(INTEGER(unsigned=True), default=0) book_count = Column(INTEGER(unsigned=True), default=0)
def __repr__(self): def __repr__(self):
return (f"<BookCategoryStatisticsOverview(book_category_id={self.book_category_id}, book_count={self.book_count})>") return (f"<BookCategoryStatisticsOverview(book_category_id={self.id}, book_count={self.book_count})>")
__all__ = ["BookCategoryStatisticsOverview"] __all__ = ["BookCategoryStatisticsOverview"]

View File

@ -1,6 +1,6 @@
import enum import enum
from sqlalchemy import Column, Integer, String, TIMESTAMP, Text, ForeignKey, Enum, UniqueConstraint, func from sqlalchemy import Column, Integer, String, TIMESTAMP, Text, ForeignKey, Enum, UniqueConstraint, func, DECIMAL,Boolean
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from .base_model import Base from .base_model import Base
@ -22,6 +22,8 @@ class Book(Base):
description = Column(Text, nullable=False) description = Column(Text, nullable=False)
year_published = Column(String(4), nullable=False) year_published = Column(String(4), nullable=False)
isbn = Column(String(13), nullable=False, unique=True) isbn = Column(String(13), nullable=False, unique=True)
price = Column(DECIMAL(5, 2), nullable=False, default=0)
is_damaged = Column(Boolean, nullable=False, default=False)
status = Column(Enum(BookStatusEnum), nullable=False, default=BookStatusEnum.available) status = Column(Enum(BookStatusEnum), nullable=False, default=BookStatusEnum.available)
created_at = Column(TIMESTAMP, nullable=False, server_default=func.now()) created_at = Column(TIMESTAMP, nullable=False, server_default=func.now())
last_updated = Column(TIMESTAMP, nullable=False, server_default=func.now()) last_updated = Column(TIMESTAMP, nullable=False, server_default=func.now())

View File

@ -1,4 +1,4 @@
from sqlalchemy import Column, String, TIMESTAMP, Integer, Text, Enum from sqlalchemy import Column, String, TIMESTAMP, Integer, Text, Enum, DECIMAL, Boolean
from .base_model import Base from .base_model import Base
from .book_model import BookStatusEnum from .book_model import BookStatusEnum
@ -15,12 +15,10 @@ class BooksOverview(Base):
categories = Column(Text, nullable=True) categories = Column(Text, nullable=True)
year_published = Column(Integer, nullable=True) year_published = Column(Integer, nullable=True)
isbn = Column(String, nullable=True) isbn = Column(String, nullable=True)
price = Column(DECIMAL(5, 2), nullable=False, default=0)
is_damaged = Column(Boolean, nullable=False, default=False)
status = Column(Enum(BookStatusEnum), nullable=False) status = Column(Enum(BookStatusEnum), nullable=False)
created_at = Column(TIMESTAMP, nullable=False) created_at = Column(TIMESTAMP, nullable=False)
borrower_name = Column(String, nullable=True)
member_id = Column(Integer, nullable=True)
librarian_name = Column(String, nullable=True)
librarian_id = Column(Integer, nullable=True)
# This prevents accidental updates/deletes as it's a view # This prevents accidental updates/deletes as it's a view
def __repr__(self): def __repr__(self):

View File

@ -1,33 +0,0 @@
import enum
from sqlalchemy import Column, Integer, String, TIMESTAMP, Text, ForeignKey, Enum, UniqueConstraint, func
from sqlalchemy.orm import relationship
from .base_model import Base
class LibrarianStatusEnum(enum.Enum):
active = 'active'
inactive = 'inactive'
class LibrarianRoleEnum(enum.Enum):
staff = 'staff'
admin = 'admin'
class Librarian(Base):
__tablename__ = 'librarian'
__table_args__ = (UniqueConstraint('id'),)
id = Column(Integer, primary_key=True, autoincrement=True)
first_name = Column(String(50), nullable=False)
last_name = Column(String(50), nullable=False)
email = Column(String(100), nullable=False, unique=True)
phone = Column(String(20), nullable=False)
hire_date = Column(TIMESTAMP, nullable=False, server_default=func.now())
status = Column(Enum(LibrarianStatusEnum), nullable=False, default=LibrarianStatusEnum.active)
role = Column(Enum(LibrarianRoleEnum), nullable=False)
last_updated = Column(TIMESTAMP, nullable=False, server_default=func.now())
__all__ = ["Librarian", "LibrarianRoleEnum", "LibrarianStatusEnum"]

View File

@ -1,39 +0,0 @@
import enum
from sqlalchemy import Column, Integer, String, TIMESTAMP, Text, ForeignKey, Enum, UniqueConstraint, Float, func
from sqlalchemy.orm import relationship
from .base_model import Base
from .book_model import Book
from .member_model import Member
from .librarian_model import Librarian
class LoanStatusEnum(enum.Enum):
borrowed = 'borrowed'
returned = 'returned'
overdue = 'overdue'
reserved = 'reserved'
class Loan(Base):
__tablename__ = 'loan'
__table_args__ = (UniqueConstraint('id'),)
id = Column(Integer, primary_key=True, autoincrement=True)
book_id = Column(Integer, ForeignKey('book.id'), nullable=False)
member_id = Column(Integer, ForeignKey('member.id'), nullable=False)
librarian_id = Column(Integer, ForeignKey('librarian.id'), nullable=False)
loan_date = Column(TIMESTAMP, nullable=False, server_default=func.now())
due_date = Column(TIMESTAMP, nullable=False)
return_date = Column(TIMESTAMP, nullable=True)
status = Column(Enum(LoanStatusEnum), nullable=False, default=LoanStatusEnum.borrowed)
overdue_fee = Column(Float, nullable=True)
last_updated = Column(TIMESTAMP, nullable=False, server_default=func.now())
book = relationship('Book', backref='loans')
member = relationship('Member', backref='loans')
librarian = relationship('Librarian', backref='loans')
__all__ = ["Loan", "LoanStatusEnum"]

View File

@ -5,3 +5,4 @@ PySide6_Essentials==6.8.1
python-dotenv==1.0.1 python-dotenv==1.0.1
SQLAlchemy==2.0.36 SQLAlchemy==2.0.36
xmlschema==3.4.3 xmlschema==3.4.3
mysql-connector-python==9.1.0

View File

@ -0,0 +1,61 @@
from typing import List
import logging
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,
)
from models import BookCategoryStatisticsOverview
from assets import asset_manager
from utils.errors import DatabaseConnectionError, DatabaseError
from database import fetch_all_book_category_statistics_overviews_with_count
logger = logging.getLogger(__name__)
def export_to_xml(file_path: str) -> None:
try:
category_list, count = fetch_all_book_category_statistics_overviews_with_count()
if not category_list:
raise NoExportEntityError("No categories found to export")
xml = category_statistics_to_xml(category_list, count)
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
except DatabaseConnectionError as e:
raise ExportError("An error with database occurred. Try again later") from e
except DatabaseError as e:
raise ExportError("Unknown error occurred") from e
def category_statistics_to_xml(category_statistics: List[BookCategoryStatisticsOverview], total_count: int):
root = ET.Element("statistics")
for statistic in category_statistics:
category_element = ET.SubElement(root, "category")
name_element = ET.SubElement(category_element, "name")
name_element.text = statistic.name
count_element = ET.SubElement(category_element, "count")
count_element.text = str(statistic.book_count)
total_count_element = ET.SubElement(root, "total_category_count")
total_count_element.text = str(total_count)
tree_str = ET.tostring(root, encoding="unicode")
pretty_xml = minidom.parseString(tree_str).toprettyxml(indent=(" " * 4))
return pretty_xml

View File

@ -58,6 +58,8 @@ def parse_from_xml(file_path: str) -> List[Dict[str, object]]:
year_published = book_element.find("year_published").text year_published = book_element.find("year_published").text
description = book_element.find("description").text description = book_element.find("description").text
isbn = book_element.find("isbn").text isbn = book_element.find("isbn").text
price = float(book_element.find("price").text)
is_damaged = bool(book_element.find("is_damaged").text)
# Parse author # Parse author
author_element = book_element.find("author") author_element = book_element.find("author")
@ -77,6 +79,8 @@ def parse_from_xml(file_path: str) -> List[Dict[str, object]]:
"year_published" : year_published, "year_published" : year_published,
"isbn" : isbn, "isbn" : isbn,
"author" : author, "author" : author,
"price": price,
"is_damaged" : is_damaged,
"categories" : categories, "categories" : categories,
} }
books.append(book) books.append(book)
@ -119,6 +123,12 @@ def books_to_xml(books: List[Book]) -> str:
isbn_element = ET.SubElement(book_element, "isbn") isbn_element = ET.SubElement(book_element, "isbn")
isbn_element.text = book.isbn isbn_element.text = book.isbn
price_element = ET.SubElement(book_element, "price")
price_element.text = str(book.price)
damaged_element = ET.SubElement(book_element, "is_damaged")
damaged_element.text = str(book.is_damaged).lower()
# Add <categories> # Add <categories>
categories_element = ET.SubElement(book_element, "categories") categories_element = ET.SubElement(book_element, "categories")
for category in book.categories: for category in book.categories:
@ -133,4 +143,4 @@ def books_to_xml(books: List[Book]) -> str:
return pretty_xml return pretty_xml
__all__ = ["export_to_xml", "parse_books_from_xml"] __all__ = ["export_to_xml", "parse_from_xml"]

View File

@ -1,9 +1,9 @@
from typing import Dict from typing import Dict, Callable
import logging import logging
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QTextEdit, QPushButton, QComboBox, QFormLayout, QMessageBox QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QTextEdit, QPushButton, QComboBox, QFormLayout, QMessageBox, QCheckBox
) )
from PySide6.QtGui import QRegularExpressionValidator from PySide6.QtGui import QRegularExpressionValidator
from PySide6.QtCore import QRegularExpression from PySide6.QtCore import QRegularExpression
@ -15,12 +15,14 @@ from utils.errors.database import DatabaseError, DatabaseConnectionError, Duplic
class BookEditor(QDialog): class BookEditor(QDialog):
def __init__(self, book: Book = None, parent=None): def __init__(self, book: Book = None, parent=None, refresh_callback: Callable[[Dict[str, object]], None] = None):
super().__init__(parent) super().__init__(parent)
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
self.create_layout() self.create_layout()
self.refresh_callback = refresh_callback
if book: if book:
self.book_id = book.id self.book_id = book.id
self.logger.debug(f"Editing book {book.title}") self.logger.debug(f"Editing book {book.title}")
@ -54,7 +56,6 @@ class BookEditor(QDialog):
# Year published field # Year published field
self.year_input = QLineEdit() self.year_input = QLineEdit()
# self.year_input.setValidator
form_layout.addRow("Year Published:", self.year_input) form_layout.addRow("Year Published:", self.year_input)
# ISBN field # ISBN field
@ -64,9 +65,24 @@ class BookEditor(QDialog):
self.isbn_input.setValidator(self.isbn_validator) self.isbn_input.setValidator(self.isbn_validator)
form_layout.addRow("ISBN:", self.isbn_input) form_layout.addRow("ISBN:", self.isbn_input)
# Categories field
self.categories_input = QLineEdit() self.categories_input = QLineEdit()
form_layout.addRow("Categories: ", self.categories_input) form_layout.addRow("Categories: ", self.categories_input)
# Damage field
self.damage_input = QCheckBox()
form_layout.addRow("Damage:", self.damage_input)
# Price field
self.price_input = QLineEdit()
self.price_input.setValidator(QRegularExpressionValidator(QRegularExpression(r"^\d+(\.\d{1,2})?$")))
form_layout.addRow("Price:", self.price_input)
# Status field
self.status_input = QComboBox()
self.status_input.addItems([status.value for status in BookStatusEnum])
form_layout.addRow("Status:", self.status_input)
layout.addLayout(form_layout) layout.addLayout(form_layout)
# Buttons # Buttons
@ -94,12 +110,16 @@ class BookEditor(QDialog):
all_categories = ", ".join(category.name for category in book.categories) all_categories = ", ".join(category.name for category in book.categories)
self.categories_input.setText(all_categories) self.categories_input.setText(all_categories)
self.damage_input.setChecked(book.is_damaged)
self.price_input.setText(str(book.price))
self.status_input.setCurrentText(book.status.value)
def save_book(self): def save_book(self):
try: try:
book_object = self.parse_inputs() book_object = self.parse_inputs()
if self.create_new: if self.create_new:
create_book(book_object) create_book(book_object, skip_existing=False)
else: else:
book_object["id"] = self.book_id book_object["id"] = self.book_id
update_book(book_object) update_book(book_object)
@ -109,6 +129,9 @@ class BookEditor(QDialog):
"Book updated successfully", "Book updated successfully",
QMessageBox.StandardButton.Ok) QMessageBox.StandardButton.Ok)
if self.refresh_callback:
self.refresh_callback(book_object)
self.accept() self.accept()
except ValueError as e: except ValueError as e:
QMessageBox.critical(None, QMessageBox.critical(None,
@ -168,6 +191,21 @@ class BookEditor(QDialog):
if not categories: if not categories:
raise ValueError("At least one category must be specified.") raise ValueError("At least one category must be specified.")
# Damage validation
damage = self.damage_input.isChecked()
# Price validation
price = self.price_input.text().strip()
try:
price = float(price)
if price < 0:
raise ValueError("Price must be a non-negative number.")
except ValueError:
raise ValueError("Price must be a valid decimal number.")
# Status validation
status = self.status_input.currentText()
# Map parsed values to dictionary format for saving # Map parsed values to dictionary format for saving
return { return {
"title": title, "title": title,
@ -178,7 +216,10 @@ class BookEditor(QDialog):
"description": description, "description": description,
"year_published": year_published, "year_published": year_published,
"isbn": isbn, "isbn": isbn,
"categories": categories "categories": categories,
"is_damaged": damage,
"price": price,
"status": status
} }
__all__ = ["BookEditor"] __all__ = ["BookEditor"]

View File

@ -1,6 +1,6 @@
import logging import logging
import re import re
from typing import Dict from typing import Dict, Callable
from PySide6.QtGui import QGuiApplication, QAction from PySide6.QtGui import QGuiApplication, QAction
from PySide6.QtQml import QQmlApplicationEngine from PySide6.QtQml import QQmlApplicationEngine
@ -15,16 +15,18 @@ from utils.errors.database import DatabaseError, DatabaseConnectionError, Duplic
class MemberEditor(QDialog): class MemberEditor(QDialog):
def __init__(self, member: Member = None): def __init__(self, member: Member = None, refresh_callback: Callable[[Dict[str, object]], None] = None):
super().__init__() super().__init__()
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
self.create_layout() self.create_layout()
self.refresh_callback = refresh_callback
if member: if member:
self.member_id = member.id self.member_id = member.id
self.logger.debug(f"Editing member {member.first_name} {member.last_name}") self.logger.debug(f"Editing member {member.first_name} {member.last_name}")
self.fill_with_existing_data() self.fill_with_existing_data(member)
self.create_new = False self.create_new = False
else: else:
self.logger.debug("Editing a new member") self.logger.debug("Editing a new member")
@ -90,13 +92,16 @@ class MemberEditor(QDialog):
QMessageBox.StandardButton.NoButton) QMessageBox.StandardButton.NoButton)
else: else:
member_object["id"] = self.member_id member_object["id"] = self.member_id
update_member(book_object) update_member(member_object)
QMessageBox.information(None, QMessageBox.information(None,
"Success", "Success",
"Member updated successfully", "Member updated successfully",
QMessageBox.StandardButton.Ok, QMessageBox.StandardButton.Ok,
QMessageBox.StandardButton.NoButton) QMessageBox.StandardButton.NoButton)
if self.refresh_callback:
self.refresh_callback(member_object)
self.accept() self.accept()
except ValueError as e: except ValueError as e:
QMessageBox.critical(None, QMessageBox.critical(None,
@ -144,8 +149,7 @@ class MemberEditor(QDialog):
"first_name": first_name, "first_name": first_name,
"last_name": last_name, "last_name": last_name,
"email": email, "email": email,
"phone_number": phone_number "phone": phone_number
} }
__all__ = ["MemberEditor"] __all__ = ["MemberEditor"]

View File

@ -1,2 +1,3 @@
from .book_overview_list import BookOverviewList from .book_overview_list import BookOverviewList
from .member_list import MemberList from .member_list import MemberList
from .category_statistics_overview_list import BookCategoryStatisticsOverview

View File

@ -1,15 +1,10 @@
from PySide6.QtGui import QGuiApplication, QAction, Qt from PySide6.QtGui import QAction, Qt
from PySide6.QtQml import QQmlApplicationEngine from PySide6.QtWidgets import QHBoxLayout, QVBoxLayout, QLabel, QWidget, QMenu, QSizePolicy, QLayout, QMessageBox, QDialog
from PySide6.QtWidgets import QHBoxLayout, QVBoxLayout, QLabel, QWidget, QMenu, QSizePolicy, QLayout, QMessageBox
from PySide6.QtCore import qDebug
from ui.editor import BookEditor
from models import BooksOverview, Book, BookStatusEnum from models import BooksOverview, Book, BookStatusEnum
from ui.editor import BookEditor
from database.manager import DatabaseManager from database.manager import DatabaseManager
from database import delete_book
from sqlalchemy import delete from utils.errors import DatabaseConnectionError, DatabaseError
STATUS_TO_COLOR_MAP = { STATUS_TO_COLOR_MAP = {
BookStatusEnum.available: "#3c702e", BookStatusEnum.available: "#3c702e",
@ -24,10 +19,8 @@ class BookCard(QWidget):
self.book_overview = book_overview self.book_overview = book_overview
self.setAttribute(Qt.WidgetAttribute.WA_Hover, self.setAttribute(Qt.WidgetAttribute.WA_Hover, True) # Enable hover events
True) # Enable hover events self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) # Enable styling for background
# Enable styling for background
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
# Set initial stylesheet with hover behavior # Set initial stylesheet with hover behavior
self.setStyleSheet(""" self.setStyleSheet("""
@ -39,106 +32,104 @@ class BookCard(QWidget):
# Layout setup # Layout setup
layout = QHBoxLayout(self) layout = QHBoxLayout(self)
layout.setSizeConstraint(QLayout.SizeConstraint.SetMinimumSize) layout.setSizeConstraint(QLayout.SizeConstraint.SetMinimumSize)
self.setSizePolicy(QSizePolicy.Policy.Preferred, self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
QSizePolicy.Policy.Fixed)
layout.setContentsMargins(10, 10, 10, 10) layout.setContentsMargins(10, 10, 10, 10)
layout.setSpacing(10) layout.setSpacing(10)
# Initialize UI components
self.title_label = QLabel()
self.author_label = QLabel()
self.isbn_label = QLabel()
self.status_label = QLabel()
self.price_label = QLabel()
self.is_damaged_label = QLabel()
# Left-side content # Left-side content
left_side = QVBoxLayout() left_side = QVBoxLayout()
layout.addLayout(left_side) layout.addLayout(left_side)
title_label = QLabel(book_overview.title) left_side.addWidget(self.title_label)
title_label.setStyleSheet("font-size: 20px; font-weight: bold;") left_side.addWidget(self.author_label)
author_label = QLabel("By: " + book_overview.author_name) left_side.addWidget(self.isbn_label)
isbn_label = QLabel("ISBN: " + (book_overview.isbn or "Not Available"))
left_side.addWidget(title_label)
left_side.addWidget(author_label)
left_side.addWidget(isbn_label)
# Right-side content # Right-side content
right_side = QVBoxLayout() right_side = QVBoxLayout()
layout.addLayout(right_side) layout.addLayout(right_side)
right_side.addWidget(self.status_label)
status_label = QLabel(str(book_overview.status.value.capitalize())) right_side.addWidget(self.price_label)
status_label.setStyleSheet(f"color: { right_side.addWidget(self.is_damaged_label)
STATUS_TO_COLOR_MAP[book_overview.status]}; font-size: 20px; font-weight: bold;")
status_label.setAlignment(Qt.AlignmentFlag.AlignRight)
right_side.addWidget(status_label)
if book_overview.librarian_name and book_overview.borrower_name:
borrower_label = QLabel("Borrowed: " + book_overview.borrower_name)
borrower_label.setAlignment(Qt.AlignmentFlag.AlignRight)
librarian_label = QLabel("By: " + book_overview.librarian_name)
librarian_label.setAlignment(Qt.AlignmentFlag.AlignRight)
right_side.addWidget(borrower_label)
right_side.addWidget(librarian_label)
self.setLayout(layout) self.setLayout(layout)
self.update_display()
def mousePressEvent(self, event): def update_display(self):
if event.button() == Qt.MouseButton.LeftButton: """Refreshes the display of the book card based on its current data."""
self.contextMenuEvent(event) self.title_label.setText(self.book_overview.title)
else: self.title_label.setStyleSheet("font-size: 20px; font-weight: bold;")
super().mousePressEvent(event)
self.author_label.setText("By: " + self.book_overview.author_name)
self.isbn_label.setText("ISBN: " + (self.book_overview.isbn or "Not Available"))
self.status_label.setText(str(self.book_overview.status.value.capitalize()))
self.status_label.setStyleSheet(f"color: {STATUS_TO_COLOR_MAP[self.book_overview.status]}; font-size: 20px; font-weight: bold;")
self.status_label.setAlignment(Qt.AlignmentFlag.AlignRight)
self.price_label.setText("Price: " + str(self.book_overview.price))
self.price_label.setAlignment(Qt.AlignmentFlag.AlignRight)
self.is_damaged_label.setText("Damaged: " + str(self.book_overview.is_damaged))
self.is_damaged_label.setAlignment(Qt.AlignmentFlag.AlignRight)
def contextMenuEvent(self, event): def contextMenuEvent(self, event):
context_menu = QMenu(self) context_menu = QMenu(self)
action_edit_book = context_menu.addAction("Edit Book") 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") delete_book_action = context_menu.addAction("Delete Book")
delete_book_action.triggered.connect(self.delete_book) delete_book_action.triggered.connect(self.delete_book)
if self.book_overview.status != BookStatusEnum.borrowed:
action_mark_returned.setVisible(False)
if self.book_overview.status != BookStatusEnum.reserved:
action_remove_reservation.setVisible(False)
action = context_menu.exec_(self.mapToGlobal(event.pos())) action = context_menu.exec_(self.mapToGlobal(event.pos()))
if action == action_edit_book: if action == action_edit_book:
self.open_editor()
def open_editor(self):
"""Opens the BookEditor and updates the card if changes are made."""
with DatabaseManager.get_session() as session: with DatabaseManager.get_session() as session:
book_id = self.book_overview.id book_id = self.book_overview.id
book = session.query(Book).filter(Book.id == book_id).one_or_none()
book = session.query(Book).filter(
Book.id == book_id).one_or_none()
if book: if book:
BookEditor(book).exec() editor = BookEditor(book)
if editor.exec() == QDialog.DialogCode.Accepted:
updated_data = editor.parse_inputs()
self.refresh(updated_data)
else: else:
QMessageBox.critical(self, QMessageBox.critical(self,
"Error", "Error",
"The book you requested could not be found. Try again later", "The book you requested could not be found. Try again later",
QMessageBox.StandardButton.Ok, QMessageBox.StandardButton.Ok)
QMessageBox.StandardButton.NoButton)
elif action == action_edit_author: def refresh(self, updated_data):
print("Edit Author selected") """Updates the card's data and refreshes the display."""
elif action == action_mark_returned: self.book_overview.title = updated_data["title"]
print("Mark as Returned selected") self.book_overview.author_name = f"{updated_data['author']['first_name']} {updated_data['author']['last_name']}"
elif action == action_remove_reservation: self.book_overview.isbn = updated_data["isbn"]
print("Remove reservation selected") self.book_overview.status = BookStatusEnum(updated_data["status"])
self.book_overview.price = updated_data["price"]
self.book_overview.is_damaged = updated_data["is_damaged"]
self.update_display()
def delete_book(self): def delete_book(self):
if not self.make_sure(): if not self.make_sure():
return return
with DatabaseManager.get_session() as session:
try: try:
stmt = delete(Book).where(Book.id == self.book_overview.id) delete_book(self.book_overview.id)
session.execute(stmt)
session.commit()
self.setVisible(False) self.setVisible(False)
except Exception as e: except DatabaseConnectionError as e:
session.rollback QMessageBox.critical(None, "Failed", "Connection with database failed", QMessageBox.StandardButton.Ok)
print(e) except DatabaseError as e:
QMessageBox.critical(None, "Failed", f"An error occurred when deleting book: {e}", QMessageBox.StandardButton.Ok)
def make_sure(self) -> bool: def make_sure(self) -> bool:
are_you_sure_box = QMessageBox() are_you_sure_box = QMessageBox()
@ -154,5 +145,4 @@ class BookCard(QWidget):
# Handle the response # Handle the response
return response == QMessageBox.Yes return response == QMessageBox.Yes
__all__ = ["BookCard"] __all__ = ["BookCard"]

View File

@ -61,10 +61,6 @@ class BookOverviewList(QWidget):
register_member_button.clicked.connect(self.register_member) register_member_button.clicked.connect(self.register_member)
button_layout.addWidget(register_member_button) button_layout.addWidget(register_member_button)
add_borrow_record_button = QPushButton("Add Borrow Record")
add_borrow_record_button.clicked.connect(self.add_borrow_record)
button_layout.addWidget(add_borrow_record_button)
main_layout.addLayout(button_layout) main_layout.addLayout(button_layout)
def filter_books(self, text): def filter_books(self, text):

View File

@ -0,0 +1,7 @@
from .category_overview_list import *
from .category_overview_card import *
__all__ = [
*category_overview_list.__all__,
*category_overview_card.__all__
]

View File

@ -47,8 +47,8 @@ class BookCategoryStatisticsOverviewCard(QWidget):
right_side = QVBoxLayout() right_side = QVBoxLayout()
layout.addLayout(right_side) layout.addLayout(right_side)
status_label = QLabel(book_category_statistics_overview.book_count) status_label = QLabel(str(book_category_statistics_overview.book_count))
status_label.setStyleSheet(f"font-size: 20px; font-weight: bold;") status_label.setStyleSheet("font-size: 20px; font-weight: bold;")
status_label.setAlignment(Qt.AlignmentFlag.AlignRight) status_label.setAlignment(Qt.AlignmentFlag.AlignRight)
right_side.addWidget(status_label) right_side.addWidget(status_label)

View File

@ -5,14 +5,11 @@ from PySide6.QtWidgets import (
) )
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from .book_card import BookCard from .category_overview_card import BookCategoryStatisticsOverviewCard
from models import BooksOverview from models import BookCategoryStatisticsOverview
from database.manager import DatabaseManager from database.manager import DatabaseManager
from database.book_overview import fetch_all_book_overviews from database import fetch_all_book_category_statistics_overviews
from ui.editor import MemberEditor
class BookCategoryStatisticsOverview(QWidget): class BookCategoryStatisticsOverview(QWidget):
def __init__(self, parent = None): def __init__(self, parent = None):
@ -47,25 +44,21 @@ class BookCategoryStatisticsOverview(QWidget):
# Align the cards to the top # Align the cards to the top
self.scroll_layout.setAlignment(Qt.AlignTop) self.scroll_layout.setAlignment(Qt.AlignTop)
self.books = [] self.category_overviews = []
self.book_cards = [] self.category_overview_cards = []
self.redraw_cards() self.redraw_cards()
self.scroll_widget.setLayout(self.scroll_layout) self.scroll_widget.setLayout(self.scroll_layout)
self.scroll_area.setWidget(self.scroll_widget) self.scroll_area.setWidget(self.scroll_widget)
main_layout.addWidget(self.scroll_area) main_layout.addWidget(self.scroll_area)
main_layout.addLayout(button_layout)
def filter_categories(self, text): def filter_categories(self, text):
"""Filter the cards based on the search input.""" """Filter the cards based on the search input."""
for card, book in zip(self.book_cards, self.books): for card, category in zip(self.category_overview_cards, self.category_overviews):
title_contains_text = text.lower() in book.title.lower() name_contains_text = text.lower() in category.name.lower()
author_name_contains_text = text.lower() in book.author_name.lower()
isbn_contains_text = text.lower() in book.isbn
card.setVisible(title_contains_text or author_name_contains_text or isbn_contains_text) card.setVisible(name_contains_text)
def clear_layout(self, layout): def clear_layout(self, layout):
@ -82,15 +75,15 @@ class BookCategoryStatisticsOverview(QWidget):
def redraw_cards(self): def redraw_cards(self):
self.clear_layout(self.scroll_layout) self.clear_layout(self.scroll_layout)
self.book_cards = [] self.category_overview_cards = []
self.books = fetch_all_book_overviews() self.category_overviews = fetch_all_book_category_statistics_overviews()
for book in self.books: for category in self.category_overviews:
card = BookCard(book) card = BookCategoryStatisticsOverviewCard(category)
self.scroll_layout.addWidget(card) self.scroll_layout.addWidget(card)
self.book_cards.append(card) self.category_overview_cards.append(card)
__all__ = ["BookCategoryStatisticsOverview"] __all__ = ["BookCategoryStatisticsOverview"]

View File

@ -2,13 +2,10 @@ from PySide6.QtWidgets import (
QHBoxLayout, QVBoxLayout, QLabel, QWidget, QMenu, QSizePolicy, QLayout, QMessageBox QHBoxLayout, QVBoxLayout, QLabel, QWidget, QMenu, QSizePolicy, QLayout, QMessageBox
) )
from PySide6.QtGui import Qt from PySide6.QtGui import Qt
from PySide6.QtCore import qDebug
from models import Member, MemberStatusEnum from models import Member, MemberStatusEnum
from database.manager import DatabaseManager from database.manager import DatabaseManager
from database import delete_member from database import delete_member
from sqlalchemy import delete from ui.editor import MemberEditor
from utils.errors import DatabaseConnectionError, DatabaseError from utils.errors import DatabaseConnectionError, DatabaseError
STATUS_TO_COLOR_MAP = { STATUS_TO_COLOR_MAP = {
@ -41,32 +38,43 @@ class MemberCard(QWidget):
layout.setContentsMargins(10, 10, 10, 10) layout.setContentsMargins(10, 10, 10, 10)
layout.setSpacing(10) layout.setSpacing(10)
# Initialize UI components
self.name_label = QLabel()
self.email_label = QLabel()
self.phone_label = QLabel()
self.status_label = QLabel()
self.register_date_label = QLabel()
# Left-side content # Left-side content
left_side = QVBoxLayout() left_side = QVBoxLayout()
layout.addLayout(left_side) layout.addLayout(left_side)
name_label = QLabel(f"{member.first_name} {member.last_name}") left_side.addWidget(self.name_label)
name_label.setStyleSheet("font-size: 20px; font-weight: bold;") left_side.addWidget(self.email_label)
email_label = QLabel(f"Email: {member.email}") left_side.addWidget(self.phone_label)
phone_label = QLabel(f"Phone: {member.phone or 'Not Available'}")
left_side.addWidget(name_label)
left_side.addWidget(email_label)
left_side.addWidget(phone_label)
# Right-side content # Right-side content
right_side = QVBoxLayout() right_side = QVBoxLayout()
layout.addLayout(right_side) layout.addLayout(right_side)
right_side.addWidget(self.status_label)
status_label = QLabel(str(member.status.value.capitalize())) right_side.addWidget(self.register_date_label)
status_label.setStyleSheet(f"color: {STATUS_TO_COLOR_MAP[member.status]}; font-size: 20px; font-weight: bold;")
status_label.setAlignment(Qt.AlignmentFlag.AlignRight)
register_date_label = QLabel(f"Registered: {member.register_date}")
register_date_label.setAlignment(Qt.AlignmentFlag.AlignRight)
right_side.addWidget(status_label)
right_side.addWidget(register_date_label)
self.setLayout(layout) self.setLayout(layout)
self.update_display()
def update_display(self):
"""Refreshes the display of the member card based on its current data."""
self.name_label.setText(f"{self.member.first_name} {self.member.last_name}")
self.name_label.setStyleSheet("font-size: 20px; font-weight: bold;")
self.email_label.setText(f"Email: {self.member.email}")
self.phone_label.setText(f"Phone: {self.member.phone or 'Not Available'}")
self.status_label.setText(str(self.member.status.value.capitalize()))
self.status_label.setStyleSheet(f"color: {STATUS_TO_COLOR_MAP[self.member.status]}; font-size: 20px; font-weight: bold;")
self.status_label.setAlignment(Qt.AlignmentFlag.AlignRight)
self.register_date_label.setText(f"Registered: {self.member.register_date}")
self.register_date_label.setAlignment(Qt.AlignmentFlag.AlignRight)
def mousePressEvent(self, event): def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton: if event.button() == Qt.MouseButton.LeftButton:
@ -92,7 +100,8 @@ class MemberCard(QWidget):
action = context_menu.exec_(self.mapToGlobal(event.pos())) action = context_menu.exec_(self.mapToGlobal(event.pos()))
if action == action_edit_member: if action == action_edit_member:
print("Edit Member selected") # Implement editor logic here editor = MemberEditor(self.member, refresh_callback=self.refresh)
editor.exec()
elif action == action_deactivate_member: elif action == action_deactivate_member:
self.update_member_status(MemberStatusEnum.inactive) self.update_member_status(MemberStatusEnum.inactive)
elif action == action_activate_member: elif action == action_activate_member:
@ -106,10 +115,9 @@ class MemberCard(QWidget):
delete_member(self.member.id) delete_member(self.member.id)
self.setVisible(False) self.setVisible(False)
except DatabaseConnectionError as e: except DatabaseConnectionError as e:
QMessageBox.critical(None, "Failed", "Connection with database failed", QMessageBox.StandardButton.Ok, QMessageBox.StandardButton.NoButton) QMessageBox.critical(None, "Failed", "Connection with database failed", QMessageBox.StandardButton.Ok)
except DatabaseError as e: except DatabaseError as e:
QMessageBox.critical(None, "Failed", f"An error occured when deleting member: {e}", QMessageBox.StandardButton.Ok, QMessageBox.StandardButton.NoButton) QMessageBox.critical(None, "Failed", f"An error occurred when deleting member: {e}", QMessageBox.StandardButton.Ok)
def update_member_status(self, new_status): def update_member_status(self, new_status):
with DatabaseManager.get_session() as session: with DatabaseManager.get_session() as session:
@ -120,17 +128,20 @@ class MemberCard(QWidget):
session.commit() session.commit()
QMessageBox.information(self, "Status Updated", f"Member status updated to {new_status.value.capitalize()}.") QMessageBox.information(self, "Status Updated", f"Member status updated to {new_status.value.capitalize()}.")
self.member.status = new_status self.member.status = new_status
self.update_status_label() self.update_display()
else: else:
QMessageBox.critical(self, "Error", "The member you requested could not be found.", QMessageBox.StandardButton.Ok) QMessageBox.critical(self, "Error", "The member you requested could not be found.", QMessageBox.StandardButton.Ok)
except Exception as e: except Exception as e:
session.rollback() session.rollback()
print(e) print(e)
def update_status_label(self): def refresh(self, updated_data):
self.findChild(QLabel, self.member.status.value).setStyleSheet( """Updates the card's data and refreshes the display."""
f"color: {STATUS_TO_COLOR_MAP[self.member.status]}; font-size: 20px; font-weight: bold;" self.member.first_name = updated_data["first_name"]
) self.member.last_name = updated_data["last_name"]
self.member.email = updated_data["email"]
self.member.phone = updated_data["phone"]
self.update_display()
def make_sure(self) -> bool: def make_sure(self) -> bool:
are_you_sure_box = QMessageBox() are_you_sure_box = QMessageBox()
@ -143,5 +154,4 @@ class MemberCard(QWidget):
response = are_you_sure_box.exec() response = are_you_sure_box.exec()
return response == QMessageBox.Yes return response == QMessageBox.Yes
__all__ = ["MemberCard"] __all__ = ["MemberCard"]

View File

@ -7,7 +7,7 @@ from ui.import_preview import PreviewDialog
from ui.editor import BookEditor, MemberEditor from ui.editor import BookEditor, MemberEditor
from utils.errors import ExportError, ExportFileError, InvalidContentsError from utils.errors import ExportError, ExportFileError, InvalidContentsError
from services import book_service, book_overview_service from services import book_service, book_overview_service, book_category_statistics_service
class MenuBar(QMenuBar): class MenuBar(QMenuBar):
@ -50,10 +50,6 @@ class MenuBar(QMenuBar):
import_books_action.triggered.connect(self.import_books) import_books_action.triggered.connect(self.import_books)
import_submenu.addAction(import_books_action) import_submenu.addAction(import_books_action)
import_members_action = QAction("Import members", self)
import_members_action.triggered.connect(self.import_members)
import_submenu.addAction(import_members_action)
# Export submenu # Export submenu
export_submenu = QMenu("Export", self) export_submenu = QMenu("Export", self)
file_menu.addMenu(export_submenu) file_menu.addMenu(export_submenu)
@ -62,13 +58,9 @@ class MenuBar(QMenuBar):
export_books_action.triggered.connect(self.export_books) export_books_action.triggered.connect(self.export_books)
export_submenu.addAction(export_books_action) export_submenu.addAction(export_books_action)
export_overview_action = QAction("Export overview", self) export_category_statistics = QAction("Export category statistics", self)
export_overview_action.triggered.connect(self.export_overviews) export_category_statistics.triggered.connect(self.export_category_statistics)
export_submenu.addAction(export_overview_action) export_submenu.addAction(export_category_statistics)
export_members_action = QAction("Export members", self)
export_members_action.triggered.connect(self.export_members)
export_submenu.addAction(export_members_action)
file_menu.addSeparator() file_menu.addSeparator()
@ -109,18 +101,12 @@ class MenuBar(QMenuBar):
def import_books(self): def import_books(self):
self.import_data("Book", None, book_service) self.import_data("Book", None, book_service)
def import_members(self):
# self.import_data("Member", memb)
pass
def export_books(self): def export_books(self):
self.export_data("Book", book_service) self.export_data("Book", book_service)
def export_overviews(self):
self.export_data("Book overview", book_overview_service)
def export_members(self): def export_category_statistics(self):
pass self.export_data("Category statistics", book_category_statistics_service)
def about(self): def about(self):
QMessageBox.information( QMessageBox.information(

View File

@ -24,14 +24,8 @@ class SettingsDialog(QDialog):
self.data_mode_label = QtWidgets.QLabel(UserConfig.get_friendly_name("transaction_level") + ":") self.data_mode_label = QtWidgets.QLabel(UserConfig.get_friendly_name("transaction_level") + ":")
data_mode_layout.addWidget(self.data_mode_label) data_mode_layout.addWidget(self.data_mode_label)
self.data_mode_dropdown = QtWidgets.QComboBox() self.data_mode_selected = QtWidgets.QLabel(self.user_config.transaction_level.name.capitalize())
for tl in TransactionLevel: data_mode_layout.addWidget(self.data_mode_selected)
self.data_mode_dropdown.addItem(tl.name.capitalize(), tl)
self.data_mode_dropdown.setCurrentIndex(
list(TransactionLevel).index(self.user_config.transaction_level)
)
data_mode_layout.addWidget(self.data_mode_dropdown)
# Slowdown simulation # Slowdown simulation
@ -48,17 +42,8 @@ class SettingsDialog(QDialog):
layout.addLayout(data_mode_layout) layout.addLayout(data_mode_layout)
layout.addLayout(self.slowdown_layout) layout.addLayout(self.slowdown_layout)
# Set the currently selected mode to the mode in UserConfig
config = UserConfig()
# Transaction level
current_level = config.transaction_level
index = self.data_mode_dropdown.findData(current_level)
if index != -1:
self.data_mode_dropdown.setCurrentIndex(index)
# Slowdown simulation # Slowdown simulation
simulate_slowdown = config.simulate_slowdown simulate_slowdown = self.user_config.simulate_slowdown
self.slowdown_checkbox.setChecked(simulate_slowdown) self.slowdown_checkbox.setChecked(simulate_slowdown)
# Buttons # Buttons
@ -75,15 +60,8 @@ class SettingsDialog(QDialog):
layout.addLayout(button_layout) layout.addLayout(button_layout)
def save_settings(self): def save_settings(self):
data_mode = self.data_mode_dropdown.currentData()
simulate_slowdown = self.slowdown_checkbox.isChecked() simulate_slowdown = self.slowdown_checkbox.isChecked()
try:
self.logger.debug("Saving user configuration") self.logger.debug("Saving user configuration")
config = UserConfig() config = UserConfig()
config.transaction_level = data_mode
config.simulate_slowdown = simulate_slowdown config.simulate_slowdown = simulate_slowdown
self.accept() self.accept()
except TypeError as e:
self.logger.error("Invalid user configuration found")
QMessageBox.critical(None, "Invalid config detected", "Double check your configuration", QMessageBox.StandardButton.Ok, QMessageBox.StandardBUttons.NoButton)

View File

@ -1,7 +1,7 @@
from PySide6.QtGui import QGuiApplication, QIcon from PySide6.QtGui import QGuiApplication, QIcon
from PySide6.QtWidgets import QMainWindow, QApplication, QTabWidget from PySide6.QtWidgets import QMainWindow, QApplication, QTabWidget
from ui.main_tabs import BookOverviewList, MemberList from ui.main_tabs import BookOverviewList, MemberList, BookCategoryStatisticsOverview
from ui.menu_bar import MenuBar from ui.menu_bar import MenuBar
@ -24,8 +24,10 @@ class LibraryWindow(QMainWindow):
self.dashboard = BookOverviewList(self) self.dashboard = BookOverviewList(self)
self.member_list = MemberList() self.member_list = MemberList()
self.category_statistics_overview_list = BookCategoryStatisticsOverview()
central_widget.addTab(self.dashboard, "Dashboard") central_widget.addTab(self.dashboard, "Dashboard")
central_widget.addTab(self.member_list, "Members") central_widget.addTab(self.member_list, "Members")
central_widget.addTab(self.category_statistics_overview_list, "Category stats")
def center_window(self): def center_window(self):
# Get the screen geometry # Get the screen geometry
@ -46,6 +48,7 @@ class LibraryWindow(QMainWindow):
def refresh_book_cards(self): def refresh_book_cards(self):
self.dashboard.redraw_cards() self.dashboard.redraw_cards()
self.category_statistics_overview_list.redraw_cards()
def refresh_member_cards(self): def refresh_member_cards(self):
self.member_list.redraw_cards() self.member_list.redraw_cards()

View File

@ -60,11 +60,12 @@ class DatabaseConfig():
class TransactionLevel(enum.Enum): class TransactionLevel(enum.Enum):
insecure = "READ UNCOMMITTED" insecure = "READ COMMITTED"
secure = "SERIALIZABLE" secure = "SERIALIZABLE"
class UserConfig: class UserConfig:
_instance = None _instance = None
_metadata = { _metadata = {
@ -78,26 +79,23 @@ class UserConfig:
return cls._instance return cls._instance
def __init__(self): def __init__(self):
if not hasattr(self, "logger"):
self.logger = logging.getLogger(__name__)
if not hasattr(self, "_transaction_level"): if not hasattr(self, "_transaction_level"):
env_level = os.getenv("TRANSACTION_LEVEL", "INSECURE").upper()
if env_level == "SECURE":
self.logger.debug("Running in SECURE mode")
self._transaction_level = TransactionLevel.secure
else:
self.logger.debug("Running in INSECURE mode")
self._transaction_level = TransactionLevel.insecure self._transaction_level = TransactionLevel.insecure
if not hasattr(self, "_simulate_slowdown"): if not hasattr(self, "_simulate_slowdown"):
self._simulate_slowdown = False self._simulate_slowdown = False
if not hasattr(self, "logger"):
self.logger = logging.getLogger(__name__)
@property @property
def transaction_level(self) -> TransactionLevel: def transaction_level(self) -> TransactionLevel:
return self._transaction_level return self._transaction_level
@transaction_level.setter
def transaction_level(self, value: Any):
if not isinstance(value, TransactionLevel):
raise TypeError(
f"Invalid value for 'transaction_level'. Must be a TransactionLevel enum, got {type(value).__name__}."
)
self.logger.debug(f"Transaction isolation level set to: {value}")
self._transaction_level = value
@property @property
def simulate_slowdown(self) -> bool: def simulate_slowdown(self) -> bool:
return self._simulate_slowdown return self._simulate_slowdown

View File

@ -20,7 +20,7 @@ class DatabaseConnectionError(DatabaseError):
class DuplicateEntryError(DatabaseError): class DuplicateEntryError(DatabaseError):
def __init__(self, duplicate_entry_name: str, message: str): def __init__(self, duplicate_entry_name: str, message: str = ""):
super().__init__(message) super().__init__(message)
self.duplicate_entry_name = duplicate_entry_name self.duplicate_entry_name = duplicate_entry_name
self.message = message self.message = message

View File

@ -1,12 +1,24 @@
import sys import sys
import os
import logging import logging
def setup_logger(): def setup_logger():
logger = logging.getLogger() logger = logging.getLogger()
logger.setLevel(logging.DEBUG)
verbosity = os.getenv("VERBOSITY", "DEBUG").upper()
level_map = {
"DEBUG": logging.DEBUG,
"INFO": logging.INFO,
"WARNING": logging.WARNING,
"ERROR": logging.ERROR,
"CRITICAL": logging.CRITICAL,
}
log_level = level_map.get(verbosity, logging.DEBUG) # Default to DEBUG if invalid
logger.setLevel(log_level)
handler = logging.StreamHandler(sys.stdout) handler = logging.StreamHandler(sys.stdout)
handler.setLevel(logging.DEBUG) handler.setLevel(log_level)
formatter = logging.Formatter("[%(levelname)s] - %(name)s:%(lineno)d - %(message)s") formatter = logging.Formatter("[%(levelname)s] - %(name)s:%(lineno)d - %(message)s")
handler.setFormatter(formatter) handler.setFormatter(formatter)