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.
|
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.
|
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
|
# Technical details
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
@ -35,37 +29,57 @@ 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 cli
|
$ poetry run worblehat --help
|
||||||
$ poetry run dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## How to configure
|
## 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
|
## TODO List
|
||||||
|
|
||||||
- [ ] High priority:
|
### Setting up a database with all of PVVs books
|
||||||
- [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).
|
- [ ] Create postgres database
|
||||||
- [ ] Web version of the program
|
- [ ] Model all bookshelfs
|
||||||
- [ ] Setting up a database with all of PVVs books
|
- [ ] Scan in all books
|
||||||
- [ ] Creating database with user and pw
|
|
||||||
- [ ] Model all bookshelfs
|
### Cli version of the program (this is currently being worked on)
|
||||||
- [ ] Scan in all books
|
|
||||||
- [ ] Inner workings
|
- [X] Ability to pull data from online sources with ISBN
|
||||||
- [X] Ability to create and update bookcases
|
- [X] Ability to create and update bookcases
|
||||||
- [X] Ability to create and update bookcase shelfs
|
- [X] Ability to create and update bookcase shelfs
|
||||||
- [~] Ability to create and update bookcase items
|
- [X] Ability to create and update bookcase items
|
||||||
- [ ] Ability to search for books
|
- [X] Ability to borrow and deliver items
|
||||||
- [ ] Ability to request book loans for PVV members
|
- [ ] Ability to borrow and deliver multiple items at a time
|
||||||
- [ ] Ability to queue book loans for PVV members
|
- [X] Ability to enter the queue for borrowing an item
|
||||||
- [ ] Ability to be notified when books are available
|
- [ ] Ability to extend a borrowing, only if no one is behind you in the queue
|
||||||
- [ ] Ability to be notified when deadlines are due
|
- [ ] Ability to list borrowed items which are overdue
|
||||||
- [ ] Ascii art of monkey
|
- [~] Ability to search for items
|
||||||
- [ ] Low priority:
|
- [ ] Ability to print PVV-specific labels for items missing a label, or which for any other reason needs a custom one
|
||||||
- [ ] Ability for PVV members to request book loans through the PVV website
|
- [X] Ascii art of monkey with fingers in eyes
|
||||||
- [ ] Ability for PVV members to search for books through the PVV website
|
|
||||||
- [ ] Discussion
|
### Deadline daemon
|
||||||
- [ ] 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.
|
- [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/
|
[logging]
|
||||||
[flask]
|
debug = true
|
||||||
TESTING = true
|
debug_sql = false
|
||||||
DEBUG = true
|
|
||||||
FLASK_ENV = 'development'
|
|
||||||
SECRET_KEY = 'change-me'
|
|
||||||
|
|
||||||
[database]
|
[database]
|
||||||
# One of (sqlite, postgres)
|
# One of (sqlite, postgres)
|
||||||
|
@ -16,5 +13,25 @@ path = './worblehat.sqlite'
|
||||||
host = 'localhost'
|
host = 'localhost'
|
||||||
port = 5432
|
port = 5432
|
||||||
username = 'worblehat'
|
username = 'worblehat'
|
||||||
password = 'change-me'
|
password = '/var/lib/worblehat/db-password' # path or plain text
|
||||||
name = 'worblehat'
|
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;
|
inherit program;
|
||||||
};
|
};
|
||||||
in {
|
in {
|
||||||
default = self.apps.${system}.dev;
|
default = self.apps.${system}.worblehat;
|
||||||
dev = app "${self.packages.${system}.worblehat}/bin/dev";
|
worblehat = app "${self.packages.${system}.worblehat}/bin/worblehat";
|
||||||
cli = app "${self.packages.${system}.worblehat}/bin/cli";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
packages.${system} = {
|
packages.${system} = {
|
||||||
|
|
|
@ -22,8 +22,7 @@ werkzeug = "^2.3.3"
|
||||||
poethepoet = "^0.20.0"
|
poethepoet = "^0.20.0"
|
||||||
|
|
||||||
[tool.poetry.scripts]
|
[tool.poetry.scripts]
|
||||||
cli = "worblehat.cli.main:main"
|
worblehat = "worblehat.main:main"
|
||||||
dev = "worblehat.flaskapp.wsgi_dev:main"
|
|
||||||
|
|
||||||
[tool.poe.tasks]
|
[tool.poe.tasks]
|
||||||
clean = """
|
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 textwrap import dedent
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
create_engine,
|
|
||||||
event,
|
event,
|
||||||
select,
|
select,
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import (
|
from sqlalchemy.orm import Session
|
||||||
Session,
|
|
||||||
)
|
from worblehat.services import (
|
||||||
from worblehat.services.bookcase_item import (
|
|
||||||
create_bookcase_item_from_isbn,
|
create_bookcase_item_from_isbn,
|
||||||
is_valid_isbn,
|
is_valid_isbn,
|
||||||
)
|
)
|
||||||
from worblehat.services.config import Config
|
|
||||||
from worblehat.services.argument_parser import parse_args
|
|
||||||
|
|
||||||
from worblehat.models import *
|
from worblehat.models import *
|
||||||
|
|
||||||
from .prompt_utils import *
|
from .prompt_utils import *
|
||||||
from .subclis.bookcase_item import BookcaseItemCli
|
from .subclis import (
|
||||||
from .subclis.bookcase_shelf_selector import select_bookcase_shelf
|
AdvancedOptionsCli,
|
||||||
|
BookcaseItemCli,
|
||||||
|
select_bookcase_shelf,
|
||||||
|
SearchCli,
|
||||||
|
)
|
||||||
|
|
||||||
# TODO: Category seems to have been forgotten. Maybe relevant interactivity should be added?
|
# 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
|
# However, is there anyone who are going to search by category rather than just look in
|
||||||
# the shelves?
|
# the shelves?
|
||||||
|
|
||||||
class WorblehatCli(NumberedCmd):
|
class WorblehatCli(NumberedCmd):
|
||||||
sql_session: Session
|
def __init__(self, sql_session: Session):
|
||||||
sql_session_dirty: bool = False
|
|
||||||
|
|
||||||
def __init__(self, args: dict[str, any] | None = None):
|
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
self.sql_session = sql_session
|
||||||
try:
|
self.sql_session_dirty = False
|
||||||
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)
|
|
||||||
|
|
||||||
@event.listens_for(self.sql_session, 'after_flush')
|
@event.listens_for(self.sql_session, 'after_flush')
|
||||||
def mark_session_as_dirty(*_):
|
def mark_session_as_dirty(*_):
|
||||||
|
@ -51,7 +42,23 @@ class WorblehatCli(NumberedCmd):
|
||||||
self.sql_session_dirty = False
|
self.sql_session_dirty = False
|
||||||
self.prompt_header = None
|
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):
|
def do_list_bookcases(self, _: str):
|
||||||
|
@ -88,82 +95,6 @@ class WorblehatCli(NumberedCmd):
|
||||||
print(f' {item.name} - {item.amount} copies')
|
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):
|
def _create_bookcase_item(self, isbn: str):
|
||||||
bookcase_item = create_bookcase_item_from_isbn(isbn, self.sql_session)
|
bookcase_item = create_bookcase_item_from_isbn(isbn, self.sql_session)
|
||||||
if bookcase_item is None:
|
if bookcase_item is None:
|
||||||
|
@ -231,7 +162,18 @@ class WorblehatCli(NumberedCmd):
|
||||||
|
|
||||||
|
|
||||||
def do_search(self, _: str):
|
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):
|
def do_save(self, _:str):
|
||||||
if not self.sql_session_dirty:
|
if not self.sql_session_dirty:
|
||||||
|
@ -274,47 +216,19 @@ class WorblehatCli(NumberedCmd):
|
||||||
'doc': 'Show a bookcase, and its items',
|
'doc': 'Show a bookcase, and its items',
|
||||||
},
|
},
|
||||||
4: {
|
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,
|
'f': do_save,
|
||||||
'doc': 'Save changes',
|
'doc': 'Save changes',
|
||||||
},
|
},
|
||||||
7: {
|
5: {
|
||||||
'f': do_abort,
|
'f': do_abort,
|
||||||
'doc': 'Abort changes',
|
'doc': 'Abort changes',
|
||||||
},
|
},
|
||||||
|
6: {
|
||||||
|
'f': do_advanced,
|
||||||
|
'doc': 'Advanced options',
|
||||||
|
},
|
||||||
9: {
|
9: {
|
||||||
'f': do_exit,
|
'f': do_exit,
|
||||||
'doc': '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 cmd import Cmd
|
||||||
|
from datetime import datetime
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
@ -24,6 +25,10 @@ def prompt_yes_no(question: str, default: bool | None = None) -> bool:
|
||||||
}[answer]
|
}[answer]
|
||||||
|
|
||||||
|
|
||||||
|
def format_date(date: datetime):
|
||||||
|
return date.strftime("%a %b %d, %Y")
|
||||||
|
|
||||||
|
|
||||||
class InteractiveItemSelector(Cmd):
|
class InteractiveItemSelector(Cmd):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
@ -124,16 +129,16 @@ class NumberedCmd(Cmd):
|
||||||
|
|
||||||
|
|
||||||
prompt_header: str | None = None
|
prompt_header: str | None = None
|
||||||
|
funcs: dict[int, dict[str, str | Callable[[Any, str], bool | None]]]
|
||||||
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
|
|
||||||
@classmethod
|
def _generate_usage_list(self) -> str:
|
||||||
def _generate_usage_list(cls) -> str:
|
|
||||||
result = ''
|
result = ''
|
||||||
for i, func in cls.funcs.items():
|
for i, func in self.funcs.items():
|
||||||
if i == 0:
|
if i == 0:
|
||||||
i = '*'
|
i = '*'
|
||||||
result += f'{i}) {func["doc"]}\n'
|
result += f'{i}) {func["doc"]}\n'
|
||||||
|
@ -181,3 +186,26 @@ class NumberedCmd(Cmd):
|
||||||
result += f'[{self.lastcmd}]> '
|
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
|
|
@ -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 textwrap import dedent
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
@ -6,11 +7,14 @@ from sqlalchemy.orm import Session
|
||||||
from worblehat.cli.prompt_utils import (
|
from worblehat.cli.prompt_utils import (
|
||||||
InteractiveItemSelector,
|
InteractiveItemSelector,
|
||||||
NumberedCmd,
|
NumberedCmd,
|
||||||
|
format_date,
|
||||||
prompt_yes_no,
|
prompt_yes_no,
|
||||||
)
|
)
|
||||||
from worblehat.models import (
|
from worblehat.models import (
|
||||||
Bookcase,
|
Bookcase,
|
||||||
BookcaseItem,
|
BookcaseItem,
|
||||||
|
BookcaseItemBorrowing,
|
||||||
|
BookcaseItemBorrowingQueue,
|
||||||
Language,
|
Language,
|
||||||
MediaType,
|
MediaType,
|
||||||
)
|
)
|
||||||
|
@ -22,13 +26,14 @@ from worblehat.services.bookcase_item import (
|
||||||
from .bookcase_shelf_selector import select_bookcase_shelf
|
from .bookcase_shelf_selector import select_bookcase_shelf
|
||||||
|
|
||||||
def _selected_bookcase_item_prompt(bookcase_item: BookcaseItem) -> str:
|
def _selected_bookcase_item_prompt(bookcase_item: BookcaseItem) -> str:
|
||||||
|
amount_borrowed = len(bookcase_item.borrowings)
|
||||||
return dedent(f'''
|
return dedent(f'''
|
||||||
Item: {bookcase_item.name}
|
Item: {bookcase_item.name}
|
||||||
ISBN: {bookcase_item.isbn}
|
ISBN: {bookcase_item.isbn}
|
||||||
Amount: {bookcase_item.amount}
|
|
||||||
Authors: {', '.join(a.name for a in bookcase_item.authors)}
|
Authors: {', '.join(a.name for a in bookcase_item.authors)}
|
||||||
Bookcase: {bookcase_item.shelf.bookcase.short_str()}
|
Bookcase: {bookcase_item.shelf.bookcase.short_str()}
|
||||||
Shelf: {bookcase_item.shelf.short_str()}
|
Shelf: {bookcase_item.shelf.short_str()}
|
||||||
|
Amount: {bookcase_item.amount - amount_borrowed}/{bookcase_item.amount}
|
||||||
''')
|
''')
|
||||||
|
|
||||||
class BookcaseItemCli(NumberedCmd):
|
class BookcaseItemCli(NumberedCmd):
|
||||||
|
@ -37,24 +42,136 @@ class BookcaseItemCli(NumberedCmd):
|
||||||
self.sql_session = sql_session
|
self.sql_session = sql_session
|
||||||
self.bookcase_item = bookcase_item
|
self.bookcase_item = bookcase_item
|
||||||
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def prompt_header(self) -> str:
|
def prompt_header(self) -> str:
|
||||||
return _selected_bookcase_item_prompt(self.bookcase_item)
|
return _selected_bookcase_item_prompt(self.bookcase_item)
|
||||||
|
|
||||||
|
|
||||||
def do_update_data(self, _: str):
|
def do_update_data(self, _: str):
|
||||||
item = create_bookcase_item_from_isbn(self.sql_session, self.bookcase_item.isbn)
|
item = create_bookcase_item_from_isbn(self.sql_session, self.bookcase_item.isbn)
|
||||||
self.bookcase_item.name = item.name
|
self.bookcase_item.name = item.name
|
||||||
# TODO: Remove any old authors
|
# TODO: Remove any old authors
|
||||||
self.bookcase_item.authors = item.authors
|
self.bookcase_item.authors = item.authors
|
||||||
self.bookcase_item.language = item.language
|
self.bookcase_item.language = item.language
|
||||||
|
self.sql_session.flush()
|
||||||
|
|
||||||
|
|
||||||
def do_edit(self, arg: str):
|
def do_edit(self, arg: str):
|
||||||
EditBookcaseCli(self.sql_session, self.bookcase_item, self).cmdloop()
|
EditBookcaseCli(self.sql_session, self.bookcase_item, self).cmdloop()
|
||||||
|
|
||||||
|
|
||||||
def do_loan(self, arg: str):
|
@staticmethod
|
||||||
print('TODO: implement loan')
|
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):
|
def do_done(self, _: str):
|
||||||
|
@ -63,14 +180,18 @@ class BookcaseItemCli(NumberedCmd):
|
||||||
|
|
||||||
funcs = {
|
funcs = {
|
||||||
1: {
|
1: {
|
||||||
'f': do_loan,
|
'f': do_borrow,
|
||||||
'doc': 'Loan',
|
'doc': 'Borrow',
|
||||||
},
|
},
|
||||||
2: {
|
2: {
|
||||||
|
'f': do_deliver,
|
||||||
|
'doc': 'Deliver',
|
||||||
|
},
|
||||||
|
3: {
|
||||||
'f': do_edit,
|
'f': do_edit,
|
||||||
'doc': 'Edit',
|
'doc': 'Edit',
|
||||||
},
|
},
|
||||||
3: {
|
4: {
|
||||||
'f': do_update_data,
|
'f': do_update_data,
|
||||||
'doc': 'Pull updated data from online databases',
|
'doc': 'Pull updated data from online databases',
|
||||||
},
|
},
|
||||||
|
@ -107,6 +228,7 @@ class EditBookcaseCli(NumberedCmd):
|
||||||
|
|
||||||
break
|
break
|
||||||
self.bookcase_item.name = name
|
self.bookcase_item.name = name
|
||||||
|
self.sql_session.flush()
|
||||||
|
|
||||||
|
|
||||||
def do_isbn(self, _: str):
|
def do_isbn(self, _: str):
|
||||||
|
@ -133,6 +255,7 @@ class EditBookcaseCli(NumberedCmd):
|
||||||
|
|
||||||
if prompt_yes_no('Update data from online databases?'):
|
if prompt_yes_no('Update data from online databases?'):
|
||||||
self.parent.do_update_data('')
|
self.parent.do_update_data('')
|
||||||
|
self.sql_session.flush()
|
||||||
|
|
||||||
|
|
||||||
def do_language(self, _: str):
|
def do_language(self, _: str):
|
||||||
|
@ -142,6 +265,7 @@ class EditBookcaseCli(NumberedCmd):
|
||||||
)
|
)
|
||||||
|
|
||||||
self.bookcase_item.language = language_selector.result
|
self.bookcase_item.language = language_selector.result
|
||||||
|
self.sql_session.flush()
|
||||||
|
|
||||||
|
|
||||||
def do_media_type(self, _: str):
|
def do_media_type(self, _: str):
|
||||||
|
@ -151,6 +275,7 @@ class EditBookcaseCli(NumberedCmd):
|
||||||
)
|
)
|
||||||
|
|
||||||
self.bookcase_item.media_type = media_type_selector.result
|
self.bookcase_item.media_type = media_type_selector.result
|
||||||
|
self.sql_session.flush()
|
||||||
|
|
||||||
|
|
||||||
def do_amount(self, _: str):
|
def do_amount(self, _: str):
|
||||||
|
@ -167,6 +292,7 @@ class EditBookcaseCli(NumberedCmd):
|
||||||
|
|
||||||
break
|
break
|
||||||
self.bookcase_item.amount = new_amount
|
self.bookcase_item.amount = new_amount
|
||||||
|
self.sql_session.flush()
|
||||||
|
|
||||||
|
|
||||||
def do_shelf(self, _: str):
|
def do_shelf(self, _: str):
|
||||||
|
@ -180,6 +306,7 @@ class EditBookcaseCli(NumberedCmd):
|
||||||
shelf = select_bookcase_shelf(bookcase, self.sql_session)
|
shelf = select_bookcase_shelf(bookcase, self.sql_session)
|
||||||
|
|
||||||
self.bookcase_item.shelf = shelf
|
self.bookcase_item.shelf = shelf
|
||||||
|
self.sql_session.flush()
|
||||||
|
|
||||||
|
|
||||||
def do_done(self, _: str):
|
def do_done(self, _: str):
|
||||||
|
@ -211,7 +338,7 @@ class EditBookcaseCli(NumberedCmd):
|
||||||
'f': do_shelf,
|
'f': do_shelf,
|
||||||
'doc': 'Change shelf',
|
'doc': 'Change shelf',
|
||||||
},
|
},
|
||||||
7: {
|
9: {
|
||||||
'f': do_done,
|
'f': do_done,
|
||||||
'doc': '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):
|
def create_app(args: dict[str, any] | None = None):
|
||||||
app = Flask(__name__)
|
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['flask'])
|
||||||
app.config.update(Config._config)
|
app.config.update(Config._config)
|
||||||
app.config['SQLALCHEMY_DATABASE_URI'] = Config.db_string()
|
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)
|
db.init_app(app)
|
||||||
|
|
||||||
|
|
|
@ -1,13 +1,11 @@
|
||||||
from werkzeug import run_simple
|
from werkzeug import run_simple
|
||||||
|
|
||||||
from worblehat.services.config import Config
|
from worblehat.services.config import Config
|
||||||
from worblehat.services.argument_parser import parse_args
|
|
||||||
|
|
||||||
from .flaskapp import create_app
|
from .flaskapp import create_app
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
args = parse_args()
|
app = create_app()
|
||||||
app = create_app(args)
|
|
||||||
run_simple(
|
run_simple(
|
||||||
hostname = 'localhost',
|
hostname = 'localhost',
|
||||||
port = 5000,
|
port = 5000,
|
||||||
|
|
|
@ -1,3 +1,8 @@
|
||||||
from .flaskapp import create_app
|
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')
|
media_type: Mapped[MediaType] = relationship(back_populates='items')
|
||||||
shelf: Mapped[BookcaseShelf] = relationship(back_populates='items')
|
shelf: Mapped[BookcaseShelf] = relationship(back_populates='items')
|
||||||
language: Mapped[Language] = relationship()
|
language: Mapped[Language] = relationship()
|
||||||
borrowings: Mapped[BookcaseItemBorrowing] = relationship(back_populates='item')
|
borrowings: Mapped[set[BookcaseItemBorrowing]] = relationship(back_populates='item')
|
||||||
borrowing_queue: Mapped[BookcaseItemBorrowingQueue] = relationship(back_populates='item')
|
borrowing_queue: Mapped[set[BookcaseItemBorrowingQueue]] = relationship(back_populates='item')
|
||||||
|
|
||||||
categories: Mapped[set[Category]] = relationship(
|
categories: Mapped[set[Category]] = relationship(
|
||||||
secondary = Item_Category.__table__,
|
secondary = Item_Category.__table__,
|
||||||
|
|
|
@ -23,7 +23,7 @@ class BookcaseItemBorrowing(Base, UidMixin):
|
||||||
username: Mapped[str] = mapped_column(String)
|
username: Mapped[str] = mapped_column(String)
|
||||||
start_time: Mapped[datetime] = mapped_column(DateTime, default=datetime.now())
|
start_time: Mapped[datetime] = mapped_column(DateTime, default=datetime.now())
|
||||||
end_time: Mapped[datetime] = mapped_column(DateTime, default=datetime.now() + timedelta(days=30))
|
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)
|
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.username = username
|
||||||
self.item = item
|
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):
|
class BookcaseItemBorrowingQueue(Base, UidMixin):
|
||||||
username: Mapped[str] = mapped_column(String)
|
username: Mapped[str] = mapped_column(String)
|
||||||
entered_queue_time = mapped_column(DateTime, default=datetime.now())
|
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)
|
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.username = username
|
||||||
self.item = item
|
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 .BookcaseItemBorrowingQueue import BookcaseItemBorrowingQueue
|
||||||
from .BookcaseShelf import BookcaseShelf
|
from .BookcaseShelf import BookcaseShelf
|
||||||
from .Category import Category
|
from .Category import Category
|
||||||
|
from .DeadlineDaemonLastRunDatetime import DeadlineDaemonLastRunDatetime
|
||||||
from .Language import Language
|
from .Language import Language
|
||||||
from .MediaType import MediaType
|
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 argparse import ArgumentParser
|
||||||
from os import path
|
|
||||||
from pathlib 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:
|
def _is_valid_file(parser: ArgumentParser, arg: str) -> Path:
|
||||||
path = Path(arg)
|
path = Path(arg)
|
||||||
|
@ -16,47 +9,45 @@ def _is_valid_file(parser: ArgumentParser, arg: str) -> Path:
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
def parse_args() -> dict[str, any]:
|
arg_parser = ArgumentParser(
|
||||||
parser = ArgumentParser(
|
|
||||||
description = 'Worblehat library management system',
|
description = 'Worblehat library management system',
|
||||||
)
|
)
|
||||||
|
|
||||||
parser.add_argument(
|
subparsers = arg_parser.add_subparsers(dest='command')
|
||||||
'--verbose',
|
subparsers.add_parser(
|
||||||
action = 'store_true',
|
'deadline-daemon',
|
||||||
help = 'Enable verbose mode',
|
help = 'Initialize a single pass of the daemon which sends deadline emails',
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
subparsers.add_parser(
|
||||||
'--verbose-sql',
|
'cli',
|
||||||
action = 'store_true',
|
help = 'Start the command line interface',
|
||||||
help = 'Enable verbose SQL mode',
|
)
|
||||||
)
|
subparsers.add_parser(
|
||||||
parser.add_argument(
|
'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',
|
'--version',
|
||||||
action = 'store_true',
|
action = 'store_true',
|
||||||
help = 'Print version and exit',
|
help = 'Print version and exit',
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
arg_parser.add_argument(
|
||||||
|
'-c',
|
||||||
'--config',
|
'--config',
|
||||||
type=lambda x: _is_valid_file(parser, x),
|
type=lambda x: _is_valid_file(arg_parser, x),
|
||||||
help = 'Path to config file',
|
help = 'Path to config file',
|
||||||
dest = 'config_file',
|
dest = 'config_file',
|
||||||
metavar = 'FILE',
|
metavar = 'FILE',
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
arg_parser.add_argument(
|
||||||
|
'-p',
|
||||||
'--print-config',
|
'--print-config',
|
||||||
action = 'store_true',
|
action = 'store_true',
|
||||||
help = 'Print configuration and quit',
|
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 False
|
||||||
return len(isbn) == 8
|
return len(isbn) == 8
|
||||||
|
|
||||||
|
|
||||||
def is_valid_isbn(isbn: str) -> bool:
|
def is_valid_isbn(isbn: str) -> bool:
|
||||||
return any([
|
return any([
|
||||||
isbnlib.is_isbn10(isbn),
|
isbnlib.is_isbn10(isbn),
|
||||||
isbnlib.is_isbn13(isbn),
|
isbnlib.is_isbn13(isbn),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
||||||
def create_bookcase_item_from_isbn(isbn: str, sql_session: Session) -> BookcaseItem | None:
|
def create_bookcase_item_from_isbn(isbn: str, sql_session: Session) -> BookcaseItem | None:
|
||||||
metadata = isbnlib.meta(isbn, 'openl')
|
metadata = isbnlib.meta(isbn, 'openl')
|
||||||
if len(metadata.keys()) == 0:
|
if len(metadata.keys()) == 0:
|
||||||
|
|
|
@ -5,6 +5,17 @@ from pprint import pformat
|
||||||
|
|
||||||
|
|
||||||
class Config:
|
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
|
_config = None
|
||||||
_expected_config_file_locations = [
|
_expected_config_file_locations = [
|
||||||
Path('./config.toml'),
|
Path('./config.toml'),
|
||||||
|
@ -20,6 +31,14 @@ class Config:
|
||||||
raise AttributeError(f'No such attribute: {name}')
|
raise AttributeError(f'No such attribute: {name}')
|
||||||
return __config
|
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
|
@classmethod
|
||||||
def _locate_configuration_file(cls) -> Path | None:
|
def _locate_configuration_file(cls) -> Path | None:
|
||||||
|
@ -56,7 +75,7 @@ class Config:
|
||||||
hostname = db_config.get('hostname')
|
hostname = db_config.get('hostname')
|
||||||
port = db_config.get('port')
|
port = db_config.get('port')
|
||||||
username = db_config.get('username')
|
username = db_config.get('username')
|
||||||
password = db_config.get('password')
|
password = cls.read_password(db_config.get('password'))
|
||||||
database = db_config.get('database')
|
database = db_config.get('database')
|
||||||
return f"psycopg2+postgresql://{username}:{password}@{hostname}:{port}/{database}"
|
return f"psycopg2+postgresql://{username}:{password}@{hostname}:{port}/{database}"
|
||||||
else:
|
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