From 369180ff8586fa49cc8bbfa21f037d569ba9735f Mon Sep 17 00:00:00 2001 From: h7x4 Date: Sun, 14 Jan 2024 03:40:27 +0100 Subject: [PATCH] deadline-daemon: implement remaining pieces --- README.md | 4 +- config-template.toml | 7 +- worblehat/deadline_daemon/main.py | 210 +++++++++++++++--- .../models/BookcaseItemBorrowingQueue.py | 3 +- worblehat/services/email.py | 25 +-- 5 files changed, 198 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 28f19ff..3b854cc 100644 --- a/README.md +++ b/README.md @@ -70,8 +70,8 @@ Run `poetry run worblehat --help` for more info ### Deadline daemon - [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 +- [X] Ability to be notified when books are available +- [X] Ability to have expiring queue positions automatically expire ### Web version of the program diff --git a/config-template.toml b/config-template.toml index e0b6116..a3fd781 100644 --- a/config-template.toml +++ b/config-template.toml @@ -33,5 +33,8 @@ 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 +enabled = true +dryrun = false +warn_days_before_borrowing_deadline = [ 5, 1 ] +days_before_queue_position_expires = 14 +warn_days_before_expiring_queue_position_deadline = [ 3, 1 ] \ No newline at end of file diff --git a/worblehat/deadline_daemon/main.py b/worblehat/deadline_daemon/main.py index af5f93d..7b03bcb 100644 --- a/worblehat/deadline_daemon/main.py +++ b/worblehat/deadline_daemon/main.py @@ -11,14 +11,25 @@ from worblehat.models import ( DeadlineDaemonLastRunDatetime, BookcaseItemBorrowingQueue, ) + from worblehat.services.email import send_email class DeadlineDaemon: def __init__(self, sql_session: Session): + if not Config['deadline_daemon.enabled']: + return + self.sql_session = sql_session + self.last_run = self.sql_session.scalars( select(DeadlineDaemonLastRunDatetime), - ).one() + ).one_or_none() + + if self.last_run is None: + logging.info('No previous run found, assuming this is the first run') + self.last_run = DeadlineDaemonLastRunDatetime(time=datetime.now()) + self.sql_session.add(self.last_run) + self.sql_session.commit() self.last_run_datetime = self.last_run.time self.current_run_datetime = datetime.now() @@ -26,6 +37,12 @@ class DeadlineDaemon: def run(self): logging.info('Deadline daemon started') + if not Config['deadline_daemon.enabled']: + logging.warn('Deadline daemon disabled, exiting') + return + + if Config['deadline_daemon.dryrun']: + logging.warn('Running in dryrun mode') self.send_close_deadline_reminder_mails() self.send_overdue_mails() @@ -36,6 +53,97 @@ class DeadlineDaemon: self.last_run.time = self.current_run_datetime self.sql_session.commit() + ################### + # EMAIL TEMPLATES # + ################### + + def _send_close_deadline_mail(self, borrowing: BookcaseItemBorrowing): + logging.info(f'Sending close deadline mail to {borrowing.username}@pvv.ntnu.no.') + 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_mail(self, borrowing: BookcaseItemBorrowing): + 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_mail(self, queue_item: BookcaseItemBorrowingQueue): + logging.info(f'Sending newly available mail to {queue_item.username}') + + days_before_queue_expires = Config['deadline_daemon.days_before_queue_position_expires'] + + # TODO: calculate and format the date of when the queue position expires in the mail. + send_email( + f'{queue_item.username}@pvv.ntnu.no', + 'An item you have queued for is now available', + dedent(f''' + The following item is now available for you to borrow: + + {queue_item.item.name} + + Please pick up the item within {days_before_queue_expires} days. + ''', + ).strip(), + ) + + + def _send_expiring_queue_position_mail(self, queue_position: BookcaseItemBorrowingQueue, day: int): + logging.info(f'Sending queue position expiry reminder to {queue_position.username}@pvv.ntnu.no.') + send_email( + f'{queue_position.username}@pvv.ntnu.no', + 'Reminder - Your queue position expiry deadline is approaching', + dedent(f''' + Your queue position expiry deadline for the following item is approaching: + + {queue_position.item.name} + + Please borrow the item by {(queue_position.item_became_available_time + timedelta(days=day)).strftime("%a %b %d, %Y")} + ''', + ).strip(), + ) + + + def _send_queue_position_expired_mail(self, queue_position: BookcaseItemBorrowingQueue): + send_email( + f'{queue_position.username}@pvv.ntnu.no', + 'Your queue position has expired', + dedent(f''' + Your queue position for the following item has expired: + + {queue_position.item.name} + + You can queue for the item again at any time, but you will be placed at the back of the queue. + + There are currently {len(queue_position.item.borrowing_queue)} users in the queue. + ''', + ).strip(), + ) + + ################## + # EMAIL ROUTINES # + ################## def _sql_subtract_date(self, x: datetime, y: timedelta): if self.sql_session.bind.dialect.name == 'sqlite': @@ -48,10 +156,10 @@ class DeadlineDaemon: def send_close_deadline_reminder_mails(self): - logging.info('Sending mails about items with a closing deadline') + logging.info('Sending mails for 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']] + days = [int(d) for d in Config['deadline_daemon.warn_days_before_borrowing_deadline']] for day in days: borrowings_to_remind = self.sql_session.scalars( @@ -69,23 +177,11 @@ class DeadlineDaemon: ), ).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(), - ) + self._send_close_deadline_mail(borrowing) def send_overdue_mails(self): - logging.info('Sending mails about overdue items') + logging.info('Sending mails for overdue items') to_remind = self.sql_session.scalars( select(BookcaseItemBorrowing) @@ -96,19 +192,7 @@ class DeadlineDaemon: ).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(), - ) + self._send_overdue_mail(borrowing) def send_newly_available_mails(self): @@ -133,15 +217,75 @@ class DeadlineDaemon: ).all() for queue_item in newly_available: - logging.info(f'Sending newly available mail to {queue_item.username}') - logging.warning('Not implemented') + logging.info(f'Adding user {queue_item.username} to queue for {queue_item.item.name}') + queue_item.item_became_available_time = self.current_run_datetime + self.sql_session.commit() + + self._send_newly_available_mail(queue_item) def send_expiring_queue_position_mails(self): logging.info('Sending mails about queue positions which are expiring soon') logging.warning('Not implemented') + days = [int(d) for d in Config['deadline_daemon.warn_days_before_expiring_queue_position_deadline']] + for day in days: + queue_positions_to_remind = self.sql_session.scalars( + select(BookcaseItemBorrowingQueue) + .join( + BookcaseItemBorrowing, + BookcaseItemBorrowing.fk_bookcase_item_uid == BookcaseItemBorrowingQueue.fk_bookcase_item_uid, + ) + .where( + self._sql_subtract_date( + BookcaseItemBorrowingQueue.item_became_available_time + timedelta(days=day), + timedelta(days=day), + ) + .between( + self.last_run_datetime, + self.current_run_datetime, + ), + ), + ).all() + + for queue_position in queue_positions_to_remind: + self._send_expiring_queue_position_mail(queue_position, day) + 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 + + queue_position_expiry_days = int(Config['deadline_daemon.days_before_queue_position_expires']) + + overdue_queue_positions = self.sql_session.scalars( + select(BookcaseItemBorrowingQueue) + .where( + BookcaseItemBorrowingQueue.item_became_available_time + timedelta(days=queue_position_expiry_days) < self.current_run_datetime, + BookcaseItemBorrowingQueue.expired.is_(False), + ), + ).all() + + for queue_position in overdue_queue_positions: + logging.info(f'Expiring queue position for {queue_position.username} for item {queue_position.item.name}') + + queue_position.expired = True + + next_queue_position = self.sql_session.scalars( + select(BookcaseItemBorrowingQueue) + .where( + BookcaseItemBorrowingQueue.fk_bookcase_item_uid == queue_position.fk_bookcase_item_uid, + BookcaseItemBorrowingQueue.item_became_available_time.is_(None), + ) + .order_by(BookcaseItemBorrowingQueue.entered_queue_time) + .limit(1), + ).one_or_none() + + self._send_queue_position_expired_mail(queue_position) + + if next_queue_position is not None: + next_queue_position.item_became_available_time = self.current_run_datetime + + logging.info(f'Next user in queue for item {next_queue_position.item.name} is {next_queue_position.username}') + self._send_newly_available_mail(next_queue_position) + + self.sql_session.commit() \ No newline at end of file diff --git a/worblehat/models/BookcaseItemBorrowingQueue.py b/worblehat/models/BookcaseItemBorrowingQueue.py index 2b7f8a4..88bb739 100644 --- a/worblehat/models/BookcaseItemBorrowingQueue.py +++ b/worblehat/models/BookcaseItemBorrowingQueue.py @@ -21,7 +21,8 @@ if TYPE_CHECKING: class BookcaseItemBorrowingQueue(Base, UidMixin): username: Mapped[str] = mapped_column(String) - entered_queue_time = mapped_column(DateTime, default=datetime.now()) + entered_queue_time: Mapped[datetime] = mapped_column(DateTime, default=datetime.now()) + item_became_available_time: Mapped[datetime | None] = mapped_column(DateTime) expired = mapped_column(Boolean, default=False) fk_bookcase_item_uid: Mapped[int] = mapped_column(ForeignKey('BookcaseItem.uid'), index=True) diff --git a/worblehat/services/email.py b/worblehat/services/email.py index 3e7c807..83c55cc 100644 --- a/worblehat/services/email.py +++ b/worblehat/services/email.py @@ -1,6 +1,7 @@ -from pathlib import Path import smtplib +from textwrap import indent + from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart @@ -8,16 +9,16 @@ 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')) + 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')) + if Config['smtp.enabled'] and not Config['deadline_daemon.dryrun']: try: with smtplib.SMTP(Config['smtp.host'], Config['smtp.port']) as server: server.starttls() @@ -31,6 +32,4 @@ def send_email(to: str, subject: str, body: str): 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 + print(indent(msg.as_string(), ' ')) \ No newline at end of file