Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/gcal integration #79

Open
wants to merge 29 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
bbdbb7d
Made code in BookView.post() a bit easier to read.
filiptypjeu Jan 22, 2023
1da62ac
Changed Booking view header and fixed bug where the bookable name did…
filiptypjeu Jan 22, 2023
b2d9fa6
Added new fields to the Bookable and Booking models.
filiptypjeu Jan 23, 2023
4193b7b
Merge branch 'master' into feature/gcal-integration
filiptypjeu Jan 23, 2023
5b492aa
Fixed broken api field.
filiptypjeu Jan 23, 2023
d53dc8f
Minor refactoring in ReapeatBookingForm.
filiptypjeu Jan 23, 2023
10b9a34
Added optional integration with Google Calendar.
filiptypjeu Jan 24, 2023
e6c5a92
Configured DEFAULT_AUTO_FIELD to get rid of warnings.
filiptypjeu Jan 24, 2023
81e84ab
Goole Calendar event url is not fetched automatically hen opening an …
filiptypjeu Jan 24, 2023
86ec9ab
Removed unused imports and variables, and changed double quotes to si…
filiptypjeu Jan 24, 2023
8c22338
Wrote tests for API and discovered faulty code, which I fixed.
filiptypjeu Jan 24, 2023
8523ba1
Make use of djanog's own timezone utility rather than using python's …
filiptypjeu Jan 24, 2023
4eb51fa
Removed unused code.
filiptypjeu Jan 24, 2023
94ef125
Making the default and max limit for Bookings API pagination a bit mo…
filiptypjeu Jan 25, 2023
49475aa
Added a bit of bottom padding to calendar month view becasue the cale…
filiptypjeu Jan 25, 2023
bb2ae6e
Added more Swedish translations.
filiptypjeu Jan 26, 2023
1a38d83
Made migrations.
filiptypjeu Jan 26, 2023
9c9f2c3
Moved initializatin of GCal helper service to be outside the GCal hel…
filiptypjeu Jan 28, 2023
539e6be
Removed GCal helper member from Booking.
filiptypjeu Jan 29, 2023
dcf9197
Made GCal handling happen in another thread to not be blockingthe mai…
filiptypjeu Jan 29, 2023
55652fa
Fixed minor bugs.
filiptypjeu Jan 29, 2023
e59cfed
Updated package requirements.
filiptypjeu Jan 29, 2023
e695564
Revert "Moved initializatin of GCal helper service to be outside the …
filiptypjeu Feb 1, 2023
7ac8fcb
- Introduced new GCalEvent model instead of storing event id on Booking
filiptypjeu Feb 1, 2023
c0ba3d6
Fixed issue with emails and repeated bookings not appearing on gcal e…
filiptypjeu Feb 1, 2023
6e16448
Added migrations.
filiptypjeu Feb 1, 2023
c39f418
Changed exception to just logging in gevent creation thread.
filiptypjeu Feb 2, 2023
4bb79f5
Removed CASCASE from GCalEvent foreign key.
filiptypjeu Feb 4, 2023
8b34692
Merged migrations.
filiptypjeu Feb 4, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion fars/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,11 @@ BILL_API_URL="https://bill.teknologforeningen.fi/api/"
BILL_API_USER="user"

# BILL API password
BILL_API_PW="hunter2"
BILL_API_PW="hunter2"


# Path to Google service account credentials
GOOGLE_SERVICE_ACCOUNT_FILE="google_service_account_credentials.json"

# Base URL of FARS instance, used to create URLs for Google Calendar events descriptions
FARS_BASE_URL="fars.com"
78 changes: 73 additions & 5 deletions fars/api/tests.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,84 @@
from django.contrib.auth.models import User
from django.contrib.auth.models import User, Group
from booking.models import Bookable, Booking
from rest_framework import status
from rest_framework.test import APIClient, APITestCase
from django.utils import timezone
import json


class BookingApiTest(APITestCase):
def setUp(self):
self.user = User.objects.create_user(username='svakar', password='teknolog')
self.user1 = User.objects.create_user(username='svakar', password='teknolog')
self.user2 = User.objects.create_user(username='another', password='user')
self.group1 = Group.objects.create(name='group1')
self.group2 = Group.objects.create(name='group2')

self.bookable_public = Bookable.objects.create(id_str='room1', name='My room', public=True)
self.bookable_private = Bookable.objects.create(id_str='room2', name='My second room', public=False)
self.bookable_hidden = Bookable.objects.create(id_str='room3', name='My third room', public=False, hidden=True)

self.bookable_hidden.admin_groups.add(self.group1)
self.user1.groups.add(self.group1)
self.user1.groups.add(self.group2)

d = timezone.now()
for _ in range(5):
Booking.objects.create(bookable=self.bookable_public, user=self.user1, start=d, end=d)
Booking.objects.create(bookable=self.bookable_private, user=self.user1, start=d, end=d)
Booking.objects.create(bookable=self.bookable_hidden, user=self.user1, start=d, end=d)


# BOOKABLES

def test_bookables_list_show_only_public_if_not_logged_in(self):
response = self.client.get('/api/bookables')
self.assertEqual(status.HTTP_200_OK, response.status_code)

content = json.loads(response.content)
self.assertEqual(1, len(content))

def test_bookables_list_show_unhidden_if_logged_in(self):
User.objects.create_user(username='new', password='user')
self.client.login(username='new', password='user')

response = self.client.get('/api/bookables')
self.assertEqual(status.HTTP_200_OK, response.status_code)

content = json.loads(response.content)
self.assertEqual(len(content), 2)

def test_bookables_list_show_hidden_to_admin(self):
self.client.login(username='svakar', password='teknolog')
response = self.client.get('/api/bookables')
self.assertEqual(status.HTTP_200_OK, response.status_code)

content = json.loads(response.content)
self.assertEqual(len(content), 3)


# BOOKINGS

def test_bookings_list_show_only_public_if_not_logged_in(self):
response = self.client.get('/api/bookings')
self.assertEqual(status.HTTP_200_OK, response.status_code)

content = json.loads(response.content)
self.assertEqual(len(content), 5)

def test_bookings_list_show_unhidden_if_logged_in(self):
User.objects.create_user(username='new', password='user')
self.client.login(username='new', password='user')

def test_bookings_list_requires_authentication(self):
response = self.client.get('/api/bookings')
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(status.HTTP_200_OK, response.status_code)

content = json.loads(response.content)
self.assertEqual(len(content), 10)

def test_bookings_list_show_hidden_to_admin(self):
self.client.login(username='svakar', password='teknolog')
response = self.client.get('/api/bookings')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(status.HTTP_200_OK, response.status_code)

content = json.loads(response.content)
self.assertEqual(len(content), 15)
31 changes: 19 additions & 12 deletions fars/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@
from booking.models import *
from api.serializers import *
from api.renderers import *
import datetime
from django.utils import timezone


class BookingFilter(filters.FilterSet):
# Custom filter fields
before = filters.IsoDateTimeFilter(field_name='start', lookup_expr='lte')
after = filters.IsoDateTimeFilter(field_name='end', lookup_expr='gte')
# Fields where the defualt filter type is overridden
bookable = filters.CharFilter(field_name='bookable__id_str')
username = filters.CharFilter(field_name='user__username')
booking_group = filters.CharFilter(field_name='booking_group')

Expand All @@ -22,10 +23,10 @@ class Meta:
fields = ['bookable', 'before', 'after', 'username', 'booking_group']

class BookingsPagination(pagination.LimitOffsetPagination):
default_limit = 5000
default_limit = 500
limit_query_param = 'limit'
offset_query_param = 'offset'
max_limit = 50000
max_limit = 5000

class BookingsList(viewsets.ViewSetMixin, generics.ListAPIView):

Expand All @@ -48,11 +49,14 @@ def get_queryset(self):
# Only show bookings for public bookables if not logged in
if not user.is_authenticated:
queryset = queryset.filter(bookable__public=True)
# Only show bookings on unhidden bookables to superusers and admins of the bookable
if not user.is_superuser:

# For ordinary users...
elif not user.is_superuser:
# ... show only bookings on unhidden bookables...
q = Q(bookable__hidden=False)
for group in user.groups.all():
q |= Q(bookable__admin_groups__contains=group)
# ... and bookings on hidden bookables if the user is part of an admin group
q |= Q(bookable__admin_groups__in=user.groups.all())

queryset = queryset.filter(q)

return queryset
Expand All @@ -67,18 +71,21 @@ def get_queryset(self):
# Only show public bookables if not logged in
if not user.is_authenticated:
queryset = queryset.filter(public=True)
# Only show unhidden bookables to superusers and admins of the bookable
if not user.is_superuser:

# For ordinary users...
elif not user.is_superuser:
# ... show only unhidden bookables...
q = Q(hidden=False)
for group in user.groups.all():
q |= Q(admin_groups__contains=group)
# ... and hidden bookables if the user is part of an admin group
q |= Q(admin_groups__in=user.groups.all())

queryset = queryset.filter(q)

return queryset

# This class provides the view used by GeneriKey to get the list of bookings they need
class GeneriKeyBookingsList(viewsets.ViewSetMixin, generics.ListAPIView):
queryset = Booking.objects.filter(end__gt=datetime.datetime.now())
queryset = Booking.objects.filter(end__gt=timezone.now())
serializer_class = BookingSerializer
filter_backends = (filters.DjangoFilterBackend,)
filter_class = BookingFilter
Expand Down
2 changes: 1 addition & 1 deletion fars/booking/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

class TimeslotInline(admin.TabularInline):
model = Timeslot
fields = ("start_weekday", "start_time", "end_weekday", "end_time",)
fields = ('start_weekday', 'start_time', 'end_weekday', 'end_time',)


class BookableAdmin(admin.ModelAdmin):
Expand Down
24 changes: 12 additions & 12 deletions fars/booking/bill.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,26 +11,26 @@ class NotAllowedException(BILLException):
class BILLChecker:

def __init__(self):
self.api_url = env("BILL_API_URL")
self.user = env("BILL_API_USER")
self.password = env("BILL_API_PW")
self.api_url = env('BILL_API_URL')
self.user = env('BILL_API_USER')
self.password = env('BILL_API_PW')

def check_user_can_book(self, user, type, group=0):
if type is None:
raise BILLException(_("BILL is not configured for this bookable"))
raise BILLException(_('BILL is not configured for this bookable'))
group = 0 if group is None else group
try:
r = requests.get(self.api_url + "query?user=%s&group=%s&type=%s" % (user, group, type), auth=(self.user, self.password))
r = requests.get(self.api_url + 'query?user=%s&group=%s&type=%s' % (user, group, type), auth=(self.user, self.password))
except:
raise BILLException("Could not connect to BILL server")
raise BILLException('Could not connect to BILL server')
if r.status_code != 200:
raise BILLException("BILL returned status: %d" % r.status_code)
raise BILLException('BILL returned status: %d' % r.status_code)
# BILL API does not use proper http status codes
try:
response = int(r.text)
except ValueError:
# Unexpected non-integer response
raise BILLException("BILL returned unexpected response: %s" % r.text)
raise BILLException('BILL returned unexpected response: %s' % r.text)

if response < 0:
# Possible error responses:
Expand All @@ -44,18 +44,18 @@ def check_user_can_book(self, user, type, group=0):
# -8 = Group has no BILL allocation
# -9 = Type does not exist in BILL database
# -10 = User does not belong to group
raise BILLException("BILL returned error response: %d" % response)
raise BILLException('BILL returned error response: %d' % response)

# 0 = Chosen combination has right to use the device and is NOT over limit
# 1 = User has no right to use device
# 2 = Group has no right to use device
# 3 = Chosen combination has right to use the device but IS over limit
# 4 = Chosen combination has right to use the device; the database has no limit specified for this device but the user IS over the default limit
if response == 1:
raise NotAllowedException(_("User has no right to use device"))
raise NotAllowedException(_('User has no right to use device'))
elif response == 2:
raise NotAllowedException(_("Group has no right to use device"))
raise NotAllowedException(_('Group has no right to use device'))
elif response == 3 or response == 4:
raise NotAllowedException(_("User has insufficient credits to use device"))
raise NotAllowedException(_('User has insufficient credits to use device'))

return True
73 changes: 52 additions & 21 deletions fars/booking/forms.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
from django import forms
from booking.models import Booking, RepeatedBookingGroup, Bookable
from booking.models import Booking, RepeatedBookingGroup
from django.contrib.auth.forms import AuthenticationForm
from django.forms.widgets import PasswordInput, TextInput, NumberInput, DateInput
from django.forms.widgets import PasswordInput, TextInput, DateInput
from django.core.exceptions import ValidationError
from django.core.validators import EmailValidator
from datetime import datetime, timedelta, date
from django.utils import timezone
from django.utils.translation import gettext as _
from django.db import transaction


class DateTimeWidget(forms.widgets.MultiWidget):
Expand All @@ -29,7 +31,7 @@ def __init__(self, *args, **kwargs):
super(DateTimeField, self).__init__(list_fields, *args, **kwargs)

def compress(self, values):
return datetime.strptime("{}T{}".format(*values), "%Y-%m-%dT%H:%M:%S")
return datetime.strptime('{}T{}'.format(*values), '%Y-%m-%dT%H:%M:%S')


class BookingForm(forms.ModelForm):
Expand All @@ -42,6 +44,16 @@ def __init__(self, *args, **kwargs):
self.fields['booking_group'].choices = \
[(None, _('Private booking'))] + [(group.id, group.name) for group in allowed_booker_groups]

# Add a field for emails if the bookable is set up with a Google Calendar
if self.instance.bookable.google_calendar_id:
# XXX: Add event description too?
self.fields['emails'] = forms.CharField(
required=False,
widget=forms.Textarea,
label=_('Email address(es)'),
help_text=_('A Google Calendar event is automatically created for this booking, and an invitation will be sent to these emails. Write one email per line.'),
)

class Meta:
model = Booking
fields = '__all__'
Expand All @@ -53,42 +65,55 @@ class Meta:
}

def clean_start(self):
start = self.cleaned_data['start']
start = timezone.make_aware(self.cleaned_data['start'])
# If start is in the past, make it "now"
return datetime.now() if start < datetime.now() else start
return timezone.now() if start < timezone.now() else start

def clean_end(self):
return timezone.make_aware(self.cleaned_data['end'])

def clean_emails(self):
# Allow for various separators
emails = self.cleaned_data['emails'].replace(',', '\n').replace(';', '\n').split('\n')
emails = map(lambda s: s.strip(), emails) # Strip whitespace from all emails
emails = filter(lambda s: s, emails) # Filter out empty rows
emails = list(emails)
validator = EmailValidator(message=_('Invalid mail address(es)'))
for email in emails:
validator(email)
return emails

def clean(self):
cleaned_data = super().clean()
bookable = cleaned_data.get("bookable")
start = cleaned_data.get("start")
end = cleaned_data.get("end")
bookable = cleaned_data.get('bookable')
start = cleaned_data.get('start')
end = cleaned_data.get('end')
user = cleaned_data.get('user')
group = cleaned_data.get('booking_group')

# Check that user has permissions to book bookable
restriction_groups = bookable.booking_restriction_groups.all()
if not user.is_superuser and restriction_groups and not user.groups.filter(id__in=restriction_groups).exists():
raise forms.ValidationError(_("You do not have permissions to book this bookable"))
raise forms.ValidationError(_('You do not have permissions to book this bookable'))

if bookable and start and end:
# Check that booking does not violate bookable forward limit
if bookable.forward_limit_days > 0 and datetime.now() + timedelta(days=bookable.forward_limit_days) < end:
if bookable.forward_limit_days > 0 and timezone.now() + timedelta(days=bookable.forward_limit_days) < end:
raise forms.ValidationError(
_("{} may not be booked more than {} days in advance").format(bookable.name, bookable.forward_limit_days)
_('{} may not be booked more than {} days in advance').format(bookable.name, bookable.forward_limit_days)
)

# Check that booking does not violate bookable length limit
booking_length = (end - start)
booking_length_hours = booking_length.days * 24 + booking_length.seconds / 3600
if bookable.length_limit_hours > 0 and booking_length_hours > bookable.length_limit_hours:
raise forms.ValidationError(
_("{} may not be booked for longer than {} hours").format(bookable.name, bookable.length_limit_hours)
_('{} may not be booked for longer than {} hours').format(bookable.name, bookable.length_limit_hours)
)

# Check that booking does not overlap with previous bookings
overlapping = Booking.objects.filter(bookable=bookable, start__lt=end, end__gt=start)
if overlapping:
warning = _("Error: Requested booking is overlapping with the following bookings:")
warning = _('Error: Requested booking is overlapping with the following bookings:')
errors = [forms.ValidationError(warning)]
for booking in overlapping:
errors.append(forms.ValidationError('• ' + str(booking)))
Expand All @@ -106,6 +131,9 @@ def get_cleaned_metadata(self):
metadata_field_names = self.get_metadata_field_names()
return None if not metadata_field_names else {k: self.cleaned_data[k] for k in metadata_field_names}

def get_cleaned_emails(self):
return self.cleaned_data['emails'] if 'emails' in self.cleaned_data else []


class CustomLoginForm(AuthenticationForm):
username = forms.CharField(widget=TextInput(attrs={'class':'validate offset-2 col-8','placeholder': 'Username'}))
Expand All @@ -123,17 +151,20 @@ def save_repeating_booking_group(self, booking):
group.save()
booking.repeatgroup = group
skipped_bookings = []
frequency = data.get('frequency')
repeat_until = data.get('repeat_until')
is_repetition = False

# Copy booking for every repetition
while(booking.start.date() <= data.get('repeat_until')):
while(booking.start.date() <= repeat_until):
overlapping = booking.get_overlapping_bookings()
if overlapping:
skipped_bookings.append(str(booking))
else:
booking.save()
booking.save(skip_gcal=is_repetition)
filiptypjeu marked this conversation as resolved.
Show resolved Hide resolved
is_repetition = True
booking.pk = None
booking.start += timedelta(days=data.get('frequency'))
booking.end += timedelta(days=data.get('frequency'))

return skipped_bookings
booking.start += timedelta(days=frequency)
booking.end += timedelta(days=frequency)

return skipped_bookings
Loading