From 35b09c61ce77c4bc49be3bfd2bbfcc690c22ac24 Mon Sep 17 00:00:00 2001 From: Andreas Date: Fri, 12 Jul 2024 19:02:13 +0200 Subject: [PATCH] Schedule booking confirmation (#519) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ➕ Add schedule booking confirmation * ➕ Enhance schedule API to handle bookings without confirmation * 🔨 Show proper success message depending on confirmation status * 🔨 Make field nullable and fix fn return * 🌐 Update frontend/src/locales/en.json Co-authored-by: Mel <97147377+MelissaAutumn@users.noreply.github.com> * 🌐 Update German translation * 🔨 Assume appointment mustn't be null --------- Co-authored-by: Mel <97147377+MelissaAutumn@users.noreply.github.com> --- backend/src/appointment/database/models.py | 1 + backend/src/appointment/database/schemas.py | 2 + ...feb76c467_schedule_booking_confirmation.py | 24 ++++ backend/src/appointment/routes/api.py | 1 + backend/src/appointment/routes/schedule.py | 132 +++++++++++------- frontend/README.md | 1 + frontend/src/components/ScheduleCreation.vue | 30 +++- .../bookingView/BookingViewSuccess.vue | 4 +- frontend/src/elements/SwitchToggle.vue | 12 +- frontend/src/locales/de.json | 4 + frontend/src/locales/en.json | 4 + frontend/src/models.ts | 1 + frontend/src/views/BookingView.vue | 5 +- 13 files changed, 164 insertions(+), 57 deletions(-) create mode 100644 backend/src/appointment/migrations/versions/2024_07_04_1501-fb1feb76c467_schedule_booking_confirmation.py diff --git a/backend/src/appointment/database/models.py b/backend/src/appointment/database/models.py index 0c437eb65..a8fb66d64 100644 --- a/backend/src/appointment/database/models.py +++ b/backend/src/appointment/database/models.py @@ -269,6 +269,7 @@ class Schedule(Base): farthest_booking: int = Column(Integer, default=20160) # in minutes, defaults to 2 weeks weekdays: str | dict = Column(JSON, default='[1,2,3,4,5]') # list of ISO weekdays, Mo-Su => 1-7 slot_duration: int = Column(Integer, default=30) # defaults to 30 minutes + booking_confirmation: bool = Column(Boolean, index=True, nullable=False, default=True) # What (if any) meeting link will we generate once the meeting is booked meeting_link_provider: MeetingLinkProviderType = Column( diff --git a/backend/src/appointment/database/schemas.py b/backend/src/appointment/database/schemas.py index c1f35cba8..5125fe2cd 100644 --- a/backend/src/appointment/database/schemas.py +++ b/backend/src/appointment/database/schemas.py @@ -129,6 +129,7 @@ class AppointmentOut(AppointmentBase): owner_name: str | None = None slots: list[SlotBase | SlotOut] = [] slot_duration: int + booking_confirmation: bool """ SCHEDULE model schemas @@ -170,6 +171,7 @@ class ScheduleBase(BaseModel): weekdays: list[int] | None = Field(min_length=1, default=[1, 2, 3, 4, 5]) slot_duration: int | None = None meeting_link_provider: MeetingLinkProviderType | None = MeetingLinkProviderType.none + booking_confirmation: bool = True class Config: json_encoders = { diff --git a/backend/src/appointment/migrations/versions/2024_07_04_1501-fb1feb76c467_schedule_booking_confirmation.py b/backend/src/appointment/migrations/versions/2024_07_04_1501-fb1feb76c467_schedule_booking_confirmation.py new file mode 100644 index 000000000..5a64b9150 --- /dev/null +++ b/backend/src/appointment/migrations/versions/2024_07_04_1501-fb1feb76c467_schedule_booking_confirmation.py @@ -0,0 +1,24 @@ +"""schedule booking confirmation + +Revision ID: fb1feb76c467 +Revises: 0c22678e25db +Create Date: 2024-07-04 15:01:47.090876 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'fb1feb76c467' +down_revision = '0c22678e25db' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column('schedules', sa.Column('booking_confirmation', sa.Boolean, nullable=False, default=True, index=True)) + + +def downgrade() -> None: + op.drop_column('schedules', 'booking_confirmation') diff --git a/backend/src/appointment/routes/api.py b/backend/src/appointment/routes/api.py index 1fa959721..fd7f2c5fc 100644 --- a/backend/src/appointment/routes/api.py +++ b/backend/src/appointment/routes/api.py @@ -475,6 +475,7 @@ def read_public_appointment(slug: str, db: Session = Depends(get_db)): owner_name=s.name, slots=slots, slot_duration=slots[0].duration if len(slots) > 0 else 0, + booking_confirmation=False ) diff --git a/backend/src/appointment/routes/schedule.py b/backend/src/appointment/routes/schedule.py index f0051e518..533f6a2cd 100644 --- a/backend/src/appointment/routes/schedule.py +++ b/backend/src/appointment/routes/schedule.py @@ -145,7 +145,8 @@ def read_schedule_availabilities( redis=Depends(get_redis), google_client: GoogleClient = Depends(get_google_client), ): - """Returns the calculated availability for the first schedule from a subscribers public profile link""" + """Returns the calculated availability for the first schedule from a subscribers public profile link + """ # Raise a schedule not found exception if the schedule owner does not have a timezone set. if subscriber.timezone is None: raise validation.ScheduleNotFoundException() @@ -180,12 +181,14 @@ def read_schedule_availabilities( if not actual_slots or len(actual_slots) == 0: raise validation.SlotNotFoundException() + # TODO: dedicate an own schema to this endpoint return schemas.AppointmentOut( title=schedule.name, details=schedule.details, owner_name=subscriber.name, slots=actual_slots, slot_duration=schedule.slot_duration, + booking_confirmation=schedule.booking_confirmation ) @@ -198,7 +201,8 @@ def request_schedule_availability_slot( redis=Depends(get_redis), google_client=Depends(get_google_client), ): - """endpoint to request a time slot for a schedule via public link and send confirmation mail to owner""" + """endpoint to request a time slot for a schedule via public link and send confirmation mail to owner if set + """ # Raise a schedule not found exception if the schedule owner does not have a timezone set. if subscriber.timezone is None: @@ -216,8 +220,8 @@ def request_schedule_availability_slot( raise validation.ScheduleNotFoundException() # get calendar - db_calendar = repo.calendar.get(db, calendar_id=schedule.calendar_id) - if db_calendar is None: + calendar = repo.calendar.get(db, calendar_id=schedule.calendar_id) + if calendar is None: raise validation.CalendarNotFoundException() # check if slot still available, might already be taken at this time @@ -226,7 +230,7 @@ def request_schedule_availability_slot( raise validation.SlotAlreadyTakenException() # We need to verify that the time is actually available on the remote calendar - if db_calendar.provider == CalendarProvider.google: + if calendar.provider == CalendarProvider.google: external_connection = utils.list_first(repo.external_connection.get_by_type(db, subscriber.id, schemas.ExternalConnectionType.google)) if external_connection is None or external_connection.token is None: @@ -236,19 +240,19 @@ def request_schedule_availability_slot( db=db, redis_instance=redis, google_client=google_client, - remote_calendar_id=db_calendar.user, + remote_calendar_id=calendar.user, subscriber_id=subscriber.id, - calendar_id=db_calendar.id, + calendar_id=calendar.id, google_tkn=external_connection.token, ) else: con = CalDavConnector( redis_instance=redis, subscriber_id=subscriber.id, - calendar_id=db_calendar.id, - url=db_calendar.url, - user=db_calendar.user, - password=db_calendar.password, + calendar_id=calendar.id, + url=calendar.url, + user=calendar.user, + password=calendar.password, ) # Ok we need to clear the cache for all calendars, because we need to recheck them. @@ -273,32 +277,20 @@ def request_schedule_availability_slot( # create attendee for this slot attendee = repo.slot.update(db, slot.id, s_a.attendee) - # 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})' - - # 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 subscriber_name = subscriber.name if subscriber.name is not None else subscriber.email title = f'Appointment - {subscriber_name} and {attendee_name}' + status = models.AppointmentStatus.opened if schedule.booking_confirmation else models.AppointmentStatus.closed appointment = repo.appointment.create( db, schemas.AppointmentFull( title=title, details=schedule.details, - calendar_id=db_calendar.id, + calendar_id=calendar.id, duration=slot.duration, - status=models.AppointmentStatus.opened, + status=status, location_type=schedule.location_type, location_url=schedule.location_url, ), @@ -308,17 +300,41 @@ def request_schedule_availability_slot( slot.appointment_id = appointment.id db.add(slot) db.commit() + db.refresh(slot) - # Sending confirmation email to owner - background_tasks.add_task( - send_confirmation_email, url=url, attendee_name=attendee.name, attendee_email=attendee.email, date=date, - to=subscriber.preferred_email - ) + # generate confirm and deny links with encoded booking token and signed owner url + url = f'{signed_url_by_subscriber(subscriber)}/confirm/{slot.id}/{token}' - # Sending pending email to attendee - background_tasks.add_task( - send_pending_email, owner_name=subscriber.name, date=attendee_date, to=slot.attendee.email - ) + # 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') + attendee_date = f'{attendee_date}, {slot.duration} minutes ({slot.attendee.timezone})' + + # Sending confirmation email to owner + background_tasks.add_task( + send_confirmation_email, url=url, attendee_name=attendee.name, attendee_email=attendee.email, date=date, + to=subscriber.preferred_email + ) + + # Sending pending email to attendee + background_tasks.add_task( + send_pending_email, owner_name=subscriber.name, date=attendee_date, to=slot.attendee.email + ) + + # If no confirmation is needed, directly confirm the booking and send invitation mail + else: + handle_schedule_availability_decision( + True, calendar, schedule, subscriber, slot, db, redis, google_client, background_tasks + ) # Mini version of slot, so we can grab the newly created slot id for tests return schemas.SlotOut( @@ -338,7 +354,6 @@ def decide_on_schedule_availability_slot( google_client: GoogleClient = Depends(get_google_client), ): """endpoint to react to owners decision to a request of a time slot of his public link - if confirmed: create an event in remote calendar and send invitation mail """ subscriber = repo.subscriber.verify_link(db, data.owner_url) if not subscriber: @@ -373,9 +388,37 @@ def decide_on_schedule_availability_slot( ): raise validation.SlotNotFoundException() + # handle decision, do the actual booking if confirmed and send invitation mail + handle_schedule_availability_decision( + data.confirmed, calendar, schedule, subscriber, slot, db, redis, google_client, background_tasks + ) + + return schemas.AvailabilitySlotAttendee( + slot=schemas.SlotBase(start=slot.start, duration=slot.duration), + attendee=schemas.AttendeeBase( + email=slot.attendee.email, name=slot.attendee.name, timezone=slot.attendee.timezone + ), + ) + + +def handle_schedule_availability_decision( + confirmed: bool, + calendar, + schedule, + subscriber, + slot, + db, + redis, + google_client, + background_tasks +): + """Actual handling of the availability decision + if confirmed: create an event in remote calendar and send invitation mail + """ + # TODO: check booking expiration date # check if request was denied - if data.confirmed is False: + 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') @@ -391,13 +434,7 @@ def decide_on_schedule_availability_slot( # delete the scheduled slot to make the time available again repo.slot.delete(db, slot.id) - # Early return - return schemas.AvailabilitySlotAttendee( - slot=schemas.SlotBase(start=slot.start, duration=slot.duration), - attendee=schemas.AttendeeBase( - email=slot.attendee.email, name=slot.attendee.name, timezone=slot.attendee.timezone - ), - ) + return True # otherwise, confirm slot and create event location_url = schedule.location_url @@ -504,9 +541,4 @@ def decide_on_schedule_availability_slot( Tools().send_vevent(background_tasks, slot.appointment, slot, subscriber, slot.attendee) - return schemas.AvailabilitySlotAttendee( - slot=schemas.SlotBase(start=slot.start, duration=slot.duration), - attendee=schemas.AttendeeBase( - email=slot.attendee.email, name=slot.attendee.name, timezone=slot.attendee.timezone - ), - ) + return True diff --git a/frontend/README.md b/frontend/README.md index f723fb4ad..559fc2960 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -27,6 +27,7 @@ npm run build ### Post-CSS We use post-css to enhance our css. Any post-css that isn't in a SFC must be in a `.pcss` file and imported into the scoped style like so: + ```css @import '@/assets/styles/custom-media.pcss'; diff --git a/frontend/src/components/ScheduleCreation.vue b/frontend/src/components/ScheduleCreation.vue index d760a5d44..8dfbb3955 100644 --- a/frontend/src/components/ScheduleCreation.vue +++ b/frontend/src/components/ScheduleCreation.vue @@ -3,7 +3,14 @@
{{ t("heading.generalAvailability") }} - +
+ +
+ +
+ {{ t('text.ownerNeedsToConfirmBooking') }} +
+
@@ -444,6 +465,7 @@ const defaultSchedule = { weekdays: [1, 2, 3, 4, 5], slot_duration: defaultSlotDuration, meeting_link_provider: meetingLinkProviderType.none, + booking_confirmation: true, }; const scheduleInput = ref({ ...defaultSchedule }); // For comparing changes, and resetting to default. @@ -676,6 +698,11 @@ const toggleZoomLinkCreation = () => { scheduleInput.value.meeting_link_provider = meetingLinkProviderType.none; }; +// handle schedule booking confirmation activation / deactivation +const toggleBookingConfirmation = (newValue) => { + scheduleInput.value.booking_confirmation = newValue; +}; + // track if steps were already visited watch( () => scheduleInput.value.active, @@ -705,6 +732,7 @@ watch( scheduleInput.value.start_time, scheduleInput.value.end_time, scheduleInput.value.weekdays, + scheduleInput.value.booking_confirmation, props.activeDate, ], () => { diff --git a/frontend/src/components/bookingView/BookingViewSuccess.vue b/frontend/src/components/bookingView/BookingViewSuccess.vue index 22edae4e0..e0a5d5e8a 100644 --- a/frontend/src/components/bookingView/BookingViewSuccess.vue +++ b/frontend/src/components/bookingView/BookingViewSuccess.vue @@ -1,7 +1,8 @@