Skip to content

Commit

Permalink
New booking email for users without booking confirmation enabled (#597)
Browse files Browse the repository at this point in the history
* Fix an annoying pycharm thing

* Add a "New Booking" email for subscribers without booking confirmation enabled.

* 🌐 Update German translation

---------

Co-authored-by: Andreas Müller <mail@devmount.de>
  • Loading branch information
MelissaAutumn and devmount authored Jul 31, 2024
1 parent 203b0c0 commit 58de59d
Show file tree
Hide file tree
Showing 10 changed files with 203 additions and 11 deletions.
27 changes: 27 additions & 0 deletions backend/src/appointment/controller/mailer.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,33 @@ def html(self):
return get_template('pending.jinja2').render(owner_name=self.owner_name, date=self.date)


class NewBookingMail(Mailer):
def __init__(self, attendee_name, attendee_email, date, *args, **kwargs):
"""init Mailer with confirmation specific defaults"""
self.attendee_name = attendee_name
self.attendee_email = attendee_email
self.date = date
default_kwargs = {'subject': l10n('new-booking-subject', {'attendee_name': self.attendee_name})}
super(NewBookingMail, self).__init__(*args, **default_kwargs, **kwargs)

def text(self):
return l10n(
'new-booking-plain',
{
'attendee_name': self.attendee_name,
'attendee_email': self.attendee_email,
'date': self.date,
},
)

def html(self):
return get_template('new_booking.jinja2').render(
attendee_name=self.attendee_name,
attendee_email=self.attendee_email,
date=self.date,
)


class SupportRequestMail(Mailer):
def __init__(self, requestee_name, requestee_email, topic, details, *args, **kwargs):
"""init Mailer with support specific defaults"""
Expand Down
19 changes: 19 additions & 0 deletions backend/src/appointment/l10n/de/email.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,25 @@ invite-mail-subject = Einladung gesendet von {-brand-name}
invite-mail-plain = {-brand-footer}
invite-mail-html = {-brand-footer}
## New Booking

# Variables
# $attendee_name (String) - Name of the person who requested the appointment
new-booking-subject = Du hast eine neue bestätigte Terminbuchung mit { $attendee_name }
# Variables:
# $attendee_name (String) - Name of the person who requested the appointment
# $appointment_email (String) - Email of the person who requested the appointment
# $date (String) - Date of the Appointment
new-booking-plain = { $attendee_name } ({ $attendee_email }) hat soeben { $date } gebucht
{-brand-footer}
# Variables:
# $attendee_name (String) - Name of the person who requested the appointment
# $appointment_email (String) - Email of the person who requested the appointment
# $date (String) - Date of the requested appointment
new-booking-html-heading = { $attendee_name } ({ $attendee_email }) hat soeben { $date } gebucht
## Confirm Appointment

confirm-mail-subject = Buchungsanfrage von {-brand-name} bestätigen
Expand Down
19 changes: 19 additions & 0 deletions backend/src/appointment/l10n/en/email.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,25 @@ invite-mail-subject = Invitation sent from {-brand-name}
invite-mail-plain = {-brand-footer}
invite-mail-html = {-brand-footer}
## New Booking

# Variables
# $attendee_name (String) - Name of the person who requested the appointment
new-booking-subject = You have a new confirmed booking with { $attendee_name }
# Variables:
# $attendee_name (String) - Name of the person who requested the appointment
# $appointment_email (String) - Email of the person who requested the appointment
# $date (String) - Date of the Appointment
new-booking-plain = { $attendee_name } ({ $attendee_email }) has just booked { $date }
{-brand-footer}
# Variables:
# $attendee_name (String) - Name of the person who requested the appointment
# $appointment_email (String) - Email of the person who requested the appointment
# $date (String) - Date of the requested appointment
new-booking-html-heading = { $attendee_name } ({ $attendee_email }) has just booked { $date }
## Confirm Appointment

# Variables
Expand Down
22 changes: 15 additions & 7 deletions backend/src/appointment/routes/schedule.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
send_pending_email,
send_confirmation_email,
send_rejection_email,
send_zoom_meeting_failed_email,
send_zoom_meeting_failed_email, send_new_booking_email,
)

router = APIRouter()
Expand Down Expand Up @@ -305,15 +305,14 @@ def request_schedule_availability_slot(
# generate confirm and deny links with encoded booking token and signed owner url
url = f'{signed_url_by_subscriber(subscriber)}/confirm/{slot.id}/{token}'

# 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 ({subscriber.timezone})'

# If bookings are configured to be confirmed by the owner for this schedule,
# send emails to owner for confirmation and attendee for information
if schedule.booking_confirmation:

# 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 ({subscriber.timezone})'

# human readable date in attendee timezone
# TODO: handle locale date representation
attendee_date = slot.start.replace(tzinfo=timezone.utc).astimezone(ZoneInfo(slot.attendee.timezone)).strftime('%c')
Expand All @@ -336,6 +335,15 @@ def request_schedule_availability_slot(
True, calendar, schedule, subscriber, slot, db, redis, google_client, background_tasks
)

# Notify the subscriber that they have a new confirmed booking
background_tasks.add_task(
send_new_booking_email,
attendee_name=attendee.name,
attendee_email=attendee.email,
date=date,
to=subscriber.preferred_email
)

# Mini version of slot, so we can grab the newly created slot id for tests
return schemas.SlotOut(
id=slot.id,
Expand Down
8 changes: 7 additions & 1 deletion backend/src/appointment/tasks/emails.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
RejectionMail,
SupportRequestMail,
InviteAccountMail,
ConfirmYourEmailMail,
ConfirmYourEmailMail, NewBookingMail,
)


Expand All @@ -24,6 +24,12 @@ def send_confirmation_email(url, attendee_name, attendee_email, date, to):
mail.send()


def send_new_booking_email(attendee_name, attendee_email, date, to):
# send notice mail to owner
mail = NewBookingMail(attendee_name, attendee_email, date, to=to)
mail.send()


def send_pending_email(owner_name, date, to):
mail = PendingRequestMail(owner_name=owner_name, date=date, to=to)
mail.send()
Expand Down
8 changes: 8 additions & 0 deletions backend/src/appointment/templates/email/new_booking.jinja2
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<html lang="{{ l10n('locale') }}">
<body>
<p>
{{ l10n('new-booking-html-heading', {'attendee_name': attendee_name, 'attendee_email': attendee_email, 'date': date}) }}
</p>
{% include 'includes/footer.jinja2' %}
</body>
</html>
5 changes: 5 additions & 0 deletions backend/test/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@
from appointment.dependencies import database, auth, google # noqa: E402
from appointment.middleware.l10n import L10n # noqa: E402

# PyCharm likes to set the working directory to backend/test...
# Small hack to fix that automagically
if os.getcwd().endswith('test'):
os.chdir('../')


def _patch_caldav_connector(monkeypatch):
"""Standard function to patch caldav connector"""
Expand Down
2 changes: 2 additions & 0 deletions backend/test/factory/schedule_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ def _make_schedule(
slot_duration=FAKER_RANDOM_VALUE,
meeting_link_provider=models.MeetingLinkProviderType.none,
slug=FAKER_RANDOM_VALUE,
booking_confirmation=True,
):
with with_db() as db:
return repo.schedule.create(
Expand All @@ -47,6 +48,7 @@ def _make_schedule(
slot_duration=slot_duration if factory_has_value(slot_duration) else fake.pyint(15, 60),
meeting_link_provider=meeting_link_provider,
slug=slug if factory_has_value(slug) else fake.uuid4(),
booking_confirmation=booking_confirmation,
calendar_id=calendar_id
if factory_has_value(calendar_id)
else make_caldav_calendar(connected=True).id,
Expand Down
87 changes: 85 additions & 2 deletions backend/test/integration/test_schedule.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import zoneinfo
from datetime import date, time, datetime, timedelta
from unittest.mock import patch

from freezegun import freeze_time

from appointment.tasks import emails as email_tasks
from appointment.controller.auth import signed_url_by_subscriber
from appointment.controller.calendar import CalDavConnector
from appointment.database import schemas, models, repo
from appointment.exceptions import validation
from defines import DAY1, DAY5, DAY14, auth_headers, DAY2


class TestSchedule:
def test_create_schedule_on_connected_calendar(self, with_client, make_caldav_calendar):
generated_calendar = make_caldav_calendar(connected=True)
Expand Down Expand Up @@ -419,7 +420,9 @@ def list_events(self, start, end):
else models.BookingStatus.booked.value
)

def test_request_schedule_availability_slot(

class TestRequestScheduleAvailability:
def test_fail_and_success(
self, monkeypatch, with_db, with_client, make_pro_subscriber, make_caldav_calendar, make_schedule
):
"""Test that a user can request a booking from a schedule"""
Expand Down Expand Up @@ -518,6 +521,86 @@ def bust_cached_events(self, all_calendars=False):
slot = repo.slot.get(db, slot_id)
assert slot.appointment_id

def test_success_with_no_confirmation(
self, monkeypatch, with_db, with_client, make_pro_subscriber, make_caldav_calendar, make_schedule
):
"""Test that a user can request a booking from a schedule"""
start_date = date(2024, 4, 1)
start_time = time(9)
start_datetime = datetime.combine(start_date, start_time)
end_time = time(10)

class MockCaldavConnector:
@staticmethod
def __init__(self, redis_instance, url, user, password, subscriber_id, calendar_id):
"""We don't want to initialize a client"""
pass

@staticmethod
def list_events(self, start, end):
return []

@staticmethod
def bust_cached_events(self, all_calendars=False):
pass

monkeypatch.setattr(CalDavConnector, '__init__', MockCaldavConnector.__init__)
monkeypatch.setattr(CalDavConnector, 'list_events', MockCaldavConnector.list_events)
monkeypatch.setattr(CalDavConnector, 'bust_cached_events', MockCaldavConnector.bust_cached_events)

subscriber = make_pro_subscriber()
generated_calendar = make_caldav_calendar(subscriber.id, connected=True)
make_schedule(
calendar_id=generated_calendar.id,
active=True,
start_date=start_date,
start_time=start_time,
end_time=end_time,
end_date=None,
earliest_booking=1440,
farthest_booking=20160,
slot_duration=30,
booking_confirmation=False
)

signed_url = signed_url_by_subscriber(subscriber)

slot_availability = schemas.AvailabilitySlotAttendee(
slot=schemas.SlotBase(start=start_datetime, duration=30),
attendee=schemas.AttendeeBase(email='hello@example.org', name='Greg', timezone='Europe/Berlin'),
).model_dump(mode='json')

with patch('fastapi.BackgroundTasks.add_task') as mock:
# Check availability at the start of the schedule
# This should work
response = with_client.put(
'/schedule/public/availability/request',
json={
's_a': slot_availability,
'url': signed_url,
},
headers=auth_headers,
)
assert response.status_code == 200, response.text
data = response.json()

assert data.get('id')

slot_id = data.get('id')

# Look up the slot
with with_db() as db:
slot = repo.slot.get(db, slot_id)
assert slot.appointment_id

# Ensure we sent out an invite email and a new booking email
assert mock.call_count == 2
send_invite_email_call, send_new_booking_email_call = mock.call_args_list

# Functions are stored in a tuple as the first item on the call
assert email_tasks.send_invite_email in send_invite_email_call[0]
assert email_tasks.send_new_booking_email in send_new_booking_email_call[0]


class TestDecideScheduleAvailabilitySlot:
start_date = datetime.now() - timedelta(days=4)
Expand Down
17 changes: 16 additions & 1 deletion backend/test/unit/test_mailer.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import datetime

from appointment.controller.mailer import ConfirmationMail, RejectionMail, ZoomMeetingFailedMail, InvitationMail
from appointment.controller.mailer import ConfirmationMail, RejectionMail, ZoomMeetingFailedMail, InvitationMail, \
NewBookingMail
from appointment.database import schemas


Expand Down Expand Up @@ -30,6 +31,20 @@ def test_confirm(self, faker, with_l10n):
assert attendee.name in content, fault
assert attendee.email in content, fault

def test_new_booking(self, faker, with_l10n):
fake_email = 'to@example.org'
now = datetime.datetime.now()
attendee = schemas.AttendeeBase(email=faker.email(), name=faker.name(), timezone='Europe/Berlin')

mailer = NewBookingMail(attendee.name, attendee.email, now, to=fake_email)
assert mailer.html()
assert mailer.text()

for idx, content in enumerate([mailer.text(), mailer.html()]):
fault = 'text' if idx == 0 else 'html'
assert attendee.name in content, fault
assert attendee.email in content, fault

def test_reject(self, faker, with_l10n, make_pro_subscriber):
subscriber = make_pro_subscriber()
now = datetime.datetime.now()
Expand Down

0 comments on commit 58de59d

Please sign in to comment.