From 836f0f26a81dc5a319cc9d48d01cea079a69866d Mon Sep 17 00:00:00 2001 From: Andreas Date: Thu, 18 Apr 2024 17:36:21 +0200 Subject: [PATCH] Timezone information in booking confirmation email (#367) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🔨 fix case sensitive filename calls 🙈 * ➕ show timezone for attendee in confirmation email * 🔨 extend schedule test with attendee timezone * 🔨 Make attendee timezone mandatory * 🔨 Fix appointment tests --- backend/src/appointment/database/models.py | 1 + backend/src/appointment/database/schemas.py | 1 + ...0823-89e1197d980d_add_attendee_timezone.py | 24 +++++++++++++++++++ backend/src/appointment/routes/schedule.py | 15 +++++++++--- backend/test/integration/test_appointment.py | 6 ++--- backend/test/integration/test_schedule.py | 3 ++- backend/test/unit/test_mailer.py | 2 +- frontend/src/components/BookingModal.vue | 10 ++++---- frontend/src/elements/PrimaryButton.vue | 2 +- frontend/src/elements/SecondaryButton.vue | 2 +- frontend/src/elements/TextButton.vue | 2 +- 11 files changed, 51 insertions(+), 17 deletions(-) create mode 100644 backend/src/appointment/migrations/versions/2024_04_18_0823-89e1197d980d_add_attendee_timezone.py diff --git a/backend/src/appointment/database/models.py b/backend/src/appointment/database/models.py index d8812167b..fb7f184e0 100644 --- a/backend/src/appointment/database/models.py +++ b/backend/src/appointment/database/models.py @@ -170,6 +170,7 @@ class Attendee(Base): id = Column(Integer, primary_key=True, index=True) email = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=True) name = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=True) + timezone = Column(String(255), index=True) slots = relationship("Slot", cascade="all,delete", back_populates="attendee") diff --git a/backend/src/appointment/database/schemas.py b/backend/src/appointment/database/schemas.py index a30bc4371..9885e0b51 100644 --- a/backend/src/appointment/database/schemas.py +++ b/backend/src/appointment/database/schemas.py @@ -30,6 +30,7 @@ class AttendeeBase(BaseModel): email: str name: str | None = None + timezone: str class Attendee(AttendeeBase): diff --git a/backend/src/appointment/migrations/versions/2024_04_18_0823-89e1197d980d_add_attendee_timezone.py b/backend/src/appointment/migrations/versions/2024_04_18_0823-89e1197d980d_add_attendee_timezone.py new file mode 100644 index 000000000..5d70c2a18 --- /dev/null +++ b/backend/src/appointment/migrations/versions/2024_04_18_0823-89e1197d980d_add_attendee_timezone.py @@ -0,0 +1,24 @@ +"""add attendee timezone + +Revision ID: 89e1197d980d +Revises: fadd0d1ef438 +Create Date: 2024-04-18 08:23:55.660065 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '89e1197d980d' +down_revision = 'fadd0d1ef438' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column('attendees', sa.Column('timezone', sa.String(255), index=True)) + + +def downgrade() -> None: + op.drop_column('attendees', 'timezone') diff --git a/backend/src/appointment/routes/schedule.py b/backend/src/appointment/routes/schedule.py index f5e545ef4..b07b40fe3 100644 --- a/backend/src/appointment/routes/schedule.py +++ b/backend/src/appointment/routes/schedule.py @@ -220,7 +220,12 @@ def request_schedule_availability_slot( # 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" + 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") + attendee_date = f"{attendee_date}, {slot.duration} minutes ({slot.attendee.timezone})" # Create a pending appointment attendee_name = slot.attendee.name if slot.attendee.name is not None else slot.attendee.email @@ -246,7 +251,7 @@ def request_schedule_availability_slot( background_tasks.add_task(send_confirmation_email, url=url, attendee=attendee, date=date, to=subscriber.email) # Sending pending email to attendee - background_tasks.add_task(send_pending_email, owner=subscriber, date=date, to=slot.attendee.email) + background_tasks.add_task(send_pending_email, owner=subscriber, date=attendee_date, to=slot.attendee.email) # Mini version of slot, so we can grab the newly created slot id for tests return schemas.SlotOut( @@ -416,5 +421,9 @@ def decide_on_schedule_availability_slot( return schemas.AvailabilitySlotAttendee( slot=schemas.SlotBase(start=slot.start, duration=slot.duration), - attendee=schemas.AttendeeBase(email=slot.attendee.email, name=slot.attendee.name) + attendee=schemas.AttendeeBase( + email=slot.attendee.email, + name=slot.attendee.name, + timezone=slot.attendee.timezone + ) ) diff --git a/backend/test/integration/test_appointment.py b/backend/test/integration/test_appointment.py index a9f1c12e3..87d3c95a6 100644 --- a/backend/test/integration/test_appointment.py +++ b/backend/test/integration/test_appointment.py @@ -338,7 +338,7 @@ def test_attendee_selects_slot_of_unavailable_appointment(self, with_db, with_cl response = with_client.put( f"/apmt/public/{generated_appointment.slug}", - json={"slot_id": generated_appointment.slots[-1].id, "attendee": {"email": "a", "name": "b"}}, + json={"slot_id": generated_appointment.slots[-1].id, "attendee": {"email": "a", "name": "b", "timezone": "c"}}, ) assert response.status_code == 403, response.text @@ -347,7 +347,7 @@ def test_attendee_selects_slot_of_missing_appointment(self, with_client, make_ap response = with_client.put( f"/apmt/public/{generated_appointment}", - json={"slot_id": generated_appointment.slots[0].id, "attendee": {"email": "a", "name": "b"}}, + json={"slot_id": generated_appointment.slots[0].id, "attendee": {"email": "a", "name": "b", "timezone": "c"}}, ) assert response.status_code == 404, response.text @@ -356,7 +356,7 @@ def test_attendee_selects_missing_slot_of_existing_appointment(self, with_client response = with_client.put( f"/apmt/public/{generated_appointment.id}", - json={"slot_id": generated_appointment.slots[0].id + 1, "attendee": {"email": "a", "name": "b"}}, + json={"slot_id": generated_appointment.slots[0].id + 1, "attendee": {"email": "a", "name": "b", "timezone": "c"}}, ) assert response.status_code == 404, response.text diff --git a/backend/test/integration/test_schedule.py b/backend/test/integration/test_schedule.py index 469107eee..2e750d267 100644 --- a/backend/test/integration/test_schedule.py +++ b/backend/test/integration/test_schedule.py @@ -462,7 +462,8 @@ def bust_cached_events(self, all_calendars = False): ), attendee=schemas.AttendeeBase( email='hello@example.org', - name='Greg' + name='Greg', + timezone='Europe/Berlin' ) ).model_dump(mode='json') diff --git a/backend/test/unit/test_mailer.py b/backend/test/unit/test_mailer.py index 25b75c3b2..edfb3a81c 100644 --- a/backend/test/unit/test_mailer.py +++ b/backend/test/unit/test_mailer.py @@ -17,7 +17,7 @@ def test_confirm(self, faker, with_l10n): deny_url = 'https://example.org/no' fake_email = 'to@example.org' now = datetime.datetime.now() - attendee = schemas.AttendeeBase(email=faker.email(), name=faker.name()) + attendee = schemas.AttendeeBase(email=faker.email(), name=faker.name(), timezone='Europe/Berlin') mailer = ConfirmationMail(confirm_url, deny_url, attendee, now, to=fake_email) assert mailer.html() diff --git a/frontend/src/components/BookingModal.vue b/frontend/src/components/BookingModal.vue index 946ce240e..a3121ffca 100644 --- a/frontend/src/components/BookingModal.vue +++ b/frontend/src/components/BookingModal.vue @@ -79,9 +79,7 @@ diff --git a/frontend/src/elements/PrimaryButton.vue b/frontend/src/elements/PrimaryButton.vue index 54cbe2ba9..ec950cfc8 100644 --- a/frontend/src/elements/PrimaryButton.vue +++ b/frontend/src/elements/PrimaryButton.vue @@ -36,7 +36,7 @@