From b83175e39aae77a47a41e8f23296d1f8cac3e4b0 Mon Sep 17 00:00:00 2001 From: h7x4 Date: Fri, 12 May 2023 02:33:13 +0200 Subject: [PATCH] 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. --- README.md | 4 +- config-template.toml | 12 ++ worblehat/cli/subclis/bookcase_item.py | 6 +- worblehat/deadline_daemon/__init__.py | 1 + worblehat/deadline_daemon/main.py | 147 ++++++++++++++++++ worblehat/main.py | 6 + worblehat/models/BookcaseItemBorrowing.py | 2 +- .../models/BookcaseItemBorrowingQueue.py | 2 +- .../models/DeadlineDaemonLastRunDatetime.py | 23 +++ worblehat/models/__init__.py | 1 + worblehat/services/argument_parser.py | 4 + worblehat/services/email.py | 36 +++++ 12 files changed, 238 insertions(+), 6 deletions(-) create mode 100644 worblehat/deadline_daemon/__init__.py create mode 100644 worblehat/deadline_daemon/main.py create mode 100644 worblehat/models/DeadlineDaemonLastRunDatetime.py create mode 100644 worblehat/services/email.py diff --git a/README.md b/README.md index cf94a38..6845940 100644 --- a/README.md +++ b/README.md @@ -57,11 +57,11 @@ See `worblehat/config.py` for configurable settings. - [X] Ability to create and update bookcases - [X] Ability to create and update bookcase shelfs - [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 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 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 - [ ] Ascii art of monkey - [ ] Low priority: diff --git a/config-template.toml b/config-template.toml index 2f71f78..e0b6116 100644 --- a/config-template.toml +++ b/config-template.toml @@ -23,3 +23,15 @@ 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" ] \ No newline at end of file diff --git a/worblehat/cli/subclis/bookcase_item.py b/worblehat/cli/subclis/bookcase_item.py index a27d714..2672a50 100644 --- a/worblehat/cli/subclis/bookcase_item.py +++ b/worblehat/cli/subclis/bookcase_item.py @@ -1,3 +1,4 @@ +from datetime import datetime from textwrap import dedent from sqlalchemy import select @@ -74,7 +75,7 @@ class BookcaseItemCli(NumberedCmd): .where( BookcaseItemBorrowing.username == username, BookcaseItemBorrowing.item == self.bookcase_item, - BookcaseItemBorrowing.delivered != True, + BookcaseItemBorrowing.delivered.is_(None), ) ).one_or_none() is not None @@ -94,7 +95,7 @@ class BookcaseItemCli(NumberedCmd): select(BookcaseItemBorrowing) .where( BookcaseItemBorrowing.item == self.bookcase_item, - BookcaseItemBorrowing.delivered != True, + BookcaseItemBorrowing.delivered.is_(None), ) .order_by(BookcaseItemBorrowing.end_time) ).all() @@ -168,6 +169,7 @@ class BookcaseItemCli(NumberedCmd): break borrowing = borrowings[selection - 1] + borrowing.delivered = datetime.now() self.sql_session.flush() print(f'Successfully delivered the item for {borrowing.username}') diff --git a/worblehat/deadline_daemon/__init__.py b/worblehat/deadline_daemon/__init__.py new file mode 100644 index 0000000..b648064 --- /dev/null +++ b/worblehat/deadline_daemon/__init__.py @@ -0,0 +1 @@ +from .main import DeadlineDaemon \ No newline at end of file diff --git a/worblehat/deadline_daemon/main.py b/worblehat/deadline_daemon/main.py new file mode 100644 index 0000000..af5f93d --- /dev/null +++ b/worblehat/deadline_daemon/main.py @@ -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') \ No newline at end of file diff --git a/worblehat/main.py b/worblehat/main.py index e5c6522..14c26bf 100644 --- a/worblehat/main.py +++ b/worblehat/main.py @@ -9,6 +9,7 @@ from .services import ( 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 @@ -49,6 +50,11 @@ def main(): 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) diff --git a/worblehat/models/BookcaseItemBorrowing.py b/worblehat/models/BookcaseItemBorrowing.py index efdd34e..9cf65e6 100644 --- a/worblehat/models/BookcaseItemBorrowing.py +++ b/worblehat/models/BookcaseItemBorrowing.py @@ -23,7 +23,7 @@ class BookcaseItemBorrowing(Base, UidMixin): username: Mapped[str] = mapped_column(String) start_time: Mapped[datetime] = mapped_column(DateTime, default=datetime.now()) end_time: Mapped[datetime] = mapped_column(DateTime, default=datetime.now() + timedelta(days=30)) - delivered: Mapped[bool] = mapped_column(Boolean, default=False) + delivered: Mapped[datetime | None] = mapped_column(DateTime, default=None) fk_bookcase_item_uid: Mapped[int] = mapped_column(ForeignKey('BookcaseItem.uid'), index=True) diff --git a/worblehat/models/BookcaseItemBorrowingQueue.py b/worblehat/models/BookcaseItemBorrowingQueue.py index 661865b..2b7f8a4 100644 --- a/worblehat/models/BookcaseItemBorrowingQueue.py +++ b/worblehat/models/BookcaseItemBorrowingQueue.py @@ -22,7 +22,7 @@ if TYPE_CHECKING: class BookcaseItemBorrowingQueue(Base, UidMixin): username: Mapped[str] = mapped_column(String) entered_queue_time = mapped_column(DateTime, default=datetime.now()) - should_notify_user = mapped_column(Boolean, default=False) + expired = mapped_column(Boolean, default=False) fk_bookcase_item_uid: Mapped[int] = mapped_column(ForeignKey('BookcaseItem.uid'), index=True) diff --git a/worblehat/models/DeadlineDaemonLastRunDatetime.py b/worblehat/models/DeadlineDaemonLastRunDatetime.py new file mode 100644 index 0000000..615e312 --- /dev/null +++ b/worblehat/models/DeadlineDaemonLastRunDatetime.py @@ -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()) \ No newline at end of file diff --git a/worblehat/models/__init__.py b/worblehat/models/__init__.py index d417c08..320e42b 100644 --- a/worblehat/models/__init__.py +++ b/worblehat/models/__init__.py @@ -6,5 +6,6 @@ from .BookcaseItemBorrowing import BookcaseItemBorrowing from .BookcaseItemBorrowingQueue import BookcaseItemBorrowingQueue from .BookcaseShelf import BookcaseShelf from .Category import Category +from .DeadlineDaemonLastRunDatetime import DeadlineDaemonLastRunDatetime from .Language import Language from .MediaType import MediaType \ No newline at end of file diff --git a/worblehat/services/argument_parser.py b/worblehat/services/argument_parser.py index 529ee51..5773deb 100644 --- a/worblehat/services/argument_parser.py +++ b/worblehat/services/argument_parser.py @@ -14,6 +14,10 @@ arg_parser = ArgumentParser( ) subparsers = arg_parser.add_subparsers(dest='command') +subparsers.add_parser( + 'deadline-daemon', + help = 'Initialize a single pass of the daemon which sends deadline emails', +) subparsers.add_parser( 'cli', help = 'Start the command line interface', diff --git a/worblehat/services/email.py b/worblehat/services/email.py new file mode 100644 index 0000000..3e7c807 --- /dev/null +++ b/worblehat/services/email.py @@ -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}') \ No newline at end of file