Skip to content

Commit a8da3b8

Browse files
authored
Merge pull request #109 from adamspd/90-allow-clients-to-reschedule-their-appointment-by-themselves
Allow clients to reschedule their appointment by themselves
2 parents 839decd + d8c5fb2 commit a8da3b8

37 files changed

+2345
-157
lines changed

appointment/admin.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
from django import forms
1010
from django.contrib import admin
1111

12-
from .models import (
13-
Appointment, AppointmentRequest, Config, DayOff, EmailVerificationCode, Service, StaffMember, WorkingHours)
12+
from .models import (Appointment, AppointmentRequest, AppointmentRescheduleHistory, Config, DayOff,
13+
EmailVerificationCode, Service, StaffMember, WorkingHours)
1414

1515

1616
@admin.register(Service)
@@ -76,3 +76,18 @@ class WorkingHoursAdmin(admin.ModelAdmin):
7676
list_display = ('staff_member', 'day_of_week', 'start_time', 'end_time')
7777
search_fields = ('day_of_week',)
7878
list_filter = ('day_of_week', 'start_time', 'end_time')
79+
80+
81+
@admin.register(AppointmentRescheduleHistory)
82+
class AppointmentRescheduleHistoryAdmin(admin.ModelAdmin):
83+
list_display = (
84+
'appointment_request', 'date', 'start_time',
85+
'end_time', 'staff_member', 'reason_for_rescheduling', 'created_at'
86+
)
87+
search_fields = (
88+
'appointment_request__id_request', 'staff_member__user__first_name',
89+
'staff_member__user__last_name', 'reason_for_rescheduling'
90+
)
91+
list_filter = ('appointment_request__service', 'date', 'created_at')
92+
date_hierarchy = 'created_at'
93+
ordering = ('-created_at',)

appointment/forms.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@
1111
from phonenumber_field.formfields import PhoneNumberField
1212
from phonenumber_field.widgets import PhoneNumberPrefixWidget
1313

14-
from .models import Appointment, AppointmentRequest, DayOff, Service, StaffMember, WorkingHours
14+
from .models import (
15+
Appointment, AppointmentRequest, AppointmentRescheduleHistory, DayOff, Service, StaffMember,
16+
WorkingHours
17+
)
1518
from .utils.db_helpers import get_user_model
1619

1720

@@ -21,6 +24,15 @@ class Meta:
2124
fields = ('date', 'start_time', 'end_time', 'service', 'staff_member')
2225

2326

27+
class ReschedulingForm(forms.ModelForm):
28+
class Meta:
29+
model = AppointmentRescheduleHistory
30+
fields = ['reason_for_rescheduling']
31+
widgets = {
32+
'reason_for_rescheduling': forms.Textarea(attrs={'rows': 4, 'placeholder': 'Reason for rescheduling...'}),
33+
}
34+
35+
2436
class AppointmentForm(forms.ModelForm):
2537
phone = PhoneNumberField(widget=PhoneNumberPrefixWidget(initial='US'))
2638

appointment/models.py

Lines changed: 95 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from django.core.validators import MaxLengthValidator, MinLengthValidator, MinValueValidator
1717
from django.db import models
1818
from django.urls import reverse
19+
from django.utils import timezone
1920
from django.utils.translation import gettext_lazy as _
2021
from phonenumber_field.modelfields import PhoneNumberField
2122

@@ -70,6 +71,14 @@ class Service(models.Model):
7071
image = models.ImageField(upload_to='services/', blank=True, null=True)
7172
currency = models.CharField(max_length=3, default='USD', validators=[MaxLengthValidator(3), MinLengthValidator(3)])
7273
background_color = models.CharField(max_length=50, null=True, blank=True, default="")
74+
reschedule_limit = models.PositiveIntegerField(
75+
default=0,
76+
help_text=_("Maximum number of times an appointment can be rescheduled.")
77+
)
78+
allow_rescheduling = models.BooleanField(
79+
default=False,
80+
help_text=_("Indicates whether appointments for this service can be rescheduled.")
81+
)
7382

7483
# meta data
7584
created_at = models.DateTimeField(auto_now_add=True)
@@ -88,6 +97,8 @@ def save(self, *args, **kwargs):
8897
# price shouldn't be negative
8998
if self.price < 0:
9099
raise ValidationError(_("Price cannot be negative"))
100+
if self.down_payment < 0:
101+
raise ValidationError(_("Down payment cannot be negative"))
91102
if self.background_color == "":
92103
self.background_color = generate_rgb_color()
93104
return super().save(*args, **kwargs)
@@ -287,6 +298,7 @@ class AppointmentRequest(models.Model):
287298
staff_member = models.ForeignKey(StaffMember, on_delete=models.SET_NULL, null=True)
288299
payment_type = models.CharField(max_length=4, choices=PAYMENT_TYPES, default='full')
289300
id_request = models.CharField(max_length=100, blank=True, null=True)
301+
reschedule_attempts = models.PositiveIntegerField(default=0)
290302

291303
# meta data
292304
created_at = models.DateTimeField(auto_now_add=True)
@@ -301,7 +313,6 @@ def clean(self):
301313
raise ValueError(_("Start time must be before end time"))
302314
if self.start_time == self.end_time:
303315
raise ValueError(_("Start time and end time cannot be the same"))
304-
305316
# Check for valid date
306317
try:
307318
# This will raise a ValueError if the date is not valid
@@ -359,6 +370,76 @@ def is_a_paid_service(self):
359370
def accepts_down_payment(self):
360371
return self.service.accepts_down_payment()
361372

373+
def can_be_rescheduled(self):
374+
return self.reschedule_attempts < self.service.reschedule_limit
375+
376+
def increment_reschedule_attempts(self):
377+
self.reschedule_attempts += 1
378+
self.save(update_fields=['reschedule_attempts'])
379+
380+
def get_reschedule_history(self):
381+
return self.reschedule_histories.all().order_by('-created_at')
382+
383+
384+
class AppointmentRescheduleHistory(models.Model):
385+
appointment_request = models.ForeignKey(
386+
'AppointmentRequest',
387+
on_delete=models.CASCADE, related_name='reschedule_histories'
388+
)
389+
date = models.DateField(help_text=_("The previous date of the appointment before it was rescheduled."))
390+
start_time = models.TimeField(
391+
help_text=_("The previous start time of the appointment before it was rescheduled.")
392+
)
393+
end_time = models.TimeField(
394+
help_text=_("The previous end time of the appointment before it was rescheduled.")
395+
)
396+
staff_member = models.ForeignKey(
397+
StaffMember, on_delete=models.SET_NULL, null=True,
398+
help_text=_("The previous staff member of the appointment before it was rescheduled.")
399+
)
400+
reason_for_rescheduling = models.TextField(
401+
blank=True, null=True,
402+
help_text=_("Reason for the appointment reschedule.")
403+
)
404+
reschedule_status = models.CharField(
405+
max_length=10,
406+
choices=[('pending', 'Pending'), ('confirmed', 'Confirmed')],
407+
default='pending',
408+
help_text=_("Indicates the status of the reschedule action.")
409+
)
410+
id_request = models.CharField(max_length=100, blank=True, null=True)
411+
412+
# meta data
413+
created_at = models.DateTimeField(auto_now_add=True, help_text=_("The date and time the reschedule was recorded."))
414+
updated_at = models.DateTimeField(auto_now=True, help_text=_("The date and time the reschedule was confirmed."))
415+
416+
class Meta:
417+
verbose_name = _("Appointment Reschedule History")
418+
verbose_name_plural = _("Appointment Reschedule Histories")
419+
ordering = ['-created_at']
420+
421+
def __str__(self):
422+
return f"Reschedule history for {self.appointment_request} from {self.date}"
423+
424+
def save(self, *args, **kwargs):
425+
# if no id_request is provided, generate one
426+
if self.id_request is None:
427+
self.id_request = f"{get_timestamp()}{generate_random_id()}"
428+
# date should not be in the past
429+
if self.date < datetime.date.today():
430+
raise ValidationError(_("Date cannot be in the past"))
431+
try:
432+
datetime.datetime.strptime(str(self.date), '%Y-%m-%d')
433+
except ValueError:
434+
raise ValidationError(_("The date is not valid"))
435+
return super().save(*args, **kwargs)
436+
437+
def still_valid(self):
438+
# if more than 5 minutes have passed, it is no longer valid
439+
now = timezone.now() # This is offset-aware to match self.created_at
440+
delta = now - self.created_at
441+
return delta.total_seconds() < 300
442+
362443

363444
class Appointment(models.Model):
364445
"""
@@ -578,8 +659,19 @@ class Config(models.Model):
578659
default="",
579660
help_text=_("Name of your website."),
580661
)
581-
app_offered_by_label = models.CharField(max_length=255, default=_("Offered by"),
582-
help_text=_("Label for `Offered by` on the appointment page"))
662+
app_offered_by_label = models.CharField(
663+
max_length=255,
664+
default=_("Offered by"),
665+
help_text=_("Label for `Offered by` on the appointment page")
666+
)
667+
default_reschedule_limit = models.PositiveIntegerField(
668+
default=3,
669+
help_text=_("Default maximum number of times an appointment can be rescheduled across all services.")
670+
)
671+
allow_staff_change_on_reschedule = models.BooleanField(
672+
default=True,
673+
help_text=_("Allows clients to change the staff member when rescheduling an appointment.")
674+
)
583675

584676
# meta data
585677
created_at = models.DateTimeField(auto_now_add=True)

appointment/services.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@
2323
from appointment.utils.db_helpers import (
2424
Appointment, AppointmentRequest, EmailVerificationCode, Service, StaffMember, WorkingHours, calculate_slots,
2525
calculate_staff_slots, check_day_off_for_staff, create_and_save_appointment, create_new_user,
26-
day_off_exists_for_date_range, exclude_booked_slots, get_all_appointments, get_all_staff_members,
26+
day_off_exists_for_date_range, exclude_booked_slots, exclude_pending_reschedules, get_all_appointments,
27+
get_all_staff_members,
2728
get_appointment_by_id, get_appointments_for_date_and_time, get_staff_member_appointment_list,
2829
get_staff_member_from_user_id_or_logged_in, get_times_from_config, get_user_by_email,
2930
get_working_hours_for_staff_and_day, parse_name, update_appointment_reminder, working_hours_exist)
@@ -304,7 +305,7 @@ def get_working_hours_and_days_off_context(request, btn_txt, form_name, form, us
304305
return context
305306

306307

307-
def save_appointment(appt, client_name, client_email, start_time, phone_number, client_address, service_id,
308+
def save_appointment(appt, client_name, client_email, start_time, phone_number, client_address, service_id, request,
308309
want_reminder=False, additional_info=None):
309310
"""Save an appointment's details.
310311
:return: The modified appointment.
@@ -329,7 +330,7 @@ def save_appointment(appt, client_name, client_email, start_time, phone_number,
329330

330331
# Update reminder here
331332
update_appointment_reminder(appointment=appt, new_date=appt_request.date, new_start_time=start_time,
332-
want_reminder=want_reminder)
333+
want_reminder=want_reminder, request=request)
333334

334335
appt_request.service = service
335336
appt_request.start_time = start_time
@@ -345,12 +346,13 @@ def save_appointment(appt, client_name, client_email, start_time, phone_number,
345346
return appt
346347

347348

348-
def save_appt_date_time(appt_start_time, appt_date, appt_id):
349+
def save_appt_date_time(appt_start_time, appt_date, appt_id, request):
349350
"""Save the date and time of an appointment request.
350351
351352
:param appt_start_time: The start time of the appointment request.
352353
:param appt_date: The date of the appointment request.
353354
:param appt_id: The ID of the appointment to modify.
355+
:param request: The request object.
354356
:return: The modified appointment.
355357
"""
356358
appt = Appointment.objects.get(id=appt_id)
@@ -373,7 +375,8 @@ def save_appt_date_time(appt_start_time, appt_date, appt_id):
373375
appt_date_obj = appt_date
374376

375377
# Update reminder here
376-
update_appointment_reminder(appointment=appt, new_date=appt_date_obj, new_start_time=appt_start_time_obj)
378+
update_appointment_reminder(appointment=appt, new_date=appt_date_obj, new_start_time=appt_start_time_obj,
379+
request=request)
377380

378381
# Modify and save appointment request details
379382
appt_request = appt.appointment_request
@@ -422,6 +425,7 @@ def get_available_slots_for_staff(date, staff_member):
422425

423426
slot_duration = datetime.timedelta(minutes=staff_member.get_slot_duration())
424427
slots = calculate_staff_slots(date, staff_member)
428+
slots = exclude_pending_reschedules(slots, staff_member, date)
425429
appointments = get_appointments_for_date_and_time(date, working_hours_dict['start_time'],
426430
working_hours_dict['end_time'], staff_member)
427431
slots = exclude_booked_slots(appointments, slots, slot_duration)
@@ -594,7 +598,7 @@ def create_new_appointment(data, request):
594598
'additional_info': data.get("additional_info", ""),
595599
'paid': False
596600
}
597-
appointment = create_and_save_appointment(appointment_request, client_data, appointment_data)
601+
appointment = create_and_save_appointment(appointment_request, client_data, appointment_data, request)
598602
appointment_list = convert_appointment_to_json(request, [appointment])
599603

600604
return json_response("Appointment created successfully.", custom_data={'appt': appointment_list})
@@ -613,7 +617,8 @@ def update_existing_appointment(data, request):
613617
client_address=data.get("client_address"),
614618
service_id=data.get("service_id"),
615619
want_reminder=want_reminder,
616-
additional_info=data.get("additional_info")
620+
additional_info=data.get("additional_info"),
621+
request=request,
617622
)
618623
appointments_json = convert_appointment_to_json(request, [appt])[0]
619624
return json_response(appt_updated_successfully, custom_data={'appt': appointments_json})

appointment/settings.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,6 @@ def check_q_cluster():
6565
for warning in missing_conf:
6666
logger.warning(warning)
6767
return False
68-
print(f"Mising conf: {missing_conf}")
68+
print(f"Missing conf: {missing_conf}")
6969
# Both 'django_q' is installed and 'Q_CLUSTER' is configured
7070
return True
123 KB
Loading
4.57 KB
Loading
6.61 KB
Loading

appointment/static/js/appointments.js

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@ const calendarEl = document.getElementById('calendar');
22
let nextAvailableDateSelector = $('.djangoAppt_next-available-date')
33
const body = $('body');
44
let nonWorkingDays = [];
5-
let selectedDate = null;
5+
let selectedDate = rescheduledDate || null;
66
let staffId = $('#staff_id').val() || null;
77
let previouslySelectedCell = null;
88

99
const calendar = new FullCalendar.Calendar(calendarEl, {
1010
initialView: 'dayGridMonth',
11+
initialDate: selectedDate,
1112
headerToolbar: {
1213
left: 'title',
1314
right: 'prev,today,next',
@@ -43,6 +44,9 @@ const calendar = new FullCalendar.Calendar(calendarEl, {
4344
selectedDate = info.dateStr;
4445
getAvailableSlots(info.dateStr, staffId);
4546
},
47+
datesSet: function (info) {
48+
highlightSelectedDate();
49+
},
4650
selectAllow: function (info) {
4751
const day = info.start.getDay(); // Get the day of the week (0 for Sunday, 6 for Saturday)
4852
if (nonWorkingDays.includes(day)) {
@@ -64,10 +68,20 @@ calendar.setOption('locale', locale);
6468
$(document).ready(function () {
6569
staffId = $('#staff_id').val() || null;
6670
calendar.render();
67-
const currentDate = moment.tz(timezone).format('YYYY-MM-DD');
71+
const currentDate = rescheduledDate || moment.tz(timezone).format('YYYY-MM-DD');
6872
getAvailableSlots(currentDate, staffId);
6973
});
7074

75+
function highlightSelectedDate() {
76+
setTimeout(function () {
77+
const dateCell = document.querySelector(`.fc-daygrid-day[data-date='${selectedDate}']`);
78+
if (dateCell) {
79+
dateCell.classList.add('selected-cell');
80+
previouslySelectedCell = dateCell;
81+
}
82+
}, 10);
83+
}
84+
7185
body.on('click', '.djangoAppt_btn-request-next-slot', function () {
7286
const serviceId = $(this).data('service-id');
7387
requestNextAvailableSlot(serviceId);
@@ -91,13 +105,22 @@ body.on('click', '.btn-submit-appointment', function () {
91105
const date = formattedDate.toISOString().slice(0, 10);
92106
const endTimeDate = new Date(formattedDate.getTime() + serviceDuration * 60000);
93107
const endTime = formatTime(endTimeDate);
94-
108+
const reasonForRescheduling = $('#reason_for_rescheduling').val();
95109
const form = $('.appointment-form');
96-
110+
let formAction = rescheduledDate ? appointmentRescheduleURL : appointmentRequestSubmitURL;
111+
form.attr('action', formAction);
112+
if (!form.find('input[name="appointment_request_id"]').length) {
113+
form.append($('<input>', {
114+
type: 'hidden',
115+
name: 'appointment_request_id',
116+
value: appointmentRequestId
117+
}));
118+
}
97119
form.append($('<input>', {type: 'hidden', name: 'date', value: date}));
98120
form.append($('<input>', {type: 'hidden', name: 'start_time', value: startTime}));
99121
form.append($('<input>', {type: 'hidden', name: 'end_time', value: endTime}));
100122
form.append($('<input>', {type: 'hidden', name: 'service', value: serviceId}));
123+
form.append($('<input>', {type: 'hidden', name: 'reason_for_rescheduling', value: reasonForRescheduling}));
101124
form.submit();
102125
} else {
103126
const warningContainer = $('.warning-message');
@@ -197,7 +220,7 @@ function getAvailableSlots(selectedDate, staffId = null) {
197220
// Check if 'staffId' is 'none', null, or undefined and display an error message
198221
if (staffId === 'none' || staffId === null || staffId === undefined) {
199222
console.log('No staff ID provided, displaying error message.');
200-
const errorMessage = $('<p class="djangoAppt_no-availability-text">'+ noStaffMemberSelectedTxt + '</p>');
223+
const errorMessage = $('<p class="djangoAppt_no-availability-text">' + noStaffMemberSelectedTxt + '</p>');
201224
errorMessageContainer.append(errorMessage);
202225
// Optionally disable the "submit" button here
203226
$('.btn-submit-appointment').attr('disabled', 'disabled');

0 commit comments

Comments
 (0)