From 81e92747908ce9b1f6d0c0f0aa1ef842ed2a53fc Mon Sep 17 00:00:00 2001 From: Andreas Date: Thu, 28 Nov 2024 17:05:09 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9E=95=20Declined=20booking=20event=20handli?= =?UTF-8?q?ng=20(#776)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/appointment/controller/calendar.py | 51 +++++++++++++++---- backend/src/appointment/controller/mailer.py | 11 +++- backend/src/appointment/routes/schedule.py | 6 +-- backend/src/appointment/tasks/emails.py | 4 +- 4 files changed, 53 insertions(+), 19 deletions(-) diff --git a/backend/src/appointment/controller/calendar.py b/backend/src/appointment/controller/calendar.py index 89e369519..f3c785e88 100644 --- a/backend/src/appointment/controller/calendar.py +++ b/backend/src/appointment/controller/calendar.py @@ -22,6 +22,7 @@ from icalendar import Calendar, Event, vCalAddress, vText from datetime import datetime, timedelta, timezone, UTC from zoneinfo import ZoneInfo +from enum import Enum from sqlalchemy.orm import Session @@ -34,8 +35,12 @@ from ..controller.mailer import Attachment from ..exceptions.validation import RemoteCalendarConnectionError from ..l10n import l10n -from ..tasks.emails import send_invite_email, send_pending_email +from ..tasks.emails import send_invite_email, send_pending_email, send_rejection_email +class RemoteEventState(Enum): + CANCELLED = 'CANCELLED' + TENTATIVE = 'TENTATIVE' + CONFIRMED = 'CONFIRMED' class BaseConnector: redis_instance: Redis | RedisCluster | None @@ -502,13 +507,13 @@ def create_vevent( appointment: schemas.Appointment, slot: schemas.Slot, organizer: schemas.Subscriber, - on_hold = False, + event_status = RemoteEventState.CONFIRMED.value, ): """create an event in ical format for .ics file creation""" cal = Calendar() cal.add('prodid', '-//Thunderbird Appointment//tba.dk//') cal.add('version', '2.0') - cal.add('method', 'REQUEST') + cal.add('method', 'CANCEL' if event_status == RemoteEventState.CANCELLED.value else 'REQUEST') org = vCalAddress('MAILTO:' + organizer.preferred_email) org.params['cn'] = vText(organizer.preferred_email) org.params['role'] = vText('CHAIR') @@ -521,7 +526,7 @@ def create_vevent( slot.start.replace(tzinfo=timezone.utc) + timedelta(minutes=slot.duration), ) event.add('dtstamp', datetime.now(UTC)) - event.add('status', 'TENTATIVE' if on_hold else 'CONFIRMED') + event.add('status', event_status) event['description'] = appointment.details event['organizer'] = org @@ -544,7 +549,7 @@ def send_invitation_vevent( attendee: schemas.AttendeeBase, ): """send a booking confirmation email to attendee with .ics file attached""" - invite = Attachment( + ics_file = Attachment( mime=('text', 'calendar'), filename='AppointmentInvite.ics', data=self.create_vevent(appointment, slot, organizer), @@ -560,7 +565,7 @@ def send_invitation_vevent( date=date, duration=slot.duration, to=attendee.email, - attachment=invite + attachment=ics_file ) def send_hold_vevent( @@ -571,11 +576,11 @@ def send_hold_vevent( organizer: schemas.Subscriber, attendee: schemas.AttendeeBase, ): - """send a booking confirmation email to attendee with .ics file attached""" - invite = Attachment( + """send a hold booking email to attendee with .ics file attached""" + ics_file = Attachment( mime=('text', 'calendar'), filename='AppointmentInvite.ics', - data=self.create_vevent(appointment, slot, organizer, True), + data=self.create_vevent(appointment, slot, organizer, RemoteEventState.TENTATIVE.value), ) if attendee.timezone is None: attendee.timezone = 'UTC' @@ -586,7 +591,33 @@ def send_hold_vevent( organizer.name, date=date, to=attendee.email, - attachment=invite + attachment=ics_file + ) + + def send_cancel_vevent( + self, + background_tasks: BackgroundTasks, + appointment: models.Appointment, + slot: schemas.Slot, + organizer: schemas.Subscriber, + attendee: schemas.AttendeeBase, + ): + """send a hold booking email to attendee with .ics file attached""" + ics_file = Attachment( + mime=('text', 'calendar'), + filename='AppointmentInvite.ics', + data=self.create_vevent(appointment, slot, organizer, RemoteEventState.CANCELLED.value), + ) + if attendee.timezone is None: + attendee.timezone = 'UTC' + date = slot.start.replace(tzinfo=timezone.utc).astimezone(ZoneInfo(attendee.timezone)) + # Send mail + background_tasks.add_task( + send_rejection_email, + organizer.name, + date=date, + to=attendee.email, + attachment=ics_file ) diff --git a/backend/src/appointment/controller/mailer.py b/backend/src/appointment/controller/mailer.py index cdaf33034..b3f173299 100644 --- a/backend/src/appointment/controller/mailer.py +++ b/backend/src/appointment/controller/mailer.py @@ -56,6 +56,7 @@ def __init__( html: str = '', plain: str = '', attachments: list[Attachment] = [], + method: str = 'REQUEST' ): self.sender = sender self.to = to @@ -64,6 +65,7 @@ def __init__( self.body_html = html self.body_plain = plain self.attachments = attachments + self.method = method def html(self): """provide email body as html per default""" @@ -98,11 +100,15 @@ def build(self): if a.mime_main == 'text' and a.mime_sub == 'calendar': message.add_attachment( a.data, - maintype=a.mime_main, subtype=a.mime_sub, + maintype=a.mime_main, + subtype=a.mime_sub, filename=a.filename ) # Fix the header of the attachment - message.get_payload()[-1].replace_header('Content-Type', f'{a.mime_main}/{a.mime_sub}; charset="UTF-8"; method=REQUEST') + message.get_payload()[-1].replace_header( + 'Content-Type', + f'{a.mime_main}/{a.mime_sub}; charset="UTF-8"; method={self.method}' + ) else: # Attach it to the html payload message.get_payload()[1].add_related( @@ -298,6 +304,7 @@ def __init__(self, owner_name, date, *args, **kwargs): self.date = date default_kwargs = {'subject': l10n('reject-mail-subject')} super(RejectionMail, self).__init__(*args, **default_kwargs, **kwargs) + self.method = 'CANCEL' def text(self): return l10n('reject-mail-plain', {'owner_name': self.owner_name, 'date': self.date}) diff --git a/backend/src/appointment/routes/schedule.py b/backend/src/appointment/routes/schedule.py index 980869a8a..52887ab2f 100644 --- a/backend/src/appointment/routes/schedule.py +++ b/backend/src/appointment/routes/schedule.py @@ -506,12 +506,8 @@ def handle_schedule_availability_decision( # TODO: Check booking expiration date # check if request was denied if confirmed is False: - # human readable date in subscribers timezone - # TODO: Handle locale date representation - date = slot.start.replace(tzinfo=timezone.utc).astimezone(ZoneInfo(subscriber.timezone)).strftime('%c') - date = f'{date}, {slot.duration} minutes' # send rejection information to bookee - background_tasks.add_task(send_rejection_email, owner_name=subscriber.name, date=date, to=slot.attendee.email) + Tools().send_cancel_vevent(background_tasks, appointment, slot, subscriber, slot.attendee) repo.slot.delete(db, slot.id) if slot.appointment_id: diff --git a/backend/src/appointment/tasks/emails.py b/backend/src/appointment/tasks/emails.py index a28ee8edc..fa5a146e3 100644 --- a/backend/src/appointment/tasks/emails.py +++ b/backend/src/appointment/tasks/emails.py @@ -72,9 +72,9 @@ def send_pending_email(owner_name, date, to, attachment): sentry_sdk.capture_exception(e) -def send_rejection_email(owner_name, date, to): +def send_rejection_email(owner_name, date, to, attachment): try: - mail = RejectionMail(owner_name=owner_name, date=date, to=to) + mail = RejectionMail(owner_name=owner_name, date=date, to=to, attachments=[attachment]) mail.send() except Exception as e: if os.getenv('APP_ENV') == APP_ENV_DEV: