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

Add fixed timeslots for bookable #69

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
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
17 changes: 14 additions & 3 deletions fars/booking/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,18 @@

# Register your models here.

app = apps.get_app_config('booking')
from .models import Bookable
from .forms import BookableForm

for model_name, model in app.models.items():
admin.site.register(model)
class BookableAdmin(admin.ModelAdmin):
form = BookableForm

admin.site.register(Bookable, BookableAdmin)


models = apps.get_models()
for model in models:
try:
admin.site.register(model)
except admin.sites.AlreadyRegistered:
pass
15 changes: 14 additions & 1 deletion fars/booking/forms.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
from django import forms
from booking.models import Booking, RepeatedBookingGroup
from booking.models import Booking, RepeatedBookingGroup, Bookable
from django.contrib.auth.forms import AuthenticationForm
from django.forms.widgets import PasswordInput, TextInput, NumberInput, DateInput
from datetime import datetime, timedelta, date
from django.utils.translation import gettext as _
from django.db import transaction

class WeekdayTimeWidget(forms.TextInput):
class Media:
css = {'all': ('css/booking_slots.css',)}
js = ('js/booking_slots.js',)


class BookableForm(forms.ModelForm):
booking_slots = forms.CharField(widget=WeekdayTimeWidget, required=False)

class Meta:
model = Bookable
fields = '__all__'


class DateTimeWidget(forms.widgets.MultiWidget):
def __init__(self, attrs=None):
Expand Down
24 changes: 24 additions & 0 deletions fars/booking/migrations/0012_auto_20201020_1038.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Generated by Django 3.1.2 on 2020-10-20 07:38

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('booking', '0011_auto_20191229_1836'),
]

operations = [
migrations.AlterField(
model_name='bookable',
name='bill_device_id',
field=models.PositiveIntegerField(blank=True, default=None, help_text='BILL device ID if BILL check is needed. If empty no BILL check will be performed', null=True),
),
migrations.AlterField(
model_name='booking',
name='repeatgroup',
field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='booking.repeatedbookinggroup'),
),
]
19 changes: 19 additions & 0 deletions fars/booking/migrations/0013_bookable_booking_slots.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Generated by Django 3.1.2 on 2020-10-22 09:58

import booking.validators
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('booking', '0012_auto_20201020_1038'),
]

operations = [
migrations.AddField(
model_name='bookable',
name='booking_slots',
field=models.JSONField(default=list, help_text='Timeslots that can be booked.', validators=[booking.validators.validate_booking_slots]),
),
]
31 changes: 26 additions & 5 deletions fars/booking/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
from django.dispatch import receiver
from django.utils.translation import gettext as _
from datetime import timedelta
import json

import logging
from .validators import validate_booking_slots

# These are the choices used in the bookable model.
# Adding your metadata form here will make it available for bookables.
Expand Down Expand Up @@ -53,6 +55,10 @@ class Bookable(models.Model):
# BILL device ID if BILL check is needed. If null no BILL check will be performed
bill_device_id = models.PositiveIntegerField(null=True, blank=True, default=None, help_text=_('BILL device ID if BILL check is needed. If empty no BILL check will be performed'))

# JSON field to only allow the specific timeslots to be booked
booking_slots = models.JSONField(default=list, validators=[validate_booking_slots], help_text=_('Timeslots that can be booked.'))


def __str__(self):
return self.name

Expand All @@ -63,6 +69,18 @@ def notify_external_services(self):
for service in ExternalService.objects.filter(bookable__id=self.id):
service.notify(session)

def has_bookable_timeslots(self):
if len(self.get_bookable_timeslots()) > 0: return True
return False

def get_bookable_timeslots(self):
# Replace all single quotes with double quotes, because single quotes are not valid JSON while they are valid characters in Python
return json.loads(str(self.booking_slots).replace("'",'"'))

def get_bookable_timeslots_by_start_and_end_time(self):
ts = self.get_bookable_timeslots()
return list(map(list, zip(*ts)))


class ExternalService(models.Model):
name = models.CharField(max_length=64, null=False, blank=False)
Expand All @@ -79,11 +97,6 @@ def notify(self, session):
# Avoid crashes from this
logger.error('Error notifying external service "{}" with URL {}: {}'.format(self.name, self.callback_url, str(e)))

# class TimeSlot(models.Model):
# start = models.CharField(null=False)
# end = models.CharField(max_length=8, null=False)
# bookable = models.ForeignKey(max_length=8, Bookable, on_delete=models.CASCADE)


class RepeatedBookingGroup(models.Model):
name = models.CharField(max_length=128)
Expand Down Expand Up @@ -136,6 +149,14 @@ def clean(self):
if self.end <= self.start:
raise ValidationError(_("Booking cannot end before it begins"))

# Check that the booking's start and end times are on the defined booking slots, if the bookable has such defined
if self.bookable.has_bookable_timeslots():
start_times, end_times = self.bookable.get_bookable_timeslots_by_start_and_end_time()
if self.start.strftime("%a %H:%M") not in start_times:
raise ValidationError(_("Booking start time is not according to the predefined booking timeslots."))
if self.end.strftime("%a %H:%M") not in end_times:
raise ValidationError(_("Booking end time is not according to the predefined booking timeslots."))

# Check that booking group is allowed
if self.booking_group and self.booking_group not in self.get_booker_groups():
raise ValidationError(_("Group booking is not allowed with the provided user and group"))
8 changes: 8 additions & 0 deletions fars/booking/static/css/booking_slots.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.bookable-timeslot-input span {
margin-left: 10px;
margin-right: 5px;
}

.bookable-timeslots-input-container {
margin-top: 10px;
}
155 changes: 155 additions & 0 deletions fars/booking/static/js/booking_slots.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
Element.prototype.appendAfter = function (element) {
element.parentNode.insertBefore(this, element.nextSibling);
}, false;

function updateBookableTimeslotValues(value, id, type) {
const slots_elem = document.getElementById("id_booking_slots");
// Need to replace single quotes with double quotes because single quotes are not JSON
let time_slots = JSON.parse(slots_elem.value.replaceAll("'",'"'));
const [start_or_end, i] = id.split('_');

let j;
switch (start_or_end) {
case "start":
j = 0;
break;
case "end":
j = 1;
break;
default:
j = undefined;
}
switch (type) {
case "booking_slot_weekday_":
if (j !== undefined) time_slots[i][j] = value.target.value + " " + time_slots[i][j].split(' ')[1];
break
case "booking_slot_time_":
if (j !== undefined) time_slots[i][j] = time_slots[i][j].split(' ')[0] + " " + value.target.value;
break
}
slots_elem.value = JSON.stringify(time_slots);
}

function createWeekdaySelectElement(value, id) {
const selectElement = document.createElement("select");


const weekdays = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];

weekdays.forEach(v => {
const optionElement = document.createElement("option");
optionElement.value = v;
optionElement.text = v;
selectElement.appendChild(optionElement);

})

if (value) { selectElement.value = value }
else { selectElement.value = weekdays[0] }

const type = "booking_slot_weekday_";
selectElement.id = type + id;
selectElement.onchange = (value) => updateBookableTimeslotValues(value, id, type)
return selectElement;
}

function createTimeSelectElement(value, id) {
const input = document.createElement("input");
input.value = value.split(":").map(n => (n.toString().length < 2 ? '0' : '') + n).join(':');
input.type = "time";
const type = "booking_slot_time_";
input.id = type + id
input.onchange = (value) => updateBookableTimeslotValues(value, id, type)
return input;
}

function createWeekdayTimeSelectElement(value, id) {
const [weekday, time] = value.split(' ');
return [createWeekdaySelectElement(weekday, id), createTimeSelectElement(time, id)];
}

function renderBookingSlotInput(timespan, i) {
const [start, end] = timespan;

const container = document.createElement('div');
container.classList.add('bookable-timeslot-input')
const start_span = document.createElement('span');
start_span.innerText = 'Starts';
container.appendChild(start_span)
const [start_weekday, start_time] = createWeekdayTimeSelectElement(start, 'start_' + i)
container.appendChild(start_weekday);
container.appendChild(start_time);
const end_span = document.createElement('span');
end_span.innerText = 'Ends';
container.appendChild(end_span)
const [end_weekday, end_time] = createWeekdayTimeSelectElement(end, 'end_' + i)
container.appendChild(end_weekday);
container.appendChild(end_time);

const delete_btn = document.createElement('button');
delete_btn.type = "button";
delete_btn.value = i;
delete_btn.textContent = "🗑️";
delete_btn.onclick = () => {
const mainInput = document.getElementById("id_booking_slots");
deleteValueAtIndex(mainInput, i)
refreshBookingSlotInputs(mainInput)
}
container.appendChild(delete_btn)

return container;
}

function renderBookingSlotInputs(mainInput, input_container) {
// Need to replace single quotes with double quotes because single quotes are not JSON
const time_slots = JSON.parse(mainInput.value.replaceAll("'",'"'));

const slot_inputs = time_slots.map((ts, i) => renderBookingSlotInput(ts, i));

slot_inputs.forEach(e => input_container.appendChild(e));
}

function appendToElementValue(element, value) {
// The element is assumed to have content in JSON format
const tmp = JSON.parse(element.value.replaceAll("'",'"')).concat(value);
element.value = JSON.stringify(tmp);
}

function deleteValueAtIndex(element, id) {
// The element is assumed to have content in JSON format
const tmp = JSON.parse(element.value.replaceAll("'",'"'));
tmp.splice(id, 1);
element.value = JSON.stringify(tmp);
}

function refreshBookingSlotInputs(element) {
const input_container = element.parentNode.querySelector('.bookable-timeslots-input-container');
input_container.textContent = '';
renderBookingSlotInputs(element, input_container);
}

function addBookingSlotInputs(element) {
const input_container = document.createElement('div');
input_container.classList.add('bookable-timeslots-input-container');
element.parentNode.appendChild(input_container);

refreshBookingSlotInputs(element, input_container);

const more_timeslots_button = document.createElement('button');
more_timeslots_button.type = "button";
more_timeslots_button.textContent = "+";
more_timeslots_button.onclick = () => {
const mainInput = document.getElementById("id_booking_slots");
appendToElementValue(mainInput, [["",""]]);
refreshBookingSlotInputs(mainInput)
}

element.parentNode.appendChild(more_timeslots_button);

// element.style.visibility = "hidden";
}

$(document).ready(function() {
const slots_elem = document.getElementById("id_booking_slots");
addBookingSlotInputs(slots_elem);
});
26 changes: 26 additions & 0 deletions fars/booking/validators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from django.core.exceptions import ValidationError
from django.utils.translation import gettext as _
import time
import json

WEEKDAYS = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")

def validate_booking_slot(value):
if value == "": return
try:
time.strptime(value, "%a %H:%M")
except ValueError:
raise ValidationError(_("Invalid timestamp given for timeslot!"))

def validate_booking_slots(value):
booking_slots = json.loads(value.replace("'",'"'))
for slot in booking_slots:
try:
start, end = slot
except:
raise ValidationError(_("Invalid values given to booking slot!"))

if start == end:
raise ValidationError(_("Booking slot start and end cannot be the same."))
validate_booking_slot(start)
validate_booking_slot(end)
23 changes: 23 additions & 0 deletions fars/booking/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from booking.forms import BookingForm, RepeatingBookingForm, CustomLoginForm
from booking.metadata_forms import get_form_class
from datetime import datetime, timedelta
import time
import dateutil.parser
from django.utils.translation import gettext as _
from django.db import transaction
Expand Down Expand Up @@ -102,8 +103,30 @@ def dispatch(self, request, bookable):
def get(self, request, bookable):
booking = Booking()
booking.start = dateutil.parser.parse(request.GET['st']) if 'st' in request.GET else datetime.now()
# Remove the seconds and microseconds if they are present
booking.start = booking.start.replace(second=0, microsecond=0)
booking.end = dateutil.parser.parse(request.GET['et']) if 'et' in request.GET else booking.start + timedelta(hours=1)
booking.bookable = self.context['bookable']
print(booking.start, booking.end)

# if the bookable has defined bookable timeslots, move the start time and end time to the closest valid bookable timespans
if booking.bookable.has_bookable_timeslots():
starts, ends = booking.bookable.get_bookable_timeslots_by_start_and_end_time()

start_timestamps = list(map(lambda t: (booking.start + timedelta(int(time.strftime("%w", time.strptime(t, "%a %H:%M"))) - 1 - booking.start.weekday())).replace(hour=int(time.strftime("%H", time.strptime(t, "%a %H:%M"))), minute=int(time.strftime("%M", time.strptime(t, "%a %H:%M")))), starts))
valid_start_timestamps = list(map(lambda ts: ts + timedelta(days=7) if ts <= datetime.now(booking.start.tzinfo) else ts, start_timestamps))
start_timedeltas = list(map(lambda ts: (ts - booking.start).total_seconds(), valid_start_timestamps))
closest_index = start_timedeltas.index(min(start_timedeltas, key=abs))
booking.start += timedelta(seconds=start_timedeltas[closest_index])

end_timestamps = list(map(lambda t: (booking.end + timedelta(int(time.strftime("%w", time.strptime(t, "%a %H:%M"))) - 1 - booking.end.weekday())).replace(hour=int(time.strftime("%H", time.strptime(t, "%a %H:%M"))), minute=int(time.strftime("%M", time.strptime(t, "%a %H:%M")))), ends))
# if end timestamp is before booking start time, move it one week forward.
valid_end_timestamps = list(map(lambda ts: ts + timedelta(days=7) if ts <= booking.start else ts, end_timestamps))
end_timedeltas = list(map(lambda ts: (ts - booking.end).total_seconds(), valid_end_timestamps))
closest_index = end_timedeltas.index(min(end_timedeltas, key=abs))
booking.end += timedelta(seconds=end_timedeltas[closest_index])

print(booking.start, booking.end)
booking.user = request.user
form = get_form_class(booking.bookable.metadata_form)(instance=booking)
self.context['form'] = form
Expand Down