Compare commits

..

10 Commits

Author SHA1 Message Date
Bjornar Orjansen Kaarevik a08c1ea5f0
Update README.md
Checked ascii art of monkey since we know have worblehat-banner.txt
2023-08-27 03:12:13 +02:00
Bjornar Orjansen Kaarevik e574fc5817 added ascii art banner of our favorite orangutan 2023-08-27 02:44:39 +02:00
Oystein Kristoffer Tveit d13a3a0932
cli: add some search functionality 2023-05-12 16:18:30 +02:00
Oystein Kristoffer Tveit e154989a16
Update README 2023-05-12 02:58:45 +02:00
Oystein Kristoffer Tveit b83175e39a
Add `DeadlineDaemon`
The deadline daemon is supposed to send emails
to users who have borrowed books and are supposed to deliver
them back, or users who have added themselves to the queue,
and is waiting for a book.
2023-05-12 02:37:56 +02:00
Oystein Kristoffer Tveit 18a1667b7b
A few small improvements:
- Add missing flushes to cli
- Update README TODOs
- Add some documentation comments
- Rename AdvancedOptions -> AdvancedOptionsCli
2023-05-12 02:31:46 +02:00
Oystein Kristoffer Tveit fad38adc50
Move all functionality under a single `worblehat` command 2023-05-12 02:27:26 +02:00
Oystein Kristoffer Tveit 31184dde12
Update README TODO-list 2023-05-10 21:55:01 +02:00
Oystein Kristoffer Tveit b2f8d23637
cli: add commands for borrowing and delivering items 2023-05-10 21:53:49 +02:00
Oystein Kristoffer Tveit 18053bf002
cli: move some commands to `Advanced` submenu 2023-05-10 17:50:18 +02:00
28 changed files with 943 additions and 256 deletions

View File

@ -20,12 +20,6 @@ Styret har derfor tatt initiativ til å opprette et biblioteksystem for å syste
Prosjektet har fått navn Worblehat etter en bibliotekar i Terry Pratchetts discworld serie.
Worblehatt har vært påbegynnt flere ganger opp gjennom historien uten å komme i noen form for funksjonell tilstand enda.
## How?
The application is split into frontend and backend. The frontend is written with react-scripts, and communicates with the backed through a REST api.
The backend is written in Flask, and uses an ORM(SQLAlchemy) to store the data in any kind of SQL database.
# Technical details
## Setup
@ -35,37 +29,57 @@ This project uses [poetry][poetry] as its buildtool as of May 2023.
```console
$ poetry install
$ poetry run alembic migrate
$ poetry run cli
$ poetry run dev
$ poetry run worblehat --help
```
## How to configure
See `worblehat/config.py` for configurable settings.
See `config.template` for configurable settings.
Unless provided through the `--config` flag, program will automatically look for a config file in these locations:
- `./config.toml`
- `~/.config/worblehat/config.toml`
- `/var/lib/worblehat/config.toml`
Run `poetry run worblehat --help` for more info
## TODO List
- [ ] High priority:
- [X] Data ingestion logic, that will pull data from online databases based on ISBN.
- [ ] Cli version of the program (this is currently being worked on).
- [ ] 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:
- [ ] Ability for PVV members to request book loans through the PVV website
- [ ] Ability for PVV members to search for books through the PVV website
- [ ] 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?
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.
### Setting up a database with all of PVVs books
- [ ] Create postgres database
- [ ] Model all bookshelfs
- [ ] Scan in all books
### Cli version of the program (this is currently being worked on)
- [X] Ability to pull data from online sources with ISBN
- [X] Ability to create and update bookcases
- [X] Ability to create and update bookcase shelfs
- [X] Ability to create and update bookcase items
- [X] Ability to borrow and deliver items
- [ ] Ability to borrow and deliver multiple items at a time
- [X] Ability to enter the queue for borrowing an item
- [ ] Ability to extend a borrowing, only if no one is behind you in the queue
- [ ] Ability to list borrowed items which are overdue
- [~] Ability to search for items
- [ ] Ability to print PVV-specific labels for items missing a label, or which for any other reason needs a custom one
- [X] Ascii art of monkey with fingers in eyes
### Deadline daemon
- [X] Ability to be notified when deadlines are due
- [ ] Ability to be notified when books are available
- [ ] Ability to have expiring queue positions automatically expire
### Web version of the program
- [ ] Ability for PVV members to search for books through the PVV website
## Points of 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?
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.
- Workaround for not being able to represent items with same ISBN and different owner: if you are absolutely adamant about placing your item at PVV while still owning it, even though PVV already owns a copy of this item, please print out a new label with a "PVV-ISBN" for it

View File

@ -1,9 +1,6 @@
# See https://flask.palletsprojects.com/en/2.3.x/config/
[flask]
TESTING = true
DEBUG = true
FLASK_ENV = 'development'
SECRET_KEY = 'change-me'
[logging]
debug = true
debug_sql = false
[database]
# One of (sqlite, postgres)
@ -16,5 +13,25 @@ path = './worblehat.sqlite'
host = 'localhost'
port = 5432
username = 'worblehat'
password = 'change-me'
password = '/var/lib/worblehat/db-password' # path or plain text
name = 'worblehat'
# See https://flask.palletsprojects.com/en/2.3.x/config/
[flask]
TESTING = true
DEBUG = true
FLASK_ENV = 'development'
SECRET_KEY = 'change-me' # path or plain text
[smtp]
enabled = false
host = 'smtp.pvv.ntnu.no'
port = 587
username = 'worblehat'
password = '/var/lib/worblehat/smtp-password' # path or plain text
from = 'worblehat@pvv.ntnu.no'
subject_prefix = '[Worblehat]'
[deadline_daemon]
warn_days_before_borrow_deadline = [ "5", "1" ]
warn_days_before_expiring_queue_position_deadline = [ "3", "1" ]

View File

@ -13,9 +13,8 @@
inherit program;
};
in {
default = self.apps.${system}.dev;
dev = app "${self.packages.${system}.worblehat}/bin/dev";
cli = app "${self.packages.${system}.worblehat}/bin/cli";
default = self.apps.${system}.worblehat;
worblehat = app "${self.packages.${system}.worblehat}/bin/worblehat";
};
packages.${system} = {

View File

@ -22,8 +22,7 @@ werkzeug = "^2.3.3"
poethepoet = "^0.20.0"
[tool.poetry.scripts]
cli = "worblehat.cli.main:main"
dev = "worblehat.flaskapp.wsgi_dev:main"
worblehat = "worblehat.main:main"
[tool.poe.tasks]
clean = """

24
worblehat-banner.txt Normal file
View File

@ -0,0 +1,24 @@
ooooo++oooooooooooooooo++++--------.............--++++o++ooooooooooooooooooooooo
ooooo+++++++++++++++-----... ........-----++++++++++oooooooooooo
+-----+++++++---..-... ......---------++++++ooooooooo
....---------...... .. ...... . .. ....-----------+++++++++-
.......-........... ......----.......---. ........--------------++++++
.................. ...------....----------... .....-----------++++++++
... ............ ....---+--+.. ......--++---... ....----------++++++++
......--...--. .-++++--..... .........------++++++
. ......--++.........-.---.--...... ............------++++
...-.--+o0o+.---.....-o00+............ ............-----+++
.......--+000o-.---++--.+o0##+............ ...............--+
........-+0000-.-++oooo+.-o0##0----.......... ..............---
........+0000o-++-..-...-.+000#0-............ . . ........
......-o0o000+++++++++-++-+ooo000-............. . ..
......+0ooo0o+oooooooo++++-oo+++0+..+ooo+......
.... o00000++ooooooooo+++-.+++ooo -oo000o-...
... .0000o+-++ooo0000ooo+-..++oo0-.o00000+..
-+ooo-.+0000-..-++ooooooooo++. .-++oo-oo0000o-. .
.+o-.-oo000000000+. -+o0000000o++-. .--+ooooo000o00oo-..
.+00+--ooo00000o00oo-. ..---++++++++-.. .--++oooooooooooo000+-.
-o0o+-+oooooo0oooo000o+ ...---------+-.. ..---++ooooooo0o++-+o00-
.+ooo++ooo+++++++++oo000o //==//==//=\\==\\==\\ ++++++0#0oo++++o+.
============================ | W O R B L E H A T | =============================
\\==\\==\\=//==//==//

View File

@ -0,0 +1 @@
from .main import WorblehatCli

View File

@ -1,44 +1,35 @@
from textwrap import dedent
from sqlalchemy import (
create_engine,
event,
select,
)
from sqlalchemy.orm import (
Session,
)
from worblehat.services.bookcase_item import (
from sqlalchemy.orm import Session
from worblehat.services import (
create_bookcase_item_from_isbn,
is_valid_isbn,
)
from worblehat.services.config import Config
from worblehat.services.argument_parser import parse_args
from worblehat.models import *
from .prompt_utils import *
from .subclis.bookcase_item import BookcaseItemCli
from .subclis.bookcase_shelf_selector import select_bookcase_shelf
from .subclis import (
AdvancedOptionsCli,
BookcaseItemCli,
select_bookcase_shelf,
SearchCli,
)
# 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, args: dict[str, any] | None = None):
def __init__(self, sql_session: Session):
super().__init__()
try:
engine = create_engine(Config.db_string(), echo=args.get('verbose_sql', False))
self.sql_session = Session(engine)
except Exception as err:
print('Error: could not connect to database.')
print(err)
exit(1)
self.sql_session = sql_session
self.sql_session_dirty = False
@event.listens_for(self.sql_session, 'after_flush')
def mark_session_as_dirty(*_):
@ -51,7 +42,23 @@ class WorblehatCli(NumberedCmd):
self.sql_session_dirty = False
self.prompt_header = None
print(f"Debug: Connected to database at '{Config.db_string()}'")
@classmethod
def run_with_safe_exit_wrapper(cls, sql_session: Session):
tool = cls(sql_session)
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)
def do_list_bookcases(self, _: str):
@ -88,82 +95,6 @@ class WorblehatCli(NumberedCmd):
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:
column = input('Column> ')
try:
column = int(column)
except ValueError:
print('Error: column must be a number')
continue
break
while True:
row = input('Row> ')
try:
row = int(row)
except ValueError:
print('Error: row must be a number')
continue
break
if self.sql_session.scalars(
select(BookcaseShelf)
.where(
BookcaseShelf.bookcase == bookcase,
BookcaseShelf.column == column,
BookcaseShelf.row == row,
)
).one_or_none() is not None:
print(f'Error: a bookshelf in bookcase {bookcase.name} with position c{column}-r{row} 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:
@ -231,7 +162,18 @@ class WorblehatCli(NumberedCmd):
def do_search(self, _: str):
print('TODO: implement search')
search_cli = SearchCli(self.sql_session)
search_cli.cmdloop()
if search_cli.result is not None:
BookcaseItemCli(
sql_session = self.sql_session,
bookcase_item = search_cli.result,
).cmdloop()
def do_advanced(self, _: str):
AdvancedOptionsCli(self.sql_session).cmdloop()
def do_save(self, _:str):
if not self.sql_session_dirty:
@ -274,47 +216,19 @@ class WorblehatCli(NumberedCmd):
'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: {
5: {
'f': do_abort,
'doc': 'Abort changes',
},
6: {
'f': do_advanced,
'doc': 'Advanced options',
},
9: {
'f': do_exit,
'doc': 'Exit',
},
}
def main():
args = parse_args()
Config.load_configuration(args)
tool = WorblehatCli(args)
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

@ -1,4 +1,5 @@
from cmd import Cmd
from datetime import datetime
from typing import Any, Callable
from sqlalchemy import select
@ -24,6 +25,10 @@ def prompt_yes_no(question: str, default: bool | None = None) -> bool:
}[answer]
def format_date(date: datetime):
return date.strftime("%a %b %d, %Y")
class InteractiveItemSelector(Cmd):
def __init__(
self,
@ -124,16 +129,16 @@ class NumberedCmd(Cmd):
prompt_header: str | None = None
funcs: dict[int, dict[str, str | Callable[[Any, str], bool | None]]]
def __init__(self):
super().__init__()
@classmethod
def _generate_usage_list(cls) -> str:
def _generate_usage_list(self) -> str:
result = ''
for i, func in cls.funcs.items():
for i, func in self.funcs.items():
if i == 0:
i = '*'
result += f'{i}) {func["doc"]}\n'
@ -180,4 +185,27 @@ class NumberedCmd(Cmd):
else:
result += f'[{self.lastcmd}]> '
return result
return result
class NumberedItemSelector(NumberedCmd):
def __init__(
self,
items: list[Any],
stringify: Callable[[Any], str] = lambda x: str(x),
):
super().__init__()
self.items = items
self.stringify = stringify
self.funcs = {
i: {
'f': self._select_item,
'doc': self.stringify(item),
}
for i, item in enumerate(items, start=1)
}
def _select_item(self, *a):
self.result = self.items[int(self.lastcmd)-1]
return True

View File

@ -0,0 +1,4 @@
from .advanced_options import AdvancedOptionsCli
from .bookcase_item import BookcaseItemCli
from .bookcase_shelf_selector import select_bookcase_shelf
from .search import SearchCli

View File

@ -0,0 +1,111 @@
from sqlalchemy import select
from sqlalchemy.orm import Session
from worblehat.cli.prompt_utils import (
InteractiveItemSelector,
NumberedCmd,
format_date,
prompt_yes_no,
)
from worblehat.models import Bookcase, BookcaseShelf
class AdvancedOptionsCli(NumberedCmd):
def __init__(self, sql_session: Session):
super().__init__()
self.sql_session = sql_session
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:
column = input('Column> ')
try:
column = int(column)
except ValueError:
print('Error: column must be a number')
continue
break
while True:
row = input('Row> ')
try:
row = int(row)
except ValueError:
print('Error: row must be a number')
continue
break
if self.sql_session.scalars(
select(BookcaseShelf)
.where(
BookcaseShelf.bookcase == bookcase,
BookcaseShelf.column == column,
BookcaseShelf.row == row,
)
).one_or_none() is not None:
print(f'Error: a bookshelf in bookcase {bookcase.name} with position c{column}-r{row} 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 do_done(self, _: str):
return True
funcs = {
1: {
'f': do_add_bookcase,
'doc': 'Add bookcase',
},
2: {
'f': do_add_bookcase_shelf,
'doc': 'Add bookcase shelf',
},
9: {
'f': do_done,
'doc': 'Done',
},
}

View File

@ -1,3 +1,4 @@
from datetime import datetime
from textwrap import dedent
from sqlalchemy import select
@ -6,11 +7,14 @@ from sqlalchemy.orm import Session
from worblehat.cli.prompt_utils import (
InteractiveItemSelector,
NumberedCmd,
format_date,
prompt_yes_no,
)
from worblehat.models import (
Bookcase,
BookcaseItem,
BookcaseItemBorrowing,
BookcaseItemBorrowingQueue,
Language,
MediaType,
)
@ -22,13 +26,14 @@ from worblehat.services.bookcase_item import (
from .bookcase_shelf_selector import select_bookcase_shelf
def _selected_bookcase_item_prompt(bookcase_item: BookcaseItem) -> str:
amount_borrowed = len(bookcase_item.borrowings)
return dedent(f'''
Item: {bookcase_item.name}
ISBN: {bookcase_item.isbn}
Amount: {bookcase_item.amount}
Authors: {', '.join(a.name for a in bookcase_item.authors)}
Bookcase: {bookcase_item.shelf.bookcase.short_str()}
Shelf: {bookcase_item.shelf.short_str()}
Amount: {bookcase_item.amount - amount_borrowed}/{bookcase_item.amount}
''')
class BookcaseItemCli(NumberedCmd):
@ -37,24 +42,136 @@ class BookcaseItemCli(NumberedCmd):
self.sql_session = sql_session
self.bookcase_item = bookcase_item
@property
def prompt_header(self) -> str:
return _selected_bookcase_item_prompt(self.bookcase_item)
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
self.sql_session.flush()
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')
@staticmethod
def _prompt_username() -> str:
while True:
username = input('Username: ')
if prompt_yes_no(f'Is {username} correct?', default = True):
return username
def _has_active_borrowing(self, username: str) -> bool:
return self.sql_session.scalars(
select(BookcaseItemBorrowing)
.where(
BookcaseItemBorrowing.username == username,
BookcaseItemBorrowing.item == self.bookcase_item,
BookcaseItemBorrowing.delivered.is_(None),
)
).one_or_none() is not None
def _has_borrowing_queue_item(self, username: str) -> bool:
return self.sql_session.scalars(
select(BookcaseItemBorrowingQueue)
.where(
BookcaseItemBorrowingQueue.username == username,
BookcaseItemBorrowingQueue.item == self.bookcase_item,
)
).one_or_none() is not None
def do_borrow(self, _: str):
active_borrowings = self.sql_session.scalars(
select(BookcaseItemBorrowing)
.where(
BookcaseItemBorrowing.item == self.bookcase_item,
BookcaseItemBorrowing.delivered.is_(None),
)
.order_by(BookcaseItemBorrowing.end_time)
).all()
if len(active_borrowings) >= self.bookcase_item.amount:
print('This item is currently not available')
print()
print('Active borrowings:')
for b in active_borrowings:
print(f' {b.username} - Until {format_date(b.end_time)}')
if len(self.bookcase_item.borrowing_queue) > 0:
print('Borrowing queue:')
for i, b in enumerate(self.bookcase_item.borrowing_queue):
print(f' {i + 1} - {b.username}')
print()
if not prompt_yes_no('Would you like to enter the borrowing queue?', default = True):
return
username = self._prompt_username()
if self._has_active_borrowing(username):
print('You already have an active borrowing')
return
if self._has_borrowing_queue_item(username):
print('You are already in the borrowing queue')
return
borrowing_queue_item = BookcaseItemBorrowingQueue(username, self.bookcase_item)
self.sql_session.add(borrowing_queue_item)
print(f'{username} entered the queue!')
return
username = self._prompt_username()
borrowing_item = BookcaseItemBorrowing(username, self.bookcase_item)
self.sql_session.add(borrowing_item)
self.sql_session.flush()
print(f'Successfully borrowed the item. Please deliver it back by {format_date(borrowing_item.end_time)}')
def do_deliver(self, _: str):
borrowings = self.sql_session.scalars(
select(BookcaseItemBorrowing)
.join(BookcaseItem, BookcaseItem.uid == BookcaseItemBorrowing.fk_bookcase_item_uid)
.where(BookcaseItem.isbn == self.bookcase_item.isbn)
.order_by(BookcaseItemBorrowing.username)
).all()
if len(borrowings) == 0:
print('No one seems to have borrowed this item')
return
print('Borrowers:')
for i, b in enumerate(borrowings):
print(f' {i + 1}) {b.username}')
while True:
try:
selection = int(input('> '))
except ValueError:
print('Error: selection must be an integer')
continue
if selection < 1 or selection > len(borrowings):
print('Error: selection out of range')
continue
break
borrowing = borrowings[selection - 1]
borrowing.delivered = datetime.now()
self.sql_session.flush()
print(f'Successfully delivered the item for {borrowing.username}')
def do_done(self, _: str):
@ -63,14 +180,18 @@ class BookcaseItemCli(NumberedCmd):
funcs = {
1: {
'f': do_loan,
'doc': 'Loan',
'f': do_borrow,
'doc': 'Borrow',
},
2: {
'f': do_deliver,
'doc': 'Deliver',
},
3: {
'f': do_edit,
'doc': 'Edit',
},
3: {
4: {
'f': do_update_data,
'doc': 'Pull updated data from online databases',
},
@ -107,6 +228,7 @@ class EditBookcaseCli(NumberedCmd):
break
self.bookcase_item.name = name
self.sql_session.flush()
def do_isbn(self, _: str):
@ -133,6 +255,7 @@ class EditBookcaseCli(NumberedCmd):
if prompt_yes_no('Update data from online databases?'):
self.parent.do_update_data('')
self.sql_session.flush()
def do_language(self, _: str):
@ -142,6 +265,7 @@ class EditBookcaseCli(NumberedCmd):
)
self.bookcase_item.language = language_selector.result
self.sql_session.flush()
def do_media_type(self, _: str):
@ -151,6 +275,7 @@ class EditBookcaseCli(NumberedCmd):
)
self.bookcase_item.media_type = media_type_selector.result
self.sql_session.flush()
def do_amount(self, _: str):
@ -167,6 +292,7 @@ class EditBookcaseCli(NumberedCmd):
break
self.bookcase_item.amount = new_amount
self.sql_session.flush()
def do_shelf(self, _: str):
@ -180,6 +306,7 @@ class EditBookcaseCli(NumberedCmd):
shelf = select_bookcase_shelf(bookcase, self.sql_session)
self.bookcase_item.shelf = shelf
self.sql_session.flush()
def do_done(self, _: str):
@ -211,7 +338,7 @@ class EditBookcaseCli(NumberedCmd):
'f': do_shelf,
'doc': 'Change shelf',
},
7: {
9: {
'f': do_done,
'doc': 'Done',
},

View File

@ -0,0 +1,145 @@
from sqlalchemy import select
from sqlalchemy.orm import Session
from worblehat.cli.prompt_utils import (
NumberedCmd,
NumberedItemSelector,
)
from worblehat.models import Author, BookcaseItem
class SearchCli(NumberedCmd):
def __init__(self, sql_session: Session):
super().__init__()
self.sql_session = sql_session
def do_search_all(self, _: str):
print('TODO: Implement search all')
def do_search_title(self, _: str):
while (input_text := input('Enter title: ')) == '':
pass
items = self.sql_session.scalars(
select(BookcaseItem)
.where(BookcaseItem.name.ilike(f'%{input_text}%')),
).all()
if len(items) == 0:
print('No items found.')
return
selector = NumberedItemSelector(
items = items,
stringify = lambda item: f"{item.name} ({item.isbn})",
)
selector.cmdloop()
if selector.result is not None:
self.result = selector.result
return True
def do_search_author(self, _: str):
while (input_text := input('Enter author name: ')) == '':
pass
author = self.sql_session.scalars(
select(Author)
.where(Author.name.ilike(f'%{input_text}%')),
).all()
if len(author) == 0:
print('No authors found.')
return
elif len(author) == 1:
selected_author = author[0]
print('Found author:')
print(f" {selected_author.name} ({sum(item.amount for item in selected_author.books)} items)")
else:
selector = NumberedItemSelector(
items = author,
stringify = lambda author: f"{author.name} ({sum(item.amount for item in author.books)} items)",
)
selector.cmdloop()
if selector.result is None:
return
selected_author = selector.result
selector = NumberedItemSelector(
items = selected_author.books,
stringify = lambda item: f"{item.name} ({item.isbn})",
)
selector.cmdloop()
if selector.result is not None:
self.result = selector.result
return True
def do_search_owner(self, _: str):
while (input_text := input('Enter username: ')) == '':
pass
users = self.sql_session.scalars(
select(BookcaseItem.owner)
.where(BookcaseItem.owner.ilike(f'%{input_text}%'))
.distinct(),
).all()
if len(users) == 0:
print('No users found.')
return
elif len(users) == 1:
selected_user = users[0]
print('Found user:')
print(f" {selected_user}")
else:
selector = NumberedItemSelector(items = users)
selector.cmdloop()
if selector.result is None:
return
selected_user = selector.result
items = self.sql_session.scalars(
select(BookcaseItem)
.where(BookcaseItem.owner == selected_user),
).all()
selector = NumberedItemSelector(
items = items,
stringify = lambda item: f"{item.name} ({item.isbn})",
)
selector.cmdloop()
if selector.result is not None:
self.result = selector.result
return True
def do_done(self, _: str):
return True
funcs = {
1: {
'f': do_search_all,
'doc': 'Search everything',
},
2: {
'f': do_search_title,
'doc': 'Search by title',
},
3: {
'f': do_search_author,
'doc': 'Search by author',
},
4: {
'f': do_search_owner,
'doc': 'Search by owner',
},
9: {
'f': do_done,
'doc': 'Done',
},
}

View File

@ -0,0 +1 @@
from .main import DeadlineDaemon

View File

@ -0,0 +1,147 @@
import logging
from datetime import datetime, timedelta
from textwrap import dedent
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from worblehat.services.config import Config
from worblehat.models import (
BookcaseItemBorrowing,
DeadlineDaemonLastRunDatetime,
BookcaseItemBorrowingQueue,
)
from worblehat.services.email import send_email
class DeadlineDaemon:
def __init__(self, sql_session: Session):
self.sql_session = sql_session
self.last_run = self.sql_session.scalars(
select(DeadlineDaemonLastRunDatetime),
).one()
self.last_run_datetime = self.last_run.time
self.current_run_datetime = datetime.now()
def run(self):
logging.info('Deadline daemon started')
self.send_close_deadline_reminder_mails()
self.send_overdue_mails()
self.send_newly_available_mails()
self.send_expiring_queue_position_mails()
self.auto_expire_queue_positions()
self.last_run.time = self.current_run_datetime
self.sql_session.commit()
def _sql_subtract_date(self, x: datetime, y: timedelta):
if self.sql_session.bind.dialect.name == 'sqlite':
# SQLite does not support timedelta in queries
return func.datetime(x, f'-{y.days} days')
elif self.sql_session.bind.dialect.name == 'postgresql':
return x - y
else:
raise NotImplementedError(f'Unsupported dialect: {self.sql_session.bind.dialect.name}')
def send_close_deadline_reminder_mails(self):
logging.info('Sending mails about items with a closing deadline')
# TODO: This should be int-parsed and validated before the daemon started
days = [int(d) for d in Config['deadline_daemon.warn_days_before_borrow_deadline']]
for day in days:
borrowings_to_remind = self.sql_session.scalars(
select(BookcaseItemBorrowing)
.where(
self._sql_subtract_date(
BookcaseItemBorrowing.end_time,
timedelta(days=day),
)
.between(
self.last_run_datetime,
self.current_run_datetime,
),
BookcaseItemBorrowing.delivered.is_(None),
),
).all()
for borrowing in borrowings_to_remind:
logging.info(f' Sending close deadline mail to {borrowing.username}@pvv.ntnu.no. {day} days left')
send_email(
f'{borrowing.username}@pvv.ntnu.no',
'Reminder - Your borrowing deadline is approaching',
dedent(f'''
Your borrowing deadline for the following item is approaching:
{borrowing.item.name}
Please return the item by {borrowing.end_time.strftime("%a %b %d, %Y")}
''',
).strip(),
)
def send_overdue_mails(self):
logging.info('Sending mails about overdue items')
to_remind = self.sql_session.scalars(
select(BookcaseItemBorrowing)
.where(
BookcaseItemBorrowing.end_time < self.current_run_datetime,
BookcaseItemBorrowing.delivered.is_(None),
)
).all()
for borrowing in to_remind:
logging.info(f' Sending overdue mail to {borrowing.username}@pvv.ntnu.no for {borrowing.item.isbn} - {borrowing.end_time.strftime("%a %b %d, %Y")}')
send_email(
f'{borrowing.username}@pvv.ntnu.no',
'Your deadline has passed',
dedent(f'''
Your delivery deadline for the following item has passed:
{borrowing.item.name}
Please return the item as soon as possible.
''',
).strip(),
)
def send_newly_available_mails(self):
logging.info('Sending mails about newly available items')
newly_available = self.sql_session.scalars(
select(BookcaseItemBorrowingQueue)
.join(
BookcaseItemBorrowing,
BookcaseItemBorrowing.fk_bookcase_item_uid == BookcaseItemBorrowingQueue.fk_bookcase_item_uid,
)
.where(
BookcaseItemBorrowingQueue.expired.is_(False),
BookcaseItemBorrowing.delivered.is_not(None),
BookcaseItemBorrowing.delivered.between(
self.last_run_datetime,
self.current_run_datetime,
),
)
.order_by(BookcaseItemBorrowingQueue.entered_queue_time)
.group_by(BookcaseItemBorrowingQueue.fk_bookcase_item_uid)
).all()
for queue_item in newly_available:
logging.info(f'Sending newly available mail to {queue_item.username}')
logging.warning('Not implemented')
def send_expiring_queue_position_mails(self):
logging.info('Sending mails about queue positions which are expiring soon')
logging.warning('Not implemented')
def auto_expire_queue_positions(self):
logging.info('Expiring queue positions which are too old')
logging.warning('Not implemented')

View File

@ -13,13 +13,10 @@ from .database import db
def create_app(args: dict[str, any] | None = None):
app = Flask(__name__)
if args is not None:
Config.load_configuration(args)
print(Config.db_string())
app.config.update(Config['flask'])
app.config.update(Config._config)
app.config['SQLALCHEMY_DATABASE_URI'] = Config.db_string()
app.config['SQLALCHEMY_ECHO'] = args.get('verbose_sql')
app.config.update(Config['flask'])
app.config.update(Config._config)
app.config['SQLALCHEMY_DATABASE_URI'] = Config.db_string()
app.config['SQLALCHEMY_ECHO'] = Config['logging.debug_sql']
db.init_app(app)

View File

@ -1,13 +1,11 @@
from werkzeug import run_simple
from worblehat.services.config import Config
from worblehat.services.argument_parser import parse_args
from .flaskapp import create_app
def main():
args = parse_args()
app = create_app(args)
app = create_app()
run_simple(
hostname = 'localhost',
port = 5000,

View File

@ -1,3 +1,8 @@
from .flaskapp import create_app
app = create_app()
def main():
app = create_app()
app.run()
if __name__ == '__main__':
main()

73
worblehat/main.py Normal file
View File

@ -0,0 +1,73 @@
import logging
from pprint import pformat
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from .services import (
Config,
arg_parser,
)
from .deadline_daemon import DeadlineDaemon
from .cli import WorblehatCli
from .flaskapp.wsgi_dev import main as flask_dev_main
from .flaskapp.wsgi_prod import main as flask_prod_main
def _print_version() -> None:
from worblehat import __version__
print(f'Worblehat version {__version__}')
def _connect_to_database(**engine_args) -> Session:
try:
engine = create_engine(Config.db_string(), **engine_args)
sql_session = Session(engine)
except Exception as err:
print('Error: could not connect to database.')
print(err)
exit(1)
print(f"Debug: Connected to database at '{Config.db_string()}'")
return sql_session
def main():
args = arg_parser.parse_args()
Config.load_configuration(vars(args))
if Config['logging.debug']:
logging.basicConfig(encoding='utf-8', level=logging.DEBUG)
else:
logging.basicConfig(encoding='utf-8', level=logging.INFO)
if args.version:
_print_version()
exit(0)
if args.print_config:
print(f'Configuration:\n{pformat(vars(args))}')
exit(0)
if args.command == 'deadline-daemon':
sql_session = _connect_to_database(echo=Config['logging.debug_sql'])
DeadlineDaemon(sql_session).run()
exit(0)
if args.command == 'cli':
sql_session = _connect_to_database(echo=Config['logging.debug_sql'])
WorblehatCli.run_with_safe_exit_wrapper(sql_session)
exit(0)
if args.command == 'flask-dev':
flask_dev_main()
exit(0)
if args.command == 'flask-prod':
if Config['logging.debug'] or Config['logging.debug_sql']:
logging.warn('Debug mode is enabled for the production server. This is not recommended.')
flask_prod_main()
exit(0)
print(arg_parser.format_help())

View File

@ -43,8 +43,8 @@ class BookcaseItem(Base, UidMixin, UniqueNameMixin):
media_type: Mapped[MediaType] = relationship(back_populates='items')
shelf: Mapped[BookcaseShelf] = relationship(back_populates='items')
language: Mapped[Language] = relationship()
borrowings: Mapped[BookcaseItemBorrowing] = relationship(back_populates='item')
borrowing_queue: Mapped[BookcaseItemBorrowingQueue] = relationship(back_populates='item')
borrowings: Mapped[set[BookcaseItemBorrowing]] = relationship(back_populates='item')
borrowing_queue: Mapped[set[BookcaseItemBorrowingQueue]] = relationship(back_populates='item')
categories: Mapped[set[Category]] = relationship(
secondary = Item_Category.__table__,

View File

@ -23,7 +23,7 @@ class BookcaseItemBorrowing(Base, UidMixin):
username: Mapped[str] = mapped_column(String)
start_time: Mapped[datetime] = mapped_column(DateTime, default=datetime.now())
end_time: Mapped[datetime] = mapped_column(DateTime, default=datetime.now() + timedelta(days=30))
delivered: Mapped[bool] = mapped_column(Boolean, default=False)
delivered: Mapped[datetime | None] = mapped_column(DateTime, default=None)
fk_bookcase_item_uid: Mapped[int] = mapped_column(ForeignKey('BookcaseItem.uid'), index=True)
@ -35,4 +35,6 @@ class BookcaseItemBorrowing(Base, UidMixin):
item: BookcaseItem,
):
self.username = username
self.item = item
self.item = item
self.start_time = datetime.now()
self.end_time = datetime.now() + timedelta(days=30)

View File

@ -22,7 +22,7 @@ if TYPE_CHECKING:
class BookcaseItemBorrowingQueue(Base, UidMixin):
username: Mapped[str] = mapped_column(String)
entered_queue_time = mapped_column(DateTime, default=datetime.now())
should_notify_user = mapped_column(Boolean, default=False)
expired = mapped_column(Boolean, default=False)
fk_bookcase_item_uid: Mapped[int] = mapped_column(ForeignKey('BookcaseItem.uid'), index=True)
@ -34,4 +34,5 @@ class BookcaseItemBorrowingQueue(Base, UidMixin):
item: BookcaseItem,
):
self.username = username
self.item = item
self.item = item
self.entered_queue_time = datetime.now()

View File

@ -0,0 +1,23 @@
from datetime import datetime
from sqlalchemy import (
CheckConstraint,
DateTime,
Boolean,
)
from sqlalchemy.orm import (
Mapped,
mapped_column,
)
from .Base import Base
class DeadlineDaemonLastRunDatetime(Base):
__table_args__ = (
CheckConstraint(
'uid = true',
name = 'single_row_only',
),
)
uid: Mapped[bool] = mapped_column(Boolean, primary_key=True, default=True)
time: Mapped[datetime] = mapped_column(DateTime, default=datetime.now())

View File

@ -6,5 +6,6 @@ from .BookcaseItemBorrowing import BookcaseItemBorrowing
from .BookcaseItemBorrowingQueue import BookcaseItemBorrowingQueue
from .BookcaseShelf import BookcaseShelf
from .Category import Category
from .DeadlineDaemonLastRunDatetime import DeadlineDaemonLastRunDatetime
from .Language import Language
from .MediaType import MediaType

View File

@ -0,0 +1,8 @@
from .argument_parser import arg_parser
from .bookcase_item import (
create_bookcase_item_from_isbn,
is_valid_isbn,
)
from .config import Config
from .email import send_email
from .seed_test_data import seed_data

View File

@ -1,12 +1,5 @@
from argparse import ArgumentParser
from os import path
from pathlib import Path
from pprint import pformat
def _print_version() -> None:
from worblehat import __version__
print(f'Worblehat version {__version__}')
def _is_valid_file(parser: ArgumentParser, arg: str) -> Path:
path = Path(arg)
@ -16,47 +9,45 @@ def _is_valid_file(parser: ArgumentParser, arg: str) -> Path:
return path
def parse_args() -> dict[str, any]:
parser = ArgumentParser(
description = 'Worblehat library management system',
)
arg_parser = ArgumentParser(
description = 'Worblehat library management system',
)
parser.add_argument(
'--verbose',
action = 'store_true',
help = 'Enable verbose mode',
)
parser.add_argument(
'--verbose-sql',
action = 'store_true',
help = 'Enable verbose SQL mode',
)
parser.add_argument(
'--version',
action = 'store_true',
help = 'Print version and exit',
)
parser.add_argument(
'--config',
type=lambda x: _is_valid_file(parser, x),
help = 'Path to config file',
dest = 'config_file',
metavar = 'FILE',
)
parser.add_argument(
'--print-config',
action = 'store_true',
help = 'Print configuration and quit',
)
subparsers = arg_parser.add_subparsers(dest='command')
subparsers.add_parser(
'deadline-daemon',
help = 'Initialize a single pass of the daemon which sends deadline emails',
)
subparsers.add_parser(
'cli',
help = 'Start the command line interface',
)
subparsers.add_parser(
'flask-dev',
help = 'Start the web interface in development mode',
)
subparsers.add_parser(
'flask-prod',
help = 'Start the web interface in production mode',
)
args = parser.parse_args()
if args.version:
_print_version()
exit(0)
if args.print_config:
print(f'Configuration:\n{pformat(vars(args))}')
exit(0)
return vars(args)
arg_parser.add_argument(
'-V',
'--version',
action = 'store_true',
help = 'Print version and exit',
)
arg_parser.add_argument(
'-c',
'--config',
type=lambda x: _is_valid_file(arg_parser, x),
help = 'Path to config file',
dest = 'config_file',
metavar = 'FILE',
)
arg_parser.add_argument(
'-p',
'--print-config',
action = 'store_true',
help = 'Print configuration and quit',
)

View File

@ -16,12 +16,14 @@ def is_valid_pvv_isbn(isbn: str) -> bool:
return False
return len(isbn) == 8
def is_valid_isbn(isbn: str) -> bool:
return any([
isbnlib.is_isbn10(isbn),
isbnlib.is_isbn13(isbn),
])
def create_bookcase_item_from_isbn(isbn: str, sql_session: Session) -> BookcaseItem | None:
metadata = isbnlib.meta(isbn, 'openl')
if len(metadata.keys()) == 0:

View File

@ -5,6 +5,17 @@ from pprint import pformat
class Config:
"""
This class is a singleton which holds the configuration for the
application. It is initialized by calling `Config.load_configuration()`
with a dictionary of arguments. The arguments are usually the result
of calling `vars(arg_parser.parse_args())` where `arg_parser` i s the
argument parser from `worblehat/services/argument_parser.py`.
The class also provides some utility functions for accessing several
kinds of values that depend on the configuration.
"""
_config = None
_expected_config_file_locations = [
Path('./config.toml'),
@ -20,6 +31,14 @@ class Config:
raise AttributeError(f'No such attribute: {name}')
return __config
@staticmethod
def read_password(password_field: str) -> str:
if Path(password_field).is_file():
with open(password_field, 'r') as f:
return f.read()
else:
return password_field
@classmethod
def _locate_configuration_file(cls) -> Path | None:
@ -56,7 +75,7 @@ class Config:
hostname = db_config.get('hostname')
port = db_config.get('port')
username = db_config.get('username')
password = db_config.get('password')
password = cls.read_password(db_config.get('password'))
database = db_config.get('database')
return f"psycopg2+postgresql://{username}:{password}@{hostname}:{port}/{database}"
else:

View File

@ -0,0 +1,36 @@
from pathlib import Path
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from .config import Config
def send_email(to: str, subject: str, body: str):
if Config['smtp.enabled']:
msg = MIMEMultipart()
msg['From'] = Config['smtp.from']
msg['To'] = to
if Config['smtp.subject_prefix']:
msg['Subject'] = f"{Config['smtp.subject_prefix']} {subject}"
else:
msg['Subject'] = subject
msg.attach(MIMEText(body, 'plain'))
try:
with smtplib.SMTP(Config['smtp.host'], Config['smtp.port']) as server:
server.starttls()
server.login(
Config['smtp.username'],
Config.read_password(Config['smtp.password']),
)
server.sendmail(Config['smtp.from'], to, msg.as_string())
except Exception as err:
print('Error: could not send email.')
print(err)
else:
print('Debug: Email sending is disabled, so the following email was not sent:')
print(f' To: {to}')
print(f' Subject: {subject}')
print(f' Body: {body}')