deadline-daemon: implement remaining pieces

This commit is contained in:
Oystein Kristoffer Tveit 2024-01-14 03:40:27 +01:00
parent 1550c1f2e3
commit 369180ff85
Signed by: oysteikt
GPG Key ID: 9F2F7D8250F35146
5 changed files with 198 additions and 51 deletions

View File

@ -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

View File

@ -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" ]
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 ]

View File

@ -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')
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()

View File

@ -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)

View File

@ -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}')
print(indent(msg.as_string(), ' '))