From 079c7cfc6abcdce1ebc13b731bf776cebdea0321 Mon Sep 17 00:00:00 2001 From: Adams Pierre David <57180807+adamspd@users.noreply.github.com> Date: Tue, 10 Jun 2025 00:19:50 +0200 Subject: [PATCH 1/5] Fixed requirements.txt # Conflicts: # requirements.txt --- appointment/forms.py | 17 +- appointment/models.py | 63 ++- appointment/services.py | 4 +- appointment/static/css/appointments.css | 2 +- .../static/css/recurring-enhancement.css | 438 ++++++++++++++++++ appointment/static/js/appointments.js | 82 +++- .../templates/appointment/appointments.html | 378 ++++++++++++++- appointment/utils/db_helpers.py | 96 +++- appointment/views.py | 53 ++- requirements.txt | 2 + 10 files changed, 1066 insertions(+), 69 deletions(-) create mode 100644 appointment/static/css/recurring-enhancement.css diff --git a/appointment/forms.py b/appointment/forms.py index 0d676c0..7282c40 100644 --- a/appointment/forms.py +++ b/appointment/forms.py @@ -25,9 +25,24 @@ class SlotForm(forms.Form): class AppointmentRequestForm(forms.ModelForm): + is_recurring = forms.BooleanField(required=False, label=_("Recurring appointment")) + recurrence_rule = forms.CharField(required=False, widget=forms.HiddenInput()) + end_recurrence = forms.DateField(required=False, label=_("End date"), + widget=forms.DateInput(attrs={'type': 'date'})) + class Meta: model = AppointmentRequest - fields = ('date', 'start_time', 'end_time', 'service', 'staff_member') + fields = ['date', 'start_time', 'end_time', 'service', 'staff_member'] + + def clean(self): + cleaned_data = super().clean() + is_recurring = cleaned_data.get('is_recurring') + recurrence_rule = cleaned_data.get('recurrence_rule') + + if is_recurring and not recurrence_rule: + raise forms.ValidationError(_("Recurrence rule is required for recurring appointments.")) + + return cleaned_data class ReschedulingForm(forms.ModelForm): diff --git a/appointment/models.py b/appointment/models.py index 11c8597..9b2e9ea 100644 --- a/appointment/models.py +++ b/appointment/models.py @@ -10,7 +10,6 @@ import random import string import uuid -from decimal import Decimal, InvalidOperation from babel.numbers import get_currency_symbol from django.conf import settings @@ -21,7 +20,9 @@ from django.utils import timezone from django.utils.translation import gettext_lazy as _ from phonenumber_field.modelfields import PhoneNumberField +from recurrence.fields import RecurrenceField +from appointment.logger_config import get_logger from appointment.utils.date_time import convert_minutes_in_human_readable_format, get_timestamp, get_weekday_num, \ time_difference from appointment.utils.view_helpers import generate_random_id, get_locale @@ -41,6 +42,7 @@ (6, 'Saturday'), ) +logger = get_logger(__name__) def generate_rgb_color(): hue = random.random() # Random hue between 0 and 1 @@ -614,6 +616,59 @@ def to_dict(self): } +class RecurringAppointment(models.Model): + appointment_request = models.OneToOneField(AppointmentRequest, on_delete=models.CASCADE, + related_name='recurring_info') + recurrence_rule = RecurrenceField() + end_date = models.DateField(null=True, blank=True, help_text=_("When the recurring pattern ends")) + is_active = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = 'appointment_recurring_appointment' + + def __str__(self): + return f"Recurring: {self.appointment_request}" + + def occurs_on_date(self, target_date): + """Check if this recurring appointment occurs on the given date.""" + if not self.is_active: + return False + + if target_date < self.appointment_request.date: + return False + + if self.end_date and target_date > self.end_date: + return False + + from appointment.utils.date_time import combine_date_and_time + from django.utils import timezone + + # Create datetime for the start of this recurring pattern + dtstart = combine_date_and_time(self.appointment_request.date, self.appointment_request.start_time) + if timezone.is_naive(dtstart): + dtstart = timezone.make_aware(dtstart) + + # Check if target_date is in the recurrence pattern + target_datetime = combine_date_and_time(target_date, self.appointment_request.start_time) + if timezone.is_naive(target_datetime): + target_datetime = timezone.make_aware(target_datetime) + + try: + # Use django-recurrence to check if this date matches the pattern + occurrences = self.recurrence_rule.between( + target_datetime, + target_datetime + timezone.timedelta(days=1), + dtstart=dtstart, + inc=True + ) + return len(occurrences) > 0 + + except (AttributeError, ValueError, TypeError) as e: + logger.error(f"Error checking recurrence for {self}: {e}") + return False + + class Config(models.Model): """ Represents configuration settings for the appointment system. There can only be one Config object in the database. @@ -657,6 +712,10 @@ class Config(models.Model): default=True, help_text=_("Allows clients to change the staff member when rescheduling an appointment.") ) + max_recurring_months = models.PositiveIntegerField( + default=3, + help_text=_("Maximum number of months a recurring appointment can be set for.") + ) # meta data created_at = models.DateTimeField(auto_now_add=True) @@ -688,7 +747,7 @@ def get_instance(cls): def __str__(self): return f"Config {self.pk}: slot_duration={self.slot_duration}, lead_time={self.lead_time}, " \ - f"finish_time={self.finish_time}" + f"finish_time={self.finish_time}, etc." class PaymentInfo(models.Model): diff --git a/appointment/services.py b/appointment/services.py index c890725..507289a 100644 --- a/appointment/services.py +++ b/appointment/services.py @@ -23,7 +23,8 @@ from appointment.utils.db_helpers import ( Appointment, AppointmentRequest, EmailVerificationCode, Service, StaffMember, WorkingHours, calculate_slots, calculate_staff_slots, check_day_off_for_staff, create_and_save_appointment, create_new_user, - day_off_exists_for_date_range, exclude_booked_slots, exclude_pending_reschedules, get_all_appointments, + day_off_exists_for_date_range, exclude_booked_slots, exclude_pending_reschedules, exclude_recurring_appointments, + get_all_appointments, get_all_staff_members, get_appointment_by_id, get_appointments_for_date_and_time, get_staff_member_appointment_list, get_staff_member_from_user_id_or_logged_in, get_times_from_config, get_user_by_email, @@ -432,6 +433,7 @@ def get_available_slots_for_staff(date, staff_member): slot_duration = datetime.timedelta(minutes=staff_member.get_slot_duration()) slots = calculate_staff_slots(date, staff_member) slots = exclude_pending_reschedules(slots, staff_member, date) + slots = exclude_recurring_appointments(slots, staff_member, date, slot_duration) appointments = get_appointments_for_date_and_time(date, working_hours_dict['start_time'], working_hours_dict['end_time'], staff_member) return exclude_booked_slots(appointments, slots, slot_duration) diff --git a/appointment/static/css/appointments.css b/appointment/static/css/appointments.css index aa75c54..915af95 100644 --- a/appointment/static/css/appointments.css +++ b/appointment/static/css/appointments.css @@ -14,4 +14,4 @@ .fc, .fc-button { padding: .3em .45em !important; } -} \ No newline at end of file +} diff --git a/appointment/static/css/recurring-enhancement.css b/appointment/static/css/recurring-enhancement.css new file mode 100644 index 0000000..ce14ebe --- /dev/null +++ b/appointment/static/css/recurring-enhancement.css @@ -0,0 +1,438 @@ +/* Keep original two-column layout intact */ +.djangoAppt_page-body { + display: flex; + flex-direction: row; + margin-top: 50px; +} + +.djangoAppt_appointment-calendar { + flex: 3; + padding: 20px; + background-color: #fff; + border-radius: 5px; + box-shadow: 0 0 2px rgba(0, 0, 0, 0.1); +} + +.djangoAppt_service-description { + flex: 1; + margin-left: 20px; + padding: 20px; + background-color: #fff; + border-radius: 5px; + box-shadow: 0 0 2px rgba(0, 0, 0, 0.1); +} + +/* Full-width bottom section for recurring + submit */ +.djangoAppt_bottom-section { + background-color: #fff; + border-radius: 5px; + box-shadow: 0 0 2px rgba(0, 0, 0, 0.1); + padding: 20px; + margin-top: 20px; + width: 100%; +} + +/* Submit button section */ +.djangoAppt_submit-section { + text-align: center; + margin-top: 20px; + padding-top: 20px; + border-top: 1px solid #eee; +} + +.djangoAppt_submit-section .btn-submit-appointment { + padding: 12px 30px; + font-size: 16px; + font-weight: 500; +} + +/* Update recurring section for full width */ +.recurring-appointment-section { + margin-top: 0; + padding-top: 0; + border-top: none; +} + +/* Improve recurring form grid for horizontal layout */ +.recurring-form-grid { + display: grid; + grid-template-columns: auto auto 1fr; + gap: 20px; + align-items: start; + margin-bottom: 20px; +} + +/* Day picker takes full width when weekly is selected */ +#weekly_day_selection { + grid-column: 1 / -1; /* Span all columns */ +} + +/* Responsive behavior - keep your existing responsive rules */ +@media (max-width: 1199px) { + .djangoAppt_page-body { + flex-direction: column; + } + + .djangoAppt_appointment-calendar { + flex: 1; + padding: 10px; + } + + .djangoAppt_service-description { + flex: 1; + margin-left: 0; + margin-top: 20px; + } + + /* Stack recurring form vertically on smaller screens */ + .recurring-form-grid { + grid-template-columns: 1fr; + gap: 15px; + } + + #weekly_day_selection { + grid-column: 1; + } +} + +@media (max-width: 768px) { + .djangoAppt_bottom-section { + padding: 15px; + margin-top: 15px; + } + + .djangoAppt_submit-section .btn-submit-appointment { + width: 100%; + padding: 15px; + } +} + +@media (max-width: 450px) { + .djangoAppt_bottom-section { + padding: 10px; + } + + .recurring-form-grid { + gap: 12px; + } +} + +/* Ultra Compact Recurring Settings + Right-Aligned Submit */ +/* Recurring Section Wrapper - Full Width */ +.recurring-section-wrapper { + margin-top: 20px; + padding: 15px 20px; + background-color: #f8f9fa; + border-radius: 6px; + border: 1px solid #e0e0e0; +} + +/* Toggle Switch - Minimal */ +.recurring-toggle-wrapper { + margin-bottom: 12px; +} + +.recurring-toggle-label { + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; + padding: 8px 12px; + background-color: white; + border-radius: 4px; + border: 1px solid #ddd; + transition: background-color 0.2s ease; + width: fit-content; +} + +.recurring-toggle-label:hover { + background-color: #f0f0f0; +} + +.recurring-checkbox { + position: absolute; + opacity: 0; + width: 0; + height: 0; +} + +.recurring-toggle-switch { + position: relative; + display: inline-block; + width: 36px; + height: 20px; + background-color: #ccc; + border-radius: 20px; + transition: background-color 0.3s; +} + +.recurring-toggle-switch:before { + content: ""; + position: absolute; + height: 16px; + width: 16px; + left: 2px; + top: 2px; + background-color: white; + border-radius: 50%; + transition: transform 0.3s; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); +} + +.recurring-checkbox:checked + .recurring-toggle-switch { + background-color: #0c042c; +} + +.recurring-checkbox:checked + .recurring-toggle-switch:before { + transform: translateX(16px); +} + +.recurring-toggle-text { + font-weight: 500; + color: #333; + font-size: 14px; +} + +/* Ultra Compact Configuration Panel */ +.recurring-config-panel { + background-color: white; + border: 1px solid #ddd; + border-radius: 4px; + padding: 12px; + margin-top: 8px; +} + +/* Super Tight Grid Layout */ +.recurring-form-grid { + display: grid; + grid-template-columns: 120px 1fr 120px; + gap: 12px; + align-items: start; + margin-bottom: 10px; +} + +.recurring-field { + display: flex; + flex-direction: column; + gap: 4px; +} + +.recurring-label { + font-weight: 500; + color: #333; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 2px; +} + +.recurring-select { + padding: 4px 6px; + border: 1px solid #ddd; + border-radius: 3px; + background-color: white; + color: #333; + font-size: 12px; + width: 100%; +} + +.recurring-select:focus { + outline: none; + border-color: #0c042c; + box-shadow: 0 0 0 1px rgba(12, 4, 44, 0.2); +} + +.recurring-date-input { + padding: 4px 6px; + border: 1px solid #ddd; + border-radius: 3px; + background-color: white; + color: #333; + font-size: 12px; + width: 100%; +} + +.recurring-date-input:focus { + outline: none; + border-color: #0c042c; + box-shadow: 0 0 0 1px rgba(12, 4, 44, 0.2); +} + +/* Ultra Compact Day Picker */ +.day-picker { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 2px; + margin-top: 2px; +} + +.day-button { + padding: 3px 2px; + border: 1px solid #ddd; + background-color: white; + color: #666; + border-radius: 3px; + cursor: pointer; + transition: all 0.15s ease; + font-size: 10px; + font-weight: 500; + text-align: center; + min-height: 22px; + display: flex; + align-items: center; + justify-content: center; +} + +.day-button:hover { + border-color: #0c042c; + color: #0c042c; +} + +.day-button.selected { + background-color: #0c042c; + border-color: #0c042c; + color: white; +} + +.day-button:focus { + outline: none; + box-shadow: 0 0 0 1px rgba(12, 4, 44, 0.4); +} + +/* Compact Preview */ +.recurring-preview-box { + background-color: #f8f9fa; + border: 1px solid #e9ecef; + border-radius: 3px; + padding: 6px 8px; + font-size: 11px; + color: #495057; + font-style: italic; +} + +/* Submit Button Section - Right Aligned */ +.submit-section-wrapper { + margin-top: 20px; + padding: 15px 20px; + text-align: right; + background-color: white; + border-radius: 6px; + border: 1px solid #e0e0e0; +} + +.btn-submit-appointment { + padding: 10px 24px; + font-size: 14px; + font-weight: 500; + border-radius: 4px; + border: none; + background-color: #0c042c; + color: white; + cursor: pointer; + transition: background-color 0.2s ease; +} + +.btn-submit-appointment:hover:not(:disabled) { + background-color: #1a0f3a; +} + +.btn-submit-appointment:disabled { + background-color: #6c757d; + cursor: not-allowed; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .recurring-section-wrapper { + margin-top: 15px; + padding: 12px 15px; + } + + .recurring-form-grid { + grid-template-columns: 1fr; + gap: 10px; + } + + .recurring-field { + gap: 3px; + } + + .day-picker { + gap: 3px; + max-width: 280px; + margin: 4px auto 0; + } + + .day-button { + min-height: 26px; + font-size: 11px; + } + + .recurring-toggle-text { + font-size: 13px; + } + + .submit-section-wrapper { + padding: 12px 15px; + text-align: center; + } + + .btn-submit-appointment { + width: 100%; + max-width: 200px; + } +} + +@media (max-width: 480px) { + .recurring-section-wrapper { + padding: 10px 12px; + } + + .recurring-config-panel { + padding: 10px; + } + + .recurring-form-grid { + gap: 8px; + } + + .day-picker { + gap: 2px; + max-width: 260px; + } + + .day-button { + min-height: 24px; + font-size: 10px; + padding: 2px 1px; + } + + .recurring-toggle-label { + padding: 6px 10px; + } + + .submit-section-wrapper { + padding: 10px 12px; + } +} + +/* Very Small Screens */ +@media (max-width: 380px) { + .day-picker { + max-width: 240px; + } + + .day-button { + min-height: 22px; + font-size: 9px; + } + + .recurring-label { + font-size: 10px; + } + + .recurring-select, + .recurring-date-input { + font-size: 11px; + } +} diff --git a/appointment/static/js/appointments.js b/appointment/static/js/appointments.js index 1df6b5d..cccaf70 100644 --- a/appointment/static/js/appointments.js +++ b/appointment/static/js/appointments.js @@ -45,6 +45,9 @@ const calendar = new FullCalendar.Calendar(calendarEl, { selectedDate = info.dateStr; getAvailableSlots(info.dateStr, staffId); + if (window.syncRecurringWithCalendarSelection) { + window.syncRecurringWithCalendarSelection(); + } }, datesSet: function (info) { highlightSelectedDate(); @@ -89,46 +92,83 @@ body.on('click', '.djangoAppt_btn-request-next-slot', function () { requestNextAvailableSlot(serviceId); }) -body.on('click', '.btn-submit-appointment', function () { +body.on('click', '.btn-submit-appointment', function (e) { + e.preventDefault(); // Prevent default form submission + const selectedSlot = $('.djangoAppt_appointment-slot.selected').text(); const selectedDate = $('.djangoAppt_date_chosen').text(); + + // Validate that both slot and date are selected if (!selectedSlot || !selectedDate) { alert(selectDateAndTimeAlertTxt); return; } - if (selectedSlot && selectedDate) { - const startTime = convertTo24Hour(selectedSlot); - const APPOINTMENT_BASE_TEMPLATE = localStorage.getItem('APPOINTMENT_BASE_TEMPLATE'); - // Convert the selectedDate string to a valid format - const dateParts = selectedDate.split(', '); - const monthDayYear = dateParts[1] + "," + dateParts[2]; - const formattedDate = new Date(monthDayYear + " " + startTime); - - const date = formattedDate.toISOString().slice(0, 10); - const endTimeDate = new Date(formattedDate.getTime() + serviceDuration * 60000); - const endTime = formatTime(endTimeDate); - const reasonForRescheduling = $('#reason_for_rescheduling').val(); + + // Additional validation - check if we actually have meaningful data + if (!selectedSlot.trim() || !selectedDate.trim()) { + const warningContainer = $('.warning-message'); + if (warningContainer.find('.submit-warning').length === 0) { + warningContainer.append('

' + selectTimeSlotWarningTxt + '

'); + } + return; + } + + const startTime = convertTo24Hour(selectedSlot); + const dateParts = selectedDate.split(', '); + const monthDayYear = dateParts[1] + "," + dateParts[2]; + const formattedDate = new Date(monthDayYear + " " + startTime); + const date = formattedDate.toISOString().slice(0, 10); + const endTimeDate = new Date(formattedDate.getTime() + serviceDuration * 60000); + const endTime = formatTime(endTimeDate); + + // Get the staff member value + const staffMember = $('#staff_id').val(); + + // Check if it's a recurring appointment + const isRecurring = $('#id_is_recurring').is(':checked'); + + if (isRecurring) { + // Use the recurring form + const recurringForm = $('.appointment-form'); + + // Populate hidden fields for recurring appointments + $('#hidden_staff_member').val(staffMember); + $('#hidden_selected_date').val(date); + $('#hidden_selected_time').val(startTime); + + if (rescheduledDate) { + const reasonForRescheduling = $('#reason_for_rescheduling').val(); + $('#hidden_reason_for_rescheduling').val(reasonForRescheduling); + } + + // Submit the form with recurring data + recurringForm.submit(); + } else { + // Use the original form for single appointments const form = $('.appointment-form'); let formAction = rescheduledDate ? appointmentRescheduleURL : appointmentRequestSubmitURL; form.attr('action', formAction); - if (!form.find('input[name="appointment_request_id"]').length) { + + // Add hidden fields to original form + if (!form.find('input[name="appointment_request_id"]').length && rescheduledDate) { form.append($('', { type: 'hidden', name: 'appointment_request_id', value: appointmentRequestId })); } + form.append($('', {type: 'hidden', name: 'date', value: date})); form.append($('', {type: 'hidden', name: 'start_time', value: startTime})); form.append($('', {type: 'hidden', name: 'end_time', value: endTime})); form.append($('', {type: 'hidden', name: 'service', value: serviceId})); - form.append($('', {type: 'hidden', name: 'reason_for_rescheduling', value: reasonForRescheduling})); - form.submit(); - } else { - const warningContainer = $('.warning-message'); - if (warningContainer.find('submit-warning') === 0) { - warningContainer.append('

' + selectTimeSlotWarningTxt + '

'); + + if (rescheduledDate) { + const reasonForRescheduling = $('#reason_for_rescheduling').val(); + form.append($('', {type: 'hidden', name: 'reason_for_rescheduling', value: reasonForRescheduling})); } + + form.submit(); } }); @@ -313,7 +353,7 @@ function getAvailableSlots(selectedDate, staffId = null) { $('#service-datetime-chosen').text(data.date_chosen); isRequestInProgress = false; }, - error: function() { + error: function () { isRequestInProgress = false; // Ensure the flag is reset even if the request fails } }); diff --git a/appointment/templates/appointment/appointments.html b/appointment/templates/appointment/appointments.html index 12abf72..d1fcca9 100644 --- a/appointment/templates/appointment/appointments.html +++ b/appointment/templates/appointment/appointments.html @@ -4,6 +4,7 @@ {% block customCSS %} + {% endblock %} {% block title %} {{ page_title }} @@ -56,37 +57,107 @@

{% endif %}
-
- {% csrf_token %} -
- - + {% if not staff_member %} + + {% endif %} + {% for sf in all_staff_members %} + + {% endfor %} + +
+ +
{% trans "Service Details" %}
+
+
+

{{ service.name }}

+

{{ date_chosen }}

+

{{ service.get_duration }}

+

{{ service.get_price_text }}

+ +
+
+ + + +
+
+ +
+ + + + +
+
+ {% csrf_token %} + + + + + + + + + + + + + {% if rescheduled_date %} + + {% endif %} + + +
+
+ {% if messages %} {% for message in messages %}
+ + {% if form %}{{ form.media }}{% endif %} {% endblock %} diff --git a/appointment/utils/db_helpers.py b/appointment/utils/db_helpers.py index e563f8a..1f4d8cd 100644 --- a/appointment/utils/db_helpers.py +++ b/appointment/utils/db_helpers.py @@ -17,6 +17,7 @@ from django.core.exceptions import FieldDoesNotExist from django.urls import reverse from django.utils import timezone +from django.db.models import Q from appointment.logger_config import get_logger from appointment.settings import ( @@ -119,8 +120,7 @@ def create_and_save_appointment(ar, client_data: dict, appointment_data: dict, r """ user = get_user_by_email(client_data['email']) appointment = Appointment.objects.create( - client=user, appointment_request=ar, - **appointment_data + client=user, appointment_request=ar, **appointment_data ) appointment.save() logger.info(f"New appointment created: {appointment.to_dict()}") @@ -348,6 +348,98 @@ def create_payment_info_and_get_url(appointment): return payment_url +def exclude_recurring_appointments(slots, staff_member, date, slot_duration): + """Exclude slots that are blocked by recurring appointments.""" + from appointment.models import RecurringAppointment + + # Get all active recurring appointments for this staff member + recurring_appointments = RecurringAppointment.objects.filter( + appointment_request__staff_member=staff_member, + is_active=True, + appointment_request__date__lte=date # Started on or before this date + ).filter( + Q(end_date__isnull=True) | Q(end_date__gte=date) # No end date or ends after this date + ).select_related('appointment_request') + + available_slots = [] + for slot in slots: + slot_end = slot + slot_duration + is_available = True + + for recurring_appt in recurring_appointments: + # Check if this recurring appointment occurs on this date + if recurring_appt.occurs_on_date(date): + # Get the time range for this recurring appointment + recurring_start = datetime.datetime.combine(date, recurring_appt.appointment_request.start_time) + recurring_end = datetime.datetime.combine(date, recurring_appt.appointment_request.end_time) + + # Check if slot conflicts with this recurring appointment + if recurring_start < slot_end and slot < recurring_end: + is_available = False + break + + if is_available: + available_slots.append(slot) + + return available_slots + + +def create_recurring_appointment(appointment_request, recurrence_rule, end_date=None): + """Create a recurring appointment from an existing AppointmentRequest.""" + from appointment.models import RecurringAppointment + + recurring_appointment = RecurringAppointment.objects.create( + appointment_request=appointment_request, + recurrence_rule=_parse_recurrence_rule(recurrence_rule), + end_date=end_date + ) + + logger.info(f"Created recurring appointment: {recurring_appointment}") + return recurring_appointment + + +def _parse_recurrence_rule(recurrence_rule_string): + """Convert RRULE string to django-recurrence Recurrence object.""" + import recurrence + from datetime import datetime + + if recurrence_rule_string.startswith('RRULE:'): + clean_rule = recurrence_rule_string[6:] + else: + clean_rule = recurrence_rule_string + + rule_parts = clean_rule.split(';') + rule_params = {} + freq = None + + for part in rule_parts: + key, value = part.split('=') + if key == 'FREQ': + if value == 'WEEKLY': + freq = recurrence.WEEKLY + elif value == 'DAILY': + freq = recurrence.DAILY + elif value == 'MONTHLY': + freq = recurrence.MONTHLY + elif key == 'BYDAY': + days = [] + day_map = { + 'MO': recurrence.MONDAY, 'TU': recurrence.TUESDAY, 'WE': recurrence.WEDNESDAY, + 'TH': recurrence.THURSDAY, 'FR': recurrence.FRIDAY, 'SA': recurrence.SATURDAY, 'SU': recurrence.SUNDAY + } + for day in value.split(','): + if day in day_map: + days.append(day_map[day]) + rule_params['byday'] = days + elif key == 'UNTIL': + until_dt = datetime.strptime(value, '%Y%m%dT%H%M%SZ') + rule_params['until'] = until_dt + + # Create the Rule and Recurrence objects + rule = recurrence.Rule(freq, **rule_params) + return recurrence.Recurrence(rrules=[rule]) + + def exclude_booked_slots(appointments, slots, slot_duration=None): """Exclude the booked slots from the given list of slots. diff --git a/appointment/views.py b/appointment/views.py index c7c5d83..b9195b8 100644 --- a/appointment/views.py +++ b/appointment/views.py @@ -31,7 +31,8 @@ from appointment.settings import check_q_cluster from appointment.utils.db_helpers import ( can_appointment_be_rescheduled, check_day_off_for_staff, create_and_save_appointment, create_new_user, - create_payment_info_and_get_url, get_non_working_days_for_staff, get_user_by_email, get_user_model, + create_payment_info_and_get_url, create_recurring_appointment, get_non_working_days_for_staff, get_user_by_email, + get_user_model, get_website_name, get_weekday_num_from_date, is_working_day, staff_change_allowed_on_reschedule, username_in_user_model ) @@ -223,38 +224,58 @@ def appointment_request(request, service_id=None, staff_member_id=None): 'date_chosen': date_chosen, 'locale': get_locale(), 'timezoneTxt': get_current_timezone_name(), - 'label': label + 'label': label, + 'max_recurring_months': config.max_recurring_months, } context = get_generic_context_with_extra(request, extra_context, admin=False) return render(request, 'appointment/appointments.html', context=context) -def appointment_request_submit(request): - """This view function handles the submission of the appointment request form. +def _log_appointment_submission(cleaned_data): + """Log the details of the appointment submission.""" + logger.info( + f"Appointment submission - date: {cleaned_data['date']}, " + f"start_time: {cleaned_data['start_time']}, end_time: {cleaned_data['end_time']}, " + f"service: {cleaned_data['service']}, staff: {cleaned_data['staff_member']}, " + f"is_recurring: {cleaned_data.get('is_recurring', False)}" + ) - :param request: The request instance. - :return: The rendered HTML page. - """ + +def appointment_request_submit(request): + """This view function handles the submission of the appointment request form.""" if request.method == 'POST': form = AppointmentRequestForm(request.POST) if form.is_valid(): - # Use form.cleaned_data to get the cleaned and validated data staff_member = form.cleaned_data['staff_member'] - staff_exists = StaffMember.objects.filter(id=staff_member.id).exists() - if not staff_exists: + if not StaffMember.objects.filter(id=staff_member.id).exists(): messages.error(request, _("Selected staff member does not exist.")) else: - logger.info( - f"date_f {form.cleaned_data['date']} start_time {form.cleaned_data['start_time']} end_time " - f"{form.cleaned_data['end_time']} service {form.cleaned_data['service']} staff {staff_member}") + _log_appointment_submission(form.cleaned_data) + + # Create the base AppointmentRequest ar = form.save() + + # Check if it's recurring + is_recurring = form.cleaned_data.get('is_recurring') + if is_recurring: + recurrence_rule = form.cleaned_data.get('recurrence_rule') + end_recurrence = form.cleaned_data.get('end_recurrence') + + if recurrence_rule: + try: + # Create the recurring appointment linked to this AppointmentRequest + create_recurring_appointment(ar, recurrence_rule, end_recurrence) + logger.info(f"Created recurring appointment for AppointmentRequest {ar.id}") + except Exception as e: + logger.error(f"Error creating recurring appointment: {e}") + # Don't fail the whole request, just log the error + request.session[f'appointment_completed_{ar.id_request}'] = False - # Redirect the user to the account creation page - return redirect('appointment:appointment_client_information', appointment_request_id=ar.id, + return redirect('appointment:appointment_client_information', + appointment_request_id=ar.id, id_request=ar.id_request) else: - # Handle the case if the form is not valid messages.error(request, _('There was an error in your submission. Please check the form and try again.')) else: form = AppointmentRequestForm() diff --git a/requirements.txt b/requirements.txt index c1d4251..9c7a39b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,3 +9,5 @@ python-dotenv==1.1.0 colorama~=0.4.6 django-q2==1.8.0 icalendar~=6.3.1 +icalendar~=6.1.3 +django-recurrence>=1.11.1 From 4cc4754becae3819f396319d0ae674e0ea478517 Mon Sep 17 00:00:00 2001 From: Adams Pierre David <57180807+adamspd@users.noreply.github.com> Date: Tue, 10 Jun 2025 01:11:29 +0200 Subject: [PATCH 2/5] check all necessary fields are given --- appointment/static/js/appointments.js | 6 +++ .../templates/appointment/appointments.html | 53 ++++++++++++++++++- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/appointment/static/js/appointments.js b/appointment/static/js/appointments.js index cccaf70..ea58a42 100644 --- a/appointment/static/js/appointments.js +++ b/appointment/static/js/appointments.js @@ -252,6 +252,7 @@ function getAvailableSlots(selectedDate, staffId = null) { // Clear previous error messages and slots slotList.empty(); + $('.btn-submit-appointment').attr('disabled', 'disabled'); errorMessageContainer.find('.djangoAppt_no-availability-text').remove(); // Remove the "Next available date" message @@ -343,6 +344,11 @@ function getAvailableSlots(selectedDate, staffId = null) { // Enable the submit button $('.btn-submit-appointment').removeAttr('disabled'); + //revalidate the recurring settings + if (window.revalidateSubmitButton) { + window.revalidateSubmitButton(); + } + // Continue with the existing logic const selectedSlot = $(this).text(); $('#service-datetime-chosen').text(data.date_chosen + ' ' + selectedSlot); diff --git a/appointment/templates/appointment/appointments.html b/appointment/templates/appointment/appointments.html index d1fcca9..3456cfa 100644 --- a/appointment/templates/appointment/appointments.html +++ b/appointment/templates/appointment/appointments.html @@ -229,6 +229,15 @@

} } + window.revalidateSubmitButton = function () { + console.log('revalidateSubmitButton called'); + console.log('Recurring checked:', isRecurringCheckbox.checked); + console.log('End date value:', endDateInput.value); + + // Force validation regardless of current button state + return validateRecurringSettings(); + }; + // Sync form data with hidden fields function syncFormData() { const staffSelect = document.getElementById('staff_id'); @@ -365,6 +374,42 @@

previewText.textContent = previewHtml || 'Configure settings'; } + // Validate recurring settings and update submit button state + function validateRecurringSettings() { + const submitButton = document.querySelector('.btn-submit-appointment'); + + if (isRecurringCheckbox.checked) { + // If recurring is enabled, end date is required + const hasEndDate = endDateInput.value && endDateInput.value.trim() !== ''; + + if (!hasEndDate) { + submitButton.disabled = true; + submitButton.title = 'End date is required for recurring appointments'; + return false; + } + + // If weekly, at least one day must be selected + if (frequencySelect.value === 'WEEKLY') { + const selectedDays = document.querySelectorAll('.day-button.selected'); + if (selectedDays.length === 0) { + submitButton.disabled = true; + submitButton.title = 'Select at least one day for weekly recurring appointments'; + return false; + } + } + } + + // If we get here, validation passed - but don't enable if other conditions aren't met + // (like no time slot selected - that's handled by the original appointments.js logic) + const hasSelectedSlot = document.querySelector('.djangoAppt_appointment-slot.selected'); + if (hasSelectedSlot) { + submitButton.disabled = false; + submitButton.title = ''; + } + + return true; + } + // Toggle recurring settings visibility if (isRecurringCheckbox && recurrenceFieldsDiv) { isRecurringCheckbox.addEventListener('change', function () { @@ -375,6 +420,7 @@

rruleInput.value = ''; } syncFormData(); + validateRecurringSettings(); }); } @@ -385,6 +431,7 @@

setTimeout(() => window.syncRecurringWithCalendarSelection(), 100); } updateRecurrenceRule(); + validateRecurringSettings(); }); // Handle day button clicks @@ -392,6 +439,7 @@

button.addEventListener('click', function () { this.classList.toggle('selected'); updateRecurrenceRule(); + validateRecurringSettings(); }); }); @@ -399,7 +447,7 @@

function setDateConstraints() { const today = new Date(); const maxDate = new Date(); - maxDate.setMonth(today.getMonth() + {{ max_recurring_months|default:3 }}); // 3 months from now + maxDate.setMonth(today.getMonth() + {{ max_recurring_months|default:3 }}); // Format dates for input[type="date"] const todayStr = today.toISOString().split('T')[0]; @@ -416,7 +464,7 @@

today.setHours(0, 0, 0, 0); const maxDate = new Date(); - maxDate.setMonth(today.getMonth() + 3); + maxDate.setMonth(today.getMonth() + {{ max_recurring_months|default:3 }}); maxDate.setHours(23, 59, 59, 999); if (selectedDate < today) { @@ -439,6 +487,7 @@

if (validateEndDate()) { updateRecurrenceRule(); syncFormData(); + validateRecurringSettings(); } }); From c70db40149b04db3aa7b972a3b3617ff79dfbc31 Mon Sep 17 00:00:00 2001 From: Adams Pierre David <57180807+adamspd@users.noreply.github.com> Date: Tue, 10 Jun 2025 04:36:09 +0200 Subject: [PATCH 3/5] 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) --- README.md | 3 +- appointment/admin.py | 11 +- appointment/models.py | 86 +++++ appointment/static/js/appointments.js | 113 +++--- .../static/js/recurring-appointments.js | 360 ++++++++++++++++++ .../templates/appointment/appointments.html | 321 +--------------- .../admin_new_appointment_email.html | 6 +- .../email_sender/reminder_email.html | 8 +- .../email_sender/reschedule_email.html | 10 +- .../email_sender/thank_you_email.html | 3 + appointment/utils/email_ops.py | 7 + appointment/utils/ics_utils.py | 61 ++- appointment/views.py | 2 +- docs/explanation.md | 49 +++ 14 files changed, 658 insertions(+), 382 deletions(-) create mode 100644 appointment/static/js/recurring-appointments.js diff --git a/README.md b/README.md index 15f6cc8..cdcf6fb 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ task scheduling. It also allows clients to reschedule appointments if it is allo Django-Appointment is a Django app engineered for managing appointment scheduling with ease and flexibility. It enables users to define custom configurations for time slots, lead time, and finish time, or use the default values provided. This app proficiently manages conflicts and availability for appointments, ensuring a seamless user -experience. +experience. It now also includes the capability to schedule recurring appointments. For a detailed walkthrough and live example of the system, please refer to [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_ - Automated email reminders sent 24 hours before the appointment (requires Django Q). - ICS file attachment for calendar synchronization. 7. Integration with Django Q for efficient task scheduling and email sending. +8. Recurring Appointments: Schedule appointments that repeat daily, weekly, or monthly, with a 3-month default end date. ## Key features introduced in previous versions. diff --git a/appointment/admin.py b/appointment/admin.py index fb18907..733e79e 100644 --- a/appointment/admin.py +++ b/appointment/admin.py @@ -11,7 +11,7 @@ from .models import ( Appointment, AppointmentRequest, AppointmentRescheduleHistory, Config, DayOff, EmailVerificationCode, - PasswordResetToken, Service, StaffMember, WorkingHours + PasswordResetToken, RecurringAppointment, Service, StaffMember, WorkingHours ) @@ -36,6 +36,15 @@ class AppointmentAdmin(admin.ModelAdmin): list_filter = ('client', 'appointment_request__service',) +@admin.register(RecurringAppointment) +class RecurringAppointmentAdmin(admin.ModelAdmin): + list_display = ('appointment_request', 'recurrence_rule', 'end_date', 'is_active', 'created_at',) + search_fields = ('appointment_request__service__name',) + list_filter = ('end_date', 'recurrence_rule') + date_hierarchy = 'created_at' + ordering = ('-created_at',) + + @admin.register(EmailVerificationCode) class EmailVerificationCodeAdmin(admin.ModelAdmin): list_display = ('user', 'code') diff --git a/appointment/models.py b/appointment/models.py index 9b2e9ea..7249731 100644 --- a/appointment/models.py +++ b/appointment/models.py @@ -364,6 +364,17 @@ def increment_reschedule_attempts(self): def get_reschedule_history(self): return self.reschedule_histories.all().order_by('-created_at') + def is_recurring(self): + """Check if this appointment request is recurring.""" + return hasattr(self, 'recurring_info') and self.recurring_info is not None + + @property + def recurrence_description(self): + """Get the recurrence description if this is a recurring appointment.""" + if self.is_recurring(): + return self.recurring_info.get_recurrence_description() + return None + class AppointmentRescheduleHistory(models.Model): appointment_request = models.ForeignKey( @@ -668,6 +679,81 @@ def occurs_on_date(self, target_date): logger.error(f"Error checking recurrence for {self}: {e}") return False + def get_recurrence_description(self): + """Get a human-readable description of the recurrence pattern.""" + from django.utils.translation import gettext as _ + + if not self.recurrence_rule or not self.recurrence_rule.rrules: + return None + + rrule = self.recurrence_rule.rrules[0] + description_parts = [] + + # Handle weekly with specific days + if rrule.freq == 2: # Weekly + weekday_map = { + 0: _('Monday'), 1: _('Tuesday'), 2: _('Wednesday'), 3: _('Thursday'), + 4: _('Friday'), 5: _('Saturday'), 6: _('Sunday') + } + + days = [] + if hasattr(rrule, 'byday') and rrule.byday: + for weekday in rrule.byday: + if isinstance(weekday, int) and weekday in weekday_map: + days.append(str(weekday_map[weekday])) + + if days: + if len(days) == 1: + description_parts.append(str(_('Every week on {day}').format(day=days[0]))) + elif len(days) == 7: + description_parts.append(str(_('Every day'))) + else: + description_parts.append(str(_('Every week on {days}').format(days=', '.join(days)))) + else: + # If no specific days are set, fall back to using the appointment start date + appointment_date = self.appointment_request.date + weekday_num = appointment_date.weekday() # 0=Monday, 6=Sunday + day_name = str(weekday_map[weekday_num]) + description_parts.append(str(_('Every week on {day}').format(day=day_name))) + + elif rrule.freq == 1: # Monthly + # Get the day of the month from the original appointment date + appointment_date = self.appointment_request.date + day_of_month = appointment_date.day + + # Format with ordinal (1st, 2nd, 3rd, etc.) + if 10 <= day_of_month % 100 <= 20: + suffix = 'th' + else: + suffix = {1: 'st', 2: 'nd', 3: 'rd'}.get(day_of_month % 10, 'th') + + description_parts.append(str(_('Every month on the {day}').format(day=f"{day_of_month}{suffix}"))) + + elif rrule.freq == 3: # Daily + description_parts.append(str(_('Every day'))) + elif rrule.freq == 0: # Yearly + # Get the date from the original appointment + appointment_date = self.appointment_request.date + date_str = appointment_date.strftime('%B %d') # e.g., "July 15" + description_parts.append(str(_('Every year on {date}').format(date=date_str))) + else: + # Other frequencies + freq_map = { + 4: _('Every hour'), + 5: _('Every minute'), + 6: _('Every second') + } + frequency = freq_map.get(rrule.freq, _('Unknown frequency')) + description_parts.append(str(frequency)) + + # Add end date if present + if self.end_date: + description_parts.append(str(_('until {date}').format(date=self.end_date.strftime('%B %d, %Y')))) + elif hasattr(rrule, 'until') and rrule.until: + description_parts.append(str(_('until {date}').format(date=rrule.until.strftime('%B %d, %Y')))) + + return ' '.join(description_parts) + class Config(models.Model): """ diff --git a/appointment/static/js/appointments.js b/appointment/static/js/appointments.js index ea58a42..e712847 100644 --- a/appointment/static/js/appointments.js +++ b/appointment/static/js/appointments.js @@ -93,83 +93,90 @@ body.on('click', '.djangoAppt_btn-request-next-slot', function () { }) body.on('click', '.btn-submit-appointment', function (e) { - e.preventDefault(); // Prevent default form submission + e.preventDefault(); - const selectedSlot = $('.djangoAppt_appointment-slot.selected').text(); - const selectedDate = $('.djangoAppt_date_chosen').text(); + const $slot = $('.djangoAppt_appointment-slot.selected'); + const $date = $('.djangoAppt_date_chosen'); + const selectedSlot = $slot.text().trim(); + const selectedDate = $date.text().trim(); - // Validate that both slot and date are selected + // Validate selection if (!selectedSlot || !selectedDate) { alert(selectDateAndTimeAlertTxt); return; } - // Additional validation - check if we actually have meaningful data + // Additional validation + const $warning = $('.warning-message'); if (!selectedSlot.trim() || !selectedDate.trim()) { - const warningContainer = $('.warning-message'); - if (warningContainer.find('.submit-warning').length === 0) { - warningContainer.append('

' + selectTimeSlotWarningTxt + '

'); + if ($warning.find('.submit-warning').length === 0) { + $warning.append(`

${selectTimeSlotWarningTxt}

`); } return; } + // Extract time and date const startTime = convertTo24Hour(selectedSlot); - const dateParts = selectedDate.split(', '); - const monthDayYear = dateParts[1] + "," + dateParts[2]; - const formattedDate = new Date(monthDayYear + " " + startTime); - const date = formattedDate.toISOString().slice(0, 10); - const endTimeDate = new Date(formattedDate.getTime() + serviceDuration * 60000); - const endTime = formatTime(endTimeDate); - // Get the staff member value - const staffMember = $('#staff_id').val(); + const dateParts = selectedDate.split(', '); // e.g. ["Tuesday", "June 11", "2025"] + if (dateParts.length < 3) { + alert('Invalid date format. Please select a valid appointment date.'); + return; + } - // Check if it's a recurring appointment - const isRecurring = $('#id_is_recurring').is(':checked'); + const monthDayYear = `${dateParts[1]} ${dateParts[2]}`; // "June 11 2025" + const formattedDate = new Date(`${monthDayYear} ${startTime}`); + if (isNaN(formattedDate)) { + console.error('Invalid formatted date:', `${monthDayYear} ${startTime}`); + alert('Invalid date/time. Please verify your selections.'); + return; + } - if (isRecurring) { - // Use the recurring form - const recurringForm = $('.appointment-form'); + const date = formattedDate.toISOString().slice(0, 10); + const endTime = formatTime(new Date(formattedDate.getTime() + serviceDuration * 60000)); - // Populate hidden fields for recurring appointments - $('#hidden_staff_member').val(staffMember); - $('#hidden_selected_date').val(date); - $('#hidden_selected_time').val(startTime); + const staffMember = $('#staff_id').val(); + const isRecurring = $('#id_is_recurring').is(':checked'); + const $form = $('.appointment-form'); + + // Prepare hidden fields + const hiddenFields = [ + { name: 'date', value: date }, + { name: 'start_time', value: startTime }, + { name: 'end_time', value: endTime }, + { name: 'service', value: serviceId }, + { name: 'staff_member', value: staffMember } + ]; + + if (rescheduledDate) { + hiddenFields.push({ + name: 'reason_for_rescheduling', + value: $('#reason_for_rescheduling').val() + }); + } - if (rescheduledDate) { - const reasonForRescheduling = $('#reason_for_rescheduling').val(); - $('#hidden_reason_for_rescheduling').val(reasonForRescheduling); - } + if (!isRecurring) { + const actionUrl = rescheduledDate ? appointmentRescheduleURL : appointmentRequestSubmitURL; + $form.attr('action', actionUrl); - // Submit the form with recurring data - recurringForm.submit(); - } else { - // Use the original form for single appointments - const form = $('.appointment-form'); - let formAction = rescheduledDate ? appointmentRescheduleURL : appointmentRequestSubmitURL; - form.attr('action', formAction); - - // Add hidden fields to original form - if (!form.find('input[name="appointment_request_id"]').length && rescheduledDate) { - form.append($('', { - type: 'hidden', + if (rescheduledDate && !$form.find('input[name="appointment_request_id"]').length) { + hiddenFields.push({ name: 'appointment_request_id', value: appointmentRequestId - })); - } - - form.append($('', {type: 'hidden', name: 'date', value: date})); - form.append($('', {type: 'hidden', name: 'start_time', value: startTime})); - form.append($('', {type: 'hidden', name: 'end_time', value: endTime})); - form.append($('', {type: 'hidden', name: 'service', value: serviceId})); - - if (rescheduledDate) { - const reasonForRescheduling = $('#reason_for_rescheduling').val(); - form.append($('', {type: 'hidden', name: 'reason_for_rescheduling', value: reasonForRescheduling})); + }); } + } - form.submit(); + // Append fields and submit + for (const field of hiddenFields) { + $form.append($('', { + type: 'hidden', + name: field.name, + value: field.value + })); } + + $form.submit(); }); $('#staff_id').on('change', function () { diff --git a/appointment/static/js/recurring-appointments.js b/appointment/static/js/recurring-appointments.js new file mode 100644 index 0000000..9140e1d --- /dev/null +++ b/appointment/static/js/recurring-appointments.js @@ -0,0 +1,360 @@ +document.addEventListener('DOMContentLoaded', function() { + const isRecurringCheckbox = document.getElementById('id_is_recurring'); + const recurrenceFieldsDiv = document.getElementById('recurrence_rule_fields'); + const frequencySelect = document.getElementById('recurrence_frequency'); + const weeklyDaySelection = document.getElementById('weekly_day_selection'); + const dayButtons = document.querySelectorAll('.day-button'); + const endDateInput = document.getElementById('id_end_recurrence'); + const rruleInput = document.getElementById('id_recurrence_rule'); + const previewText = document.getElementById('recurring_preview_text'); + + // Make revalidation function globally available + window.revalidateSubmitButton = function() { + return validateRecurringSettings(); + }; + + // Sync form data with hidden fields + function syncFormData() { + const hiddenIsRecurring = document.getElementById('hidden_is_recurring'); + const hiddenEndRecurrence = document.getElementById('hidden_end_recurrence'); + + if (hiddenIsRecurring) { + hiddenIsRecurring.value = isRecurringCheckbox.checked ? 'true' : 'false'; + } + + if (hiddenEndRecurrence && endDateInput) { + hiddenEndRecurrence.value = endDateInput.value; + } + } + + // Get the selected appointment date from the calendar or date display + function getSelectedAppointmentDate() { + // Try to get from global variable first + if (window.selectedDate) { + return new Date(window.selectedDate); + } + + // Try to get from selected calendar cell + const selectedCell = document.querySelector('.selected-cell'); + if (selectedCell && selectedCell.getAttribute('data-date')) { + return new Date(selectedCell.getAttribute('data-date')); + } + + // Fallback to today + return new Date(); + } + + // Set date constraints for the until field based on selected appointment date + function setDateConstraints() { + const appointmentDate = getSelectedAppointmentDate(); + const maxDate = new Date(appointmentDate); + maxDate.setMonth(appointmentDate.getMonth() + maxRecurringMonths); + + // Format dates for input[type="date"] + const appointmentDateStr = appointmentDate.toISOString().split('T')[0]; + const maxDateStr = maxDate.toISOString().split('T')[0]; + + endDateInput.setAttribute('min', appointmentDateStr); + endDateInput.setAttribute('max', maxDateStr); + } + + // Auto-set until date when recurring is enabled + function autoSetUntilDate() { + if (isRecurringCheckbox.checked && !endDateInput.value) { + const appointmentDate = getSelectedAppointmentDate(); + // Set until date to same date as appointment (user can change it) + endDateInput.value = appointmentDate.toISOString().split('T')[0]; + updateRecurrenceRule(); + } + } + + // Update until date when calendar selection changes (even if field has value) + function updateUntilDateToMatch() { + if (isRecurringCheckbox.checked) { + const appointmentDate = getSelectedAppointmentDate(); + // Always update to match the selected appointment date + endDateInput.value = appointmentDate.toISOString().split('T')[0]; + updateRecurrenceRule(); + syncFormData(); + } + } + + // Validate end date input + function validateEndDate() { + const selectedDate = new Date(endDateInput.value); + const appointmentDate = getSelectedAppointmentDate(); + appointmentDate.setHours(0, 0, 0, 0); + + const maxDate = new Date(appointmentDate); + maxDate.setMonth(appointmentDate.getMonth() + maxRecurringMonths); + maxDate.setHours(23, 59, 59, 999); + + if (selectedDate < appointmentDate) { + alert('End date cannot be before the appointment date.'); + endDateInput.value = ''; + return false; + } + + if (selectedDate > maxDate) { + alert(`End date cannot be more than ${maxRecurringMonths} months from the appointment date.`); + endDateInput.value = ''; + return false; + } + + return true; + } + + // Make calendar sync function globally available + window.syncRecurringWithCalendarSelection = function() { + if (!document.getElementById('id_is_recurring').checked || + document.getElementById('recurrence_frequency').value !== 'WEEKLY') { + // Still update the until date even if not weekly recurring + updateUntilDateToMatch(); + return; + } + + let dateStr = null; + + // Method 1: Use global selectedDate variable + if (window.selectedDate) { + dateStr = window.selectedDate; + } + + // Method 2: Find selected cell and get its data-date + if (!dateStr) { + const selectedCell = document.querySelector('.selected-cell'); + if (selectedCell) { + dateStr = selectedCell.getAttribute('data-date'); + } + } + + if (dateStr) { + const date = new Date(dateStr); + const dayOfWeek = date.getDay(); + const dayMapping = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA']; + const dayCode = dayMapping[dayOfWeek]; + + // Clear existing selections + document.querySelectorAll('.day-button').forEach(btn => btn.classList.remove('selected')); + + // Select the matching day + const dayButton = document.querySelector(`.day-button[data-day="${dayCode}"]`); + if (dayButton) { + dayButton.classList.add('selected'); + updateRecurrenceRule(); + } + } + + // Update date constraints and until date when calendar selection changes + setDateConstraints(); + updateUntilDateToMatch(); + }; + + function updateRecurrenceRule() { + const frequency = frequencySelect.value; + let rrule = `RRULE:FREQ=${frequency}`; + + if (frequency === 'WEEKLY') { + const selectedDays = Array.from(document.querySelectorAll('.day-button.selected')) + .map(btn => btn.dataset.day); + if (selectedDays.length > 0) { + rrule += `;BYDAY=${selectedDays.join(',')}`; + } + } + + if (endDateInput.value) { + const endDate = new Date(endDateInput.value); + endDate.setHours(23, 59, 59, 999); + rrule += `;UNTIL=${endDate.toISOString().replace(/[-:]/g, '').split('.')[0]}Z`; + } + + rruleInput.value = rrule; + updatePreview(); + } + + function updatePreview() { + const frequency = frequencySelect.value; + const endDate = endDateInput.value; + let previewHtml = ''; + + if (frequency === 'WEEKLY') { + const selectedDays = Array.from(document.querySelectorAll('.day-button.selected')); + if (selectedDays.length > 0) { + const dayNames = selectedDays.map(btn => { + const day = btn.dataset.day; + const names = {MO:'Monday', TU:'Tuesday', WE:'Wednesday', TH:'Thursday', FR:'Friday', SA:'Saturday', SU:'Sunday'}; + return names[day]; + }); + previewHtml = `Every week on ${dayNames.join(', ')}`; + } else { + previewHtml = 'Select days for weekly recurrence'; + } + } else if (frequency === 'DAILY') { + previewHtml = 'Every day'; + } else if (frequency === 'MONTHLY') { + previewHtml = 'Every month'; + } + + if (endDate && previewHtml) { + // Format date as "Month Day, Year" (e.g., "June 11th, 2025") + const endDateObj = new Date(endDate); + const formattedEndDate = endDateObj.toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric' + }); + + // Add ordinal suffix (st, nd, rd, th) + const day = endDateObj.getDate(); + const ordinalSuffix = getOrdinalSuffix(day); + const finalFormattedDate = formattedEndDate.replace(/(\d+)/, `$1${ordinalSuffix}`); + + previewHtml += ` until ${finalFormattedDate}`; + } + + const finalPreviewText = previewHtml || 'Configure settings'; + + // Update the main preview text + previewText.textContent = finalPreviewText; + + // Update or create the service description preview + updateServiceDescriptionPreview(finalPreviewText); + } + + // Helper function to get ordinal suffix (st, nd, rd, th) + function getOrdinalSuffix(day) { + if (day > 3 && day < 21) return 'th'; + switch (day % 10) { + case 1: return 'st'; + case 2: return 'nd'; + case 3: return 'rd'; + default: return 'th'; + } + } + + // Function to manage the recurring preview in service description + function updateServiceDescriptionPreview(previewText) { + const serviceDescriptionContent = document.querySelector('.djangoAppt_service-description-content'); + const serviceDateTime = document.getElementById('service-datetime-chosen'); + + if (!serviceDescriptionContent || !serviceDateTime) return; + + // Look for existing recurring preview element + let recurringPreview = serviceDescriptionContent.querySelector('.service-recurring-preview'); + + if (isRecurringCheckbox.checked && previewText && previewText !== 'Configure settings') { + if (!recurringPreview) { + // Create new recurring preview element + recurringPreview = document.createElement('p'); + recurringPreview.className = 'service-recurring-preview'; + recurringPreview.style.fontSize = '0.8em'; + recurringPreview.style.color = '#888'; + recurringPreview.style.fontStyle = 'italic'; + recurringPreview.style.margin = '-12px 0 12px 0'; + recurringPreview.style.lineHeight = '1.2'; + + // Insert after service-datetime-chosen + serviceDateTime.parentNode.insertBefore(recurringPreview, serviceDateTime.nextSibling); + } + recurringPreview.textContent = previewText; + } else if (recurringPreview) { + // Remove the preview if recurring is disabled or no valid preview + recurringPreview.remove(); + } + } + + // Validate recurring settings and update submit button state + function validateRecurringSettings() { + const submitButton = document.querySelector('.btn-submit-appointment'); + + if (isRecurringCheckbox.checked) { + // If recurring is enabled, end date is required + const hasEndDate = endDateInput.value && endDateInput.value.trim() !== ''; + + if (!hasEndDate) { + submitButton.disabled = true; + submitButton.title = 'End date is required for recurring appointments'; + return false; + } + + // If weekly, at least one day must be selected + if (frequencySelect.value === 'WEEKLY') { + const selectedDays = document.querySelectorAll('.day-button.selected'); + if (selectedDays.length === 0) { + submitButton.disabled = true; + submitButton.title = 'Select at least one day for weekly recurring appointments'; + return false; + } + } + } + + // Check if a time slot is selected + const hasSelectedSlot = document.querySelector('.djangoAppt_appointment-slot.selected'); + if (hasSelectedSlot) { + submitButton.disabled = false; + submitButton.title = ''; + } + + return true; + } + + // Toggle recurring settings visibility + if (isRecurringCheckbox && recurrenceFieldsDiv) { + isRecurringCheckbox.addEventListener('change', function() { + recurrenceFieldsDiv.style.display = this.checked ? 'block' : 'none'; + if (this.checked && frequencySelect.value === 'WEEKLY') { + setTimeout(() => window.syncRecurringWithCalendarSelection(), 100); + } else if (this.checked) { + // Update until date even for non-weekly recurring + setTimeout(() => updateUntilDateToMatch(), 100); + } else { + rruleInput.value = ''; + } + + // Update constraints when enabling recurring + setDateConstraints(); + + // Update the service description preview when toggling recurring + updateServiceDescriptionPreview(''); + + syncFormData(); + validateRecurringSettings(); + }); + } + + // Handle frequency changes + frequencySelect.addEventListener('change', function() { + weeklyDaySelection.style.display = this.value === 'WEEKLY' ? 'block' : 'none'; + if (this.value === 'WEEKLY') { + setTimeout(() => window.syncRecurringWithCalendarSelection(), 100); + } else { + // For non-weekly frequencies, still update the until date + updateUntilDateToMatch(); + } + updateRecurrenceRule(); + validateRecurringSettings(); + }); + + // Handle day button clicks + dayButtons.forEach(button => { + button.addEventListener('click', function() { + this.classList.toggle('selected'); + updateRecurrenceRule(); + validateRecurringSettings(); + }); + }); + + // Handle end date changes + endDateInput.addEventListener('change', function() { + if (validateEndDate()) { + updateRecurrenceRule(); + syncFormData(); + validateRecurringSettings(); + } + }); + + // Initialize + weeklyDaySelection.style.display = frequencySelect.value === 'WEEKLY' ? 'block' : 'none'; + setDateConstraints(); + syncFormData(); +}); diff --git a/appointment/templates/appointment/appointments.html b/appointment/templates/appointment/appointments.html index 3456cfa..c0ce267 100644 --- a/appointment/templates/appointment/appointments.html +++ b/appointment/templates/appointment/appointments.html @@ -78,7 +78,6 @@

{{ date_chosen }}

{{ service.get_duration }}

{{ service.get_price_text }}

-

@@ -136,22 +135,11 @@

class="appointment-form"> {% csrf_token %} - - - - - - - + - {% if rescheduled_date %} - - {% endif %} - @@ -191,6 +179,7 @@

const appointmentRequestId = "{{ ar_id_request }}"; const appointmentRequestSubmitURL = "{% url 'appointment:appointment_request_submit' %}"; const appointmentRescheduleURL = "{% url 'appointment:reschedule_appointment_submit' %}"; + const maxRecurringMonths = {{ max_recurring_months|default:3 }}; - + {% if form %}{{ form.media }}{% endif %} {% endblock %} diff --git a/appointment/templates/email_sender/admin_new_appointment_email.html b/appointment/templates/email_sender/admin_new_appointment_email.html index a631b03..2b1c549 100644 --- a/appointment/templates/email_sender/admin_new_appointment_email.html +++ b/appointment/templates/email_sender/admin_new_appointment_email.html @@ -62,8 +62,10 @@

{% translate 'New Appointment Request' %}

{% translate 'Client Name' %}: {{ client_name }}

{% translate 'Service Requested' %}: {{ appointment.get_service_name }}

{% translate 'Appointment Date' %}: {{ appointment.appointment_request.date }}

-

{% translate 'Time' %}: {{ appointment.appointment_request.start_time }} - - {{ appointment.appointment_request.end_time }}

+

{% translate 'Time' %}: {{ appointment.appointment_request.start_time }} - {{ appointment.appointment_request.end_time }}

+ {% if appointment.appointment_request.is_recurring %} +

{% translate 'Recurrence' %}: {{ appointment.appointment_request.recurrence_description }}

+ {% endif %}

{% translate 'Contact Details' %}: {{ appointment.phone }} | {{ appointment.client.email }}

{% translate 'Additional Info' %}: {{ appointment.additional_info|default:"N/A" }}

diff --git a/appointment/templates/email_sender/reminder_email.html b/appointment/templates/email_sender/reminder_email.html index a5d230b..f7f39ec 100644 --- a/appointment/templates/email_sender/reminder_email.html +++ b/appointment/templates/email_sender/reminder_email.html @@ -138,6 +138,12 @@

{% translate 'Appointment Details' %}

{% translate 'Time' %} {{ appointment.appointment_request.start_time }} - {{ appointment.appointment_request.end_time }} + {% if appointment.appointment_request.is_recurring %} +
+ {% translate 'Recurrence' %} + {{ appointment.appointment_request.recurrence_description }} +
+ {% endif %}
{% translate 'Location' %} {{ appointment.address }} @@ -157,4 +163,4 @@

{% translate 'Appointment Details' %}

- \ No newline at end of file + diff --git a/appointment/templates/email_sender/reschedule_email.html b/appointment/templates/email_sender/reschedule_email.html index 00d3b08..49688e8 100644 --- a/appointment/templates/email_sender/reschedule_email.html +++ b/appointment/templates/email_sender/reschedule_email.html @@ -62,12 +62,18 @@

{% trans "Appointment Reschedule" %}

{% trans "Original Appointment:" %}
{% trans "Date" %}: {{ old_date }}
- {% trans "Time" %}: {{ old_start_time }} {% trans ' to ' %} {{ old_end_time }} + {% trans "Time" %}: {{ old_start_time }} {% trans ' to ' %} {{ old_end_time }}
+ {% if old_appointment_is_recurring %} + {% trans "Recurrence" %}: {{ old_recurrence_description }}
+ {% endif %}

{% trans "Rescheduled Appointment:" %}
{% trans "Date" %}: {{ reschedule_date }}
- {% trans "Time" %}: {{ start_time }} {% trans ' to ' %} {{ end_time }} + {% trans "Time" %}: {{ start_time }} {% trans ' to ' %} {{ end_time }}
+ {% if appointment.appointment_request.is_recurring %} + {% trans "Recurrence" %}: {{ appointment.appointment_request.recurrence_description }}
+ {% endif %}

{% if is_confirmation %} diff --git a/appointment/templates/email_sender/thank_you_email.html b/appointment/templates/email_sender/thank_you_email.html index 1f9b23d..05737c1 100644 --- a/appointment/templates/email_sender/thank_you_email.html +++ b/appointment/templates/email_sender/thank_you_email.html @@ -216,6 +216,9 @@

{% trans "Appointment Details" %}

{% for key, value in more_details.items %}
  • {{ key }}: {{ value }}
  • {% endfor %} + {% if appointment.appointment_request.is_recurring %} +
  • {% translate 'Recurrence' %}: {{ appointment.appointment_request.recurrence_description }}
  • + {% endif %} diff --git a/appointment/utils/email_ops.py b/appointment/utils/email_ops.py index 844eb6a..451b862 100644 --- a/appointment/utils/email_ops.py +++ b/appointment/utils/email_ops.py @@ -93,6 +93,7 @@ def send_thank_you_email(ar: AppointmentRequest, user, request, email: str, appo 'activation_link': set_passwd_link, 'main_title': _("Appointment successfully scheduled"), 'reschedule_link': reschedule_link, + 'appointment': appt, } send_email( recipient_list=[email], subject=_("Thank you for booking us."), @@ -245,6 +246,9 @@ def send_reschedule_confirmation_email(request, reschedule_history, appointment_ 'end_time': convert_24_hour_time_to_12_hour_time(reschedule_history.end_time), 'confirmation_link': confirmation_link, 'company': get_website_name(), + 'old_appointment_is_recurring': appointment_request.is_recurring(), + 'old_recurrence_description': appointment_request.recurrence_description, + 'appointment': Appointment.objects.get(appointment_request=appointment_request), } subject = _("Confirm Your Appointment Rescheduling") @@ -272,6 +276,9 @@ def notify_admin_about_reschedule(reschedule_history, appointment_request, clien 'old_end_time': convert_24_hour_time_to_12_hour_time(appointment_request.end_time), 'end_time': convert_24_hour_time_to_12_hour_time(reschedule_history.end_time), 'company': get_website_name(), + 'old_appointment_is_recurring': appointment_request.is_recurring(), + 'old_recurrence_description': appointment_request.recurrence_description, + 'appointment': Appointment.objects.get(appointment_request=appointment_request), } # let's get the new ics file diff --git a/appointment/utils/ics_utils.py b/appointment/utils/ics_utils.py index 4db168a..d5210f7 100644 --- a/appointment/utils/ics_utils.py +++ b/appointment/utils/ics_utils.py @@ -1,11 +1,15 @@ # appointment/utils/ics_utils.py -from datetime import datetime +import datetime +import logging -from icalendar import Calendar, Event +from icalendar import Calendar, Event, vRecur +from appointment.models import RecurringAppointment from appointment.utils.db_helpers import Appointment, get_website_name +logger = logging.getLogger(__name__) + def generate_ics_file(appointment: Appointment): company_name = get_website_name() @@ -18,7 +22,7 @@ def generate_ics_file(appointment: Appointment): event.add('summary', appointment.get_service_name()) event.add('dtstart', appointment.get_start_time()) event.add('dtend', appointment.get_end_time()) - event.add('dtstamp', datetime.now()) + event.add('dtstamp', datetime.datetime.now()) event.add('location', appointment.address) event.add('description', appointment.additional_info) @@ -28,5 +32,56 @@ def generate_ics_file(appointment: Appointment): attendee = f"MAILTO:{appointment.client.email}" event.add('attendee', attendee) + # Add recurrence rule if applicable + try: + recurring_info = RecurringAppointment.objects.get(appointment_request=appointment.appointment_request) + + if recurring_info.recurrence_rule: + # Convert the django-recurrence object to RRULE string + rrule_string = str(recurring_info.recurrence_rule) + logger.debug(f"Recurrence rule string: {rrule_string}") + + # Remove RRULE: prefix if present + if rrule_string.startswith('RRULE:'): + rrule_content = rrule_string[6:] + else: + rrule_content = rrule_string + + if rrule_content.strip(): + # Parse the RRULE string into a dictionary that icalendar can understand + rrule_dict = {} + parts = rrule_content.split(';') + + for part in parts: + if '=' in part: + key, value = part.split('=', 1) + if key == 'FREQ': + rrule_dict['FREQ'] = [value] + elif key == 'BYDAY': + rrule_dict['BYDAY'] = value.split(',') + elif key == 'UNTIL': + # Parse the UNTIL date - DON'T put it in a list + until_dt = datetime.datetime.strptime(value, '%Y%m%dT%H%M%SZ') + rrule_dict['UNTIL'] = until_dt # Single datetime object, not a list + elif key == 'INTERVAL': + rrule_dict['INTERVAL'] = [int(value)] + + # Create vRecur object + if rrule_dict: + rrule_obj = vRecur(rrule_dict) + event.add('rrule', rrule_obj) + logger.debug(f"Added RRULE to appointment {appointment.id}: {rrule_dict}") + else: + logger.warning(f"Could not parse RRULE for appointment {appointment.id}") + else: + logger.warning(f"Empty RRULE content for appointment {appointment.id}") + + except RecurringAppointment.DoesNotExist: + logger.debug(f"No recurring info for appointment {appointment.id}, not adding RRULE.") + except Exception as e: + logger.error(f"Error generating RRULE for ICS for appointment {appointment.id}: {e}") + import traceback + logger.error(traceback.format_exc()) + cal.add_component(event) return cal.to_ical() diff --git a/appointment/views.py b/appointment/views.py index b9195b8..b917f51 100644 --- a/appointment/views.py +++ b/appointment/views.py @@ -48,7 +48,6 @@ from .settings import (APPOINTMENT_PAYMENT_URL, APPOINTMENT_THANK_YOU_URL) from .utils.date_time import convert_str_to_date from .utils.error_codes import ErrorCode -from .utils.ics_utils import generate_ics_file from .utils.json_context import get_generic_context_with_extra, json_response CLIENT_MODEL = get_user_model() @@ -276,6 +275,7 @@ def appointment_request_submit(request): appointment_request_id=ar.id, id_request=ar.id_request) else: + logger.error(f"Form submission error: {form.errors}") messages.error(request, _('There was an error in your submission. Please check the form and try again.')) else: form = AppointmentRequestForm() diff --git a/docs/explanation.md b/docs/explanation.md index abe7f24..c832c8c 100644 --- a/docs/explanation.md +++ b/docs/explanation.md @@ -71,6 +71,55 @@ If the service is paid and a payment link is set in settings, the `Pay now` butt Upon completion, an account is created for the user, and an email is sent with appointment and account details, provided the email settings are correctly configured. +## Scheduling Recurring Appointments + +*(Note: Screenshots for this feature will be added later.)* + +This section describes how clients can schedule appointments that repeat over time, and how these appointments appear to administrators or staff. + +### Enabling Recurring Appointments (Client View): + +When a client is making an appointment, they will have an option to make it a recurring event. This is typically done through a checkbox on the appointment form. + +- **UI Element**: Look for a checkbox labeled something like: + `[ ] Make this a recurring appointment?` + +- **Frequency Selection**: If the checkbox is selected, further options will appear: + - A dropdown menu to select the recurrence frequency: + - Daily + - Weekly + - Monthly + - **For "Weekly" recurrence**: Day pickers (e.g., checkboxes for Monday, Tuesday, Wednesday, etc.) will allow the client to select which days of the week the appointment should repeat. + - An optional date input field labeled "Repeat until" where the client can specify an end date for the recurrence. If left blank, the appointment might recur indefinitely or up to a system-defined limit. + +- **Form Representation Example**: + + ``` + Service: Consultation + Date: 2023-10-26 + Time: 10:00 AM + Staff: Dr. Smith + + [x] Make this a recurring appointment? + + Frequency: [Weekly \v] + Repeat on: [x] Mon [ ] Tue [x] Wed [ ] Thu [ ] Fri [ ] Sat [ ] Sun + Repeat until: [2023-12-31] (optional) + ``` + +### Understanding Recurrence Details (Client View): + +After setting up a recurring appointment, the system should provide a clear summary of the recurrence schedule before final confirmation. This helps the client verify their settings. + +- **Confirmation Text Example**: "Your appointment for Consultation with Dr. Smith is scheduled for 10:00 AM, repeating every Monday and Wednesday, starting on 2023-10-26 until 2023-12-31." + +### Viewing Recurring Appointments (Admin/Staff View): + +In the admin or staff interface, recurring appointments should be clearly distinguishable from single, non-repeating appointments. + +- **Indicators**: This might be through a special icon, a note in the appointment details, or a separate view for recurring series. For example, an appointment entry might show "Repeats weekly (M, W) until 2023-12-31". +- *(Details on managing recurring series, like editing or deleting the entire series versus a single instance, will be covered in advanced documentation.)* + ### Managing Appointments As an admin, you can now view the newly created appointment in your staff member appointment list: From 15702f0488f742bff3e292f6aba47d2fd256bdc5 Mon Sep 17 00:00:00 2001 From: Adams Pierre David <57180807+adamspd@users.noreply.github.com> Date: Tue, 10 Jun 2025 04:58:33 +0200 Subject: [PATCH 4/5] fixed test and removed code scanning's warning --- appointment/models.py | 2 +- appointment/utils/db_helpers.py | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/appointment/models.py b/appointment/models.py index 7249731..7c5f3a4 100644 --- a/appointment/models.py +++ b/appointment/models.py @@ -833,7 +833,7 @@ def get_instance(cls): def __str__(self): return f"Config {self.pk}: slot_duration={self.slot_duration}, lead_time={self.lead_time}, " \ - f"finish_time={self.finish_time}, etc." + f"finish_time={self.finish_time}" class PaymentInfo(models.Model): diff --git a/appointment/utils/db_helpers.py b/appointment/utils/db_helpers.py index 1f4d8cd..a8d56bc 100644 --- a/appointment/utils/db_helpers.py +++ b/appointment/utils/db_helpers.py @@ -388,15 +388,12 @@ def create_recurring_appointment(appointment_request, recurrence_rule, end_date= """Create a recurring appointment from an existing AppointmentRequest.""" from appointment.models import RecurringAppointment - recurring_appointment = RecurringAppointment.objects.create( + return RecurringAppointment.objects.create( appointment_request=appointment_request, recurrence_rule=_parse_recurrence_rule(recurrence_rule), end_date=end_date ) - logger.info(f"Created recurring appointment: {recurring_appointment}") - return recurring_appointment - def _parse_recurrence_rule(recurrence_rule_string): """Convert RRULE string to django-recurrence Recurrence object.""" From 63ca1e2e614ea03c079959f23b86b891e72329fa Mon Sep 17 00:00:00 2001 From: Adams Pierre David <57180807+adamspd@users.noreply.github.com> Date: Tue, 10 Jun 2025 05:03:43 +0200 Subject: [PATCH 5/5] fixed requirements (removed duplicate dependency) --- requirements-test.txt | 5 ++++- requirements.txt | 3 +-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/requirements-test.txt b/requirements-test.txt index fc9420b..1937057 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -4,5 +4,8 @@ django-phonenumber-field==8.1.0 babel==2.17.0 setuptools==80.9.0 requests~=2.32.3 -django-q2==1.8.0 python-dotenv==1.1.0 +colorama~=0.4.6 +django-q2==1.8.0 +icalendar~=6.3.1 +django-recurrence>=1.11.1 diff --git a/requirements.txt b/requirements.txt index 9c7a39b..e42a44d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -Django==5.1.7 +Django==5.2.2 Pillow==11.2.1 phonenumbers==9.0.7 django-phonenumber-field==8.1.0 @@ -9,5 +9,4 @@ python-dotenv==1.1.0 colorama~=0.4.6 django-q2==1.8.0 icalendar~=6.3.1 -icalendar~=6.1.3 django-recurrence>=1.11.1