Skip to content

Commit

Permalink
Features/341 use calendar specified not primary calendar (#344)
Browse files Browse the repository at this point in the history
* Add UUID fields to the appointments table.

* Remove /schedule/serve/ics. It wasn't actually being used.

* Use uuid as the ical's UID, and google's iCalUID. As well as name the remote calendar id as the organizer.

* Import the event instead of inserting it. This prevents the event from being duplicated on your primary google calendar.
  • Loading branch information
MelissaAutumn authored Apr 2, 2024
1 parent bd64f83 commit 3402495
Show file tree
Hide file tree
Showing 9 changed files with 101 additions and 87 deletions.
2 changes: 1 addition & 1 deletion backend/src/appointment/controller/apis/google_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ def create_event(self, calendar_id, body, token):
response = None
with build("calendar", "v3", credentials=token, cache_discovery=False) as service:
try:
response = service.events().insert(calendarId=calendar_id, body=body).execute()
response = service.events().import_(calendarId=calendar_id, body=body).execute()
except HttpError as e:
logging.warning(f"[google_client.create_event] Request Error: {e.status_code}/{e.error_details}")
raise EventNotCreatedException()
Expand Down
41 changes: 27 additions & 14 deletions backend/src/appointment/controller/calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
Handle connection to a CalDAV server.
"""
import json
import logging
import uuid
import zoneinfo
import os

Expand Down Expand Up @@ -35,7 +37,7 @@ def __init__(self, subscriber_id: int, calendar_id: int | None, redis_instance:
self.redis_instance = redis_instance
self.subscriber_id = subscriber_id
self.calendar_id = calendar_id

def obscure_key(self, key):
"""Obscure part of a key with our encryption algo"""
return utils.setup_encryption_engine().encrypt(key)
Expand All @@ -44,14 +46,14 @@ def get_key_body(self, only_subscriber = False):
parts = [self.obscure_key(self.subscriber_id)]
if not only_subscriber:
parts.append(self.obscure_key(self.calendar_id))

return ":".join(parts)

def get_cached_events(self, key_scope):
"""Retrieve any cached events, else returns None if redis is not available or there's no cache."""
if self.redis_instance is None:
return None

key_scope = self.obscure_key(key_scope)

encrypted_events = self.redis_instance.get(f'{REDIS_REMOTE_EVENTS_KEY}:{self.get_key_body()}:{key_scope}')
Expand Down Expand Up @@ -81,7 +83,7 @@ def bust_cached_events(self, all_calendars = False):

# Scan returns a tuple like: (Cursor start, [...keys found])
ret = self.redis_instance.scan(0, f'{REDIS_REMOTE_EVENTS_KEY}:{self.get_key_body(only_subscriber=all_calendars)}:*')

if len(ret[1]) == 0:
return False

Expand All @@ -107,7 +109,7 @@ def __init__(
google_tkn: str = None,
):
super().__init__(subscriber_id, calendar_id, redis_instance)

self.db = db
self.google_client = google_client
self.provider = CalendarProvider.google
Expand Down Expand Up @@ -215,6 +217,7 @@ def create_event(
description.append(l10n('join-phone', {'phone': event.location.phone}))

body = {
"iCalUID": event.uuid.hex,
"summary": event.title,
"location": event.location.name,
"description": "\n".join(description),
Expand All @@ -224,11 +227,15 @@ def create_event(
{"displayName": organizer.name, "email": organizer_email},
{"displayName": attendee.name, "email": attendee.email},
],
"organizer": {
"displayName": organizer.name,
"email": self.remote_calendar_id,
}
}
self.google_client.create_event(calendar_id=self.remote_calendar_id, body=body, token=self.google_token)

self.bust_cached_events()

return event

def delete_events(self, start):
Expand All @@ -241,7 +248,7 @@ def delete_events(self, start):
class CalDavConnector(BaseConnector):
def __init__(self, subscriber_id: int, calendar_id: int, redis_instance, url: str, user: str, password: str):
super().__init__(subscriber_id, calendar_id, redis_instance)

self.provider = CalendarProvider.caldav
self.url = url
self.password = password
Expand All @@ -251,11 +258,15 @@ def __init__(self, subscriber_id: int, calendar_id: int, redis_instance, url: st

def test_connection(self) -> bool:
"""Ensure the connection information is correct and the calendar connection works"""
cal = self.client.calendar(url=self.url)

try:
cal = self.client.calendar(url=self.url)
supported_comps = cal.get_supported_components()
except IndexError: # Library has an issue with top level urls, probably due to caldav spec?
except IndexError as ex: # Library has an issue with top level urls, probably due to caldav spec?
logging.error(f"Error testing connection {ex}")
return False
except KeyError as ex:
logging.error(f"Error testing connection {ex}")
return False
except requests.exceptions.RequestException: # Max retries exceeded, bad connection, missing schema, etc...
return False
Expand Down Expand Up @@ -334,6 +345,7 @@ def create_event(
calendar = self.client.calendar(url=self.url)
# save event
caldav_event = calendar.save_event(
uid=event.uuid,
dtstart=event.start,
dtend=event.end,
summary=event.title,
Expand All @@ -346,7 +358,7 @@ def create_event(
caldav_event.save()

self.bust_cached_events()

return event

def delete_events(self, start):
Expand All @@ -362,14 +374,14 @@ def delete_events(self, start):
count += 1

self.bust_cached_events()

return count


class Tools:
def create_vevent(
self,
appointment: schemas.Appointment | schemas.AppointmentBase,
appointment: schemas.Appointment,
slot: schemas.Slot,
organizer: schemas.Subscriber,
):
Expand All @@ -381,6 +393,7 @@ def create_vevent(
org.params["cn"] = vText(organizer.name)
org.params["role"] = vText("CHAIR")
event = Event()
event.add("uid", appointment.uuid.hex)
event.add("summary", appointment.title)
event.add("dtstart", slot.start.replace(tzinfo=timezone.utc))
event.add(
Expand All @@ -403,7 +416,7 @@ def create_vevent(
def send_vevent(
self,
background_tasks: BackgroundTasks,
appointment: schemas.Appointment | schemas.AppointmentBase,
appointment: models.Appointment,
slot: schemas.Slot,
organizer: schemas.Subscriber,
attendee: schemas.AttendeeBase,
Expand Down
3 changes: 2 additions & 1 deletion backend/src/appointment/database/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import zoneinfo

from sqlalchemy import Column, ForeignKey, Integer, String, DateTime, Enum, Boolean, JSON, Date, Time
from sqlalchemy_utils import StringEncryptedType, ChoiceType
from sqlalchemy_utils import StringEncryptedType, ChoiceType, UUIDType
from sqlalchemy_utils.types.encrypted.encrypted_type import AesEngine
from sqlalchemy.orm import relationship, as_declarative, declared_attr
from sqlalchemy.sql import func
Expand Down Expand Up @@ -136,6 +136,7 @@ class Appointment(Base):
__tablename__ = "appointments"

id = Column(Integer, primary_key=True, index=True)
uuid = Column(UUIDType(native=False), default=uuid.uuid4(), index=True)
calendar_id = Column(Integer, ForeignKey("calendars.id"))
duration = Column(Integer)
title = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255))
Expand Down
3 changes: 3 additions & 0 deletions backend/src/appointment/database/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
Definitions of valid data shapes for database and query models.
"""
import json
from uuid import UUID
from datetime import datetime, date, time
from typing import Annotated

Expand Down Expand Up @@ -104,6 +105,7 @@ class AppointmentFull(AppointmentBase):

class Appointment(AppointmentFull):
id: int
uuid: UUID
time_created: datetime | None = None
time_updated: datetime | None = None
slots: list[Slot] = []
Expand Down Expand Up @@ -283,6 +285,7 @@ class Event(BaseModel):
calendar_title: str | None = None
calendar_color: str | None = None
location: EventLocation | None = None
uuid: UUID | None = None

"""Ideally this would just be a mixin, but I'm having issues figuring out a good
static constructor that will work for anything."""
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""add uuid to appointments table
Revision ID: e4c5a32de9fb
Revises: bbdfad87a7fb
Create Date: 2024-03-26 17:21:55.528828
"""
import uuid

from alembic import op
import sqlalchemy as sa
from sqlalchemy import func
from sqlalchemy_utils import UUIDType

# revision identifiers, used by Alembic.
revision = 'e4c5a32de9fb'
down_revision = 'bbdfad87a7fb'
branch_labels = None
depends_on = None


def upgrade() -> None:
op.add_column('appointments', sa.Column('uuid', UUIDType(native=False), default=uuid.uuid4(), index=True))


def downgrade() -> None:
op.drop_column('appointments', 'uuid')
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""[data migration] fill uuid in appointments table
Revision ID: c5b9fc31b555
Revises: e4c5a32de9fb
Create Date: 2024-03-26 17:22:03.157695
"""
import uuid

from alembic import op
from sqlalchemy.orm import Session

from appointment.database import models

# revision identifiers, used by Alembic.
revision = 'c5b9fc31b555'
down_revision = 'e4c5a32de9fb'
branch_labels = None
depends_on = None


def upgrade() -> None:
session = Session(op.get_bind())
appointments: list[models.Appointment] = session.query(models.Appointment).where(models.Appointment.uuid.is_(None)).all()
for appointment in appointments:
appointment.uuid = uuid.uuid4()
session.add(appointment)
session.commit()


def downgrade() -> None:
pass
44 changes: 5 additions & 39 deletions backend/src/appointment/routes/schedule.py
Original file line number Diff line number Diff line change
Expand Up @@ -321,8 +321,6 @@ def decide_on_schedule_availability_slot(

# otherwise, confirm slot and create event
else:
slot = repo.book_slot(db, slot.id)

location_url = schedule.location_url

# FIXME: This is just duplicated from the appointment code. We should find a nice way to merge the two.
Expand Down Expand Up @@ -372,6 +370,7 @@ def decide_on_schedule_availability_slot(
url=location_url,
name=None,
),
uuid=slot.appointment.uuid if slot.appointment else None
)

organizer_email = subscriber.email
Expand Down Expand Up @@ -410,45 +409,12 @@ def decide_on_schedule_availability_slot(
except EventNotCreatedException:
raise EventCouldNotBeAccepted

# send mail with .ics attachment to attendee
appointment = schemas.AppointmentBase(title=title, details=schedule.details, location_url=location_url)
Tools().send_vevent(background_tasks, appointment, slot, subscriber, slot.attendee)
# Book the slot at the end
slot = repo.book_slot(db, slot.id)

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)
)


@router.put("/serve/ics", response_model=schemas.FileDownload)
def schedule_serve_ics(
s_a: schemas.AvailabilitySlotAttendee,
url: str = Body(..., embed=True),
db: Session = Depends(get_db),
):
"""endpoint to serve ICS file for availability time slot to download"""
subscriber = repo.verify_subscriber_link(db, url)
if not subscriber:
raise validation.InvalidLinkException

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 IndexError:
raise validation.ScheduleNotFoundException()

# check if schedule is enabled
if not schedule.active:
raise validation.ScheduleNotFoundException()

# get calendar
db_calendar = repo.get_calendar(db, calendar_id=schedule.calendar_id)
if db_calendar is None:
raise validation.CalendarNotFoundException()

appointment = schemas.AppointmentBase(title=schedule.name, details=schedule.details, location_url=schedule.location_url)
return schemas.FileDownload(
name="invite",
content_type="text/calendar",
data=Tools().create_vevent(appointment=appointment, slot=s_a.slot, organizer=subscriber).decode("utf-8"),
)
11 changes: 4 additions & 7 deletions frontend/src/components/BookingModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -73,18 +73,15 @@
:disabled="!validAttendee || isLoading"
@click="bookIt"
/>
<primary-button
v-else-if="!requiresConfirmation"
:label="t('label.downloadInvitation')"
@click="emit('download')"
/>
</div>
</div>
</div>
</template>

<script setup>
import { inject, computed, reactive, ref, onMounted } from 'vue';
import {
inject, computed, reactive, ref, onMounted,
} from 'vue';
import { timeFormat } from '@/utils';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
Expand All @@ -105,7 +102,7 @@ const { t } = useI18n();
const route = useRoute();
const dj = inject('dayjs');
const emit = defineEmits(['book', 'download', 'close']);
const emit = defineEmits(['book', 'close']);
const props = defineProps({
event: Object, // event data to display and book
Expand Down
Loading

0 comments on commit 3402495

Please sign in to comment.