Compare commits

...

10 Commits

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

View File

@ -20,12 +20,6 @@ Styret har derfor tatt initiativ til å opprette et biblioteksystem for å syste
Prosjektet har fått navn Worblehat etter en bibliotekar i Terry Pratchetts discworld serie. 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

View File

@ -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" ]

View File

@ -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} = {

View File

@ -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 = """

24
worblehat-banner.txt Normal file
View File

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

View File

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

View File

@ -1,44 +1,35 @@
from textwrap import dedent from 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()

View File

@ -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

View File

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

View File

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

View File

@ -1,3 +1,4 @@
from datetime import datetime
from textwrap import dedent from 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',
}, },

View File

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

View File

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

View File

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

View File

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

View File

@ -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,

View File

@ -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()

73
worblehat/main.py Normal file
View File

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

View File

@ -43,8 +43,8 @@ class BookcaseItem(Base, UidMixin, UniqueNameMixin):
media_type: Mapped[MediaType] = relationship(back_populates='items') 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__,

View File

@ -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)

View File

@ -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()

View File

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

View File

@ -6,5 +6,6 @@ from .BookcaseItemBorrowing import BookcaseItemBorrowing
from .BookcaseItemBorrowingQueue import BookcaseItemBorrowingQueue from .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

View File

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

View File

@ -1,12 +1,5 @@
from argparse import ArgumentParser from 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',
'--version', help = 'Start the web interface in development mode',
action = 'store_true', )
help = 'Print version and exit', subparsers.add_parser(
) 'flask-prod',
parser.add_argument( help = 'Start the web interface in production mode',
'--config', )
type=lambda x: _is_valid_file(parser, x),
help = 'Path to config file',
dest = 'config_file',
metavar = 'FILE',
)
parser.add_argument(
'--print-config',
action = 'store_true',
help = 'Print configuration and quit',
)
args = parser.parse_args() arg_parser.add_argument(
'-V',
if args.version: '--version',
_print_version() action = 'store_true',
exit(0) help = 'Print version and exit',
)
if args.print_config: arg_parser.add_argument(
print(f'Configuration:\n{pformat(vars(args))}') '-c',
exit(0) '--config',
type=lambda x: _is_valid_file(arg_parser, x),
return vars(args) help = 'Path to config file',
dest = 'config_file',
metavar = 'FILE',
)
arg_parser.add_argument(
'-p',
'--print-config',
action = 'store_true',
help = 'Print configuration and quit',
)

View File

@ -16,12 +16,14 @@ def is_valid_pvv_isbn(isbn: str) -> bool:
return False return 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:

View File

@ -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:

View File

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