Compare commits
10 Commits
634397cdaf
...
a08c1ea5f0
Author | SHA1 | Date |
---|---|---|
Bjornar Orjansen Kaarevik | a08c1ea5f0 | |
Bjornar Orjansen Kaarevik | e574fc5817 | |
Oystein Kristoffer Tveit | d13a3a0932 | |
Oystein Kristoffer Tveit | e154989a16 | |
Oystein Kristoffer Tveit | b83175e39a | |
Oystein Kristoffer Tveit | 18a1667b7b | |
Oystein Kristoffer Tveit | fad38adc50 | |
Oystein Kristoffer Tveit | 31184dde12 | |
Oystein Kristoffer Tveit | b2f8d23637 | |
Oystein Kristoffer Tveit | 18053bf002 |
80
README.md
80
README.md
|
@ -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
|
||||
|
|
|
@ -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" ]
|
|
@ -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} = {
|
||||
|
|
|
@ -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 = """
|
||||
|
|
|
@ -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 | =============================
|
||||
\\==\\==\\=//==//==//
|
|
@ -0,0 +1 @@
|
|||
from .main import WorblehatCli
|
|
@ -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()
|
|
@ -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'
|
||||
|
@ -181,3 +186,26 @@ class NumberedCmd(Cmd):
|
|||
result += f'[{self.lastcmd}]> '
|
||||
|
||||
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
|
|
@ -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
|
|
@ -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',
|
||||
},
|
||||
}
|
|
@ -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',
|
||||
},
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
from .main import DeadlineDaemon
|
|
@ -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')
|
|
@ -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['SQLALCHEMY_ECHO'] = Config['logging.debug_sql']
|
||||
|
||||
db.init_app(app)
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -1,3 +1,8 @@
|
|||
from .flaskapp import create_app
|
||||
|
||||
app = create_app()
|
||||
def main():
|
||||
app = create_app()
|
||||
app.run()
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
|
|
@ -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())
|
|
@ -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__,
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
@ -36,3 +36,5 @@ class BookcaseItemBorrowing(Base, UidMixin):
|
|||
):
|
||||
self.username = username
|
||||
self.item = item
|
||||
self.start_time = datetime.now()
|
||||
self.end_time = datetime.now() + timedelta(days=30)
|
|
@ -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)
|
||||
|
||||
|
@ -35,3 +35,4 @@ class BookcaseItemBorrowingQueue(Base, UidMixin):
|
|||
):
|
||||
self.username = username
|
||||
self.item = item
|
||||
self.entered_queue_time = datetime.now()
|
|
@ -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())
|
|
@ -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
|
|
@ -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
|
|
@ -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(
|
||||
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(
|
||||
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',
|
||||
)
|
||||
|
||||
arg_parser.add_argument(
|
||||
'-V',
|
||||
'--version',
|
||||
action = 'store_true',
|
||||
help = 'Print version and exit',
|
||||
)
|
||||
parser.add_argument(
|
||||
)
|
||||
arg_parser.add_argument(
|
||||
'-c',
|
||||
'--config',
|
||||
type=lambda x: _is_valid_file(parser, x),
|
||||
type=lambda x: _is_valid_file(arg_parser, x),
|
||||
help = 'Path to config file',
|
||||
dest = 'config_file',
|
||||
metavar = 'FILE',
|
||||
)
|
||||
parser.add_argument(
|
||||
)
|
||||
arg_parser.add_argument(
|
||||
'-p',
|
||||
'--print-config',
|
||||
action = 'store_true',
|
||||
help = 'Print configuration and quit',
|
||||
)
|
||||
|
||||
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)
|
||||
)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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}')
|
Loading…
Reference in New Issue