From 8b8cdd00d0ca441ce8aafe841d7dcb4ca9315467 Mon Sep 17 00:00:00 2001 From: Kristofers Solo Date: Wed, 1 Nov 2023 18:57:35 +0200 Subject: [PATCH] feat(tui): display books as a table --- bookstore.sqlite | 0 main.py | 10 +----- pyproject.toml | 2 +- requirements.txt | 4 +-- src/bookstore/book.py | 11 +++++-- src/bookstore/inventory.py | 18 +++++------ src/main.py | 16 ++++++++++ src/ui/handlers.py | 7 +++++ src/ui/tui.py | 31 ++++++++++++++++++ src/ui/utils.py | 64 ++++++++++++++++++++++++++++++++++++++ src/ui/widgets/table.py | 6 ++++ 11 files changed, 145 insertions(+), 24 deletions(-) create mode 100644 bookstore.sqlite mode change 100755 => 120000 main.py create mode 100755 src/main.py create mode 100644 src/ui/handlers.py create mode 100644 src/ui/tui.py create mode 100644 src/ui/utils.py create mode 100644 src/ui/widgets/table.py diff --git a/bookstore.sqlite b/bookstore.sqlite new file mode 100644 index 0000000..e69de29 diff --git a/main.py b/main.py deleted file mode 100755 index dec9a7d..0000000 --- a/main.py +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env python3 - - -def main() -> None: - pass - - -if __name__ == "__main__": - main() diff --git a/main.py b/main.py new file mode 120000 index 0000000..a8c92b0 --- /dev/null +++ b/main.py @@ -0,0 +1 @@ +src/main.py \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 1100b7b..749d684 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ authors = [{ name = "Kristofers Solo", email = "dev@kristofers.xyz" }] readme = "README.md" requires-python = ">=3.11" license = { text = "MIT" } -dependencies = ["loguru==0.7", "attrs==23.1.0"] +dependencies = ["attrs==23.1.0", "urwid==2.2.3"] [tool.mypy] check_untyped_defs = true diff --git a/requirements.txt b/requirements.txt index 9c3363b..6e7ff89 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ -attrs==23.1.0 -loguru==0.7.0 +attrs>=23.1.0 +urwid>=2.2.3 diff --git a/src/bookstore/book.py b/src/bookstore/book.py index a73988a..dd34dd0 100644 --- a/src/bookstore/book.py +++ b/src/bookstore/book.py @@ -19,8 +19,15 @@ def _to_isbn(number: str): @define class Book: - title: str = field() - author: str = field() # TODO: add default author as "Unknown" isbn: ISBN = field(converter=_to_isbn, validator=validators.instance_of(ISBN), on_setattr=setters.frozen, repr=lambda value: f"'{value}'") + title: str = field() + author: str = field() price: float = field(converter=float, validator=[validators.instance_of(float), _check_price_value]) stock: int = field(converter=int, validator=[validators.instance_of(int), _check_stock_value]) + + @classmethod + def fields(cls) -> tuple[str, str, str, str, str]: + return "ISBN", "Title", "Author", "Price", "Stock" + + def field_values(self) -> tuple[ISBN, str, str, float, int]: + return self.isbn, self.title, self.author, self.price, self.stock diff --git a/src/bookstore/inventory.py b/src/bookstore/inventory.py index f218dec..a4d3b25 100644 --- a/src/bookstore/inventory.py +++ b/src/bookstore/inventory.py @@ -29,14 +29,14 @@ class Inventory: """Close database connection.""" self.conn.close() - def add(self, *books: Book) -> None: + def add(self, book: Book) -> None: """Add `Book` to the `Inventory`. `Book`s ISBN must be unique.""" - for book in books: - try: - self.cursor.execute("INSERT INTO Book VALUES (?, ?, ?, ?, ?)", (book.isbn, book.title, book.author, book.price, book.stock)) - self.conn.commit() - except sqlite3.InternalError: - print(f"A book with ISBN {book.isbn} already exists in the database.") + try: + self.cursor.execute("INSERT INTO Book VALUES (?, ?, ?, ?, ?)", (book.isbn, book.title, book.author, book.price, book.stock)) + self.conn.commit() + print(f"Book with ISBN: {book.isbn} was saved") + except sqlite3.InternalError: + print(f"A book with ISBN {book.isbn} already exists in the database.") def delete(self, isbn: ISBN) -> Book | None: """Deletes `Book` from `Inventory` by `ISBN` and returns deleted `Book`""" @@ -71,10 +71,8 @@ class Inventory: return None return [Book(*book) for book in books] - def list_all(self) -> list[Book] | None: + def list_all(self) -> list[Book | None]: """Returns `List` of all `Book`s.""" self.cursor.execute("SELECT * FROM Book") books = self.cursor.fetchall() - if not books: - return None return [Book(*book) for book in books] diff --git a/src/main.py b/src/main.py new file mode 100755 index 0000000..38dcd8c --- /dev/null +++ b/src/main.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 + +from pathlib import Path + +from bookstore.inventory import Inventory +from ui import tui + + +def main() -> None: + db_path = Path("db.sqlite3") + inventory = Inventory(db_path) + tui.render(inventory) + + +if __name__ == "__main__": + main() diff --git a/src/ui/handlers.py b/src/ui/handlers.py new file mode 100644 index 0000000..060888c --- /dev/null +++ b/src/ui/handlers.py @@ -0,0 +1,7 @@ +import urwid + + +def exit_on_q(key): + """Define a function to handle exit when the `q` key is pressed""" + if key in ("q", "Q"): + raise urwid.ExitMainLoop() diff --git a/src/ui/tui.py b/src/ui/tui.py new file mode 100644 index 0000000..69dbe12 --- /dev/null +++ b/src/ui/tui.py @@ -0,0 +1,31 @@ +import urwid +from bookstore.book import Book +from bookstore.inventory import Inventory + +from .handlers import exit_on_q +from .widgets.table import Table + + +FOCUS_STYLE = urwid.AttrMap("default", "dark red", "standout") +UNFOCUS_STYLE = urwid.AttrMap("default", "default", "standout") + + +def create_books_table(books: list[Book]) -> Table: + header = urwid.Columns([urwid.Text(header) for header in Book.fields()]) + header = urwid.AttrMap(header, "header", focus_map=FOCUS_STYLE) + + rows = [header] + + for book in books: + row = urwid.Columns([urwid.Text(str(value)) for value in book.field_values()]) + rows.append(urwid.AttrMap(row, "body", focus_map=FOCUS_STYLE)) + + walker = urwid.SimpleListWalker(rows) + books_table = Table(walker) + return books_table + + +def render(inventory: Inventory): + books_table = create_books_table(inventory.list_all()) + loop = urwid.MainLoop(books_table, unhandled_input=exit_on_q) + loop.run() diff --git a/src/ui/utils.py b/src/ui/utils.py new file mode 100644 index 0000000..58f0803 --- /dev/null +++ b/src/ui/utils.py @@ -0,0 +1,64 @@ +import customtkinter as ctk +from bookstore.book import Book +from bookstore.inventory import Inventory + + +class UI: + def __init__(self, inventory: Inventory) -> None: + self.inventory = inventory + self.theme() + self.root = ctk.CTk() + self.root.geometry("650x400") + self.frame = ctk.CTkFrame(self.root) + + def render(self) -> None: + self._create_gui() + self.root.mainloop() + + def theme(self, mode: str = "dark", color: str = "dark-blue") -> None: + ctk.set_appearance_mode(mode) + ctk.set_default_color_theme(color) + + def _create_gui(self) -> None: + self._show() + + label = ctk.CTkLabel(self.frame, text="LU Bookstore") + label.pack(pady=12, padx=10) + + button = ctk.CTkButton(self.frame, text="New book", command=self._add_book) + button.pack(pady=12, padx=10) + + def _list_books(self) -> None: + pass + + def _add_book(self) -> None: + self._hide() + self._show() + + placeholders = ("ISBN", "Title", "Author", "Price", "Stock") + self.entries: list[ctk.CTkEntry] = [] + + for placeholder in placeholders: + entry = ctk.CTkEntry(self.frame, placeholder_text=placeholder) + entry.pack(pady=12, padx=10) + self.entries.append(entry) + + button = ctk.CTkButton(self.frame, text="Save", command=self._save_book) + button.pack(pady=12, padx=10) + + def _save_book(self) -> None: + entry_values = [entry.get() for entry in self.entries] + if entry_values[0]: # ISBN must be a value + new_book = Book(*entry_values) + self.inventory.add(new_book) + + for entry in self.entries: + entry.destroy() + self.entries = [] + self._show() + + def _hide(self) -> None: + self.frame.pack_forget() + + def _show(self) -> None: + self.frame.pack(pady=20, padx=60, fill="both", expand=True) diff --git a/src/ui/widgets/table.py b/src/ui/widgets/table.py new file mode 100644 index 0000000..bf5df2d --- /dev/null +++ b/src/ui/widgets/table.py @@ -0,0 +1,6 @@ +import urwid + + +class Table(urwid.ListBox): + def __init__(self, body): + super().__init__(body)