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.
This commit is contained in:
Oystein Kristoffer Tveit 2023-05-12 02:33:13 +02:00
parent 18a1667b7b
commit b83175e39a
Signed by: oysteikt
GPG Key ID: 9F2F7D8250F35146
12 changed files with 238 additions and 6 deletions

View File

@ -57,11 +57,11 @@ See `worblehat/config.py` for configurable settings.
- [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
- [X] Ability to create and update bookcase items - [X] Ability to create and update bookcase items
- [ ] Ability to search for books
- [X] Ability to request book loans for PVV members - [X] Ability to request book loans for PVV members
- [X] Ability to queue book loans for PVV members - [X] Ability to queue book loans for PVV members
- [X] Ability to be notified when deadlines are due
- [ ] Ability to be notified when books are available - [ ] Ability to be notified when books are available
- [ ] Ability to be notified when deadlines are due - [ ] Ability to search for books
- [ ] Ability to print PVV-specific labels for items without a label, or for any other reason needs a new one - [ ] Ability to print PVV-specific labels for items without a label, or for any other reason needs a new one
- [ ] Ascii art of monkey - [ ] Ascii art of monkey
- [ ] Low priority: - [ ] Low priority:

View File

@ -23,3 +23,15 @@ DEBUG = true
FLASK_ENV = 'development' FLASK_ENV = 'development'
SECRET_KEY = 'change-me' # path or plain text 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

@ -1,3 +1,4 @@
from datetime import datetime
from textwrap import dedent from textwrap import dedent
from sqlalchemy import select from sqlalchemy import select
@ -74,7 +75,7 @@ class BookcaseItemCli(NumberedCmd):
.where( .where(
BookcaseItemBorrowing.username == username, BookcaseItemBorrowing.username == username,
BookcaseItemBorrowing.item == self.bookcase_item, BookcaseItemBorrowing.item == self.bookcase_item,
BookcaseItemBorrowing.delivered != True, BookcaseItemBorrowing.delivered.is_(None),
) )
).one_or_none() is not None ).one_or_none() is not None
@ -94,7 +95,7 @@ class BookcaseItemCli(NumberedCmd):
select(BookcaseItemBorrowing) select(BookcaseItemBorrowing)
.where( .where(
BookcaseItemBorrowing.item == self.bookcase_item, BookcaseItemBorrowing.item == self.bookcase_item,
BookcaseItemBorrowing.delivered != True, BookcaseItemBorrowing.delivered.is_(None),
) )
.order_by(BookcaseItemBorrowing.end_time) .order_by(BookcaseItemBorrowing.end_time)
).all() ).all()
@ -168,6 +169,7 @@ class BookcaseItemCli(NumberedCmd):
break break
borrowing = borrowings[selection - 1] borrowing = borrowings[selection - 1]
borrowing.delivered = datetime.now()
self.sql_session.flush() self.sql_session.flush()
print(f'Successfully delivered the item for {borrowing.username}') print(f'Successfully delivered the item for {borrowing.username}')

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

@ -9,6 +9,7 @@ from .services import (
arg_parser, arg_parser,
) )
from .deadline_daemon import DeadlineDaemon
from .cli import WorblehatCli from .cli import WorblehatCli
from .flaskapp.wsgi_dev import main as flask_dev_main from .flaskapp.wsgi_dev import main as flask_dev_main
from .flaskapp.wsgi_prod import main as flask_prod_main from .flaskapp.wsgi_prod import main as flask_prod_main
@ -49,6 +50,11 @@ def main():
print(f'Configuration:\n{pformat(vars(args))}') print(f'Configuration:\n{pformat(vars(args))}')
exit(0) 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': if args.command == 'cli':
sql_session = _connect_to_database(echo=Config['logging.debug_sql']) sql_session = _connect_to_database(echo=Config['logging.debug_sql'])
WorblehatCli.run_with_safe_exit_wrapper(sql_session) WorblehatCli.run_with_safe_exit_wrapper(sql_session)

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)

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)

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

@ -14,6 +14,10 @@ arg_parser = ArgumentParser(
) )
subparsers = arg_parser.add_subparsers(dest='command') subparsers = arg_parser.add_subparsers(dest='command')
subparsers.add_parser(
'deadline-daemon',
help = 'Initialize a single pass of the daemon which sends deadline emails',
)
subparsers.add_parser( subparsers.add_parser(
'cli', 'cli',
help = 'Start the command line interface', help = 'Start the command line interface',

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}')