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:
parent
18a1667b7b
commit
b83175e39a
|
@ -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:
|
||||||
|
|
|
@ -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" ]
|
|
@ -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}')
|
||||||
|
|
||||||
|
|
|
@ -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')
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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
|
|
@ -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',
|
||||||
|
|
|
@ -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