diff --git a/backend/src/appointment/database/repo/slot.py b/backend/src/appointment/database/repo/slot.py index 1e5af4014..83beaf3fb 100644 --- a/backend/src/appointment/database/repo/slot.py +++ b/backend/src/appointment/database/repo/slot.py @@ -35,12 +35,14 @@ def get_by_subscriber(db: Session, subscriber_id: int): def add_for_appointment(db: Session, slots: list[schemas.SlotBase], appointment_id: int): """create new slots for appointment of given id""" + return_slots = [] for slot in slots: db_slot = models.Slot(**slot.dict()) db_slot.appointment_id = appointment_id db.add(db_slot) + return_slots.append(db_slot) db.commit() - return slots + return return_slots def add_for_schedule(db: Session, slot: schemas.SlotBase, schedule_id: int): diff --git a/backend/src/appointment/routes/api.py b/backend/src/appointment/routes/api.py index 8637ba733..6a12c350d 100644 --- a/backend/src/appointment/routes/api.py +++ b/backend/src/appointment/routes/api.py @@ -437,121 +437,6 @@ def read_public_appointment(slug: str, db: Session = Depends(get_db)): ) -@router.put("/apmt/public/{slug}", response_model=schemas.SlotAttendee, deprecated=True) -def update_public_appointment_slot( - slug: str, - s_a: schemas.SlotAttendee, - background_tasks: BackgroundTasks, - db: Session = Depends(get_db), - google_client: GoogleClient = Depends(get_google_client), -): - """endpoint to update a time slot for an appointment via public link and create an event in remote calendar""" - db_appointment = repo.appointment.get_public(db, slug=slug) - if db_appointment is None: - raise validation.AppointmentNotFoundException() - db_calendar = repo.calendar.get(db, calendar_id=db_appointment.calendar_id) - if db_calendar is None: - raise validation.CalendarNotFoundException() - if not repo.appointment.has_slot(db, appointment_id=db_appointment.id, slot_id=s_a.slot_id): - raise validation.SlotNotFoundException() - if not repo.slot.is_available(db, slot_id=s_a.slot_id): - raise validation.SlotAlreadyTakenException() - if not validators.email(s_a.attendee.email): - raise HTTPException(status_code=400, detail=l10n('slot-invalid-email')) - - slot = repo.slot.get(db=db, slot_id=s_a.slot_id) - - # grab the subscriber - organizer = repo.subscriber.get_by_appointment(db=db, appointment_id=db_appointment.id) - - location_url = db_appointment.location_url - - if db_appointment.meeting_link_provider == MeetingLinkProviderType.zoom: - try: - zoom_client = get_zoom_client(organizer) - response = zoom_client.create_meeting(db_appointment.title, slot.start.isoformat(), slot.duration, - organizer.timezone) - if 'id' in response: - slot.meeting_link_url = zoom_client.get_meeting(response['id'])['join_url'] - slot.meeting_link_id = response['id'] - - location_url = slot.meeting_link_url - - # TODO: If we move to a model-based db functions replace this with a .save() - # Save the updated slot information - db.add(slot) - db.commit() - except HTTPError as err: # Not fatal, just a bummer - logging.error("Zoom meeting creation error: ", err) - - # Ensure sentry captures the error too! - if os.getenv('SENTRY_DSN') != '': - capture_exception(err) - - # Notify the organizer that the meeting link could not be created! - background_tasks.add_task(send_zoom_meeting_failed_email, to=organizer.email, - appointment=db_appointment.title) - - except SQLAlchemyError as err: # Not fatal, but could make things tricky - logging.error("Failed to save the zoom meeting link to the appointment: ", err) - if os.getenv('SENTRY_DSN') != '': - capture_exception(err) - - event = schemas.Event( - title=db_appointment.title, - start=slot.start.replace(tzinfo=timezone.utc), - end=slot.start.replace(tzinfo=timezone.utc) + timedelta(minutes=slot.duration), - description=db_appointment.details, - location=schemas.EventLocation( - type=db_appointment.location_type, - suggestions=db_appointment.location_suggestions, - selected=db_appointment.location_selected, - name=db_appointment.location_name, - url=location_url, - phone=db_appointment.location_phone, - ), - ) - - organizer_email = organizer.email - - # create remote event - if db_calendar.provider == CalendarProvider.google: - external_connection = utils.list_first(repo.external_connection.get_by_type(db, organizer.id, schemas.ExternalConnectionType.google)) - - if external_connection is None or external_connection.token is None: - raise RemoteCalendarConnectionError() - - organizer_email = external_connection.name - - con = GoogleConnector( - db=db, - redis_instance=None, - google_client=google_client, - remote_calendar_id=db_calendar.user, - calendar_id=db_calendar.id, - subscriber_id=organizer.id, - google_tkn=external_connection.token, - ) - else: - con = CalDavConnector( - redis_instance=None, - url=db_calendar.url, - user=db_calendar.user, - password=db_calendar.password, - subscriber_id=organizer.id, - calendar_id=db_calendar.id, - ) - con.create_event(event=event, attendee=s_a.attendee, organizer=organizer, organizer_email=organizer_email) - - # update appointment slot data - repo.slot.update(db=db, slot_id=s_a.slot_id, attendee=s_a.attendee) - - # send mail with .ics attachment to attendee - Tools().send_vevent(background_tasks, db_appointment, slot, organizer, s_a.attendee) - - return schemas.SlotAttendee(slot_id=s_a.slot_id, attendee=s_a.attendee) - - @router.get("/apmt/serve/ics/{slug}/{slot_id}", response_model=schemas.FileDownload) def public_appointment_serve_ics(slug: str, slot_id: int, db: Session = Depends(get_db)): """endpoint to serve ICS file for time slot to download""" diff --git a/backend/src/appointment/routes/schedule.py b/backend/src/appointment/routes/schedule.py index 05b89c741..449dd0d02 100644 --- a/backend/src/appointment/routes/schedule.py +++ b/backend/src/appointment/routes/schedule.py @@ -278,7 +278,6 @@ def decide_on_schedule_availability_slot( ): """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 - TODO: if denied: send information mail to bookee """ subscriber = repo.subscriber.verify_link(db, data.owner_url) if not subscriber: @@ -322,6 +321,7 @@ def decide_on_schedule_availability_slot( 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) + repo.slot.delete(db, slot.id) if slot.appointment_id: # delete the appointment, this will also delete the slot. @@ -330,100 +330,111 @@ 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 + ) + ) + # otherwise, confirm slot and create event + location_url = schedule.location_url + + 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 + + attendees = f"{subscriber_name} and {attendee_name}" + + if not slot.appointment: + title = f"Appointment - {attendees}" else: - location_url = schedule.location_url - - # FIXME: This is just duplicated from the appointment code. We should find a nice way to merge the two. - if schedule.meeting_link_provider == MeetingLinkProviderType.zoom: - try: - zoom_client = get_zoom_client(subscriber) - response = zoom_client.create_meeting(schedule.name, slot.start.isoformat(), slot.duration, - subscriber.timezone) - if 'id' in response: - location_url = zoom_client.get_meeting(response['id'])['join_url'] - slot.meeting_link_id = response['id'] - slot.meeting_link_url = location_url - - db.add(slot) - db.commit() - except HTTPError as err: # Not fatal, just a bummer - logging.error("Zoom meeting creation error: ", err) - - # Ensure sentry captures the error too! - if os.getenv('SENTRY_DSN') != '': - capture_exception(err) - - # Notify the organizer that the meeting link could not be created! - background_tasks.add_task(send_zoom_meeting_failed_email, to=subscriber.email, appointment_title=schedule.name) - except SQLAlchemyError as err: # Not fatal, but could make things tricky - logging.error("Failed to save the zoom meeting link to the appointment: ", err) - if os.getenv('SENTRY_DSN') != '': - capture_exception(err) - - if not slot.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}" - else: - title = slot.appointment.title - # Update the appointment to closed - repo.appointment.update_status(db, slot.appointment_id, models.AppointmentStatus.closed) - - event = schemas.Event( - title=title, - start=slot.start.replace(tzinfo=timezone.utc), - end=slot.start.replace(tzinfo=timezone.utc) + timedelta(minutes=slot.duration), - description=schedule.details, - location=schemas.EventLocation( - type=schedule.location_type, - url=location_url, - name=None, - ), - uuid=slot.appointment.uuid if slot.appointment else None - ) + title = slot.appointment.title + # Update the appointment to closed + repo.appointment.update_status(db, slot.appointment_id, models.AppointmentStatus.closed) - organizer_email = subscriber.email + # If needed: Create a zoom meeting link for this booking + if schedule.meeting_link_provider == MeetingLinkProviderType.zoom: + try: + zoom_client = get_zoom_client(subscriber) + response = zoom_client.create_meeting(attendees, slot.start.isoformat(), slot.duration, + subscriber.timezone) + if 'id' in response: + location_url = zoom_client.get_meeting(response['id'])['join_url'] + slot.meeting_link_id = response['id'] + slot.meeting_link_url = location_url + + db.add(slot) + db.commit() + except HTTPError as err: # Not fatal, just a bummer + logging.error("Zoom meeting creation error: ", err) + + # Ensure sentry captures the error too! + if os.getenv('SENTRY_DSN') != '': + capture_exception(err) + + # Notify the organizer that the meeting link could not be created! + background_tasks.add_task(send_zoom_meeting_failed_email, to=subscriber.email, appointment_title=schedule.name) + except SQLAlchemyError as err: # Not fatal, but could make things tricky + logging.error("Failed to save the zoom meeting link to the appointment: ", err) + if os.getenv('SENTRY_DSN') != '': + capture_exception(err) + + event = schemas.Event( + title=title, + start=slot.start.replace(tzinfo=timezone.utc), + end=slot.start.replace(tzinfo=timezone.utc) + timedelta(minutes=slot.duration), + description=schedule.details, + location=schemas.EventLocation( + type=schedule.location_type, + url=location_url, + name=None, + ), + uuid=slot.appointment.uuid if slot.appointment else None + ) - # create remote event - if calendar.provider == CalendarProvider.google: - external_connection: ExternalConnection|None = utils.list_first(repo.external_connection.get_by_type(db, subscriber.id, schemas.ExternalConnectionType.google)) + organizer_email = subscriber.email - if external_connection is None or external_connection.token is None: - raise RemoteCalendarConnectionError() + # create remote event + if calendar.provider == CalendarProvider.google: + external_connection: ExternalConnection|None = utils.list_first(repo.external_connection.get_by_type(db, subscriber.id, schemas.ExternalConnectionType.google)) - # Email is stored in the name - organizer_email = external_connection.name + if external_connection is None or external_connection.token is None: + raise RemoteCalendarConnectionError() - con = GoogleConnector( - db=db, - redis_instance=redis, - google_client=google_client, - remote_calendar_id=calendar.user, - subscriber_id=subscriber.id, - calendar_id=calendar.id, - google_tkn=external_connection.token, - ) - else: - con = CalDavConnector( - redis_instance=redis, - subscriber_id=subscriber.id, - calendar_id=calendar.id, - url=calendar.url, - user=calendar.user, - password=calendar.password - ) + # Email is stored in the name + organizer_email = external_connection.name - try: - con.create_event(event=event, attendee=slot.attendee, organizer=subscriber, organizer_email=organizer_email) - except EventNotCreatedException: - raise EventCouldNotBeAccepted + con = GoogleConnector( + db=db, + redis_instance=redis, + google_client=google_client, + remote_calendar_id=calendar.user, + subscriber_id=subscriber.id, + calendar_id=calendar.id, + google_tkn=external_connection.token, + ) + else: + con = CalDavConnector( + redis_instance=redis, + subscriber_id=subscriber.id, + calendar_id=calendar.id, + url=calendar.url, + user=calendar.user, + password=calendar.password + ) + + try: + con.create_event(event=event, attendee=slot.attendee, organizer=subscriber, organizer_email=organizer_email) + except EventNotCreatedException: + raise EventCouldNotBeAccepted - # Book the slot at the end - slot = repo.slot.book(db, slot.id) + # Book the slot at the end + slot = repo.slot.book(db, slot.id) - Tools().send_vevent(background_tasks, slot.appointment, slot, subscriber, slot.attendee) + Tools().send_vevent(background_tasks, slot.appointment, slot, subscriber, slot.attendee) return schemas.AvailabilitySlotAttendee( slot=schemas.SlotBase(start=slot.start, duration=slot.duration), diff --git a/backend/test/factory/slot_factory.py b/backend/test/factory/slot_factory.py index fda08151c..09a19ac51 100644 --- a/backend/test/factory/slot_factory.py +++ b/backend/test/factory/slot_factory.py @@ -26,7 +26,7 @@ def _make_appointment_slot(appointment_id=None, booking_expires_at=booking_expires_at, booking_status=booking_status, meeting_link_id=meeting_link_id, - meeting_link_url=meeting_link_url + meeting_link_url=meeting_link_url, )], appointment_id) return _make_appointment_slot diff --git a/backend/test/integration/test_appointment.py b/backend/test/integration/test_appointment.py index 87d3c95a6..d82109161 100644 --- a/backend/test/integration/test_appointment.py +++ b/backend/test/integration/test_appointment.py @@ -324,42 +324,6 @@ def test_read_public_appointment_after_attendee_selection(self, with_db, with_cl assert len(data["slots"]) == len(generated_appointment.slots) assert data["slots"][-1]["attendee_id"] == generated_attendee.id - def test_attendee_selects_slot_of_unavailable_appointment(self, with_db, with_client, make_appointment, make_attendee, make_appointment_slot): - generated_appointment = make_appointment() - generated_attendee = make_attendee() - make_appointment_slot(generated_appointment.id, attendee_id=generated_attendee.id) - - # db.refresh doesn't work because it only refreshes instances created by the current db session? - with with_db() as db: - from appointment.database import models - generated_appointment = db.get(models.Appointment, generated_appointment.id) - # Reload slots - generated_appointment.slots - - response = with_client.put( - f"/apmt/public/{generated_appointment.slug}", - json={"slot_id": generated_appointment.slots[-1].id, "attendee": {"email": "a", "name": "b", "timezone": "c"}}, - ) - assert response.status_code == 403, response.text - - def test_attendee_selects_slot_of_missing_appointment(self, with_client, make_appointment): - generated_appointment = make_appointment() - - response = with_client.put( - f"/apmt/public/{generated_appointment}", - json={"slot_id": generated_appointment.slots[0].id, "attendee": {"email": "a", "name": "b", "timezone": "c"}}, - ) - assert response.status_code == 404, response.text - - def test_attendee_selects_missing_slot_of_existing_appointment(self, with_client, make_appointment): - generated_appointment = make_appointment() - - response = with_client.put( - f"/apmt/public/{generated_appointment.id}", - json={"slot_id": generated_appointment.slots[0].id + 1, "attendee": {"email": "a", "name": "b", "timezone": "c"}}, - ) - assert response.status_code == 404, response.text - def test_get_remote_caldav_events(self, with_client, make_appointment, monkeypatch): """Test against a fake remote caldav, we're testing the route controller, not the actual caldav connector here!""" from appointment.controller.calendar import CalDavConnector diff --git a/backend/test/integration/test_schedule.py b/backend/test/integration/test_schedule.py index cc5b02321..474890ca1 100644 --- a/backend/test/integration/test_schedule.py +++ b/backend/test/integration/test_schedule.py @@ -1,5 +1,5 @@ import zoneinfo -from datetime import date, time, datetime, timedelta, timezone +from datetime import date, time, datetime, timedelta from freezegun import freeze_time @@ -75,7 +75,8 @@ def test_create_schedule_on_missing_calendar(self, with_client, make_schedule): ) assert response.status_code == 404, response.text - def test_create_schedule_on_foreign_calendar(self, with_client, make_pro_subscriber, make_caldav_calendar, make_schedule): + def test_create_schedule_on_foreign_calendar(self, with_client, make_pro_subscriber, make_caldav_calendar, + make_schedule): the_other_guy = make_pro_subscriber() generated_calendar = make_caldav_calendar(the_other_guy.id) generated_schedule = make_schedule(calendar_id=generated_calendar.id) @@ -236,7 +237,8 @@ def test_update_foreign_schedule(self, with_client, make_pro_subscriber, make_ca ) assert response.status_code == 403, response.text - def test_public_availability(self, monkeypatch, with_client, make_pro_subscriber, make_caldav_calendar, make_schedule): + def test_public_availability(self, monkeypatch, with_client, make_pro_subscriber, make_caldav_calendar, + make_schedule): class MockCaldavConnector: @staticmethod def __init__(self, redis_instance, url, user, password, subscriber_id, calendar_id): @@ -316,7 +318,8 @@ def list_events(self, start, end): assert slots[0]['start'] == '2025-06-30T09:00:00-07:00' assert slots[-1]['start'] == '2025-07-11T16:30:00-07:00' - def test_public_availability_with_blockers(self, monkeypatch, with_client, make_pro_subscriber, make_caldav_calendar, make_schedule): + def test_public_availability_with_blockers(self, monkeypatch, with_client, make_pro_subscriber, + make_caldav_calendar, make_schedule): """Test public availability route with blocked off times. Ensuring the blocked off time displays as such and is otherwise normal.""" start_date = date(2024, 3, 3) end_date = date(2024, 3, 6) @@ -400,11 +403,13 @@ def list_events(self, start, end): # Format our test time as an iso date string iso = test_time[0].isoformat() assert iso in slots_dict - + slot = slots_dict[iso] - assert slot['booking_status'] == models.BookingStatus.none.value if expected_assert else models.BookingStatus.booked.value + assert slot[ + 'booking_status'] == models.BookingStatus.none.value if expected_assert else models.BookingStatus.booked.value - def test_request_schedule_availability_slot(self, monkeypatch, with_db, with_client, make_pro_subscriber, make_caldav_calendar, make_schedule): + def test_request_schedule_availability_slot(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) @@ -433,7 +438,7 @@ def list_events(self, start, end): ] @staticmethod - def bust_cached_events(self, all_calendars = False): + def bust_cached_events(self, all_calendars=False): pass monkeypatch.setattr(CalDavConnector, "__init__", MockCaldavConnector.__init__) @@ -442,7 +447,7 @@ def bust_cached_events(self, all_calendars = False): subscriber = make_pro_subscriber() generated_calendar = make_caldav_calendar(subscriber.id, connected=True) - schedule = make_schedule( + make_schedule( calendar_id=generated_calendar.id, active=True, start_date=start_date, @@ -510,3 +515,129 @@ def bust_cached_events(self, all_calendars = False): with with_db() as db: slot = repo.slot.get(db, slot_id) assert slot.appointment_id + + +class TestDecideScheduleAvailabilitySlot: + start_date = datetime.now() - timedelta(days=4) + start_date = start_date.date() + start_time = time(9) + start_datetime = datetime.combine(start_date, start_time) + end_time = time(10) + + def test_confirm(self, with_db, with_client, make_pro_subscriber, make_caldav_calendar, make_schedule, + make_appointment, make_appointment_slot, make_attendee): + subscriber = make_pro_subscriber() + generated_calendar = make_caldav_calendar(subscriber.id, connected=True) + schedule = make_schedule( + calendar_id=generated_calendar.id, + active=True, + start_date=self.start_date, + start_time=self.start_time, + end_time=self.end_time, + end_date=None, + earliest_booking=1440, + farthest_booking=20160, + slot_duration=30) + + signed_url = signed_url_by_subscriber(subscriber) + + # Requested booking + attendee = make_attendee() + appointment = make_appointment(generated_calendar.id, status=models.AppointmentStatus.draft, slots=[]) + slot: models.Slot = make_appointment_slot(appointment_id=appointment.id, + attendee_id=attendee.id, + booking_status=models.BookingStatus.requested, + booking_tkn='abcd', + )[0] + + with with_db() as db: + # Bring the db slot to our db session + db.add(slot) + db.add(appointment) + + slot_id = slot.id + appointment_id = appointment.id + + slot.schedule_id = schedule.id + db.commit() + + availability = schemas.AvailabilitySlotConfirmation( + slot_id=slot_id, + slot_token=slot.booking_tkn, + owner_url=signed_url, + confirmed=True + ).model_dump() + + response = with_client.put( + "/schedule/public/availability/booking", + json=availability, + headers=auth_headers, + ) + + with with_db() as db: + assert response.status_code == 200, response.content + + slot = db.get(models.Slot, slot_id) + appointment = db.get(models.Appointment, appointment_id) + + assert slot.booking_status == models.BookingStatus.booked + assert appointment.status == models.AppointmentStatus.closed + + def test_deny(self, with_db, with_client, make_pro_subscriber, make_caldav_calendar, make_schedule, + make_appointment, make_appointment_slot, make_attendee): + subscriber = make_pro_subscriber() + generated_calendar = make_caldav_calendar(subscriber.id, connected=True) + schedule = make_schedule( + calendar_id=generated_calendar.id, + active=True, + start_date=self.start_date, + start_time=self.start_time, + end_time=self.end_time, + end_date=None, + earliest_booking=1440, + farthest_booking=20160, + slot_duration=30) + + signed_url = signed_url_by_subscriber(subscriber) + + # Requested booking + attendee = make_attendee() + appointment = make_appointment(generated_calendar.id, status=models.AppointmentStatus.draft, slots=[]) + slot: models.Slot = make_appointment_slot(appointment_id=appointment.id, + attendee_id=attendee.id, + booking_status=models.BookingStatus.requested, + booking_tkn='abcd', + )[0] + + with with_db() as db: + # Bring the db slot to our db session + db.add(slot) + db.add(appointment) + + slot_id = slot.id + appointment_id = appointment.id + + slot.schedule_id = schedule.id + db.commit() + + availability = schemas.AvailabilitySlotConfirmation( + slot_id=slot_id, + slot_token=slot.booking_tkn, + owner_url=signed_url, + confirmed=False + ).model_dump() + + response = with_client.put( + "/schedule/public/availability/booking", + json=availability, + headers=auth_headers, + ) + + with with_db() as db: + assert response.status_code == 200, response.content + + slot = db.get(models.Slot, slot_id) + appointment = db.get(models.Appointment, appointment_id) + + assert slot is None + assert appointment is None