Skip to content

Commit 8a89d9f

Browse files
committed
Emails and ics file contains recurrence if exists
Selected date on calendar is the same in "until" field by default Added documentation (needs update) Added RecurrenceAppointment object in admin.py Removed the JS for recurring appointment in HTML file (added in its own js file)
1 parent 7b96afd commit 8a89d9f

File tree

14 files changed

+658
-382
lines changed

14 files changed

+658
-382
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ task scheduling. It also allows clients to reschedule appointments if it is allo
2929
Django-Appointment is a Django app engineered for managing appointment scheduling with ease and flexibility. It enables
3030
users to define custom configurations for time slots, lead time, and finish time, or use the default values
3131
provided. This app proficiently manages conflicts and availability for appointments, ensuring a seamless user
32-
experience.
32+
experience. It now also includes the capability to schedule recurring appointments.
3333

3434
For a detailed walkthrough and live example of the system, please refer to
3535
[this tutorial](https://github.com/adamspd/django-appointment/tree/main/docs/explanation.md).
@@ -52,6 +52,7 @@ and [here](https://github.com/adamspd/django-appointment/tree/main/docs/release_
5252
- Automated email reminders sent 24 hours before the appointment (requires Django Q).
5353
- ICS file attachment for calendar synchronization.
5454
7. Integration with Django Q for efficient task scheduling and email sending.
55+
8. Recurring Appointments: Schedule appointments that repeat daily, weekly, or monthly, with a 3-month default end date.
5556

5657
## Key features introduced in previous versions.
5758

appointment/admin.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
from .models import (
1313
Appointment, AppointmentRequest, AppointmentRescheduleHistory, Config, DayOff, EmailVerificationCode,
14-
PasswordResetToken, Service, StaffMember, WorkingHours
14+
PasswordResetToken, RecurringAppointment, Service, StaffMember, WorkingHours
1515
)
1616

1717

@@ -36,6 +36,15 @@ class AppointmentAdmin(admin.ModelAdmin):
3636
list_filter = ('client', 'appointment_request__service',)
3737

3838

39+
@admin.register(RecurringAppointment)
40+
class RecurringAppointmentAdmin(admin.ModelAdmin):
41+
list_display = ('appointment_request', 'recurrence_rule', 'end_date', 'is_active', 'created_at',)
42+
search_fields = ('appointment_request__service__name',)
43+
list_filter = ('end_date', 'recurrence_rule')
44+
date_hierarchy = 'created_at'
45+
ordering = ('-created_at',)
46+
47+
3948
@admin.register(EmailVerificationCode)
4049
class EmailVerificationCodeAdmin(admin.ModelAdmin):
4150
list_display = ('user', 'code')

appointment/models.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,17 @@ def increment_reschedule_attempts(self):
364364
def get_reschedule_history(self):
365365
return self.reschedule_histories.all().order_by('-created_at')
366366

367+
def is_recurring(self):
368+
"""Check if this appointment request is recurring."""
369+
return hasattr(self, 'recurring_info') and self.recurring_info is not None
370+
371+
@property
372+
def recurrence_description(self):
373+
"""Get the recurrence description if this is a recurring appointment."""
374+
if self.is_recurring():
375+
return self.recurring_info.get_recurrence_description()
376+
return None
377+
367378

368379
class AppointmentRescheduleHistory(models.Model):
369380
appointment_request = models.ForeignKey(
@@ -668,6 +679,81 @@ def occurs_on_date(self, target_date):
668679
logger.error(f"Error checking recurrence for {self}: {e}")
669680
return False
670681

682+
def get_recurrence_description(self):
683+
"""Get a human-readable description of the recurrence pattern."""
684+
from django.utils.translation import gettext as _
685+
686+
if not self.recurrence_rule or not self.recurrence_rule.rrules:
687+
return None
688+
689+
rrule = self.recurrence_rule.rrules[0]
690+
description_parts = []
691+
692+
# Handle weekly with specific days
693+
if rrule.freq == 2: # Weekly
694+
weekday_map = {
695+
0: _('Monday'), 1: _('Tuesday'), 2: _('Wednesday'), 3: _('Thursday'),
696+
4: _('Friday'), 5: _('Saturday'), 6: _('Sunday')
697+
}
698+
699+
days = []
700+
if hasattr(rrule, 'byday') and rrule.byday:
701+
for weekday in rrule.byday:
702+
if isinstance(weekday, int) and weekday in weekday_map:
703+
days.append(str(weekday_map[weekday]))
704+
705+
if days:
706+
if len(days) == 1:
707+
description_parts.append(str(_('Every week on {day}').format(day=days[0])))
708+
elif len(days) == 7:
709+
description_parts.append(str(_('Every day')))
710+
else:
711+
description_parts.append(str(_('Every week on {days}').format(days=', '.join(days))))
712+
else:
713+
# If no specific days are set, fall back to using the appointment start date
714+
appointment_date = self.appointment_request.date
715+
weekday_num = appointment_date.weekday() # 0=Monday, 6=Sunday
716+
day_name = str(weekday_map[weekday_num])
717+
description_parts.append(str(_('Every week on {day}').format(day=day_name)))
718+
719+
elif rrule.freq == 1: # Monthly
720+
# Get the day of the month from the original appointment date
721+
appointment_date = self.appointment_request.date
722+
day_of_month = appointment_date.day
723+
724+
# Format with ordinal (1st, 2nd, 3rd, etc.)
725+
if 10 <= day_of_month % 100 <= 20:
726+
suffix = 'th'
727+
else:
728+
suffix = {1: 'st', 2: 'nd', 3: 'rd'}.get(day_of_month % 10, 'th')
729+
730+
description_parts.append(str(_('Every month on the {day}').format(day=f"{day_of_month}{suffix}")))
731+
732+
elif rrule.freq == 3: # Daily
733+
description_parts.append(str(_('Every day')))
734+
elif rrule.freq == 0: # Yearly
735+
# Get the date from the original appointment
736+
appointment_date = self.appointment_request.date
737+
date_str = appointment_date.strftime('%B %d') # e.g., "July 15"
738+
description_parts.append(str(_('Every year on {date}').format(date=date_str)))
739+
else:
740+
# Other frequencies
741+
freq_map = {
742+
4: _('Every hour'),
743+
5: _('Every minute'),
744+
6: _('Every second')
745+
}
746+
frequency = freq_map.get(rrule.freq, _('Unknown frequency'))
747+
description_parts.append(str(frequency))
748+
749+
# Add end date if present
750+
if self.end_date:
751+
description_parts.append(str(_('until {date}').format(date=self.end_date.strftime('%B %d, %Y'))))
752+
elif hasattr(rrule, 'until') and rrule.until:
753+
description_parts.append(str(_('until {date}').format(date=rrule.until.strftime('%B %d, %Y'))))
754+
755+
return ' '.join(description_parts)
756+
671757

672758
class Config(models.Model):
673759
"""

appointment/static/js/appointments.js

Lines changed: 60 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -93,83 +93,90 @@ body.on('click', '.djangoAppt_btn-request-next-slot', function () {
9393
})
9494

9595
body.on('click', '.btn-submit-appointment', function (e) {
96-
e.preventDefault(); // Prevent default form submission
96+
e.preventDefault();
9797

98-
const selectedSlot = $('.djangoAppt_appointment-slot.selected').text();
99-
const selectedDate = $('.djangoAppt_date_chosen').text();
98+
const $slot = $('.djangoAppt_appointment-slot.selected');
99+
const $date = $('.djangoAppt_date_chosen');
100+
const selectedSlot = $slot.text().trim();
101+
const selectedDate = $date.text().trim();
100102

101-
// Validate that both slot and date are selected
103+
// Validate selection
102104
if (!selectedSlot || !selectedDate) {
103105
alert(selectDateAndTimeAlertTxt);
104106
return;
105107
}
106108

107-
// Additional validation - check if we actually have meaningful data
109+
// Additional validation
110+
const $warning = $('.warning-message');
108111
if (!selectedSlot.trim() || !selectedDate.trim()) {
109-
const warningContainer = $('.warning-message');
110-
if (warningContainer.find('.submit-warning').length === 0) {
111-
warningContainer.append('<p class="submit-warning">' + selectTimeSlotWarningTxt + '</p>');
112+
if ($warning.find('.submit-warning').length === 0) {
113+
$warning.append(`<p class="submit-warning">${selectTimeSlotWarningTxt}</p>`);
112114
}
113115
return;
114116
}
115117

118+
// Extract time and date
116119
const startTime = convertTo24Hour(selectedSlot);
117-
const dateParts = selectedDate.split(', ');
118-
const monthDayYear = dateParts[1] + "," + dateParts[2];
119-
const formattedDate = new Date(monthDayYear + " " + startTime);
120-
const date = formattedDate.toISOString().slice(0, 10);
121-
const endTimeDate = new Date(formattedDate.getTime() + serviceDuration * 60000);
122-
const endTime = formatTime(endTimeDate);
123120

124-
// Get the staff member value
125-
const staffMember = $('#staff_id').val();
121+
const dateParts = selectedDate.split(', '); // e.g. ["Tuesday", "June 11", "2025"]
122+
if (dateParts.length < 3) {
123+
alert('Invalid date format. Please select a valid appointment date.');
124+
return;
125+
}
126126

127-
// Check if it's a recurring appointment
128-
const isRecurring = $('#id_is_recurring').is(':checked');
127+
const monthDayYear = `${dateParts[1]} ${dateParts[2]}`; // "June 11 2025"
128+
const formattedDate = new Date(`${monthDayYear} ${startTime}`);
129+
if (isNaN(formattedDate)) {
130+
console.error('Invalid formatted date:', `${monthDayYear} ${startTime}`);
131+
alert('Invalid date/time. Please verify your selections.');
132+
return;
133+
}
129134

130-
if (isRecurring) {
131-
// Use the recurring form
132-
const recurringForm = $('.appointment-form');
135+
const date = formattedDate.toISOString().slice(0, 10);
136+
const endTime = formatTime(new Date(formattedDate.getTime() + serviceDuration * 60000));
133137

134-
// Populate hidden fields for recurring appointments
135-
$('#hidden_staff_member').val(staffMember);
136-
$('#hidden_selected_date').val(date);
137-
$('#hidden_selected_time').val(startTime);
138+
const staffMember = $('#staff_id').val();
139+
const isRecurring = $('#id_is_recurring').is(':checked');
140+
const $form = $('.appointment-form');
141+
142+
// Prepare hidden fields
143+
const hiddenFields = [
144+
{ name: 'date', value: date },
145+
{ name: 'start_time', value: startTime },
146+
{ name: 'end_time', value: endTime },
147+
{ name: 'service', value: serviceId },
148+
{ name: 'staff_member', value: staffMember }
149+
];
150+
151+
if (rescheduledDate) {
152+
hiddenFields.push({
153+
name: 'reason_for_rescheduling',
154+
value: $('#reason_for_rescheduling').val()
155+
});
156+
}
138157

139-
if (rescheduledDate) {
140-
const reasonForRescheduling = $('#reason_for_rescheduling').val();
141-
$('#hidden_reason_for_rescheduling').val(reasonForRescheduling);
142-
}
158+
if (!isRecurring) {
159+
const actionUrl = rescheduledDate ? appointmentRescheduleURL : appointmentRequestSubmitURL;
160+
$form.attr('action', actionUrl);
143161

144-
// Submit the form with recurring data
145-
recurringForm.submit();
146-
} else {
147-
// Use the original form for single appointments
148-
const form = $('.appointment-form');
149-
let formAction = rescheduledDate ? appointmentRescheduleURL : appointmentRequestSubmitURL;
150-
form.attr('action', formAction);
151-
152-
// Add hidden fields to original form
153-
if (!form.find('input[name="appointment_request_id"]').length && rescheduledDate) {
154-
form.append($('<input>', {
155-
type: 'hidden',
162+
if (rescheduledDate && !$form.find('input[name="appointment_request_id"]').length) {
163+
hiddenFields.push({
156164
name: 'appointment_request_id',
157165
value: appointmentRequestId
158-
}));
159-
}
160-
161-
form.append($('<input>', {type: 'hidden', name: 'date', value: date}));
162-
form.append($('<input>', {type: 'hidden', name: 'start_time', value: startTime}));
163-
form.append($('<input>', {type: 'hidden', name: 'end_time', value: endTime}));
164-
form.append($('<input>', {type: 'hidden', name: 'service', value: serviceId}));
165-
166-
if (rescheduledDate) {
167-
const reasonForRescheduling = $('#reason_for_rescheduling').val();
168-
form.append($('<input>', {type: 'hidden', name: 'reason_for_rescheduling', value: reasonForRescheduling}));
166+
});
169167
}
168+
}
170169

171-
form.submit();
170+
// Append fields and submit
171+
for (const field of hiddenFields) {
172+
$form.append($('<input>', {
173+
type: 'hidden',
174+
name: field.name,
175+
value: field.value
176+
}));
172177
}
178+
179+
$form.submit();
173180
});
174181

175182
$('#staff_id').on('change', function () {

0 commit comments

Comments
 (0)