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