Start converting the scanner tool into a cli tool

The scanner tool is about to become a fullfledged repl, much in the same
manner as dibbler.

Changes:

- Rename `tools/scanner.py` -> `cli/main.py`
- Create abstraction layer for Cmd with numbered commands
- Create submenu for items
- Lots of functionality edits
- Fill out more todos in REAMDE
This commit is contained in:
Oystein Kristoffer Tveit 2023-05-06 02:30:24 +02:00
parent c445cb2dbd
commit 3525e84576
Signed by: oysteikt
GPG Key ID: 9F2F7D8250F35146
11 changed files with 723 additions and 285 deletions

View File

@ -35,7 +35,7 @@ This project uses [poetry][poetry] as its buildtool as of May 2023.
```console ```console
$ poetry install $ poetry install
$ poetry run alembic migrate $ poetry run alembic migrate
$ poetry run scanner $ poetry run cli
$ poetry run dev $ poetry run dev
``` ```
@ -46,11 +46,26 @@ See `worblehat/config.py` for configurable settings.
## TODO List ## TODO List
- [ ] High priority: - [ ] High priority:
- [ ] Book ingestion tool in order to create the database in a quick manner. It should pull data from online ISBN databases (this is almost done) - [X] Data ingestion logic, that will pull data from online databases based on ISBN.
- [ ] A database with all of PVVs books should be created - [ ] Cli version of the program (this is currently being worked on).
- [ ] Ability to request book loans for PVV members, e.g. through Dibblers interface. - [ ] Web version of the program
- [ ] Setting up a database with all of PVVs books
- [ ] Creating database with user and pw
- [ ] Model all bookshelfs
- [ ] Scan in all books
- [ ] Inner workings
- [X] Ability to create and update bookcases
- [X] Ability to create and update bookcase shelfs
- [~] Ability to create and update bookcase items
- [ ] Ability to search for books
- [ ] Ability to request book loans for PVV members
- [ ] Ability to queue book loans for PVV members
- [ ] Ability to be notified when books are available
- [ ] Ability to be notified when deadlines are due
- [ ] Ascii art of monkey
- [ ] Low priority: - [ ] Low priority:
- [ ] Ability for PVV members to request book loans through the PVV website - [ ] Ability for PVV members to request book loans through the PVV website
- [ ] Ability for PVV members to search for books through the PVV website - [ ] Ability for PVV members to search for books through the PVV website
- [ ] Discussion - [ ] Discussion
- [ ] Should this project run in a separate tty-instance on Dibblers interface, or should they share the tty with some kind of switching ability? - [ ] Should this project run in a separate tty-instance on Dibblers interface, or should they share the tty with some kind of switching ability?
After some discussion with other PVV members, we came up with an idea where we run the programs in separate ttys, and use a set of large mechanical switches connected to a QMK-flashed microcontroller to switch between them.

View File

@ -15,7 +15,7 @@
in { in {
default = self.apps.${system}.dev; default = self.apps.${system}.dev;
dev = app "${self.packages.${system}.worblehat}/bin/dev"; dev = app "${self.packages.${system}.worblehat}/bin/dev";
scanner = app "${self.packages.${system}.worblehat}/bin/scanner"; cli = app "${self.packages.${system}.worblehat}/bin/cli";
}; };
packages.${system} = { packages.${system} = {

View File

@ -21,7 +21,7 @@ sqlalchemy = "^2.0.8"
werkzeug = "^2.3.3" werkzeug = "^2.3.3"
[tool.poetry.scripts] [tool.poetry.scripts]
scanner = "worblehat.tools.scanner:main" cli = "worblehat.cli.main:main"
dev = "worblehat.wsgi_dev:main" dev = "worblehat.wsgi_dev:main"
[build-system] [build-system]

View File

352
worblehat/cli/main.py Normal file
View File

@ -0,0 +1,352 @@
from textwrap import dedent
from sqlalchemy import (
create_engine,
event,
select,
)
from sqlalchemy.orm import (
Session,
)
from worblehat.cli.subclis.bookcase_item import BookcaseItemCli
from worblehat.services.bookcase_item import (
create_bookcase_item_from_isbn,
is_valid_isbn,
)
from .prompt_utils import *
from worblehat.config import Config
from worblehat.models import *
# TODO: Category seems to have been forgotten. Maybe relevant interactivity should be added?
# However, is there anyone who are going to search by category rather than just look in
# the shelves?
class WorblehatCli(NumberedCmd):
sql_session: Session
sql_session_dirty: bool = False
def __init__(self):
super().__init__()
try:
engine = create_engine(Config.SQLALCHEMY_DATABASE_URI)
self.sql_session = Session(engine)
except Exception:
print('Error: could not connect to database.')
exit(1)
@event.listens_for(self.sql_session, 'after_flush')
def mark_session_as_dirty(*_):
self.sql_session_dirty = True
self.prompt_header = f'(unsaved changes)'
@event.listens_for(self.sql_session, 'after_commit')
@event.listens_for(self.sql_session, 'after_rollback')
def mark_session_as_clean(*_):
self.sql_session_dirty = False
self.prompt_header = None
print(f"Debug: Connected to database at '{Config.SQLALCHEMY_DATABASE_URI}'")
def do_list_bookcases(self, _: str):
bookcase_shelfs = self.sql_session.scalars(
select(BookcaseShelf)
.join(Bookcase)
.order_by(
Bookcase.name,
BookcaseShelf.column,
BookcaseShelf.row,
)
).all()
bookcase_uid = None
for shelf in bookcase_shelfs:
if shelf.bookcase.uid != bookcase_uid:
print(shelf.bookcase.name)
bookcase_uid = shelf.bookcase.uid
name = f"r{shelf.row}-c{shelf.column}"
if shelf.description is not None:
name += f" [{shelf.description}]"
print(f' {name} - {sum(i.amount for i in shelf.items)} items')
def do_show_bookcase(self, arg: str):
bookcase_selector = InteractiveItemSelector(
cls = Bookcase,
sql_session = self.sql_session,
)
bookcase_selector.cmdloop()
bookcase = bookcase_selector.result
for shelf in bookcase.shelfs:
name = f"r{shelf.row}-c{shelf.column}"
if shelf.description is not None:
name += f" [{shelf.description}]"
print(name)
for item in shelf.items:
print(f' {item.name} - {item.amount} copies')
def do_add_bookcase(self, _: str):
while True:
name = input('Name of bookcase> ')
if name == '':
print('Error: name cannot be empty')
continue
if self.sql_session.scalars(
select(Bookcase)
.where(Bookcase.name == name)
).one_or_none() is not None:
print(f'Error: a bookcase with name {name} already exists')
continue
break
description = input('Description of bookcase> ')
if description == '':
description = None
bookcase = Bookcase(name, description)
self.sql_session.add(bookcase)
self.sql_session.flush()
def do_add_bookcase_shelf(self, arg: str):
bookcase_selector = InteractiveItemSelector(
cls = Bookcase,
sql_session = self.sql_session,
)
bookcase_selector.cmdloop()
bookcase = bookcase_selector.result
while True:
row = input('Row> ')
try:
row = int(row)
except ValueError:
print('Error: row must be a number')
continue
break
while True:
column = input('Column> ')
try:
column = int(column)
except ValueError:
print('Error: column must be a number')
continue
break
if self.sql_session.scalars(
select(BookcaseShelf)
.where(
BookcaseShelf.bookcase == bookcase,
BookcaseShelf.row == row,
BookcaseShelf.column == column,
)
).one_or_none() is not None:
print(f'Error: a bookshelf in bookcase {bookcase.name} with position {row}-{column} already exists')
return
description = input('Description> ')
if description == '':
description = None
shelf = BookcaseShelf(
row,
column,
bookcase,
description,
)
self.sql_session.add(shelf)
self.sql_session.flush()
def _create_bookcase_item(self, isbn: str):
bookcase_item = create_bookcase_item_from_isbn(isbn, self.sql_session)
if bookcase_item is None:
print(f'Could not find data about item with isbn {isbn} online.')
print(f'If you think this is not due to a bug, please add the book to openlibrary.org before continuing.')
return
else:
print(dedent(f"""
Found item:
title: {bookcase_item.name}
authors: {', '.join(a.name for a in bookcase_item.authors)}
language: {bookcase_item.language}
"""))
print('Please select the bookcase where the item is placed:')
bookcase_selector = InteractiveItemSelector(
cls = Bookcase,
sql_session = self.sql_session,
)
bookcase_selector.cmdloop()
bookcase = bookcase_selector.result
def __complete_bookshelf_selection(session: Session, cls: type, arg: str):
args = arg.split('-')
query = select(cls.row, cls.column).where(cls.bookcase == bookcase)
try:
if arg != '' and len(args) > 0:
query = query.where(cls.row == int(args[0]))
if len(args) > 1:
query = query.where(cls.column == int(args[1]))
except ValueError:
return []
result = session.execute(query).all()
return [f"{r}-{c}" for r,c in result]
print('Please select the shelf where the item is placed:')
bookcase_shelf_selector = InteractiveItemSelector(
cls = BookcaseShelf,
sql_session = self.sql_session,
execute_selection = lambda session, cls, arg: session.scalars(
select(cls)
.where(
cls.bookcase == bookcase,
cls.column == int(arg.split('-')[1]),
cls.row == int(arg.split('-')[0]),
)
).all(),
complete_selection = __complete_bookshelf_selection,
)
bookcase_shelf_selector.cmdloop()
bookcase_item.shelf = bookcase_shelf_selector.result
print('Please select the items media type:')
media_type_selector = InteractiveItemSelector(
cls = MediaType,
sql_session = self.sql_session,
default = self.sql_session.scalars(
select(MediaType)
.where(MediaType.name.ilike("book")),
).one(),
)
media_type_selector.cmdloop()
bookcase_item.media_type = media_type_selector.result
username = input('Who owns this book? [PVV]> ')
if username != '':
bookcase_item.owner = username
self.sql_session.add(bookcase_item)
self.sql_session.flush()
def default(self, isbn: str):
isbn = isbn.strip()
if not is_valid_isbn(isbn):
super()._default(isbn)
return
if (existing_item := self.sql_session.scalars(
select(BookcaseItem)
.where(BookcaseItem.isbn == isbn)
).one_or_none()) is not None:
print('Found existing BookcaseItem:', existing_item)
BookcaseItemCli(
sql_session = self.sql_session,
bookcase_item = existing_item,
).cmdloop()
return
if prompt_yes_no(f"Could not find item with isbn '{isbn}'.\nWould you like to create it?", default=True):
self._create_bookcase_item(isbn)
def do_search(self, _: str):
print('TODO: implement search')
def do_save(self, _:str):
if not self.sql_session_dirty:
print('No changes to save.')
return
self.sql_session.commit()
def do_abort(self, _:str):
if not self.sql_session_dirty:
print('No changes to abort.')
return
self.sql_session.rollback()
def do_exit(self, _: str):
if self.sql_session_dirty:
if prompt_yes_no('Would you like to save your changes?'):
self.sql_session.commit()
else:
self.sql_session.rollback()
exit(0)
funcs = {
0: {
'f': default,
'doc': 'Choose / Add item with its ISBN',
},
1: {
'f': do_list_bookcases,
'doc': 'List all bookcases',
},
2: {
'f': do_search,
'doc': 'Search',
},
3: {
'f': do_show_bookcase,
'doc': 'Show a bookcase, and its items',
},
4: {
'f': do_add_bookcase,
'doc': 'Add a new bookcase',
},
5: {
'f': do_add_bookcase_shelf,
'doc': 'Add a new bookshelf',
},
6: {
'f': do_save,
'doc': 'Save changes',
},
7: {
'f': do_abort,
'doc': 'Abort changes',
},
8: {
'f': do_exit,
'doc': 'Exit',
},
}
def main():
tool = WorblehatCli()
while True:
try:
tool.cmdloop()
except KeyboardInterrupt:
if not tool.sql_session_dirty:
exit(0)
try:
print()
if prompt_yes_no('Are you sure you want to exit without saving?', default=False):
raise KeyboardInterrupt
except KeyboardInterrupt:
if tool.sql_session is not None:
tool.sql_session.rollback()
exit(0)
if __name__ == '__main__':
main()

View File

@ -0,0 +1,182 @@
from cmd import Cmd
from typing import Any, Callable
from sqlalchemy import select
from sqlalchemy.orm import Session
def prompt_yes_no(question: str, default: bool | None = None) -> bool:
prompt = {
None: '[y/n]',
True: '[Y/n]',
False: '[y/N]',
}[default]
while not any([
(answer := input(f'{question} {prompt} ').lower()) in ('y','n'),
(default != None and answer.strip() == '')
]):
pass
return {
'y': True,
'n': False,
'': default,
}[answer]
class InteractiveItemSelector(Cmd):
def __init__(
self,
cls: type,
sql_session: Session,
execute_selection: Callable[[Session, type, str], list[Any]] = lambda session, cls, arg: session.scalars(
select(cls)
.where(cls.name == arg),
).all(),
complete_selection: Callable[[Session, type, str], list[str]] = lambda session, cls, text: session.scalars(
select(cls.name)
.where(cls.name.ilike(f'{text}%')),
).all(),
default: Any | None = None,
):
"""
This is a utility class for prompting the user to select an
item from the database. The default functions assumes that
the item has a name attribute, and that the name is unique.
However, this can be overridden by passing in custom functions.
"""
super().__init__()
self.cls = cls
self.sql_session = sql_session
self.execute_selection = execute_selection
self.complete_selection = complete_selection
self.default_item = default
if default is not None:
self.prompt = f'Select {cls.__name__} [{default.name}]> '
else:
self.prompt = f'Select {cls.__name__}> '
def emptyline(self) -> bool:
if self.default_item is not None:
self.result = self.default_item
return True
def default(self, arg: str):
result = self.execute_selection(self.sql_session, self.cls, arg)
if len(result) != 1:
print(f'No such {self.cls.__name__} found: {arg}')
return
self.result = result[0]
return True
# TODO: Override this function to not act as an argument completer
# but to complete the entire value name
def completedefault(self, text: str, line: str, *_) -> list[str]:
return []
def completenames(self, text: str, *_) -> list[str]:
x = self.complete_selection(self.sql_session, self.cls, text)
return x
class NumberedCmd(Cmd):
"""
This is a utility class for creating a numbered command line.
It will automatically generate a prompt that lists all the
available commands, and will automatically call the correct
function based on the user input.
If the user input is not a number, it will call the default
function, which can be overridden by the subclass.
Example:
```
class MyCmd(NumberedCmd):
def __init__(self):
super().__init__()
def do_foo(self, arg: str):
pass
def do_bar(self, arg: str):
pass
funcs = {
1: {
'f': do_foo,
'doc': 'do foo',
},
2: {
'f': do_bar,
'doc': 'do bar',
},
}
```
"""
prompt_header: str | None = None
def __init__(self):
super().__init__()
@classmethod
def _generate_usage_list(cls) -> str:
result = ''
for i, func in cls.funcs.items():
if i == 0:
i = '*'
result += f'{i}) {func["doc"]}\n'
return result
def _default(self, arg: str):
try:
i = int(arg)
self.funcs[i]
except (ValueError, KeyError):
return
self.funcs[i]['f'](self, arg)
def default(self, arg: str):
self._default(arg)
def _postcmd(self, stop: bool, line: str) -> bool:
print()
print('-----------------')
print()
return stop
def postcmd(self, stop: bool, line: str) -> bool:
return self._postcmd(stop, line)
@property
def prompt(self):
result = ''
if self.prompt_header != None:
result += self.prompt_header + '\n'
result += self._generate_usage_list()
if self.lastcmd == '':
result += f'> '
else:
result += f'[{self.lastcmd}]> '
return result

View File

View File

@ -0,0 +1,163 @@
from textwrap import dedent
from sqlalchemy import select
from sqlalchemy.orm import Session
from worblehat.cli.prompt_utils import (
InteractiveItemSelector,
NumberedCmd,
prompt_yes_no,
)
from worblehat.models import (
BookcaseItem,
Language,
MediaType,
)
from worblehat.services.bookcase_item import (
create_bookcase_item_from_isbn,
is_valid_isbn,
)
class BookcaseItemCli(NumberedCmd):
def __init__(self, sql_session: Session, bookcase_item: BookcaseItem):
super().__init__()
self.sql_session = sql_session
self.bookcase_item = bookcase_item
def do_show(self, _: str):
print(dedent(f"""
Bookcase Item:
Name: {self.bookcase_item.name}
ISBN: {self.bookcase_item.isbn}
Amount: {self.bookcase_item.amount}
Shelf: {self.bookcase_item.shelf.column}-{self.bookcase_item.shelf.row}
Description: {self.bookcase_item.shelf.description}
"""))
def do_update_data(self, _: str):
item = create_bookcase_item_from_isbn(self.sql_session, self.bookcase_item.isbn)
self.bookcase_item.name = item.name
# TODO: Remove any old authors
self.bookcase_item.authors = item.authors
self.bookcase_item.language = item.language
def do_edit(self, arg: str):
EditBookcaseCli(self.sql_session, self.bookcase_item, self).cmdloop()
def do_loan(self, arg: str):
print('TODO: implement loan')
def do_exit(self, _: str):
return True
funcs = {
1: {
'f': do_show,
'doc': 'Show bookcase item',
},
2: {
'f': do_update_data,
'doc': 'Pull updated data from online databases',
},
3: {
'f': do_edit,
'doc': 'Edit',
},
4: {
'f': do_loan,
'doc': 'Loan bookcase item',
},
5: {
'f': do_exit,
'doc': 'Exit',
},
}
class EditBookcaseCli(NumberedCmd):
def __init__(self, sql_session: Session, bookcase_item: BookcaseItem, parent: BookcaseItemCli):
super().__init__()
self.sql_session = sql_session
self.bookcase_item = bookcase_item
self.parent = parent
def do_name(self, _: str):
while True:
name = input('New name> ')
if name == '':
print('Error: name cannot be empty')
continue
if self.sql_session.scalars(
select(BookcaseItem)
.where(BookcaseItem.name == name)
).one_or_none() is not None:
print(f'Error: an item with name {name} already exists')
continue
break
self.bookcase_item.name = name
def do_isbn(self, _: str):
while True:
isbn = input('New ISBN> ')
if isbn == '':
print('Error: ISBN cannot be empty')
continue
if not is_valid_isbn(isbn):
print('Error: ISBN is not valid')
continue
if self.sql_session.scalars(
select(BookcaseItem)
.where(BookcaseItem.isbn == isbn)
).one_or_none() is not None:
print(f'Error: an item with ISBN {isbn} already exists')
continue
break
self.bookcase_item.isbn = isbn
if prompt_yes_no('Update data from online databases?'):
self.parent.do_update_data('')
def do_language(self, _: str):
language_selector = InteractiveItemSelector(
Language,
self.sql_session,
)
self.bookcase_item.language = language_selector.result
def do_media_type(self, _: str):
media_type_selector = InteractiveItemSelector(
MediaType,
self.sql_session,
)
self.bookcase_item.media_type = media_type_selector.result
def do_amount(self, _: str):
while (new_amount := input(f'New amount [{self.bookcase_item.amount}]> ')) != '':
try:
new_amount = int(new_amount)
except ValueError:
print('Error: amount must be an integer')
continue
if new_amount < 1:
print('Error: amount must be greater than 0')
continue
break
self.bookcase_item.amount = new_amount
def do_exit():
return True

View File

@ -36,9 +36,9 @@ class BookcaseItem(Base, UidMixin, UniqueNameMixin):
owner: Mapped[str] = mapped_column(String, default='PVV') owner: Mapped[str] = mapped_column(String, default='PVV')
amount: Mapped[int] = mapped_column(SmallInteger, default=1) amount: Mapped[int] = mapped_column(SmallInteger, default=1)
fk_media_type_uid: Mapped[int] = mapped_column(Integer, ForeignKey('MediaType.uid')) fk_media_type_uid: Mapped[int] = mapped_column(ForeignKey('MediaType.uid'))
fk_bookcase_shelf_uid: Mapped[int | None] = mapped_column(Integer, ForeignKey('BookcaseShelf.uid')) fk_bookcase_shelf_uid: Mapped[int | None] = mapped_column(ForeignKey('BookcaseShelf.uid'))
fk_language_uid: Mapped[int] = mapped_column(Integer, ForeignKey('Language.uid')) fk_language_uid: Mapped[int | None] = mapped_column(ForeignKey('Language.uid'))
media_type: Mapped[MediaType] = relationship(back_populates='items') media_type: Mapped[MediaType] = relationship(back_populates='items')
shelf: Mapped[BookcaseShelf] = relationship(back_populates='items') shelf: Mapped[BookcaseShelf] = relationship(back_populates='items')

View File

@ -35,7 +35,7 @@ class BookcaseShelf(Base, UidMixin):
row: Mapped[int] = mapped_column(SmallInteger) row: Mapped[int] = mapped_column(SmallInteger)
column: Mapped[int] = mapped_column(SmallInteger) column: Mapped[int] = mapped_column(SmallInteger)
fk_bookcase_uid: Mapped[int] = mapped_column(Integer, ForeignKey('Bookcase.uid')) fk_bookcase_uid: Mapped[int] = mapped_column(ForeignKey('Bookcase.uid'))
bookcase: Mapped[Bookcase] = relationship(back_populates='shelfs') bookcase: Mapped[Bookcase] = relationship(back_populates='shelfs')
items: Mapped[set[BookcaseItem]] = relationship(back_populates='shelf') items: Mapped[set[BookcaseItem]] = relationship(back_populates='shelf')

View File

@ -1,274 +0,0 @@
from cmd import Cmd
from typing import Any
from textwrap import dedent
from sqlalchemy import (
create_engine,
select,
)
from sqlalchemy.orm import (
Session,
)
from worblehat.services.bookcase_item import (
create_bookcase_item_from_isbn,
is_valid_isbn,
)
from ..config import Config
from ..models import *
def _prompt_yes_no(question: str, default: bool | None = None) -> bool:
prompt = {
None: '[y/n]',
True: '[Y/n]',
False: '[y/N]',
}[default]
while not any([
(answer := input(f'{question} {prompt} ').lower()) in ('y','n'),
(default != None and answer.strip() == '')
]):
pass
return {
'y': True,
'n': False,
'': default,
}[answer]
class _InteractiveItemSelector(Cmd):
def __init__(
self,
cls: type,
sql_session: Session,
default: Any | None = None,
):
super().__init__()
self.cls = cls
self.sql_session = sql_session
self.default_item = default
if default is not None:
self.prompt = f'Select {cls.__name__} [{default.name}]> '
else:
self.prompt = f'Select {cls.__name__}> '
def default(self, arg: str):
if arg == '' and self.default_item is not None:
self.result = self.default_item
return True
result = self.sql_session.scalars(
select(self.cls)
.where(self.cls.name == arg),
).all()
if len(result) != 1:
print(f'No such {self.cls.__name__} found: {arg}')
return
self.result = result[0]
return True
def completenames(self, text: str, *_) -> list[str]:
return self.sql_session.scalars(
select(self.cls.name)
.where(self.cls.name.like(f'{text}%')),
).all()
class BookScanTool(Cmd):
prompt = '> '
intro = """
Welcome to the worblehat scanner tool.
Start by entering a command, or entering an ISBN of a new item.
Type "help" to see list of commands
"""
def __init__(self):
super().__init__()
try:
engine = create_engine(Config.SQLALCHEMY_DATABASE_URI)
self.sql_session = Session(engine)
except Exception:
print('Error: could not connect to database.')
exit(1)
print(f"Connected to database at '{Config.SQLALCHEMY_DATABASE_URI}'")
def do_list_bookcases(self, _: str):
"""Usage: list_bookcases"""
bookcase_shelfs = self.sql_session.scalars(
select(BookcaseShelf)
.join(Bookcase)
.order_by(
Bookcase.name,
BookcaseShelf.column,
BookcaseShelf.row,
)
).all()
bookcase_uid = None
for shelf in bookcase_shelfs:
if shelf.bookcase.uid != bookcase_uid:
print(shelf.bookcase.name)
bookcase_uid = shelf.bookcase.uid
name = f"r{shelf.row}-c{shelf.column}"
if shelf.description is not None:
name += f" [{shelf.description}]"
print(f' {name} - {sum(i.amount for i in shelf.items)} items')
def do_add_bookcase(self, arg: str):
"""Usage: add_bookcase <name> [description]"""
arg = arg.split(' ')
if len(arg) < 1:
print('Usage: add_bookcase <name> [description]')
return
name = arg[0]
description = ' '.join(arg[1:])
if self.sql_session.scalars(
select(Bookcase)
.where(Bookcase.name == name)
).one_or_none() is not None:
print(f'Error: a bookcase with name {name} already exists')
return
bookcase = Bookcase(name, description)
self.sql_session.add(bookcase)
def do_add_bookcase_shelf(self, arg: str):
"""Usage: add_bookcase_shelf <bookcase_name> <row>-<column> [description]"""
arg = arg.split(' ')
if len(arg) < 2:
print('Usage: add_bookcase_shelf <bookcase_name> <row>-<column> [description]')
return
bookcase_name = arg[0]
row, column = [int(x) for x in arg[1].split('-')]
description = ' '.join(arg[2:])
if (bookcase := self.sql_session.scalars(
select(Bookcase)
.where(Bookcase.name == bookcase_name)
).one_or_none()) is None:
print(f'Error: Could not find bookcase with name {bookcase_name}')
return
if self.sql_session.scalars(
select(BookcaseShelf)
.where(
BookcaseShelf.bookcase == bookcase,
BookcaseShelf.row == row,
BookcaseShelf.column == column,
)
).one_or_none() is not None:
print(f'Error: a bookshelf in bookcase {bookcase.name} with position {row}-{column} already exists')
return
shelf = BookcaseShelf(
row,
column,
bookcase,
description,
)
self.sql_session.add(shelf)
def do_list_bookcase_items(self, _: str):
"""Usage: list_bookcase_items"""
self.columnize([repr(bi) for bi in self.bookcase_items])
def default(self, isbn: str):
isbn = isbn.strip()
if not is_valid_isbn(isbn):
print(f'"{isbn}" is not a valid isbn')
return
if (existing_item := self.sql_session.scalars(
select(BookcaseItem)
.where(BookcaseItem.isbn == isbn)
).one_or_none()) is not None:
print('Found existing BookcaseItem:', existing_item)
print(f'There are currently {existing_item.amount} of these in the system.')
if _prompt_yes_no('Would you like to add another?', default=True):
existing_item.amount += 1
return
bookcase_item = create_bookcase_item_from_isbn(isbn, self.sql_session)
if bookcase_item is None:
print(f'Could not find data about item with isbn {isbn} online.')
print(f'If you think this is not due to a bug, please add the book to openlibrary.org before continuing.')
return
else:
print(dedent(f"""
Found item:
title: {bookcase_item.name}
authors: {', '.join(a.name for a in bookcase_item.authors)}
language: {bookcase_item.language}
"""))
print('Please select the shelf where the item is placed:')
bookcase_shelf_selector = _InteractiveItemSelector(
cls = BookcaseShelf,
sql_session = self.sql_session,
)
bookcase_shelf_selector.cmdloop()
bookcase_item.shelf = bookcase_shelf_selector.result
print('Please select the items media type:')
media_type_selector = _InteractiveItemSelector(
cls = MediaType,
sql_session = self.sql_session,
default = self.sql_session.scalars(
select(MediaType)
.where(MediaType.name.ilike("book")),
).one(),
)
media_type_selector.cmdloop()
bookcase_item.media_type = media_type_selector.result
username = input('Who owns this book? [PVV]: ')
if username != '':
bookcase_item.owner = username
self.sql_session.add(bookcase_item)
def do_exit(self, _: str):
"""Usage: exit"""
if _prompt_yes_no('Would you like to save your changes?'):
self.sql_session.commit()
else:
self.sql_session.rollback()
exit(0)
def main():
tool = BookScanTool()
while True:
try:
tool.cmdloop()
except KeyboardInterrupt:
try:
print()
if _prompt_yes_no('Are you sure you want to exit without saving?', default=False):
raise KeyboardInterrupt
except KeyboardInterrupt:
if tool.sql_session is not None:
tool.sql_session.rollback()
exit(0)
if __name__ == '__main__':
main()