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 @@{% 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 @@