Skip to content

Commit

Permalink
Schedule API endpoints (#114)
Browse files Browse the repository at this point in the history
* ➕ add schedule API endpoints

* Get current backend tests (#122)

* General documentation (#116)

* ➕ add general documentation

* ➕ add general documentation

* 📜 update component chart

* Backend testing (#117)

* 🔨 updated pytests

* ➕ extend health and authentication tests

* ➕ add authentication for test user

* 📜 document testing in readme

* ➕ add subscriber related tests

* 👕 fix linter issues

* 🔨 prevent connecting calendars manually

* 🔨 check tier limit on connecting calendars

* ➕ add calendar related tests

* 🔨 only allow appointment creations on connected calendars

* 🔨 cascade delete attendees on slot deletion

* ➕ add appointment related tests

* 📜 add hint for smtp server for testing

* ➕ prepare google calendar tests

* ➕ add google test env vars

* 🔨 migrate data structure to current mockup

* ➕ endpoint for schedule creation

* ➕ add schedule test

* ❌ remove appointment type

* 🔨 improve model types and link verification

* ➕ time slots calculation from schedule config

* ➕ time slots comparison with remote events of assigned calendar

* ➕ compare schedule to all connected calendars

* ➕ check calendar connections first

* ➕ add test for invalid availability link

* ➕ check if actual booking slots exist
  • Loading branch information
devmount authored Aug 21, 2023
1 parent 6e8744c commit 221090d
Show file tree
Hide file tree
Showing 12 changed files with 768 additions and 183 deletions.
57 changes: 57 additions & 0 deletions backend/src/controller/calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
33 changes: 22 additions & 11 deletions backend/src/database/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand All @@ -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):
Expand Down Expand Up @@ -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)
Expand All @@ -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")
102 changes: 100 additions & 2 deletions backend/src/database/repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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 <username> 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
"""

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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()
Loading

0 comments on commit 221090d

Please sign in to comment.