Skip to content

Commit dce2cd1

Browse files
committed
Added model for rescheduling & more tests
1 parent 6fc23f7 commit dce2cd1

File tree

7 files changed

+494
-27
lines changed

7 files changed

+494
-27
lines changed

appointment/models.py

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

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

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

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

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

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

584676
# meta data
585677
created_at = models.DateTimeField(auto_now_add=True)
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from datetime import timedelta
2+
3+
from django.core.exceptions import ValidationError
4+
from django.utils import timezone
5+
6+
from appointment.models import AppointmentRescheduleHistory
7+
from appointment.tests.base.base_test import BaseTest
8+
9+
10+
class AppointmentRescheduleHistoryTestCase(BaseTest):
11+
def setUp(self):
12+
super().setUp()
13+
self.appointment_request = self.create_appt_request_for_sm1()
14+
15+
def test_successful_creation(self):
16+
reschedule_history = AppointmentRescheduleHistory.objects.create(
17+
appointment_request=self.appointment_request,
18+
date=timezone.now().date() + timedelta(days=1), # Future date
19+
start_time=timezone.now().time(),
20+
end_time=(timezone.now() + timedelta(hours=1)).time(),
21+
staff_member=self.staff_member1,
22+
reason_for_rescheduling="Client request",
23+
reschedule_status='pending'
24+
)
25+
self.assertIsNotNone(reschedule_history.id_request) # Auto-generated id_request
26+
self.assertTrue(reschedule_history.still_valid())
27+
28+
def test_date_in_past_validation(self):
29+
with self.assertRaises(ValidationError):
30+
AppointmentRescheduleHistory.objects.create(
31+
appointment_request=self.appointment_request,
32+
date=timezone.now().date() - timedelta(days=1), # Past date
33+
start_time=timezone.now().time(),
34+
end_time=(timezone.now() + timedelta(hours=1)).time(),
35+
staff_member=self.staff_member1
36+
)
37+
38+
def test_invalid_date_validation(self):
39+
with self.assertRaises(TypeError):
40+
AppointmentRescheduleHistory.objects.create(
41+
appointment_request=self.appointment_request,
42+
date="invalid-date", # Invalid date format
43+
start_time=timezone.now().time(),
44+
end_time=(timezone.now() + timedelta(hours=1)).time(),
45+
staff_member=self.staff_member1
46+
)
47+
48+
def test_still_valid(self):
49+
reschedule_history = AppointmentRescheduleHistory.objects.create(
50+
appointment_request=self.appointment_request,
51+
date=timezone.now().date() + timedelta(days=1),
52+
start_time=timezone.now().time(),
53+
end_time=(timezone.now() + timedelta(hours=1)).time(),
54+
staff_member=self.staff_member1,
55+
reason_for_rescheduling="Client request",
56+
reschedule_status='pending'
57+
)
58+
# Directly test the still_valid method
59+
self.assertTrue(reschedule_history.still_valid())
60+
61+
# Simulate passages of time beyond the validity window
62+
reschedule_history.created_at -= timedelta(minutes=6)
63+
reschedule_history.save()
64+
self.assertFalse(reschedule_history.still_valid())

appointment/tests/models/test_model_appointment.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22

33
from django.core.exceptions import ValidationError
44
from django.db import IntegrityError
5+
from django.utils import timezone
56

6-
from appointment.models import Appointment
7+
from appointment.models import Appointment, DayOff, WorkingHours
78
from appointment.tests.base.base_test import BaseTest
9+
from appointment.utils.date_time import get_weekday_num
810

911

1012
class AppointmentModelTestCase(BaseTest):
@@ -220,3 +222,48 @@ def test_get_staff_member_name_without_staff_member(self):
220222
self.appointment.appointment_request.staff_member = None
221223
self.appointment.appointment_request.save()
222224
self.assertEqual(self.appointment.get_staff_member_name(), "")
225+
226+
def test_get_service_img_url_no_image(self):
227+
"""Service should handle cases where no image is provided gracefully."""
228+
self.assertEqual(self.appointment.get_service_img_url(), "")
229+
230+
231+
class AppointmentValidDateTestCase(BaseTest):
232+
def setUp(self):
233+
super().setUp()
234+
self.weekday = "Monday" # Example weekday
235+
self.weekday_num = get_weekday_num(self.weekday)
236+
WorkingHours.objects.create(staff_member=self.staff_member1, day_of_week=self.weekday_num,
237+
start_time=time(9, 0),
238+
end_time=time(17, 0))
239+
self.appt_date = timezone.now().date() + timedelta(days=(self.weekday_num - timezone.now().weekday()) % 7)
240+
self.start_time = timezone.now().replace(hour=10, minute=0, second=0, microsecond=0)
241+
self.current_appointment_id = None
242+
243+
def test_staff_member_works_on_given_day(self):
244+
is_valid, message = Appointment.is_valid_date(self.appt_date, self.start_time, self.staff_member1,
245+
self.current_appointment_id, self.weekday)
246+
self.assertTrue(is_valid)
247+
248+
def test_staff_member_does_not_work_on_given_day(self):
249+
non_working_day = "Sunday"
250+
non_working_day_num = get_weekday_num(non_working_day)
251+
appt_date = self.appt_date + timedelta(days=(non_working_day_num - self.weekday_num) % 7)
252+
is_valid, message = Appointment.is_valid_date(appt_date, self.start_time, self.staff_member1,
253+
self.current_appointment_id, non_working_day)
254+
self.assertFalse(is_valid)
255+
self.assertIn("does not work on this day", message)
256+
257+
def test_start_time_outside_working_hours(self):
258+
early_start_time = timezone.now().replace(hour=8, minute=0) # Before working hours
259+
is_valid, message = Appointment.is_valid_date(self.appt_date, early_start_time, self.staff_member1,
260+
self.current_appointment_id, self.weekday)
261+
self.assertFalse(is_valid)
262+
self.assertIn("outside of", message)
263+
264+
def test_staff_member_has_day_off(self):
265+
DayOff.objects.create(staff_member=self.staff_member1, start_date=self.appt_date, end_date=self.appt_date)
266+
is_valid, message = Appointment.is_valid_date(self.appt_date, self.start_time, self.staff_member1,
267+
self.current_appointment_id, self.weekday)
268+
self.assertFalse(is_valid)
269+
self.assertIn("has a day off on this date", message)

appointment/tests/models/test_model_appointment_request.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import datetime
12
from datetime import date, time, timedelta
23

34
from django.core.exceptions import ValidationError
@@ -9,6 +10,7 @@ class AppointmentRequestModelTestCase(BaseTest):
910
def setUp(self):
1011
super().setUp()
1112
self.ar = self.create_appointment_request_(self.service1, self.staff_member1)
13+
self.today = date.today()
1214

1315
def test_appointment_request_creation(self):
1416
"""Test if an appointment request can be created."""
@@ -115,3 +117,53 @@ def test_appointment_duration_exceeds_service_time(self):
115117
with self.assertRaises(ValidationError):
116118
self.create_appointment_request_(self.service1, self.staff_member1, start_time=time(9, 0),
117119
end_time=time(13, 0))
120+
121+
def test_reschedule_attempts_limit(self):
122+
"""Test appointment request's ability to be rescheduled based on service's limit."""
123+
self.service1.reschedule_limit = 2
124+
self.service1.save()
125+
126+
# Simulate rescheduling attempts
127+
self.ar.increment_reschedule_attempts()
128+
self.assertTrue(self.ar.can_be_rescheduled())
129+
130+
self.ar.increment_reschedule_attempts()
131+
self.assertFalse(self.ar.can_be_rescheduled(),
132+
"Should not be reschedulable after reaching the limit")
133+
134+
def test_appointment_request_with_invalid_date(self):
135+
"""Appointment date should be valid and not in the past."""
136+
invalid_date = self.today - timedelta(days=1)
137+
with self.assertRaises(ValidationError, msg="Date cannot be in the past"):
138+
self.create_appointment_request_(
139+
self.service1, self.staff_member1, date_=invalid_date, start_time=time(10, 0), end_time=time(11, 0)
140+
)
141+
with self.assertRaises(ValidationError, msg="The date is not valid"):
142+
date_ = datetime.datetime.strptime("31-03-2021", "%d-%m-%Y").date()
143+
self.create_appointment_request_(
144+
self.service1, self.staff_member1, date_=date_,
145+
start_time=time(10, 0), end_time=time(11, 0)
146+
)
147+
148+
def test_start_time_after_end_time(self):
149+
"""Start time should not be after end time."""
150+
with self.assertRaises(ValueError, msg="Start time must be before end time"):
151+
self.create_appointment_request_(
152+
self.service1, self.staff_member1, date_=self.today, start_time=time(11, 0), end_time=time(10, 0)
153+
)
154+
155+
def test_start_time_equals_end_time(self):
156+
"""Start time and end time should not be the same."""
157+
with self.assertRaises(ValidationError, msg="Start time and end time cannot be the same"):
158+
self.create_appointment_request_(
159+
self.service1, self.staff_member1, date_=self.today, start_time=time(10, 0), end_time=time(10, 0)
160+
)
161+
162+
def test_appointment_duration_not_exceed_service(self):
163+
"""Appointment duration should not exceed the service's duration."""
164+
extended_end_time = time(11, 30) # 2.5 hours, exceeding the 1-hour service duration
165+
with self.assertRaises(ValidationError, msg="Duration cannot exceed the service duration"):
166+
self.create_appointment_request_(
167+
self.service1, self.staff_member1, date_=self.today, start_time=time(9, 0), end_time=extended_end_time
168+
)
169+

appointment/tests/models/test_model_config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,8 @@ def test_update_website_name(self):
7777

7878
updated_config = Config.objects.get(pk=self.config.pk)
7979
self.assertEqual(updated_config.website_name, new_name)
80+
81+
def test_cant_delete_config(self):
82+
"""Test that a configuration cannot be deleted."""
83+
self.config.delete()
84+
self.assertIsNotNone(Config.objects.first())

0 commit comments

Comments
 (0)