Skip to content

Commit

Permalink
Test initial calendar connection for caldav (#245)
Browse files Browse the repository at this point in the history
* Add a test_connection to the calendar connectors, and test before adding a new caldav calendar.

* Fix error messages on frontend, and hook up our invalid caldav connection error message.

* Fix alert box not closing on 'x'

* Fix tests

* 🌐 add German translation

* 💚 improve alert box appearance

---------

Co-authored-by: Andreas Müller <mail@devmount.de>
  • Loading branch information
MelissaAutumn and devmount authored Jan 26, 2024
1 parent d6a0071 commit 2458aef
Show file tree
Hide file tree
Showing 11 changed files with 90 additions and 15 deletions.
23 changes: 23 additions & 0 deletions backend/src/appointment/controller/calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
Handle connection to a CalDAV server.
"""
import json

import caldav.lib.error
import requests
from caldav import DAVClient
from google.oauth2.credentials import Credentials
from icalendar import Calendar, Event, vCalAddress, vText
Expand Down Expand Up @@ -43,6 +46,10 @@ def __init__(
if google_tkn:
self.google_token = Credentials.from_authorized_user_info(json.loads(google_tkn), self.google_client.SCOPES)

def test_connection(self) -> bool:
"""This occurs during Google OAuth login"""
return bool(self.google_token)

def sync_calendars(self):
"""Sync google calendars"""

Expand Down Expand Up @@ -155,6 +162,22 @@ def __init__(self, url: str, user: str, password: str):
# connect to CalDAV server
self.client = DAVClient(url=url, username=user, password=password)

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

try:
supported_comps = cal.get_supported_components()
except IndexError: # Library has an issue with top level urls, probably due to caldav spec?
return False
except requests.exceptions.RequestException: # Max retries exceeded, bad connection, missing schema, etc...
return False
except caldav.lib.error.NotFoundError: # Good server, bad url.
return False

# They need at least VEVENT support for appointment to work.
return 'VEVENT' in supported_comps

def sync_calendars(self):
pass

Expand Down
8 changes: 8 additions & 0 deletions backend/src/appointment/exceptions/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,11 @@ class ZoomNotConnectedException(APIException):

def get_msg(self):
return l10n('zoom-not-connected')


class RemoteCalendarConnectionError(APIException):
id_code = 'REMOTE_CALENDAR_CONNECTION_ERROR'
status_code = 400

def get_msg(self):
return l10n('remote-calendar-connection-error')
2 changes: 2 additions & 0 deletions backend/src/appointment/l10n/de/main.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ slot-not-found = Es gibt keine freien Zeitfenster zu buchen.
slot-already-taken = Das gewählte Zeitfenster ist nicht mehr verfügbar. Bitte erneut versuchen.
slot-invalid-email = Die angegebene E-Mail-Adresse war nicht gültig. Bitte erneut versuchen.
remote-calendar-connection-error = Der angebundene Kalender konnte nicht erreicht werden. Bitte die Verbindungsinformationen überprüfen und noch einmal versuchen.
## Authentication Exceptions

email-mismatch = E-Mail-Adresse stimmen nicht überein.
Expand Down
2 changes: 2 additions & 0 deletions backend/src/appointment/l10n/en/main.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ slot-not-found = There are no available time slots to book.
slot-already-taken = The time slot you have selected is no longer available. Please try again.
slot-invalid-email = The email you have provided was not valid. Please try again.
remote-calendar-connection-error = The remote calendar could not be reached. Please verify your connection information and try again.
## Authentication Exceptions

email-mismatch = Email mismatch.
Expand Down
20 changes: 20 additions & 0 deletions backend/src/appointment/routes/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from ..dependencies.database import get_db
from ..dependencies.zoom import get_zoom_client
from ..exceptions import validation
from ..exceptions.validation import RemoteCalendarConnectionError
from ..l10n import l10n

router = APIRouter()
Expand Down Expand Up @@ -107,8 +108,27 @@ def create_my_calendar(
calendar: schemas.CalendarConnection,
db: Session = Depends(get_db),
subscriber: Subscriber = Depends(get_subscriber),
google_client: GoogleClient = Depends(get_google_client),
):
"""endpoint to add a new calendar connection for authenticated subscriber"""

# Test the connection first
if calendar.provider == CalendarProvider.google:
# I don't believe google cal touches this route, but just in case!
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)

# Make sure we can connect to the calendar before we save it
if not con.test_connection():
raise RemoteCalendarConnectionError()

# create calendar
try:
cal = repo.create_subscriber_calendar(db=db, calendar=calendar, subscriber_id=subscriber.id)
Expand Down
5 changes: 5 additions & 0 deletions backend/test/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,17 @@ def create_event(self, event, attendee, organizer):
def delete_event(self, start):
return True

@staticmethod
def test_connection(self):
return True

# Patch up the caldav constructor, and list_calendars
from appointment.controller.calendar import CalDavConnector
monkeypatch.setattr(CalDavConnector, "__init__", MockCaldavConnector.__init__)
monkeypatch.setattr(CalDavConnector, "list_calendars", MockCaldavConnector.list_calendars)
monkeypatch.setattr(CalDavConnector, "create_event", MockCaldavConnector.create_event)
monkeypatch.setattr(CalDavConnector, "delete_events", MockCaldavConnector.delete_event)
monkeypatch.setattr(CalDavConnector, "test_connection", MockCaldavConnector.test_connection)


def _patch_mailer(monkeypatch):
Expand Down
8 changes: 5 additions & 3 deletions frontend/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,9 @@ const call = createFetch({
}
return { options };
},
async onFetchError({ data, response, error }) {
updateDataOnError: true, // Needed to access the actual error message...
async onFetchError(context) {
const { data, response } = context;
// Catch any google refresh error that may occur
if (
data?.detail?.id === 'GOOGLE_REFRESH_ERROR'
Expand All @@ -86,11 +88,11 @@ const call = createFetch({
// Clear current user data, and ship them to the login screen!
await currentUser.reset();
await router.push('/login');
return;
return context;
}
// Pass the error along
return { data, response, error };
return context;
},
},
fetchOptions: {
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/AppointmentCreation.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
{{ t("heading.createNewAppointment") }}
</div>
<alert-box
@close="appointmentCreationError = ''"
v-if="appointmentCreationError"
:title="t('label.appointmentCreationError')"
>
Expand Down Expand Up @@ -435,8 +436,7 @@ const createAppointment = async () => {
if (error.value) {
// Error message is in data
appointmentCreationError.value =
data.value.detail || t("error.unknownAppointmentError");
appointmentCreationError.value = data.value?.detail?.message || t("error.unknownAppointmentError");
// Open the form
emit("start");
return;
Expand Down
8 changes: 6 additions & 2 deletions frontend/src/components/ScheduleCreation.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@
<span>{{ t("heading.generalAvailability") }}</span>
<switch-toggle v-if="existing" class="mt-0.5" :active="schedule.active" no-legend @changed="toggleActive" />
</div>
<alert-box v-if="scheduleCreationError" :title="t('label.scheduleCreationError')">
<alert-box
@close="scheduleCreationError = ''"
v-if="scheduleCreationError"
:title="t('label.scheduleCreationError')"
>
{{ scheduleCreationError }}
</alert-box>

Expand Down Expand Up @@ -503,7 +507,7 @@ const saveSchedule = async (withConfirmation = true) => {
if (error.value) {
// error message is in data
scheduleCreationError.value = data.value.detail || t("error.unknownScheduleError");
scheduleCreationError.value = data.value?.detail?.message || t("error.unknownScheduleError");
// go back to the start
state.value = scheduleCreationState.details;
savingInProgress.value = false;
Expand Down
15 changes: 13 additions & 2 deletions frontend/src/components/SettingsCalendar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@
<div class="flex flex-col gap-8">
<div class="text-3xl text-gray-500 font-semibold">{{ t('heading.calendarSettings') }}</div>
<div class="pl-6 flex flex-col gap-6">
<alert-box title="Calendar Connect Error" v-if="calendarConnectError">{{calendarConnectError}}</alert-box>
<alert-box
@close="calendarConnectError = ''"
title="Calendar Connect Error"
v-if="calendarConnectError"
>{{calendarConnectError}}</alert-box>

<!-- list of possible calendars to connect -->
<calendar-management
Expand Down Expand Up @@ -327,7 +331,14 @@ const saveCalendar = async () => {
// add new caldav calendar
if (isCalDav.value && inputMode.value === inputModes.add) {
await call('cal').post(calendarInput.data);
const { error, data } = await call('cal').post(calendarInput.data).json();
if (error.value) {
calendarConnectError.value = data.value?.detail?.message;
loading.value = false;
// Show them the error message because I haven't thought this ux process through.
window.scrollTo(0, 0);
return;
}
}
// add all google calendars connected to given gmail address
if (isGoogle.value && inputMode.value === inputModes.add) {
Expand Down
10 changes: 4 additions & 6 deletions frontend/src/elements/AlertBox.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,11 @@
text-white shadow-black/30"
role="alert"
:class="{
hidden: isHidden,
'bg-rose-600 dark:bg-rose-900': isError,
'bg-orange-400 dark:bg-orange-700': isAlert,
}"
>
<span class="flex rounded-full uppercase px-2 py-1 text-xs font-bold mr-3"
<span class="flex rounded-full uppercase px-2 py-1 text-center text-xs font-bold mr-3"
:class="{
'bg-rose-500 dark:bg-rose-800': isError,
'bg-orange-500 dark:bg-orange-800': isAlert,
Expand All @@ -21,14 +20,14 @@
<span class="block sm:inline ml-1">
<slot></slot>
</span>
<span class="ml-auto place-self-start" @click="onClose">
<span class="ml-auto" @click="emit('close')">
<icon-x class="h-6 w-6 stroke-1 fill-transparent stroke-white cursor-pointer" />
</span>
</div>
</template>

<script setup>
import { computed, ref } from 'vue';
import { computed } from 'vue';
import { IconX } from '@tabler/icons-vue';
const props = defineProps({
Expand All @@ -42,7 +41,6 @@ const props = defineProps({
const isError = computed(() => props.scheme === 'error');
const isAlert = computed(() => props.scheme === 'alert');
const isHidden = ref(false);
const onClose = () => { isHidden.value = true; };
const emit = defineEmits(['close']);
</script>

0 comments on commit 2458aef

Please sign in to comment.