diff --git a/backend/src/controller/calendar.py b/backend/src/controller/calendar.py index 47584086..ed2db630 100644 --- a/backend/src/controller/calendar.py +++ b/backend/src/controller/calendar.py @@ -3,10 +3,12 @@ Handle connection to a CalDAV server. """ import json +import logging from caldav import DAVClient from google.oauth2.credentials import Credentials from icalendar import Calendar, Event, vCalAddress, vText from datetime import datetime, timedelta, timezone +from dateutil.parser import parse from .google_client import GoogleClient from ..database import schemas @@ -270,3 +272,58 @@ def send_vevent( ) mail = InvitationMail(sender=organizer.email, to=attendee.email, attachments=[invite]) mail.send() + + def available_slots_from_schedule(s: schemas.ScheduleBase): + """This helper calculates a list of slots according to the given schedule.""" + now = datetime.utcnow() + earliest_start = now + timedelta(minutes=s.earliest_booking) + farthest_end = now + timedelta(minutes=s.farthest_booking) + start = max([datetime.combine(s.start_date, s.start_time), earliest_start]) + end = min([datetime.combine(s.end_date, s.end_time), farthest_end]) + slots = [] + # set the first date to an allowed weekday + weekdays = s.weekdays if type(s.weekdays) == list else json.loads(s.weekdays) + if not weekdays or len(weekdays) == 0: + weekdays = [1, 2, 3, 4, 5] + while start.isoweekday() not in weekdays: + start = start + timedelta(days=1) + # init date generation: pointer holds the current slot start datetime + pointer = start + counter = 0 + # set fix event limit of 1000 for now for performance reasons. Can be removed later. + while pointer < end and counter < 1000: + counter += 1 + slots.append(schemas.SlotBase(start=pointer, duration=s.slot_duration)) + next_start = pointer + timedelta(minutes=s.slot_duration) + # if the next slot still fits into the current day + if next_start.time() < s.end_time: + pointer = next_start + # if the next slot has to be on the next available day + else: + next_date = datetime.combine(pointer.date() + timedelta(days=1), s.start_time) + # check weekday and skip da if it isn't allowed + while next_date.isoweekday() not in weekdays: + next_date = next_date + timedelta(days=1) + pointer = next_date + return slots + + def events_set_difference(a_list: list[schemas.SlotBase], b_list: list[schemas.Event]): + """This helper removes all events from list A, which have a time collision with any event in list B + and returns all remaining elements from A as new list. + """ + available_slots = [] + for a in a_list: + a_start = a.start + a_end = a_start + timedelta(minutes=a.duration) + collision_found = False + for b in b_list: + b_start = parse(b.start) + b_end = parse(b.end) + # if there is an overlap of both date ranges, a collision was found + # see https://en.wikipedia.org/wiki/De_Morgan%27s_laws + if a_start.timestamp() < b_end.timestamp() and a_end.timestamp() > b_start.timestamp(): + collision_found = True + break + if not collision_found: + available_slots.append(a) + return available_slots diff --git a/backend/src/database/models.py b/backend/src/database/models.py index 1da08a6f..ec7d96aa 100644 --- a/backend/src/database/models.py +++ b/backend/src/database/models.py @@ -5,7 +5,7 @@ import enum import os import uuid -from sqlalchemy import Column, ForeignKey, Integer, String, DateTime, Enum, Boolean +from sqlalchemy import Column, ForeignKey, Integer, String, DateTime, Enum, Boolean, JSON, Date, Time from sqlalchemy_utils import StringEncryptedType from sqlalchemy_utils.types.encrypted.encrypted_type import AesEngine from sqlalchemy.orm import relationship @@ -44,11 +44,6 @@ class CalendarProvider(enum.Enum): google = 2 # calendar provider is Google via its own Rest API -class AppointmentType(enum.Enum): - oneoff = 1 # An appointment created through "Create Appointment" - schedule = 2 # A template appointment used in Scheduling - - # Use ISO 8601 format to specify day of week class DayOfWeek(enum.Enum): Monday = 1 @@ -109,6 +104,7 @@ class Calendar(Base): owner = relationship("Subscriber", back_populates="calendars") appointments = relationship("Appointment", cascade="all,delete", back_populates="calendar") + schedules = relationship("Schedule", cascade="all,delete", back_populates="calendar") class Appointment(Base): @@ -134,11 +130,9 @@ class Appointment(Base): ) keep_open = Column(Boolean) status = Column(Enum(AppointmentStatus), default=AppointmentStatus.draft) - appointment_type = Column(Enum(AppointmentType)) calendar = relationship("Calendar", back_populates="appointments") slots = relationship("Slot", cascade="all,delete", back_populates="appointment") - schedule = relationship("Schedule", cascade="all,delete", back_populates="appointment") class Attendee(Base): @@ -171,15 +165,31 @@ class Schedule(Base): __tablename__ = "schedules" id = Column(Integer, primary_key=True, index=True) - appointment_id = Column(Integer, ForeignKey("appointments.id")) + calendar_id = Column(Integer, ForeignKey("calendars.id")) name = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=True) + location_type = Column(Enum(LocationType), default=LocationType.inperson) + location_url = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=2048)) + details = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255)) + start_date = Column(StringEncryptedType(Date, secret, AesEngine, "pkcs5", length=255), index=True) + end_date = Column(StringEncryptedType(Date, secret, AesEngine, "pkcs5", length=255), index=True) + start_time = Column(StringEncryptedType(Time, secret, AesEngine, "pkcs5", length=255), index=True) + end_time = Column(StringEncryptedType(Time, secret, AesEngine, "pkcs5", length=255), index=True) + earliest_booking = Column(Integer, default=1440) # in minutes, defaults to 24 hours + farthest_booking = Column(Integer, default=20160) # in minutes, defaults to 2 weeks + weekdays = Column(JSON, default="[1,2,3,4,5]") # list of ISO weekdays, Mo-Su => 1-7 + slot_duration = Column(Integer, default=30) # defaults to 30 minutes time_created = Column(DateTime, server_default=func.now()) time_updated = Column(DateTime, server_default=func.now(), onupdate=func.now()) - appointment = relationship("Appointment", cascade="all,delete", back_populates="schedule") + calendar = relationship("Calendar", back_populates="schedules") + availabilities = relationship("Availability", cascade="all,delete", back_populates="schedule") class Availability(Base): + """This table will be used as soon as the application provides custom availability + in addition to the general availability + """ + __tablename__ = "availabilities" id = Column(Integer, primary_key=True, index=True) @@ -191,6 +201,7 @@ class Availability(Base): StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=True ) # i.e., Can't book if it's less than X minutes before start time. slot_duration = Column(Integer) # Size of the Slot that can be booked. - time_created = Column(DateTime, server_default=func.now()) time_updated = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + schedule = relationship("Schedule", back_populates="availabilities") diff --git a/backend/src/database/repo.py b/backend/src/database/repo.py index 48b36465..1e4a5ffa 100644 --- a/backend/src/database/repo.py +++ b/backend/src/database/repo.py @@ -3,13 +3,18 @@ Repository providing CRUD functions for all database models. """ import os +import re +import logging from datetime import timedelta, datetime from fastapi import HTTPException from sqlalchemy.orm import Session from . import models, schemas +from ..controller.auth import sign_url -"""ATTENDEES repository functions""" + +"""ATTENDEES repository functions +""" def get_attendees_by_subscriber(db: Session, subscriber_id: int): @@ -143,6 +148,40 @@ def get_connections_limit(db: Session, subscriber_id: int): return mapping[db_subscriber.level] +def verify_subscriber_link(db: Session, url: str): + """Check if a given url is a valid signed subscriber profile link + Return subscriber if valid. + """ + # Look for a followed by an optional signature that ends the string + pattern = r"[\/]([\w\d\-_\.\@]+)[\/]?([\w\d]*)[\/]?$" + match = re.findall(pattern, url) + + if match is None or len(match) == 0: + return False + + # Flatten + match = match[0] + clean_url = url + + username = match[0] + signature = None + if len(match) > 1: + signature = match[1] + clean_url = clean_url.replace(signature, "") + + subscriber = get_subscriber_by_username(db, username) + if not subscriber: + return False + + clean_url_with_short_link = clean_url + f"{subscriber.short_link_hash}" + signed_signature = sign_url(clean_url_with_short_link) + + # Verify the signature matches the incoming one + if signed_signature == signature: + return subscriber + return False + + """ CALENDAR repository functions """ @@ -206,11 +245,14 @@ def update_subscriber_calendar(db: Session, calendar: schemas.CalendarConnection """update existing calendar by id""" db_calendar = get_calendar(db, calendar_id) + # list of all attributes that must never be updated + # # because they have dedicated update functions for security reasons ignore = ["connected", "connected_at"] + # list of all attributes that will keep their current value if None is passed keep_if_none = ["password"] for key, value in calendar: - # if this key exists in the keep_if_none list, then ignore it + # skip update, if attribute is ignored or current value should be kept if given value is falsey/empty if key in ignore or (key in keep_if_none and (not value or len(str(value)) == 0)): continue @@ -420,3 +462,59 @@ def slot_is_available(db: Session, slot_id: int): """check if slot is still available""" db_slot = get_slot(db, slot_id) return db_slot and not db_slot.attendee_id and not db_slot.subscriber_id + + +"""SCHEDULES repository functions +""" + + +def create_calendar_schedule(db: Session, schedule: schemas.ScheduleBase): + """create new schedule with slots for calendar""" + db_schedule = models.Schedule(**schedule.dict()) + db.add(db_schedule) + db.commit() + db.refresh(db_schedule) + return db_schedule + + +def get_schedules_by_subscriber(db: Session, subscriber_id: int): + """Get schedules by subscriber id. Should be only one for now (general availability).""" + return ( + db.query(models.Schedule) + .join(models.Calendar, models.Schedule.calendar_id == models.Calendar.id) + .filter(models.Calendar.owner_id == subscriber_id) + .all() + ) + + +def get_schedule(db: Session, schedule_id: int): + """retrieve schedule by id""" + if schedule_id: + return db.get(models.Schedule, schedule_id) + return None + + +def schedule_is_owned(db: Session, schedule_id: int, subscriber_id: int): + """check if the given schedule belongs to subscriber""" + schedules = get_schedules_by_subscriber(db, subscriber_id) + return any(s.id == schedule_id for s in schedules) + + +def schedule_exists(db: Session, schedule_id: int): + """true if schedule of given id exists""" + return True if get_schedule(db, schedule_id) is not None else False + + +def update_calendar_schedule(db: Session, schedule: schemas.ScheduleBase, schedule_id: int): + """update existing schedule by id""" + db_schedule = get_schedule(db, schedule_id) + for key, value in schedule: + setattr(db_schedule, key, value) + db.commit() + db.refresh(db_schedule) + return db_schedule + + +def get_availability_by_schedule(db: Session, schedule_id: int): + """retrieve availability by schedule id""" + return db.query(models.Availability).filter(models.Availability.schedule_id == schedule_id).all() diff --git a/backend/src/database/schemas.py b/backend/src/database/schemas.py index 7466be66..6c31f45f 100644 --- a/backend/src/database/schemas.py +++ b/backend/src/database/schemas.py @@ -2,14 +2,13 @@ Definitions of valid data shapes for database and query models. """ -from datetime import datetime +from datetime import datetime, date, time from pydantic import BaseModel, Field from .models import ( SubscriberLevel, AppointmentStatus, LocationType, CalendarProvider, - AppointmentType, DayOfWeek, random_slug, ) @@ -53,7 +52,7 @@ class Config: class SlotOut(SlotBase): - id: int + id: int | None = None class SlotAttendee(BaseModel): @@ -69,7 +68,6 @@ class AppointmentBase(BaseModel): title: str details: str | None = None slug: str | None = Field(default_factory=random_slug) - appointment_type: AppointmentType | None = AppointmentType.oneoff class AppointmentFull(AppointmentBase): @@ -96,11 +94,64 @@ class Config: class AppointmentOut(AppointmentBase): - id: int + id: int | None = None owner_name: str | None = None slots: list[SlotOut] = [] +""" SCHEDULE model schemas +""" + + +class AvailabilityBase(BaseModel): + schedule_id: int + day_of_week: DayOfWeek + start_time: datetime | None = None + end_time: datetime | None = None + min_time_before_meeting: int + slot_duration: int | None = None + + +class Availability(AvailabilityBase): + id: int + time_created: datetime | None = None + time_updated: datetime | None = None + + class Config: + orm_mode = True + + +class ScheduleBase(BaseModel): + name: str + calendar_id: int + location_type: LocationType | None = LocationType.inperson + location_url: str | None = None + details: str | None = None + start_date: date | None = None + end_date: date | None = None + start_time: time | None = None + end_time: time | None = None + earliest_booking: int | None = None + farthest_booking: int | None = None + weekdays: list[int] | None = [1, 2, 3, 4, 5] + slot_duration: int | None = None + + class Config: + json_encoders = { + time: lambda t: t.strftime("%H:%M"), + } + + +class Schedule(ScheduleBase): + id: int + time_created: datetime | None = None + time_updated: datetime | None = None + availabilities: list[Availability] = [] + + class Config: + orm_mode = True + + """ CALENDAR model schemas """ @@ -125,6 +176,7 @@ class Calendar(CalendarConnection): id: int owner_id: int appointments: list[Appointment] = [] + schedules: list[Schedule] = [] class Config: orm_mode = True @@ -199,35 +251,3 @@ class FileDownload(BaseModel): name: str content_type: str data: str - - -class ScheduleBase(BaseModel): - name: str - - -class Schedule(ScheduleBase): - id: int - appointment_id: int - time_created: datetime | None = None - time_updated: datetime | None = None - - class Config: - orm_mode = True - - -class AvailabilityBase(BaseModel): - schedule_id: int - day_of_week: DayOfWeek - start_time: datetime | None = None - end_time: datetime | None = None - min_time_before_meeting: int - duration: int | None = None - - -class Availability(AvailabilityBase): - id: int - time_created: datetime | None = None - time_updated: datetime | None = None - - class Config: - orm_mode = True diff --git a/backend/src/main.py b/backend/src/main.py index 64384bdb..67ebaae9 100755 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -55,9 +55,10 @@ models.Base.metadata.create_all(bind=engine) # extra routes -from .routes import google from .routes import api from .routes import account +from .routes import google +from .routes import schedule # init app app = FastAPI() @@ -97,3 +98,4 @@ async def catch_google_refresh_errors(request, exc): app.include_router(api.router) app.include_router(account.router, prefix="/account") app.include_router(google.router, prefix="/google") +app.include_router(schedule.router, prefix="/schedule") diff --git a/backend/src/migrations/versions/2023_06_27_1108-f9660871710e_add_general_availability_tables.py b/backend/src/migrations/versions/2023_06_27_1108-f9660871710e_add_general_availability_tables.py index bc3cfd8a..cd11f66f 100644 --- a/backend/src/migrations/versions/2023_06_27_1108-f9660871710e_add_general_availability_tables.py +++ b/backend/src/migrations/versions/2023_06_27_1108-f9660871710e_add_general_availability_tables.py @@ -11,7 +11,8 @@ from sqlalchemy import DateTime from sqlalchemy_utils import StringEncryptedType from sqlalchemy_utils.types.encrypted.encrypted_type import AesEngine -from database.models import AppointmentType + +# from database.models import AppointmentType def secret(): @@ -65,17 +66,17 @@ def upgrade() -> None: sa.Column("time_created", DateTime()), sa.Column("time_updated", DateTime()), ) - op.add_column( - "appointments", - sa.Column( - "appointment_type", - sa.Enum(AppointmentType), - default=AppointmentType.schedule, - ), - ) + # op.add_column( + # "appointments", + # sa.Column( + # "appointment_type", + # sa.Enum(AppointmentType), + # default=AppointmentType.schedule, + # ), + # ) def downgrade() -> None: op.drop_table("schedules") op.drop_table("availabilities") - op.drop_column("appointments", "appointment_type") + # op.drop_column("appointments", "appointment_type") diff --git a/backend/src/migrations/versions/2023_07_27_1102-f9c5471478d0_modify_schedules_table.py b/backend/src/migrations/versions/2023_07_27_1102-f9c5471478d0_modify_schedules_table.py new file mode 100644 index 00000000..6abe54b3 --- /dev/null +++ b/backend/src/migrations/versions/2023_07_27_1102-f9c5471478d0_modify_schedules_table.py @@ -0,0 +1,63 @@ +"""modify_schedules_table + +Revision ID: f9c5471478d0 +Revises: f9660871710e +Create Date: 2023-07-27 11:02:39.900134 + +""" +import os +from alembic import op +import sqlalchemy as sa +from sqlalchemy import DateTime +from sqlalchemy_utils import StringEncryptedType +from sqlalchemy_utils.types.encrypted.encrypted_type import AesEngine +from database.models import LocationType + + +def secret(): + return os.getenv("DB_SECRET") + + +# revision identifiers, used by Alembic. +revision = "f9c5471478d0" +down_revision = "f9660871710e" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.drop_column("appointments", "appointment_type") + op.drop_constraint("schedules_ibfk_1", "schedules", type_="foreignkey") + op.drop_column("schedules", "appointment_id") + op.add_column("schedules", sa.Column("calendar_id", sa.Integer, sa.ForeignKey("calendars.id"))) + op.add_column("schedules", sa.Column("location_type", sa.Enum(LocationType), default=LocationType.online)) + op.add_column( + "schedules", sa.Column("location_url", StringEncryptedType(sa.String, secret, AesEngine, "pkcs5", length=2048)) + ) + op.add_column( + "schedules", sa.Column("details", StringEncryptedType(sa.String, secret, AesEngine, "pkcs5", length=255)) + ) + op.add_column( + "schedules", + sa.Column("start_date", StringEncryptedType(sa.Date, secret, AesEngine, "pkcs5", length=255), index=True), + ) + op.add_column( + "schedules", + sa.Column("end_date", StringEncryptedType(sa.Date, secret, AesEngine, "pkcs5", length=255), index=True), + ) + op.add_column( + "schedules", + sa.Column("start_time", StringEncryptedType(sa.Time, secret, AesEngine, "pkcs5", length=255), index=True), + ) + op.add_column( + "schedules", + sa.Column("end_time", StringEncryptedType(sa.Time, secret, AesEngine, "pkcs5", length=255), index=True), + ) + op.add_column("schedules", sa.Column("earliest_booking", sa.Integer, default=1440)) + op.add_column("schedules", sa.Column("farthest_booking", sa.Integer, default=20160)) + op.add_column("schedules", sa.Column("weekdays", sa.JSON)) + op.add_column("schedules", sa.Column("slot_duration", sa.Integer, default=30)) + + +def downgrade() -> None: + pass diff --git a/backend/src/routes/api.py b/backend/src/routes/api.py index 97c99a56..7fffeae7 100644 --- a/backend/src/routes/api.py +++ b/backend/src/routes/api.py @@ -2,7 +2,6 @@ import secrets import validators -import re # database from sqlalchemy.orm import Session @@ -14,7 +13,6 @@ from fastapi import APIRouter, Depends, HTTPException, Security, Body from fastapi_auth0 import Auth0User from datetime import timedelta -from ..database.schemas import EventLocation from ..controller.google_client import GoogleClient from ..controller.auth import sign_url from ..database.models import Subscriber, CalendarProvider @@ -120,37 +118,11 @@ def refresh_signature(db: Session = Depends(get_db), subscriber: Subscriber = De @router.post("/verify/signature") -def verify_my_signature(url: str = Body(..., embed=True), db: Session = Depends(get_db)): +def verify_signature(url: str = Body(..., embed=True), db: Session = Depends(get_db)): """Verify a signed short link""" - # Look for a followed by an optional signature that ends the string - pattern = r"[\/]([\w\d\-_\.\@]+)[\/]?([\w\d]*)[\/]?$" - match = re.findall(pattern, url) - - if match is None or len(match) == 0: - raise HTTPException(400, "Unable to validate signature") - - # Flatten - match = match[0] - clean_url = url - - username = match[0] - signature = None - if len(match) > 1: - signature = match[1] - clean_url = clean_url.replace(signature, "") - - subscriber = repo.get_subscriber_by_username(db, username) - if not subscriber: - raise HTTPException(400, "Unable to validate signature") - - clean_url_with_short_link = clean_url + f"{subscriber.short_link_hash}" - signed_signature = sign_url(clean_url_with_short_link) - - # Verify the signature matches the incoming one - if signed_signature == signature: + if repo.verify_subscriber_link(db, url): return True - - raise HTTPException(400, "Invalid signature") + raise HTTPException(400, "Invalid link") @router.post("/cal", response_model=schemas.CalendarOut) @@ -292,7 +264,6 @@ def sync_remote_calendars( return True - @router.get("/rmt/cal/{id}/{start}/{end}", response_model=list[schemas.Event]) def read_remote_events( id: int, @@ -428,7 +399,7 @@ def update_public_appointment_slot( start=slot.start.isoformat(), end=(slot.start + timedelta(minutes=slot.duration)).isoformat(), description=db_appointment.details, - location=EventLocation( + location=schemas.EventLocation( type=db_appointment.location_type, suggestions=db_appointment.location_suggestions, selected=db_appointment.location_selected, diff --git a/backend/src/routes/schedule.py b/backend/src/routes/schedule.py new file mode 100644 index 00000000..31a9cd4b --- /dev/null +++ b/backend/src/routes/schedule.py @@ -0,0 +1,120 @@ +from fastapi import APIRouter, Depends, HTTPException, Body +import logging + +from sqlalchemy.orm import Session +from ..controller.calendar import CalDavConnector, Tools, GoogleConnector +from ..controller.google_client import GoogleClient +from ..database import repo, schemas +from ..database.models import Subscriber, Schedule, CalendarProvider +from ..dependencies.auth import get_subscriber +from ..dependencies.database import get_db +from ..dependencies.google import get_google_client + +router = APIRouter() + + +@router.post("/", response_model=schemas.Schedule) +def create_calendar_schedule( + schedule: schemas.ScheduleBase, db: Session = Depends(get_db), subscriber: Subscriber = Depends(get_subscriber) +): + """endpoint to add a new schedule for a given calendar""" + if not subscriber: + raise HTTPException(status_code=401, detail="No valid authentication credentials provided") + if not repo.calendar_exists(db, calendar_id=schedule.calendar_id): + raise HTTPException(status_code=404, detail="Calendar not found") + if not repo.calendar_is_owned(db, calendar_id=schedule.calendar_id, subscriber_id=subscriber.id): + raise HTTPException(status_code=403, detail="Calendar not owned by subscriber") + if not repo.calendar_is_connected(db, calendar_id=schedule.calendar_id): + raise HTTPException(status_code=403, detail="Calendar connection is not active") + return repo.create_calendar_schedule(db=db, schedule=schedule) + + +@router.get("/", response_model=list[schemas.Schedule]) +def read_schedules(db: Session = Depends(get_db), subscriber: Subscriber = Depends(get_subscriber)): + """Gets all of the available schedules for the logged in subscriber (only one for the time being)""" + if not subscriber: + raise HTTPException(status_code=401, detail="No valid authentication credentials provided") + return repo.get_schedules_by_subscriber(db, subscriber_id=subscriber.id) + + +@router.get("/{id}", response_model=schemas.Schedule) +def read_schedule( + id: int, + db: Session = Depends(get_db), + subscriber: Subscriber = Depends(get_subscriber), +): + """Gets information regarding a specific schedule""" + if not subscriber: + raise HTTPException(status_code=401, detail="No valid authentication credentials provided") + schedule = repo.get_schedule(db, schedule_id=id) + if schedule is None: + raise HTTPException(status_code=404, detail="Schedule not found") + if not repo.schedule_is_owned(db, schedule_id=id, subscriber_id=subscriber.id): + raise HTTPException(status_code=403, detail="Schedule not owned by subscriber") + return schedule + + +@router.put("/{id}", response_model=schemas.Schedule) +def update_schedule( + id: int, + schedule: schemas.ScheduleBase, + db: Session = Depends(get_db), + subscriber: Subscriber = Depends(get_subscriber), +): + """endpoint to update an existing calendar connection for authenticated subscriber""" + if not subscriber: + raise HTTPException(status_code=401, detail="No valid authentication credentials provided") + if not repo.schedule_exists(db, schedule_id=id): + raise HTTPException(status_code=404, detail="Schedule not found") + if not repo.schedule_is_owned(db, schedule_id=id, subscriber_id=subscriber.id): + raise HTTPException(status_code=403, detail="Schedule not owned by subscriber") + return repo.update_calendar_schedule(db=db, schedule=schedule, schedule_id=id) + + +@router.post("/public/availability", response_model=schemas.AppointmentOut) +def read_schedule_availabilities( + url: str = Body(..., embed=True), + db: Session = Depends(get_db), + google_client: GoogleClient = Depends(get_google_client), +): + """Returns the calculated availability for the first schedule from a subscribers public profile link""" + subscriber = repo.verify_subscriber_link(db, url) + if not subscriber: + raise HTTPException(status_code=401, detail="Invalid profile link") + schedules = repo.get_schedules_by_subscriber(db, subscriber_id=subscriber.id) + try: + schedule = schedules[0] # for now we only process the first existing schedule + except KeyError: + raise HTTPException(status_code=404, detail="Schedule not found") + # calculate theoretically possible slots from schedule config + availableSlots = Tools.available_slots_from_schedule(schedule) + # get all events from all connected calendars in scheduled date range + existingEvents = [] + calendars = repo.get_calendars_by_subscriber(db, subscriber.id, False) + if not calendars or len(calendars) == 0: + raise HTTPException(status_code=404, detail="No calendars found") + for calendar in calendars: + if calendar is None: + raise HTTPException(status_code=404, detail="Calendar not found") + if calendar.provider == CalendarProvider.google: + con = GoogleConnector( + db=db, + google_client=google_client, + calendar_id=calendar.user, + subscriber_id=subscriber.id, + google_tkn=subscriber.google_tkn, + ) + else: + con = CalDavConnector(calendar.url, calendar.user, calendar.password) + existingEvents.extend( + con.list_events(schedule.start_date.strftime("%Y-%m-%d"), schedule.end_date.strftime("%Y-%m-%d")) + ) + actualSlots = Tools.events_set_difference(availableSlots, existingEvents) + if not actualSlots or len(actualSlots) == 0: + raise HTTPException(status_code=404, detail="No possible booking slots found") + return schemas.AppointmentOut( + title=schedule.name, + details=schedule.details, + owner_name=subscriber.name, + slots=actualSlots, + ) diff --git a/backend/test/test_main.py b/backend/test/test_main.py index 656ea9ec..5799236b 100644 --- a/backend/test/test_main.py +++ b/backend/test/test_main.py @@ -1,4 +1,5 @@ import json +import time from os import getenv as conf from fastapi.testclient import TestClient @@ -17,11 +18,13 @@ SQLALCHEMY_DATABASE_URL = "sqlite:///test/test.db" -DAY1 = datetime.today().strftime("%Y-%m-%d") -DAY2 = (datetime.today() + timedelta(days=1)).strftime("%Y-%m-%d") -DAY3 = (datetime.today() + timedelta(days=2)).strftime("%Y-%m-%d") -DAY4 = (datetime.today() + timedelta(days=3)).strftime("%Y-%m-%d") -DAY5 = (datetime.today() + timedelta(days=4)).strftime("%Y-%m-%d") +now = datetime.today() +DAY1 = now.strftime("%Y-%m-%d") +DAY2 = (now + timedelta(days=1)).strftime("%Y-%m-%d") +DAY3 = (now + timedelta(days=2)).strftime("%Y-%m-%d") +DAY5 = (now + timedelta(days=4)).strftime("%Y-%m-%d") +DAY14 = (now + timedelta(days=13)).strftime("%Y-%m-%d") + engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}) TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) @@ -149,6 +152,7 @@ def test_first_login(): def test_second_login(): + time.sleep(1) # to not run into Auth0 query limitations response = client.get("/login", headers=headers) assert response.status_code == 200, response.text data = response.json() @@ -503,6 +507,78 @@ def test_connect_more_calendars_than_tier_allows(): db.commit() response = client.post("/cal/3/connect", headers=headers) assert response.status_code == 403, response.text + # delete test calendar connections for further testing + client.delete("/cal/5", headers=headers) + client.delete("/cal/6", headers=headers) + + +""" CALENDARS tests (Google) +""" + + +def test_google_auth(): + response = client.get("/google/auth?email=" + conf("GOOGLE_TEST_USER"), headers=headers) + assert response.status_code == 200, response.text + url = response.json() + urlobj = urlparse(url) + params = parse_qs(urlobj.query) + assert urlobj.scheme == "https" + assert urlobj.hostname == "accounts.google.com" + assert params["client_id"][0] == conf("GOOGLE_AUTH_CLIENT_ID") + assert params["login_hint"][0] == conf("GOOGLE_TEST_USER") + + +# TODO +# def test_read_remote_google_calendars(): +# response = client.post("/rmt/sync", headers=headers) +# assert response.status_code == 200, response.text +# assert response.json() + + +# TODO +# def test_create_google_calendar(): +# response = client.post( +# "/cal", +# json={ +# "title": "First Google calendar", +# "color": "#123456", +# "provider": CalendarProvider.google.value, +# "connected": False, +# }, +# headers=headers, +# ) +# assert response.status_code == 200, response.text +# data = response.json() +# assert data["title"] == "First Google calendar" +# assert data["color"] == "#123456" +# assert not data["connected"] + + +# TODO +# def test_read_existing_google_calendar(): +# response = client.get("/cal/1", headers=headers) +# assert response.status_code == 200, response.text +# data = response.json() +# assert data["title"] == "First Google calendar" +# assert data["color"] == "#123456" +# assert data["provider"] == CalendarProvider.google.value +# assert data["url"] == conf("Google_TEST_CALENDAR_URL") +# assert data["user"] == conf("Google_TEST_USER") +# assert not data["connected"] +# assert "password" not in data + + +# TODO +# def test_connect_google_calendar(): +# response = client.post("/cal/1/connect", headers=headers) +# assert response.status_code == 200, response.text +# data = response.json() +# assert data["title"] == "First modified Google calendar" +# assert data["color"] == "#234567" +# assert data["connected"] +# assert "url" not in data +# assert "user" not in data +# assert "password" not in data """ APPOINTMENT tests @@ -524,7 +600,6 @@ def test_create_appointment_on_connected_calendar(): "details": "Lorem Ipsum", "status": 2, "keep_open": True, - "appointment_type": 1, }, "slots": [ {"start": DAY1 + " 09:00:00", "duration": 60}, @@ -549,7 +624,6 @@ def test_create_appointment_on_connected_calendar(): assert data["slug"] is not None, len(data["slug"]) > 8 assert data["status"] == 2 assert data["keep_open"] - assert data["appointment_type"] == 1 assert len(data["slots"]) == 3 assert data["slots"][0]["start"] == DAY1 + "T09:00:00" assert data["slots"][0]["duration"] == 60 @@ -571,7 +645,7 @@ def test_create_appointment_on_unconnected_calendar(): assert response.status_code == 403, response.text -def test_create_missing_calendar_appointment(): +def test_create_appointment_on_missing_calendar(): response = client.post( "/apmt", json={ @@ -583,7 +657,7 @@ def test_create_missing_calendar_appointment(): assert response.status_code == 404, response.text -def test_create_foreign_calendar_appointment(): +def test_create_appointment_on_foreign_calendar(): response = client.post( "/apmt", json={ @@ -615,7 +689,6 @@ def test_read_appointments(): assert data["slug"] is not None, len(data["slug"]) > 8 assert data["status"] == 2 assert data["keep_open"] - assert data["appointment_type"] == 1 assert len(data["slots"]) == 3 assert data["slots"][0]["start"] == DAY1 + "T09:00:00" assert data["slots"][0]["duration"] == 60 @@ -642,7 +715,6 @@ def test_read_existing_appointment(): assert data["slug"] is not None, len(data["slug"]) > 8 assert data["status"] == 2 assert data["keep_open"] - assert data["appointment_type"] == 1 assert len(data["slots"]) == 3 assert data["slots"][0]["start"] == DAY1 + "T09:00:00" assert data["slots"][0]["duration"] == 60 @@ -681,7 +753,6 @@ def test_update_existing_appointment(): "details": "Lorem Ipsumx", "status": 1, "keep_open": False, - "appointment_type": 2, }, "slots": [ {"start": DAY1 + " 11:00:00", "duration": 30}, @@ -706,7 +777,6 @@ def test_update_existing_appointment(): assert data["slug"] is not None, len(data["slug"]) > 8 assert data["status"] == 1 assert not data["keep_open"] - assert data["appointment_type"] == 2 assert len(data["slots"]) == 3 assert data["slots"][0]["start"] == DAY1 + "T11:00:00" assert data["slots"][0]["duration"] == 30 @@ -757,7 +827,6 @@ def test_delete_existing_appointment(): assert data["slug"] is not None, len(data["slug"]) > 8 assert data["status"] == 1 assert not data["keep_open"] - assert data["appointment_type"] == 2 assert len(data["slots"]) == 3 assert data["slots"][0]["start"] == DAY1 + "T11:00:00" assert data["slots"][0]["duration"] == 30 @@ -787,7 +856,6 @@ def test_delete_existing_appointment(): "details": "Lorem Ipsum", "status": 2, "keep_open": True, - "appointment_type": 1, "slug": "abcdef", }, "slots": [ @@ -819,7 +887,6 @@ def test_read_public_existing_appointment(): assert data["title"] == "Appointment" assert data["details"] == "Lorem Ipsum" assert data["slug"] == "abcdef" - assert data["appointment_type"] == 1 assert data["owner_name"] == "Test Account" assert len(data["slots"]) == 3 assert data["slots"][0]["start"] == DAY1 + "T09:00:00" @@ -861,7 +928,7 @@ def test_read_public_appointment_after_attendee_selection(): assert data["slots"][0]["attendee_id"] == 1 -def test_attendee_selects_unavailable_appointment_slot(): +def test_attendee_selects_slot_of_unavailable_appointment(): response = client.put( "/apmt/public/abcdef", json={"slot_id": 1, "attendee": {"email": "a", "name": "b"}}, @@ -869,7 +936,7 @@ def test_attendee_selects_unavailable_appointment_slot(): assert response.status_code == 403, response.text -def test_attendee_selects_missing_appointment_slot(): +def test_attendee_selects_slot_of_missing_appointment(): response = client.put( "/apmt/public/missing", json={"slot_id": 1, "attendee": {"email": "a", "name": "b"}}, @@ -877,7 +944,7 @@ def test_attendee_selects_missing_appointment_slot(): assert response.status_code == 404, response.text -def test_attendee_selects_appointment_missing_slot(): +def test_attendee_selects_missing_slot_of_existing_appointment(): response = client.put( "/apmt/public/abcdef", json={"slot_id": 999, "attendee": {"email": "a", "name": "b"}}, @@ -907,74 +974,248 @@ def test_get_remote_caldav_events(): assert n == 1 -""" CALENDARS tests (Google) +""" SCHEDULE tests """ -def test_google_auth(): - response = client.get("/google/auth?email=" + conf("GOOGLE_TEST_USER"), headers=headers) +def test_create_schedule_on_connected_calendar(): + response = client.post( + "/schedule", + json={ + "calendar_id": 4, + "name": "Schedule", + "location_type": 2, + "location_url": "https://test.org", + "details": "Lorem Ipsum", + "start_date": DAY1, + "end_date": DAY14, + "start_time": "10:00", + "end_time": "18:00", + "earliest_booking": 1440, + "farthest_booking": 20160, + "weekdays": [1, 2, 3, 4, 5], + "slot_duration": 30, + }, + headers=headers, + ) assert response.status_code == 200, response.text - url = response.json() - urlobj = urlparse(url) - params = parse_qs(urlobj.query) - assert urlobj.scheme == "https" - assert urlobj.hostname == "accounts.google.com" - assert params['client_id'][0] == conf("GOOGLE_AUTH_CLIENT_ID") - assert params['client_id'][0] - assert params['login_hint'][0] == conf("GOOGLE_TEST_USER") + data = response.json() + assert data["time_created"] is not None + assert data["time_updated"] is not None + assert data["calendar_id"] == 4 + assert data["name"] == "Schedule" + assert data["location_type"] == 2 + assert data["location_url"] == "https://test.org" + assert data["details"] == "Lorem Ipsum" + assert data["start_date"] == DAY1 + assert data["end_date"] == DAY14 + assert data["start_time"] == "10:00" + assert data["end_time"] == "18:00" + assert data["earliest_booking"] == 1440 + assert data["farthest_booking"] == 20160 + assert data["weekdays"] is not None + weekdays = data["weekdays"] + assert len(weekdays) == 5 + assert weekdays == [1, 2, 3, 4, 5] + assert data["slot_duration"] == 30 + + +def test_create_schedule_on_unconnected_calendar(): + response = client.post( + "/schedule", + json={"calendar_id": 3, "name": "Schedule"}, + headers=headers, + ) + assert response.status_code == 403, response.text -# TODO -# def test_read_remote_google_calendars(): -# response = client.post("/rmt/sync", headers=headers) -# assert response.status_code == 200, response.text -# assert response.json() +def test_create_schedule_on_missing_calendar(): + response = client.post( + "/schedule", + json={"calendar_id": 999, "name": "Schedule"}, + headers=headers, + ) + assert response.status_code == 404, response.text + + +def test_create_schedule_on_foreign_calendar(): + response = client.post( + "/schedule", + json={"calendar_id": 2, "name": "Schedule"}, + headers=headers, + ) + assert response.status_code == 403, response.text + + +def test_read_schedules(): + response = client.get("/schedule", headers=headers) + assert response.status_code == 200, response.text + data = response.json() + assert type(data) is list + assert len(data) == 1 + data = data[0] + assert data["time_created"] is not None + assert data["time_updated"] is not None + assert data["calendar_id"] == 4 + assert data["name"] == "Schedule" + assert data["location_type"] == 2 + assert data["location_url"] == "https://test.org" + assert data["details"] == "Lorem Ipsum" + assert data["start_date"] == DAY1 + assert data["end_date"] == DAY14 + assert data["start_time"] == "10:00" + assert data["end_time"] == "18:00" + assert data["earliest_booking"] == 1440 + assert data["farthest_booking"] == 20160 + assert data["weekdays"] is not None + weekdays = data["weekdays"] + assert len(weekdays) == 5 + assert weekdays == [1, 2, 3, 4, 5] + assert data["slot_duration"] == 30 + + +def test_read_existing_schedule(): + response = client.get("/schedule/1", headers=headers) + assert response.status_code == 200, response.text + data = response.json() + assert data["time_created"] is not None + assert data["time_updated"] is not None + assert data["calendar_id"] == 4 + assert data["name"] == "Schedule" + assert data["location_type"] == 2 + assert data["location_url"] == "https://test.org" + assert data["details"] == "Lorem Ipsum" + assert data["start_date"] == DAY1 + assert data["end_date"] == DAY14 + assert data["start_time"] == "10:00" + assert data["end_time"] == "18:00" + assert data["earliest_booking"] == 1440 + assert data["farthest_booking"] == 20160 + assert data["weekdays"] is not None + weekdays = data["weekdays"] + assert len(weekdays) == 5 + assert weekdays == [1, 2, 3, 4, 5] + assert data["slot_duration"] == 30 + + +def test_read_missing_schedule(): + response = client.get("/schedule/999", headers=headers) + assert response.status_code == 404, response.text + + +def test_read_foreign_schedule(): + stmt = insert(models.Schedule).values(calendar_id=2, name="abc") + db = TestingSessionLocal() + db.execute(stmt) + db.commit() + response = client.get("/schedule/2", headers=headers) + assert response.status_code == 403, response.text + + +def test_update_existing_schedule(): + response = client.put( + "/schedule/1", + json={ + "calendar_id": 4, + "name": "Schedulex", + "location_type": 1, + "location_url": "https://testx.org", + "details": "Lorem Ipsumx", + "start_date": DAY2, + "end_date": DAY5, + "start_time": "09:00", + "end_time": "17:00", + "earliest_booking": 1000, + "farthest_booking": 20000, + "weekdays": [2, 4, 6], + "slot_duration": 60, + }, + headers=headers, + ) + assert response.status_code == 200, response.text + data = response.json() + assert data["time_created"] is not None + assert data["time_updated"] is not None + assert data["calendar_id"] == 4 + assert data["name"] == "Schedulex" + assert data["location_type"] == 1 + assert data["location_url"] == "https://testx.org" + assert data["details"] == "Lorem Ipsumx" + assert data["start_date"] == DAY2 + assert data["end_date"] == DAY5 + assert data["start_time"] == "09:00" + assert data["end_time"] == "17:00" + assert data["earliest_booking"] == 1000 + assert data["farthest_booking"] == 20000 + assert data["weekdays"] is not None + weekdays = data["weekdays"] + assert len(weekdays) == 3 + assert weekdays == [2, 4, 6] + assert data["slot_duration"] == 60 + + +def test_update_missing_schedule(): + response = client.put( + "/schedule/999", + json={"calendar_id": 1, "name": "Schedule"}, + headers=headers, + ) + assert response.status_code == 404, response.text + + +def test_update_foreign_schedule(): + response = client.put( + "/schedule/2", + json={"calendar_id": 2, "name": "Schedule"}, + headers=headers, + ) + assert response.status_code == 403, response.text + + +def test_read_schedule_availabilities(): + response = client.get("/me/signature", headers=headers) + url = response.json()["url"] + response = client.post("/schedule/public/availability", json={"url": url}) + assert response.status_code == 200, response.text + data = response.json() + assert data["title"] == "Schedulex" + assert data["details"] == "Lorem Ipsumx" + assert data["owner_name"] == "Test Account" + assert len(data["slots"]) > 5 + # TODO: some more assertions are needed here to check for correct slot generation, like: + # no slots on unchecked weekdays + # no slots before start or earliest start + # no slots after end or farthest end + # no slots during existing events + + +def test_read_schedule_availabilities_from_invalid_link(): + response = client.post("/schedule/public/availability", json={"url": "https://evil.corp"}) + assert response.status_code == 401, response.text # TODO -# def test_create_google_calendar(): -# response = client.post( -# "/cal", -# json={ -# "title": "First Google calendar", -# "color": "#123456", -# "provider": CalendarProvider.google.value, -# "connected": False, -# }, -# headers=headers, -# ) -# assert response.status_code == 200, response.text -# data = response.json() -# assert data["title"] == "First Google calendar" -# assert data["color"] == "#123456" -# assert not data["connected"] +# def test_read_schedule_availabilities_with_no_connected_calendars(): +# response = client.get("/me/signature", headers=headers) +# url = response.json()["url"] +# response = client.post("/schedule/public/availability", json={"url": url}) +# assert response.status_code == 404, response.text # TODO -# def test_read_existing_google_calendar(): -# response = client.get("/cal/1", headers=headers) -# assert response.status_code == 200, response.text -# data = response.json() -# assert data["title"] == "First Google calendar" -# assert data["color"] == "#123456" -# assert data["provider"] == CalendarProvider.google.value -# assert data["url"] == conf("Google_TEST_CALENDAR_URL") -# assert data["user"] == conf("Google_TEST_USER") -# assert not data["connected"] -# assert "password" not in data +# def test_read_schedule_availabilities_with_no_existing_schedule(): +# response = client.get("/me/signature", headers=headers) +# url = response.json()["url"] +# response = client.post("/schedule/public/availability", json={"url": url}) +# assert response.status_code == 404, response.text # TODO -# def test_connect_google_calendar(): -# response = client.post("/cal/1/connect", headers=headers) -# assert response.status_code == 200, response.text -# data = response.json() -# assert data["title"] == "First modified Google calendar" -# assert data["color"] == "#234567" -# assert data["connected"] -# assert "url" not in data -# assert "user" not in data -# assert "password" not in data +# def test_read_schedule_availabilities_with_no_actual_booking_slots(): +# response = client.get("/me/signature", headers=headers) +# url = response.json()["url"] +# response = client.post("/schedule/public/availability", json={"url": url}) +# assert response.status_code == 404, response.text """ MISCELLANEOUS tests @@ -998,7 +1239,3 @@ def test_get_invitation_ics_file_for_missing_appointment(): def test_get_invitation_ics_file_for_missing_slot(): response = client.get("/serve/ics/abcdef/999") assert response.status_code == 404, response.text - - -# TDOD: add sync calendar tests post'rmt/sync' -# TDOD: add scheduling/availability tests diff --git a/docs/README.md b/docs/README.md index 04f5ffc4..e7373633 100644 --- a/docs/README.md +++ b/docs/README.md @@ -94,17 +94,27 @@ erDiagram string slug "Generated random string to build share links for the appointment" bool keep_open "If true appointment accepts selection of multiple slots (future feature)" enum status "Appointment state [draft, ready, close]" - enum appointment_type "Single or template appointment [Oneoff, schedule]" } - APPOINTMENTS ||--|| SCHEDULES : template_for + CALENDARS ||--|{ SCHEDULES : connected_to SCHEDULES { int id PK "Unique schedule key" - int appointment_id FK "Appointment serving as template for this schedule" + int calendar_id FK "Calendar which events are created in for this schedule" string name "Schedule title" + enum location_type "[In person, online]" + string location_url "URL events are held at" + string details "Detailed event description or agenda" + date start_date "UTC start date of scheduled date range" + date end_date "UTC end date of scheduled date range" + date start_time "UTC start time on selected weekdays" + date end_time "UTC end time on selected weekdays" + json weekdays "List of selected weekdays (1-7, ISO format)" + int earliest_booking "Can't book if it's less than this many minutes before start time" + int farthest_booking "Can't book if start time is more than this many minutes away" + int slot_duration "Size of the Slot that can be booked in minutes" date time_created "UTC timestamp of schedule creation" date time_updated "UTC timestamp of last schedule modification" } - SCHEDULES ||--|{ AVAILABILITIES : has + SCHEDULES ||--|{ AVAILABILITIES : hold_custom AVAILABILITIES { int id PK "Unique availability key" int schedule_id FK "Schedule this availability is for" diff --git a/frontend/src/views/BookingView.vue b/frontend/src/views/BookingView.vue index 6cb36c49..5cc57b94 100644 --- a/frontend/src/views/BookingView.vue +++ b/frontend/src/views/BookingView.vue @@ -311,20 +311,15 @@ const downloadIcs = async () => { // async get appointment data either from public single appointment link // or from a general availability link of a subscriber +// returns true if error occured const getAppointment = async () => { if (isAvailabilityRoute.value) { - const { error, data } = await call('verify/signature').post({ url: window.location.href }).json(); + const { error, data } = await call('schedule/public/availability').post({ url: window.location.href }).json(); if (error.value || !data.value) { return true; } else { - // TODO: here we need to make another API call to get the actual general appointment data or include it - // in the signature verification call. For now, here is fake example data for testing. - appointment.value = { - "title": "General Available", - "details": "These are the time slots that are currently free for you to choose.", - "owner_name": "Jane Doe", - "slots": [{ "start": "2023-07-03T08:00:00", "duration": 60, "attendee_id": null, "id": 9960 }, { "start": "2023-07-03T09:00:00", "duration": 60, "attendee_id": null, "id": 9961 }, { "start": "2023-07-03T11:00:00", "duration": 60, "attendee_id": null, "id": 9962 }, { "start": "2023-07-03T12:00:00", "duration": 60, "attendee_id": null, "id": 9963 }, { "start": "2023-07-03T13:00:00", "duration": 60, "attendee_id": null, "id": 9964 }, { "start": "2023-07-03T15:00:00", "duration": 60, "attendee_id": null, "id": 9965 }, { "start": "2023-07-04T08:00:00", "duration": 60, "attendee_id": null, "id": 9966 }, { "start": "2023-07-04T09:00:00", "duration": 60, "attendee_id": null, "id": 9967 }, { "start": "2023-07-04T10:00:00", "duration": 60, "attendee_id": null, "id": 9968 }, { "start": "2023-07-04T11:00:00", "duration": 60, "attendee_id": null, "id": 9969 }, { "start": "2023-07-04T12:00:00", "duration": 60, "attendee_id": null, "id": 9970 }, { "start": "2023-07-05T12:00:00", "duration": 60, "attendee_id": null, "id": 9971 }, { "start": "2023-07-05T13:00:00", "duration": 60, "attendee_id": null, "id": 9972 }, { "start": "2023-07-05T14:00:00", "duration": 60, "attendee_id": null, "id": 9973 }, { "start": "2023-07-05T15:00:00", "duration": 60, "attendee_id": null, "id": 9974 }, { "start": "2023-07-06T08:00:00", "duration": 60, "attendee_id": null, "id": 9975 }, { "start": "2023-07-06T15:00:00", "duration": 60, "attendee_id": null, "id": 9976 }, { "start": "2023-07-07T08:00:00", "duration": 60, "attendee_id": null, "id": 9977 }, { "start": "2023-07-07T09:00:00", "duration": 60, "attendee_id": null, "id": 9978 }, { "start": "2023-07-07T10:00:00", "duration": 60, "attendee_id": null, "id": 9979 }, { "start": "2023-07-07T11:00:00", "duration": 60, "attendee_id": null, "id": 9980 }, { "start": "2023-07-07T12:00:00", "duration": 60, "attendee_id": null, "id": 9981 }, { "start": "2023-07-07T13:00:00", "duration": 60, "attendee_id": null, "id": 9982 }, { "start": "2023-07-07T14:00:00", "duration": 60, "attendee_id": null, "id": 9983 }, { "start": "2023-07-07T15:00:00", "duration": 60, "attendee_id": null, "id": 9984}] - }; + // now assign the actual general appointment data that is returned. + appointment.value = data.value; } } else if (isBookingRoute.value) {