+
{% trans 'Reset Your Password' %}
+
+ {% if messages %}
+
+ {% for message in messages %}
+ - {{ message }}
+ {% endfor %}
+
+ {% endif %}
+
+
+
+{% endblock %}
+{% block customJS %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/appointment/templates/appointment/thank_you.html b/appointment/templates/appointment/thank_you.html
new file mode 100644
index 0000000..7f3c315
--- /dev/null
+++ b/appointment/templates/appointment/thank_you.html
@@ -0,0 +1,54 @@
+{% extends BASE_TEMPLATE %}
+{% load i18n %}
+{% load static %}
+{% block customCSS %}
+
+{% endblock %}
+{% block title %}{{ page_title }}{% endblock %}
+{% block description %}{{ page_description }}{% endblock %}
+{% block body %}
+
+
{{ page_title }}
+
+ {{ page_message }}
+
+ {% if messages %}
+ {% for message in messages %}
+
{{ message }}
+ {% endfor %}
+ {% endif %}
+
+{% endblock %}
+{% block customJS %}
+
+{% endblock %}
diff --git a/appointment/templates/email_sender/admin_new_appointment_email.html b/appointment/templates/email_sender/admin_new_appointment_email.html
new file mode 100644
index 0000000..895a3c1
--- /dev/null
+++ b/appointment/templates/email_sender/admin_new_appointment_email.html
@@ -0,0 +1,68 @@
+{% load i18n %}
+
+
+
+
+
+
{% translate 'Appointment Request Notification' %}
+
+
+
+
+
{% translate 'New Appointment Request' %}
+
{% translate 'Dear Admin,' %}
+
{% translate 'You have received a new appointment request. Here are the details:' %}
+
+
+
{% 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 'Contact Details' %}: {{ appointment.phone }} | {{ client_email }}
+
{% translate 'Additional Info' %}: {{ appointment.additional_info|default:"N/A" }}
+
+
+
{% translate 'Please review the appointment request and take the necessary action.' %}
+
+
+
+
+
diff --git a/appointment/templates/email_sender/reminder_email.html b/appointment/templates/email_sender/reminder_email.html
index 65f7b2a..ba08459 100644
--- a/appointment/templates/email_sender/reminder_email.html
+++ b/appointment/templates/email_sender/reminder_email.html
@@ -3,25 +3,94 @@
+
{% translate 'Appointment Reminder' %}
+
-
{% translate 'Appointment Reminder' %}
-
{% translate 'Dear' %} {{ first_name }},
-
{% translate 'This is a reminder for your upcoming appointment' %}.
-
{% translate 'Service' %}: {{ appointment.get_service_name }}
-
{% translate 'Date' %}: {{ appointment.appointment_request.date }}
-
{% translate 'Time' %}: {{ appointment.appointment_request.start_time }}
-
{% translate 'Location' %}: {{ appointment.address }}
-
- {% translate 'If you need to reschedule, please click the link below or contact us for further assistance.' %}
-
-
-
- {% translate 'Reschedule Appointment' %}
-
-
-
{% translate 'Thank you for choosing us' %}!
+
+
+
+
+ {% if recipient_type == 'client' %}
+ {% translate 'Dear' %} {{ first_name }},
+ {% else %}
+ {% translate 'Dear Administrator,' %}
+ {% endif %}
+
+
{% translate 'This is a reminder for your upcoming appointment.' %}
+
{% translate 'Service' %}: {{ appointment.get_service_name }}
+
{% translate 'Date' %}: {{ appointment.appointment_request.date }}
+
{% translate 'Time' %}: {{ appointment.appointment_request.start_time }}
+ - {{ appointment.appointment_request.end_time }}
+
{% translate 'Location' %}: {{ appointment.address }}
+ {% if recipient_type == 'client' %}
+
{% translate 'If you need to reschedule, please click the button below or contact us for further assistance.' %}
+
{% translate 'Reschedule Appointment' %}
+
{% translate 'Thank you for choosing us!' %}
+ {% else %}
+
{% translate 'Please ensure the appointment setup is complete and ready for the client.' %}
+ {% endif %}
+
+
+
diff --git a/appointment/templates/email_sender/thank_you_email.html b/appointment/templates/email_sender/thank_you_email.html
index 2b82fad..afb8787 100644
--- a/appointment/templates/email_sender/thank_you_email.html
+++ b/appointment/templates/email_sender/thank_you_email.html
@@ -87,7 +87,9 @@
- {% trans "Booking session successfully saved." %}
+ {% if pre_header %}
+ {{ pre_header }}
+ {% endif %}
@@ -109,7 +111,7 @@
- {% trans "Appointment successfully scheduled" %}
+ {{ main_title }}
|
@@ -136,8 +138,8 @@ {% trans "Appointment successfully
-
- Hi {{ first_name }}, {{ message }}
+
+ {% trans 'Thank you for choosing us.' %}
|
@@ -163,20 +165,37 @@ {% trans "Appointment successfully
- {{ account_message }}
+ Hi {{ first_name }},
+ {{ message_1 }}
|
- {% trans "Account Details" %}
+ {{ message_2 }}
|
+ {% if activation_link %}
+
+
+ {% trans 'Account Activation' %}
+
+ {% blocktranslate with link=activation_link %}
+ To activate your account and set your password, please use the following secure
+ link: Set Your Password. Please
+ note that this link will expire in 2 days for your security.
+ {% endblocktranslate %}
+
+ |
+
+ {% endif %}
+
-
-
+ {% trans "Account Information" %}
+
{% for key, value in account_details.items %}
- {{ key }}: {{ value }}
@@ -188,23 +207,11 @@ {% trans "Account Details" %}
|
{% endif %}
{% if more_details %}
-
-
- {{ message }}
- |
-
-
-
- {% trans "Appointment details" %}
- |
-
-
-
+ {% trans "Appointment Details" %}
+
{% for key, value in more_details.items %}
- {{ key }}: {{ value }}
@@ -215,17 +222,36 @@ {% trans "Appointment details" %}
|
{% endif %}
+ {% if reschedule_link %}
+
+
+ {% trans 'Rescheduling' %}
+
+ {% translate 'If your plans change and you need to reschedule your appointment, you can easily do so by following this link: ' %}
+
+ {% translate 'Reschedule Appointment' %}
+
+
+ |
+
+ {% endif %}
+
-
+ style="padding: 0; font-family: Open Sans, Helvetica, Arial, sans-serif;">
+ {% trans 'Support' %}
+
{% blocktranslate %}
- If you have any questions or need assistance, please don't hesitate to reach out to our
- support team.
+ Should you have any inquiries or require further assistance, our support team is here to
+ help. You can reach us anytime.
{% endblocktranslate %}
- {% trans "Best regards" %},
- {% trans "The Team" %}
+
+ {% trans "We look forward to serving you and ensuring that your experience with us is both rewarding and satisfactory." %}
+
+ {% trans "Warm regards" %},
+ {% trans "The Team" %}
|
diff --git a/appointment/tests/models/test_model_password_reset_token.py b/appointment/tests/models/test_model_password_reset_token.py
new file mode 100644
index 0000000..365eb07
--- /dev/null
+++ b/appointment/tests/models/test_model_password_reset_token.py
@@ -0,0 +1,187 @@
+import time
+
+from django.utils import timezone
+
+from appointment.models import PasswordResetToken
+from appointment.tests.base.base_test import BaseTest
+import datetime
+
+
+class PasswordResetTokenTests(BaseTest):
+ def setUp(self):
+ super().setUp()
+ self.user = self.create_user_(username='test_user', email='test@example.com', password='test_pass123')
+ self.expired_time = timezone.now() - datetime.timedelta(minutes=5)
+
+ def test_create_token(self):
+ """Test token creation for a user."""
+ token = PasswordResetToken.create_token(user=self.user)
+ self.assertIsNotNone(token)
+ self.assertFalse(token.is_expired)
+ self.assertFalse(token.is_verified)
+
+ def test_str_representation(self):
+ """Test the string representation of the token."""
+ token = PasswordResetToken.create_token(self.user)
+ expected_str = (f"Password reset token for {self.user} "
+ f"[{token.token} status: {token.status} expires at {token.expires_at}]")
+ self.assertEqual(str(token), expected_str)
+
+ def test_is_verified_property(self):
+ """Test the is_verified property to check if the token status is correctly identified as verified."""
+ token = PasswordResetToken.create_token(self.user)
+ self.assertFalse(token.is_verified, "Newly created token should not be verified.")
+ token.mark_as_verified()
+ self.assertTrue(token.is_verified, "Token should be marked as verified after calling mark_as_verified.")
+
+ def test_is_active_property(self):
+ """Test the is_active property to check if the token status is correctly identified as active."""
+ token = PasswordResetToken.create_token(self.user)
+ self.assertTrue(token.is_active, "Newly created token should be active.")
+ token.mark_as_verified()
+ token.refresh_from_db()
+ self.assertFalse(token.is_active, "Token should not be active after being verified.")
+
+ # Invalidate the token and check is_active property
+ token.status = PasswordResetToken.TokenStatus.INVALIDATED
+ token.save()
+ self.assertFalse(token.is_active, "Token should not be active after being invalidated.")
+
+ def test_is_invalidated_property(self):
+ """Test the is_invalidated property to check if the token status is correctly identified as invalidated."""
+ token = PasswordResetToken.create_token(self.user)
+ self.assertFalse(token.is_invalidated, "Newly created token should not be invalidated.")
+
+ # Invalidate the token and check is_invalidated property
+ token.status = PasswordResetToken.TokenStatus.INVALIDATED
+ token.save()
+ self.assertTrue(token.is_invalidated, "Token should be marked as invalidated after status change.")
+
+ def test_token_expiration(self):
+ """Test that a token is considered expired after the expiration time."""
+ token = PasswordResetToken.create_token(user=self.user, expiration_minutes=-1) # Token already expired
+ self.assertTrue(token.is_expired)
+
+ def test_verify_token_success(self):
+ """Test successful token verification."""
+ token = PasswordResetToken.create_token(user=self.user)
+ verified_token = PasswordResetToken.verify_token(user=self.user, token=token.token)
+ self.assertIsNotNone(verified_token)
+
+ def test_verify_token_failure_expired(self):
+ """Test token verification fails if the token has expired."""
+ token = PasswordResetToken.create_token(user=self.user, expiration_minutes=-1) # Token already expired
+ verified_token = PasswordResetToken.verify_token(user=self.user, token=token.token)
+ self.assertIsNone(verified_token)
+
+ def test_verify_token_failure_wrong_user(self):
+ """Test token verification fails if the token does not belong to the given user."""
+ another_user = self.create_user_(username='another_user', email='another@example.com',
+ password='test_pass456')
+ token = PasswordResetToken.create_token(user=self.user)
+ verified_token = PasswordResetToken.verify_token(user=another_user, token=token.token)
+ self.assertIsNone(verified_token)
+
+ def test_verify_token_failure_already_verified(self):
+ """Test token verification fails if the token has already been verified."""
+ token = PasswordResetToken.create_token(user=self.user)
+ token.mark_as_verified()
+ verified_token = PasswordResetToken.verify_token(user=self.user, token=token.token)
+ self.assertIsNone(verified_token)
+
+ def test_mark_as_verified(self):
+ """Test marking a token as verified."""
+ token = PasswordResetToken.create_token(user=self.user)
+ self.assertFalse(token.is_verified)
+ token.mark_as_verified()
+ token.refresh_from_db() # Refresh the token object from the database
+ self.assertTrue(token.is_verified)
+
+ def test_verify_token_invalid_token(self):
+ """Test token verification fails if the token does not exist."""
+ PasswordResetToken.create_token(user=self.user)
+ invalid_token_uuid = "12345678-1234-1234-1234-123456789012" # An invalid token UUID
+ verified_token = PasswordResetToken.verify_token(user=self.user, token=invalid_token_uuid)
+ self.assertIsNone(verified_token)
+
+ def test_token_expiration_boundary(self):
+ """Test token verification at the exact moment of expiration."""
+ token = PasswordResetToken.create_token(user=self.user, expiration_minutes=0) # Token expires now
+ # Assuming there might be a very slight delay before verification, we wait a second
+ time.sleep(1)
+ verified_token = PasswordResetToken.verify_token(user=self.user, token=token.token)
+ self.assertIsNone(verified_token)
+
+ def test_create_multiple_tokens_for_user(self):
+ """Test that multiple tokens can be created for a single user and only the latest is valid."""
+ old_token = PasswordResetToken.create_token(user=self.user)
+ new_token = PasswordResetToken.create_token(user=self.user)
+
+ old_verified = PasswordResetToken.verify_token(user=self.user, token=old_token.token)
+ new_verified = PasswordResetToken.verify_token(user=self.user, token=new_token.token)
+
+ self.assertIsNone(old_verified, "Old token should not be valid after creating a new one")
+ self.assertIsNotNone(new_verified, "New token should be valid")
+
+ def test_expired_token_does_not_verify(self):
+ """Test that an expired token does not verify even if correct."""
+ token = PasswordResetToken.create_token(user=self.user, expiration_minutes=-5) # Already expired
+ # Fast-forward time to after expiration
+ token.expires_at = timezone.now() - datetime.timedelta(minutes=5)
+ token.save()
+
+ verified_token = PasswordResetToken.verify_token(user=self.user, token=token.token)
+ self.assertIsNone(verified_token, "Expired token should not verify")
+
+ def test_mark_as_verified_is_idempotent(self):
+ """Test that marking a token as verified multiple times has no adverse effect."""
+ token = PasswordResetToken.create_token(user=self.user)
+ token.mark_as_verified()
+ first_verification_time = token.updated_at
+
+ time.sleep(1) # Ensure time has passed
+ token.mark_as_verified()
+ token.refresh_from_db()
+
+ self.assertTrue(token.is_verified)
+ self.assertEqual(first_verification_time, token.updated_at,
+ "Token verification time should not update on subsequent calls")
+
+ def test_deleting_user_cascades_to_tokens(self):
+ """Test that deleting a user deletes associated password reset tokens."""
+ token = PasswordResetToken.create_token(user=self.user)
+ self.user.delete()
+
+ with self.assertRaises(PasswordResetToken.DoesNotExist):
+ PasswordResetToken.objects.get(pk=token.pk)
+
+ def test_token_verification_resets_after_expiration(self):
+ """Test that an expired token cannot be verified after its expiration, even if marked as verified."""
+ token = PasswordResetToken.create_token(user=self.user, expiration_minutes=-1) # Already expired
+ token.mark_as_verified()
+
+ verified_token = PasswordResetToken.verify_token(user=self.user, token=token.token)
+ self.assertIsNone(verified_token, "Expired token should not verify, even if marked as verified")
+
+ def test_verify_token_invalidated(self):
+ """Test token verification fails if the token has been invalidated."""
+ token = PasswordResetToken.create_token(self.user)
+ # Invalidate the token by creating a new one
+ PasswordResetToken.create_token(self.user)
+ verified_token = PasswordResetToken.verify_token(self.user, token.token)
+ self.assertIsNone(verified_token)
+
+ def test_expired_token_verification(self):
+ """Test that an expired token cannot be verified."""
+ token = PasswordResetToken.objects.create(user=self.user, expires_at=self.expired_time,
+ status=PasswordResetToken.TokenStatus.ACTIVE)
+ self.assertTrue(token.is_expired)
+ verified_token = PasswordResetToken.verify_token(self.user, token.token)
+ self.assertIsNone(verified_token, "Expired token should not verify")
+
+ def test_token_verification_after_user_deletion(self):
+ """Test that a token cannot be verified after the associated user is deleted."""
+ token = PasswordResetToken.create_token(self.user)
+ self.user.delete()
+ verified_token = PasswordResetToken.verify_token(None, token.token)
+ self.assertIsNone(verified_token, "Token should not verify after user deletion")
diff --git a/appointment/tests/test_services.py b/appointment/tests/test_services.py
index 792951c..cc37c1f 100644
--- a/appointment/tests/test_services.py
+++ b/appointment/tests/test_services.py
@@ -416,7 +416,6 @@ def get_next_weekday(d, weekday):
if days_ahead <= 0: # Target day already happened this week
days_ahead += 7
next_day = d + datetime.timedelta(days_ahead)
- print(f"Day asked is {days_of_week[weekday]}, which is {next_day.strftime('%Y-%m-%d')}")
return next_day
diff --git a/appointment/tests/test_tasks.py b/appointment/tests/test_tasks.py
index 50762c8..878f54d 100644
--- a/appointment/tests/test_tasks.py
+++ b/appointment/tests/test_tasks.py
@@ -31,12 +31,14 @@ def test_send_email_reminder(self, mock_notify_admin, mock_send_email):
recipient_list=[to_email],
subject=_("Reminder: Upcoming Appointment"),
template_url='email_sender/reminder_email.html',
- context={'first_name': first_name, 'appointment': appointment, 'reschedule_link': ""}
+ context={'first_name': first_name, 'appointment': appointment, 'reschedule_link': "",
+ 'recipient_type': 'admin'}
)
# Verify notify_admin was called with correct parameters
mock_notify_admin.assert_called_once_with(
subject=_("Admin Reminder: Upcoming Appointment"),
template_url='email_sender/reminder_email.html',
- context={'first_name': first_name, 'appointment': appointment, 'reschedule_link': ""}
+ context={'first_name': first_name, 'appointment': appointment, 'reschedule_link': "",
+ 'recipient_type': 'admin'}
)
diff --git a/appointment/tests/test_views.py b/appointment/tests/test_views.py
index 1e20810..271152a 100644
--- a/appointment/tests/test_views.py
+++ b/appointment/tests/test_views.py
@@ -3,6 +3,7 @@
import datetime
import json
+import uuid
from datetime import date, time, timedelta
from unittest.mock import MagicMock, patch
@@ -10,21 +11,27 @@
from django.contrib.messages import get_messages
from django.contrib.messages.middleware import MessageMiddleware
from django.contrib.sessions.middleware import SessionMiddleware
-from django.core.exceptions import ValidationError
from django.http import HttpResponseRedirect
from django.test import Client
from django.test.client import RequestFactory
from django.urls import reverse
from django.utils import timezone
+from django.utils.encoding import force_bytes
+from django.utils.http import urlsafe_base64_encode
from django.utils.translation import gettext as _
-from appointment.models import Appointment, AppointmentRequest, AppointmentRescheduleHistory, Config, \
- DayOff, EmailVerificationCode, StaffMember
+from appointment.messages_ import passwd_error
+from appointment.models import (
+ Appointment, AppointmentRequest, AppointmentRescheduleHistory, Config, DayOff, EmailVerificationCode,
+ PasswordResetToken, StaffMember
+)
from appointment.tests.base.base_test import BaseTest
-from appointment.utils.db_helpers import Service, WorkingHours
+from appointment.utils.db_helpers import Service, WorkingHours, create_user_with_username
from appointment.utils.error_codes import ErrorCode
-from appointment.views import create_appointment, create_user_and_notify_admin, get_appointment_data_from_post_request, \
- get_client_data_from_post, redirect_to_payment_or_thank_you_page, verify_user_and_login
+from appointment.views import (
+ create_appointment, get_appointment_data_from_post_request, get_client_data_from_post,
+ redirect_to_payment_or_thank_you_page, verify_user_and_login
+)
class ViewsTestCase(BaseTest):
@@ -645,7 +652,6 @@ def test_update_day_off_unauthorized_user(self):
url = reverse('appointment:update_day_off', args=[self.day_off.id])
response = self.client.post(url, {'start_date': '2050-01-01', 'end_date': '2050-01-01',
'description': 'Trying unauthorized update'}, 'json')
- print(f"response: {response}")
self.assertEqual(response.status_code, 403) # Expect forbidden error
def test_update_nonexistent_day_off(self):
@@ -678,6 +684,74 @@ def test_delete_nonexistent_day_off(self):
self.assertEqual(response.status_code, 404)
+class SetPasswordViewTests(BaseTest):
+ def setUp(self):
+ super().setUp()
+ user_data = {
+ 'username': 'test_user', 'email': 'test@example.com', 'password': 'oldpassword', 'first_name': 'John',
+ 'last_name': 'Doe'
+ }
+ self.user = create_user_with_username(user_data)
+ self.token = PasswordResetToken.create_token(user=self.user, expiration_minutes=2880) # 2 days expiration
+ self.ui_db64 = urlsafe_base64_encode(force_bytes(self.user.pk))
+ self.relative_set_passwd_link = reverse('appointment:set_passwd', args=[self.ui_db64, self.token.token])
+ self.valid_link = reverse('appointment:set_passwd', args=[self.ui_db64, str(self.token.token)])
+
+ def test_get_request_with_valid_token(self):
+ assert PasswordResetToken.objects.filter(user=self.user, token=self.token.token).exists(), ("Token not found "
+ "in database")
+ response = self.client.get(self.valid_link)
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, "form")
+ self.assertNotContains(response, "The password reset link is invalid or has expired.")
+
+ def test_post_request_with_valid_token_and_correct_password(self):
+ new_password_data = {'new_password1': 'newstrongpassword123', 'new_password2': 'newstrongpassword123'}
+ response = self.client.post(self.valid_link, new_password_data)
+ self.user.refresh_from_db()
+ self.assertTrue(self.user.check_password(new_password_data['new_password1']))
+ messages_ = list(get_messages(response.wsgi_request))
+ self.assertTrue(any(msg.message == _("Password reset successfully.") for msg in messages_))
+
+ def test_get_request_with_expired_token(self):
+ expired_token = PasswordResetToken.create_token(user=self.user, expiration_minutes=-60)
+ expired_token_link = reverse('appointment:set_passwd', args=[self.ui_db64, str(expired_token.token)])
+ response = self.client.get(expired_token_link)
+ self.assertEqual(response.status_code, 200)
+ self.assertIn('messages', response.context)
+ self.assertEqual(response.context['page_message'], passwd_error)
+
+ def test_get_request_with_invalid_token(self):
+ invalid_token = str(uuid.uuid4())
+ invalid_token_link = reverse('appointment:set_passwd', args=[self.ui_db64, invalid_token])
+ response = self.client.get(invalid_token_link, follow=True)
+ self.assertEqual(response.status_code, 200)
+ messages_ = list(get_messages(response.wsgi_request))
+ self.assertTrue(
+ any(msg.message == _("The password reset link is invalid or has expired.") for msg in messages_))
+
+ def test_post_request_with_invalid_token(self):
+ invalid_token = str(uuid.uuid4())
+ invalid_token_link = reverse('appointment:set_passwd', args=[self.ui_db64, invalid_token])
+ new_password = 'newpassword123'
+ post_data = {'new_password1': new_password, 'new_password2': new_password}
+ response = self.client.post(invalid_token_link, post_data)
+ self.user.refresh_from_db()
+ self.assertFalse(self.user.check_password(new_password))
+ messages_ = list(get_messages(response.wsgi_request))
+ self.assertTrue(any(_("The password reset link is invalid or has expired.") in str(m) for m in messages_))
+
+ def test_post_request_with_expired_token(self):
+ expired_token = PasswordResetToken.create_token(user=self.user, expiration_minutes=-60)
+ expired_token_link = reverse('appointment:set_passwd', args=[self.ui_db64, str(expired_token.token)])
+ new_password_data = {'new_password1': 'newpassword', 'new_password2': 'newpassword'}
+ response = self.client.post(expired_token_link, new_password_data)
+ self.assertEqual(response.status_code, 200)
+ messages_ = list(get_messages(response.wsgi_request))
+ self.assertTrue(
+ any(msg.message == _("The password reset link is invalid or has expired.") for msg in messages_))
+
+
class GetNonWorkingDaysAjaxTests(BaseTest):
def setUp(self):
super().setUp()
@@ -702,7 +776,6 @@ def test_valid_staff_member_selected(self):
response_data = response.json()
self.assertTrue(response_data['success'])
self.assertEqual(response_data['message'], _('Successfully retrieved non-working days'))
- print(f"Response data: {response_data}")
self.assertIn('non_working_days', response_data)
self.assertTrue(isinstance(response_data['non_working_days'], list))
@@ -888,73 +961,6 @@ def test_notify_admin_about_reschedule_called(self, mock_notify_admin):
self.assertTrue(mock_notify_admin.called)
-class CreateUserAndNotifyAdminTests(BaseTest):
- def setUp(self):
- self.client_data = {
- 'email': 'test@example.com',
- 'first_name': 'Test',
- 'last_name': 'User'
- }
- self.appointment_data = {
- 'service_id': 1,
- 'appointment_time': '10:00 AM'
- }
- self.factory = RequestFactory()
-
- self.request = self.factory.get('/')
- self.middleware = SessionMiddleware(lambda req: None)
- self.middleware.process_request(self.request)
- self.request.session.save()
-
- self.middleware = MessageMiddleware(lambda req: None)
- self.middleware.process_request(self.request)
- self.request.session.save()
-
- @patch('appointment.views.create_new_user')
- @patch('appointment.views.notify_admin')
- def test_create_user_and_notify_admin_success(self, mock_notify_admin, mock_create_new_user):
- """Test creating a new user and notifying the admin."""
- mock_user = mock_create_new_user.return_value
- mock_user.email = self.client_data['email']
-
- user = create_user_and_notify_admin(self.request, self.client_data, self.appointment_data)
-
- # Check that the create_new_user function was called with the right parameters
- mock_create_new_user.assert_called_once_with(self.client_data)
- # Check that the notify_admin function was called with the right parameters
- mock_notify_admin.assert_called_once()
- subject_arg = mock_notify_admin.call_args[1]['subject']
- message_arg = mock_notify_admin.call_args[1]['message']
- self.assertIn("New Appointment Request", subject_arg)
- self.assertIn(self.client_data['email'], message_arg)
- self.assertIn(str(self.appointment_data), message_arg)
- # Check that a success message was added to the request
- messages_list = list(get_messages(self.request))
- self.assertTrue(any(msg.message == "An account was created for you." for msg in messages_list))
- # Check that the returned user is the mock user
- self.assertEqual(user.email, self.client_data['email'])
-
- @patch('appointment.views.create_new_user', side_effect=Exception('Test Exception'))
- def test_create_user_and_notify_admin_failure(self, mock_create_new_user):
- """Test handling exceptions when creating a new user."""
- request = self.factory.get('/')
- middleware = SessionMiddleware(lambda req: None)
- middleware.process_request(request)
- request.session.save()
-
- middleware = MessageMiddleware(lambda req: None)
- middleware.process_request(request)
- request.session.save()
-
- with self.assertRaises(Exception) as context:
- create_user_and_notify_admin(request, self.client_data, self.appointment_data)
-
- # Check that the exception message is correct
- self.assertTrue('Test Exception' in str(context.exception))
- # Check that the create_new_user function was called with the right parameters
- mock_create_new_user.assert_called_once_with(self.client_data)
-
-
class GetAppointmentDataFromPostRequestTests(BaseTest):
def setUp(self):
self.factory = RequestFactory()
diff --git a/appointment/tests/utils/test_db_helpers.py b/appointment/tests/utils/test_db_helpers.py
index c78d357..0ea1567 100644
--- a/appointment/tests/utils/test_db_helpers.py
+++ b/appointment/tests/utils/test_db_helpers.py
@@ -1037,8 +1037,8 @@ def test_create_new_user_check_password(self):
"""Test creating a new user with a password."""
client_data = {'name': 'John Doe', 'email': 'john.doe@example.com'}
user = create_new_user(client_data)
- password = f"{get_website_name()}{get_current_year()}"
- self.assertTrue(user.check_password(password))
+ # Check that no password has been set
+ self.assertFalse(user.has_usable_password())
class UsernameInUserModelTests(TestCase):
diff --git a/appointment/tests/utils/test_email_ops.py b/appointment/tests/utils/test_email_ops.py
new file mode 100644
index 0000000..8738655
--- /dev/null
+++ b/appointment/tests/utils/test_email_ops.py
@@ -0,0 +1,135 @@
+from unittest import mock
+from unittest.mock import MagicMock, patch
+
+from django.test import TestCase
+from django.test.client import RequestFactory
+from django.utils import timezone
+from django.utils.translation import gettext as _
+
+from appointment.messages_ import thank_you_no_payment, thank_you_payment, thank_you_payment_plus_down
+from appointment.models import AppointmentRescheduleHistory
+from appointment.tests.base.base_test import BaseTest
+from appointment.utils.email_ops import (
+ get_thank_you_message, notify_admin_about_appointment, send_reschedule_confirmation_email, send_thank_you_email,
+ send_verification_email
+)
+
+
+class GetThankYouMessageTests(TestCase):
+ def test_thank_you_no_payment(self):
+ with patch('appointment.utils.email_ops.APPOINTMENT_PAYMENT_URL', None):
+ ar = MagicMock()
+ message = get_thank_you_message(ar)
+ self.assertIn(thank_you_no_payment, message)
+
+ def test_thank_you_payment_plus_down(self):
+ with patch('appointment.utils.email_ops.APPOINTMENT_PAYMENT_URL', "http://payment.url"):
+ ar = MagicMock()
+ ar.accepts_down_payment.return_value = True
+ message = get_thank_you_message(ar)
+ self.assertIn(thank_you_payment_plus_down, message)
+
+ def test_thank_you_payment(self):
+ with patch('appointment.utils.email_ops.APPOINTMENT_PAYMENT_URL', "http://payment.url"):
+ ar = MagicMock()
+ ar.accepts_down_payment.return_value = False
+ message = get_thank_you_message(ar)
+ self.assertIn(thank_you_payment, message)
+
+
+class SendThankYouEmailTests(BaseTest):
+ def setUp(self):
+ super().setUp()
+ self.factory = RequestFactory()
+ self.request = self.factory.get('/')
+
+ @patch('appointment.utils.email_ops.send_email')
+ @patch('appointment.utils.email_ops.get_thank_you_message')
+ def test_send_thank_you_email(self, mock_get_thank_you_message, mock_send_email):
+ ar = self.create_appt_request_for_sm1()
+ email = "test@example.com"
+ appointment_details = "Details about the appointment"
+ account_details = "Details about the account"
+
+ mock_get_thank_you_message.return_value = "Thank you message"
+
+ send_thank_you_email(ar, self.user1, self.request, email, appointment_details, account_details)
+
+ mock_send_email.assert_called_once()
+ args, kwargs = mock_send_email.call_args
+ self.assertIn(email, kwargs['recipient_list'])
+ self.assertIn("Thank you message", kwargs['context']['message_1'])
+
+
+class NotifyAdminAboutAppointmentTests(BaseTest):
+ def setUp(self):
+ super().setUp()
+ self.appointment = self.create_appointment_for_user1()
+
+ @patch('appointment.utils.email_ops.notify_admin')
+ @patch('appointment.utils.email_ops.send_email')
+ def test_notify_admin_about_appointment(self, mock_send_email, mock_notify_admin):
+ client_name = "John Doe"
+
+ notify_admin_about_appointment(self.appointment, client_name)
+
+ mock_notify_admin.assert_called_once()
+ mock_send_email.assert_called_once()
+
+
+class SendVerificationEmailTests(BaseTest):
+ def setUp(self):
+ super().setUp()
+ self.appointment = self.create_appointment_for_user1()
+
+ @patch('appointment.utils.email_ops.send_email')
+ @patch('appointment.models.EmailVerificationCode.generate_code', return_value="123456")
+ def test_send_verification_email(self, mock_generate_code, mock_send_email):
+ user = MagicMock()
+ email = "test@example.com"
+
+ send_verification_email(user, email)
+
+ mock_send_email.assert_called_once_with(
+ recipient_list=[email],
+ subject=_("Email Verification"),
+ message=mock.ANY
+ )
+ self.assertIn("123456", mock_send_email.call_args[1]['message'])
+
+
+class SendRescheduleConfirmationEmailTests(BaseTest):
+ def setUp(self):
+ # Setup test data
+ super().setUp()
+ self.user = self.user1
+ self.appointment_request = self.create_appt_request_for_sm1()
+ self.reschedule_history = AppointmentRescheduleHistory.objects.create(
+ appointment_request=self.appointment_request,
+ date=self.appointment_request.date + timezone.timedelta(days=1),
+ start_time=self.appointment_request.start_time,
+ end_time=self.appointment_request.end_time,
+ staff_member=self.staff_member1,
+ reason_for_rescheduling="Test reason"
+ )
+ self.first_name = "Test"
+ self.email = "test@example.com"
+
+ @mock.patch('appointment.utils.email_ops.get_absolute_url_')
+ @mock.patch('appointment.utils.email_ops.send_email')
+ def test_send_reschedule_confirmation_email(self, mock_send_email, mock_get_absolute_url):
+ request = mock.MagicMock()
+ mock_get_absolute_url.return_value = "http://testserver/confirmation_link"
+
+ send_reschedule_confirmation_email(request, self.reschedule_history, self.appointment_request, self.first_name,
+ self.email)
+
+ # Check if `send_email` was called correctly
+ mock_send_email.assert_called_once()
+ call_args, call_kwargs = mock_send_email.call_args
+
+ self.assertEqual(call_kwargs['recipient_list'], [self.email])
+ self.assertEqual(call_kwargs['subject'], _("Confirm Your Appointment Rescheduling"))
+ self.assertIn('reschedule_date', call_kwargs['context'])
+ self.assertIn('confirmation_link', call_kwargs['context'])
+ self.assertEqual(call_kwargs['context']['confirmation_link'], "http://testserver/confirmation_link")
diff --git a/appointment/urls.py b/appointment/urls.py
index c45336f..a60aee8 100644
--- a/appointment/urls.py
+++ b/appointment/urls.py
@@ -11,7 +11,7 @@
from appointment.views import (
appointment_client_information, appointment_request, appointment_request_submit, confirm_reschedule,
default_thank_you, enter_verification_code, get_available_slots_ajax, get_next_available_date_ajax,
- get_non_working_days_ajax, prepare_reschedule_appointment, reschedule_appointment_submit
+ get_non_working_days_ajax, prepare_reschedule_appointment, reschedule_appointment_submit, set_passwd
)
from appointment.views_admin import (
add_day_off, add_or_update_service, add_or_update_staff_info, add_working_hours, create_new_staff_member,
@@ -109,6 +109,7 @@
name='enter_verification_code'),
path('verification-code/', email_change_verification_code, name='email_change_verification_code'),
path('thank-you/
/', default_thank_you, name='default_thank_you'),
+ path('verify///', set_passwd, name='set_passwd'),
path('ajax/', include(ajax_urlpatterns)),
path('app-admin/', include(admin_urlpatterns)),
]
diff --git a/appointment/utils/db_helpers.py b/appointment/utils/db_helpers.py
index 06a127c..7c011ea 100644
--- a/appointment/utils/db_helpers.py
+++ b/appointment/utils/db_helpers.py
@@ -276,11 +276,6 @@ def create_new_user(client_data: dict):
user = create_user_with_username(client_data)
except FieldDoesNotExist:
user = create_user_with_email(client_data)
-
- password = f"{get_website_name()}{get_current_year()}"
- user.set_password(password)
- user.save()
-
return user
diff --git a/appointment/utils/email_ops.py b/appointment/utils/email_ops.py
index 9037218..b62ee79 100644
--- a/appointment/utils/email_ops.py
+++ b/appointment/utils/email_ops.py
@@ -10,11 +10,13 @@
from django.conf import settings
from django.urls import reverse
+from django.utils.encoding import force_bytes
+from django.utils.http import urlsafe_base64_encode
from django.utils.translation import gettext as _
from appointment import messages_ as email_messages
from appointment.email_sender import notify_admin, send_email
-from appointment.models import AppointmentRequest, EmailVerificationCode
+from appointment.models import AppointmentRequest, EmailVerificationCode, PasswordResetToken
from appointment.settings import APPOINTMENT_PAYMENT_URL
from appointment.utils.date_time import convert_24_hour_time_to_12_hour_time
from appointment.utils.db_helpers import get_absolute_url_, get_website_name
@@ -40,32 +42,45 @@ def get_thank_you_message(ar: AppointmentRequest) -> str:
return message
-def send_thank_you_email(ar: AppointmentRequest, first_name: str, email: str, appointment_details=None,
+def send_thank_you_email(ar: AppointmentRequest, user, request, email: str, appointment_details=None,
account_details=None):
"""Send a thank-you email to the client for booking an appointment.
:param ar: The appointment request associated with the booking.
- :param first_name: The first name of the client.
+ :param user: The user who booked the appointment.
:param email: The email address of the client.
:param appointment_details: Additional details about the appointment (default None).
:param account_details: Additional details about the account (default None).
+ :param request: The request object.
:return: None
"""
- message = _("We've created an account for you to manage your appointment for the requested service ")
- message += _("{s}. Your password is temporary. Please change it on your first login.").format(s=ar.service)
# Month and year like "J A N 2 0 2 1"
month_year = ar.date.strftime("%b %Y").upper()
day = ar.date.strftime("%d")
+ token = PasswordResetToken.create_token(user=user, expiration_minutes=2880) # 2 days expiration
+ ui_db64 = urlsafe_base64_encode(force_bytes(user.pk))
+ relative_set_passwd_link = reverse('appointment:set_passwd', args=[ui_db64, token.token])
+ set_passwd_link = get_absolute_url_(relative_set_passwd_link, request=request)
+
+ relative_reschedule_url = reverse('appointment:prepare_reschedule_appointment', args=[ar.get_id_request()])
+ reschedule_link = get_absolute_url_(relative_reschedule_url, request)
+
+ message = _("To enhance your experience, we have created a personalized account for you. It will allow "
+ "you to manage your appointments, view service details, and make any necessary adjustments with ease.")
+
email_context = {
- 'first_name': first_name,
- 'message': get_thank_you_message(ar),
+ 'first_name': user.first_name,
+ 'message_1': get_thank_you_message(ar),
'current_year': datetime.datetime.now().year,
'company': get_website_name(),
'more_details': appointment_details,
'account_details': account_details,
- 'account_message': message if account_details is not None else None,
+ 'message_2': message if account_details is not None else None,
'month_year': month_year,
'day': day,
+ 'activation_link': set_passwd_link,
+ 'main_title': _("Appointment successfully scheduled"),
+ 'reschedule_link': reschedule_link,
}
send_email(
recipient_list=[email], subject=_("Thank you for booking us."),
@@ -73,6 +88,22 @@ def send_thank_you_email(ar: AppointmentRequest, first_name: str, email: str, ap
)
+def notify_admin_about_appointment(appointment, client_name: str):
+ """Notify the admin and the staff member about a new appointment request."""
+ email_context = {
+ 'client_name': client_name,
+ 'appointment': appointment
+ }
+
+ subject = _("New Appointment Request for ") + client_name
+ staff_member = appointment.get_staff_member()
+ # Assuming notify_admin and send_email are previously defined functions
+ notify_admin(subject=subject, template_url='email_sender/admin_new_appointment_email.html', context=email_context)
+ if staff_member.user.email not in settings.ADMINS:
+ send_email(recipient_list=[staff_member.user.email], subject=subject,
+ template_url='email_sender/admin_new_appointment_email.html', context=email_context)
+
+
def send_verification_email(user, email: str):
"""
Send an email with a verification code to the user for email verification.
diff --git a/appointment/views.py b/appointment/views.py
index 8d25943..f5d94d3 100644
--- a/appointment/views.py
+++ b/appointment/views.py
@@ -11,17 +11,21 @@
import pytz
from django.contrib import messages
from django.contrib.auth import login
+from django.contrib.auth.forms import SetPasswordForm
from django.db.models import Q
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
+from django.utils.encoding import force_str
+from django.utils.http import urlsafe_base64_decode
from django.utils.translation import gettext as _
from appointment.email_sender import notify_admin
from appointment.forms import AppointmentForm, AppointmentRequestForm
from appointment.logger_config import logger
from appointment.models import (
- Appointment, AppointmentRequest, AppointmentRescheduleHistory, Config, DayOff, EmailVerificationCode, Service,
+ Appointment, AppointmentRequest, AppointmentRescheduleHistory, Config, DayOff, EmailVerificationCode,
+ PasswordResetToken, Service,
StaffMember
)
from appointment.utils.db_helpers import (
@@ -30,11 +34,13 @@
get_website_name, get_weekday_num_from_date, is_working_day, staff_change_allowed_on_reschedule,
username_in_user_model
)
-from appointment.utils.email_ops import notify_admin_about_reschedule, send_reschedule_confirmation_email, \
+from appointment.utils.email_ops import notify_admin_about_appointment, notify_admin_about_reschedule, \
+ send_reschedule_confirmation_email, \
send_thank_you_email
from appointment.utils.session import get_appointment_data_from_session, handle_existing_email
from appointment.utils.view_helpers import get_locale, get_timezone_txt
from .decorators import require_ajax
+from .messages_ import passwd_error, passwd_set_successfully
from .services import get_appointments_and_slots, get_available_slots_for_staff
from .settings import (APPOINTMENT_PAYMENT_URL, APPOINTMENT_THANK_YOU_URL, APP_TIME_ZONE)
from .utils.date_time import convert_str_to_date, convert_str_to_time, get_current_year
@@ -283,6 +289,7 @@ def create_appointment(request, appointment_request_obj, client_data, appointmen
:return: The redirect response.
"""
appointment = create_and_save_appointment(appointment_request_obj, client_data, appointment_data, request)
+ notify_admin_about_appointment(appointment, appointment.client.first_name)
return redirect_to_payment_or_thank_you_page(appointment)
@@ -312,23 +319,6 @@ def get_appointment_data_from_post_request(request):
}
-def create_user_and_notify_admin(request, client_data, appointment_data):
- """This function creates a new user, sends a thank-you email, and notifies the admin.
-
- :param request: The request instance.
- :param client_data: The client data.
- :param appointment_data: The appointment data.
- :return: The newly created user.
- """
- logger.info("Creating a new user with the given information {client_data}")
- user = create_new_user(client_data)
-
- notify_admin(subject="New Appointment Request",
- message=f"New appointment request from {client_data['email']} for {appointment_data}")
- messages.success(request, _("An account was created for you."))
- return user
-
-
def appointment_client_information(request, appointment_request_id, id_request):
"""This view function handles client information submission for an appointment.
@@ -357,7 +347,9 @@ def appointment_client_information(request, appointment_request_id, id_request):
if is_email_in_db:
return handle_existing_email(request, client_data, appointment_data, appointment_request_id, id_request)
- create_user_and_notify_admin(request, client_data, appointment_data)
+ logger.info(f"Creating a new user with the given information {client_data}")
+ user = create_new_user(client_data)
+ messages.success(request, _("An account was created for you."))
# Create a new appointment
response = create_appointment(request, ar, client_data, appointment_data)
@@ -414,13 +406,12 @@ def enter_verification_code(request, appointment_request_id, id_request):
appointment = Appointment.objects.get(appointment_request=appointment_request_object)
appointment_details = {
'Service': appointment.get_service_name(),
- 'Appointment ID': appointment.id_request,
'Appointment Date': appointment.get_appointment_date(),
'Appointment Time': appointment.appointment_request.start_time,
'Duration': appointment.get_service_duration()
}
- send_thank_you_email(ar=appointment_request_object, first_name=user.first_name, email=email,
- appointment_details=appointment_details)
+ send_thank_you_email(ar=appointment_request_object, user=user, email=email,
+ appointment_details=appointment_details, request=request)
return response
else:
messages.error(request, _("Invalid verification code."))
@@ -445,7 +436,6 @@ def default_thank_you(request, appointment_id):
"""
appointment = get_object_or_404(Appointment, pk=appointment_id)
ar = appointment.appointment_request
- first_name = appointment.client.first_name
email = appointment.client.email
appointment_details = {
_('Service'): appointment.get_service_name(),
@@ -454,13 +444,12 @@ def default_thank_you(request, appointment_id):
_('Duration'): appointment.get_service_duration()
}
account_details = {
- _('The email address linked to this account'): email,
- _('Your temporary password'): f"{get_website_name()}{get_current_year()}",
+ _('Email address'): email,
}
if username_in_user_model():
- account_details[_('Your username')] = appointment.client.username
- send_thank_you_email(ar=ar, first_name=first_name, email=email, appointment_details=appointment_details,
- account_details=account_details)
+ account_details[_('Username')] = appointment.client.username
+ send_thank_you_email(ar=ar, user=appointment.client, email=email, appointment_details=appointment_details,
+ account_details=account_details, request=request)
extra_context = {
'appointment': appointment,
}
@@ -468,6 +457,45 @@ def default_thank_you(request, appointment_id):
return render(request, 'appointment/default_thank_you.html', context=context)
+def set_passwd(request, uidb64, token):
+ extra = {
+ 'page_title': _("Error"),
+ 'page_message': passwd_error,
+ 'page_description': _("Please try resetting your password again or contact support for help."),
+ }
+ context_ = get_generic_context_with_extra(request, extra, admin=False)
+ try:
+ uid = force_str(urlsafe_base64_decode(uidb64))
+ user = get_user_model().objects.get(pk=uid)
+ token_verification = PasswordResetToken.verify_token(user, token)
+ if token_verification is not None:
+ if request.method == 'POST':
+ form = SetPasswordForm(user, request.POST)
+ if form.is_valid():
+ form.save()
+ messages.success(request, _("Password reset successfully."))
+ # Invalidate the token after successful password reset
+ token_verification.mark_as_verified()
+ extra = {
+ 'page_title': _("Password Reset Successful"),
+ 'page_message': passwd_set_successfully,
+ 'page_description': _("You can now use your new password to log in.")
+ }
+ context = get_generic_context_with_extra(request, extra, admin=False)
+ return render(request, 'appointment/thank_you.html', context=context)
+ else:
+ form = SetPasswordForm(user) # Display empty form for GET request
+ else:
+ messages.error(request, passwd_error)
+ return render(request, 'appointment/thank_you.html', context=context_)
+ except (TypeError, ValueError, OverflowError, get_user_model().DoesNotExist):
+ messages.error(request, _("The password reset link is invalid or has expired."))
+ return render(request, 'appointment/thank_you.html', context=context_)
+
+ context_.update({'form': form})
+ return render(request, 'appointment/set_password.html', context_)
+
+
def prepare_reschedule_appointment(request, id_request):
ar = get_object_or_404(AppointmentRequest, id_request=id_request)
@@ -486,7 +514,7 @@ def prepare_reschedule_appointment(request, id_request):
'services_offered': ar.service}
all_staff_members = StaffMember.objects.filter(**staff_filter_criteria)
available_slots = get_available_slots_for_staff(ar.date, selected_sm)
- page_title = f"Rescheduling appointment for {service.name}"
+ page_title = _("Rescheduling appointment for {s}").format(s=service.name)
page_description = _("Reschedule your appointment for {s} at {wn}.").format(s=service.name, wn=get_website_name())
date_chosen = ar.date.strftime("%a, %B %d, %Y")
@@ -539,7 +567,6 @@ def reschedule_appointment_submit(request):
email=email, appointment_request=ar)
return render(request, 'appointment/rescheduling_thank_you.html', context=context)
else:
- print(f"form is invalid", form.errors)
messages.error(request, _("There was an error in your submission. Please check the form and try again."))
else:
form = AppointmentRequestForm()