diff --git a/README.md b/README.md index 6155e2f..06b1a4b 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,11 @@ see their [release notes](https://github.com/adamspd/django-appointment/tree/mai ```bash pip install django-appointment ``` + Optionally installing django_q2 if you need email reminders: + + ```bash + pip install django_q2 + ``` 2. Add "appointment" (& "django_q" if you want to enable email reminders) to your `INSTALLED_APPS` setting like so: @@ -184,13 +189,13 @@ If you're using a base.html template, you must include the following block in yo {% endblock %} ``` -At least the block for css, body and js are required; otherwise the application will not work properly. +At least the block for css, body and js are required; otherwise the application will not work properly. Jquery is also required to be included in the template. The title and description are optional but recommended for SEO purposes. -See an example of a base.html template [here](https://github.com/adamspd/django-appointment/blob/main/appointment/templates/base_templates/base.html). - +See an example of a base.html +template [here](https://github.com/adamspd/django-appointment/blob/main/appointment/templates/base_templates/base.html). ## Customization 🔧 @@ -200,7 +205,6 @@ See an example of a base.html template [here](https://github.com/adamspd/django- 2. Modify these values as needed for your application, and the app will adapt to the new settings. 3. For further customization, you can extend the provided models, views, and templates or create your own. - ## Docker Support 🐳 Django-Appointment now supports Docker, making it easier to set up, develop, and test. @@ -297,7 +301,6 @@ Here's how you can set it up: > **Note:** I used the default database settings for the Docker container. > If you want to use a different database, you can modify the Dockerfile and docker-compose.yml files to use your > preferred database. - ## Compatibility Matrix 📊 @@ -333,9 +336,12 @@ information. ## Notes 📝⚠️ -I'm working on a testing website for the application that is not fully functional yet, no hard feelings. Before using it, -it's important to me that you read the terms of use, only then you can use it if you agree to them. The demo website is located -at [https://django-appt.adamspierredavid.com/](https://django-appt.adamspierredavid.com/terms-and-conditions/). Ideas are welcome. +I'm working on a testing website for the application that is not fully functional yet, no hard feelings. Before using +it, +it's important to me that you read the terms of use, only then you can use it if you agree to them. The demo website is +located +at [https://django-appt.adamspierredavid.com/](https://django-appt.adamspierredavid.com/terms-and-conditions/). Ideas +are welcome. ## About the Author diff --git a/appointment/email_sender/email_sender.py b/appointment/email_sender/email_sender.py index 465c263..03e04dd 100644 --- a/appointment/email_sender/email_sender.py +++ b/appointment/email_sender/email_sender.py @@ -3,13 +3,21 @@ from django.core.mail import mail_admins, send_mail from django.template import loader -from django_q.tasks import async_task from appointment.logger_config import get_logger from appointment.settings import APP_DEFAULT_FROM_EMAIL, check_q_cluster logger = get_logger(__name__) +try: + from django_q.tasks import async_task + + DJANGO_Q_AVAILABLE = True +except ImportError: + async_task = None + DJANGO_Q_AVAILABLE = False + logger.warning("django-q is not installed. Email will be send synchronously.") + def has_required_email_settings(): """Check if all required email settings are configured and warn if any are missing.""" @@ -45,7 +53,7 @@ def send_email(recipient_list, subject: str, template_url: str = None, context: from_email = from_email or APP_DEFAULT_FROM_EMAIL html_message = render_email_template(template_url, context) - if get_use_django_q_for_emails() and check_q_cluster(): + if get_use_django_q_for_emails() and check_q_cluster() and DJANGO_Q_AVAILABLE: # Asynchronously send the email using Django-Q async_task( "appointment.tasks.send_email_task", recipient_list=recipient_list, subject=subject, @@ -69,7 +77,7 @@ def notify_admin(subject: str, template_url: str = None, context: dict = None, m html_message = render_email_template(template_url, context) - if get_use_django_q_for_emails() and check_q_cluster(): + if get_use_django_q_for_emails() and check_q_cluster() and DJANGO_Q_AVAILABLE: # Enqueue the task to send admin email asynchronously async_task('appointment.tasks.notify_admin_task', subject=subject, message=message, html_message=html_message) else: diff --git a/appointment/settings.py b/appointment/settings.py index 63d7789..a7d4059 100644 --- a/appointment/settings.py +++ b/appointment/settings.py @@ -37,29 +37,15 @@ def check_q_cluster(): logger.info("Checking missing configuration for django q cluster") # Check if Django Q is installed if 'django_q' not in settings.INSTALLED_APPS: - missing_conf.append("Django Q is not in settings.INSTALLED_APPS. Please add it to the list.\n" - "Example: \n\n" - "INSTALLED_APPS = [\n" - " ...\n" - " 'appointment',\n" - " 'django_q',\n" - "]\n") + missing_conf.append("Django Q is not in settings.INSTALLED_APPS. Please add it to the list. " + "See https://django-appt-doc.adamspierredavid.com/getting-started/#installation " + "for more information") # Check if Q_CLUSTER configuration is defined if not hasattr(settings, 'Q_CLUSTER'): - missing_conf.append("Q_CLUSTER is not defined in settings. Please define it.\n" - "Example: \n\n" - "Q_CLUSTER = {\n" - " 'name': 'DjangORM',\n" - " 'workers': 4,\n" - " 'timeout': 90,\n" - " 'retry': 120,\n" - " 'queue_limit': 50,\n" - " 'bulk': 10,\n" - " 'orm': 'default',\n" - "}\n" - "Then run 'python manage.py qcluster' to start the worker.\n" - "See https://django-q.readthedocs.io/en/latest/configure.html for more information.") + missing_conf.append("Q_CLUSTER is not defined in settings. Please define it. " + "See https://django-appt-doc.adamspierredavid.com/project-structure/#configuration " + "for more information.") # Log warnings if any configurations are missing if missing_conf: diff --git a/appointment/tests/test_settings.py b/appointment/tests/test_settings.py index 5232c4f..23d2150 100644 --- a/appointment/tests/test_settings.py +++ b/appointment/tests/test_settings.py @@ -22,13 +22,9 @@ def test_check_q_cluster_with_django_q_missing(self, mock_logger, mock_settings) self.assertFalse(result) # Verify logger was called with the expected warning about 'django_q' not being installed mock_logger.warning.assert_called_with( - "Django Q is not in settings.INSTALLED_APPS. Please add it to the list.\n" - "Example: \n\n" - "INSTALLED_APPS = [\n" - " ...\n" - " 'appointment',\n" - " 'django_q',\n" - "]\n") + "Django Q is not in settings.INSTALLED_APPS. Please add it to the list. " + "See https://django-appt-doc.adamspierredavid.com/getting-started/#installation " + "for more information") @patch('appointment.settings.settings') @patch('appointment.settings.logger') diff --git a/appointment/tests/utils/test_db_helpers.py b/appointment/tests/utils/test_db_helpers.py index fb0b992..2011703 100644 --- a/appointment/tests/utils/test_db_helpers.py +++ b/appointment/tests/utils/test_db_helpers.py @@ -2,6 +2,7 @@ # Path: appointment/tests/utils/test_db_helpers.py import datetime +from unittest import skip from unittest.mock import MagicMock, PropertyMock, patch from django.apps import apps @@ -12,8 +13,8 @@ from django.test.client import RequestFactory from django.urls import reverse from django.utils import timezone -from django_q.models import Schedule +from appointment.logger_config import get_logger from appointment.models import Config, DayOff, PaymentInfo from appointment.tests.base.base_test import BaseTest from appointment.tests.mixins.base_mixin import ConfigMixin @@ -31,6 +32,25 @@ staff_change_allowed_on_reschedule, update_appointment_reminder, username_in_user_model, working_hours_exist ) +logger = get_logger(__name__) + +# Check if django-q is installed +try: + from django_q.models import Schedule + from django_q.tasks import schedule + + DJANGO_Q_AVAILABLE = True +except ImportError: + DJANGO_Q_AVAILABLE = False + Schedule = None + schedule = None + + +@skip("Django-Q is not available") +class DjangoQUnavailableTest(TestCase): + def test_placeholder(self): + self.skipTest("Django-Q is not available") + class TestCalculateSlots(TestCase): def setUp(self): @@ -165,18 +185,12 @@ def test_another_staff_member_no_day_off(self): self.assertFalse(check_day_off_for_staff(self.staff_member2, "2023-10-06")) -class TestCreateAndSaveAppointment(BaseTest): - +class TestCreateAndSaveAppointment(BaseTest, TestCase): def setUp(self): - super().setUp() # Call the parent class setup - # Specific setups for this test class - self.ar = self.create_appt_request_for_sm1() + super().setUp() self.factory = RequestFactory() self.request = self.factory.get('/') - - def tearDown(self): - Appointment.objects.all().delete() - AppointmentRequest.objects.all().delete() + self.ar = self.create_appt_request_for_sm1() def test_create_and_save_appointment(self): client_data = { @@ -190,7 +204,8 @@ def test_create_and_save_appointment(self): 'additional_info': 'Please bring a Zat gun.' } - appointment = create_and_save_appointment(self.ar, client_data, appointment_data, self.request) + with patch('appointment.utils.db_helpers.schedule_email_reminder') as mock_schedule_reminder: + appointment = create_and_save_appointment(self.ar, client_data, appointment_data, self.request) self.assertIsNotNone(appointment) self.assertEqual(appointment.client.email, client_data['email']) @@ -199,6 +214,32 @@ def test_create_and_save_appointment(self): self.assertEqual(appointment.address, appointment_data['address']) self.assertEqual(appointment.additional_info, appointment_data['additional_info']) + if DJANGO_Q_AVAILABLE: + mock_schedule_reminder.assert_called_once() + else: + mock_schedule_reminder.assert_not_called() + + @patch('appointment.utils.db_helpers.DJANGO_Q_AVAILABLE', False) + def test_create_and_save_appointment_without_django_q(self): + client_data = { + 'email': 'samantha.carter@django-appointment.com', + 'name': 'samantha.carter', + } + appointment_data = { + 'phone': '987654321', + 'want_reminder': True, + 'address': '456, SGC, Colorado Springs, USA', + 'additional_info': 'Bring naquadah generator.' + } + + with patch('appointment.utils.db_helpers.logger.warning') as mock_logger_warning: + appointment = create_and_save_appointment(self.ar, client_data, appointment_data, self.request) + + self.assertIsNotNone(appointment) + self.assertEqual(appointment.client.email, client_data['email']) + mock_logger_warning.assert_called_with( + f"Email reminder requested for appointment {appointment.id}, but django-q is not available.") + def get_mock_reverse(url_name, **kwargs): """A mocked version of the reverse function.""" @@ -208,6 +249,13 @@ def get_mock_reverse(url_name, **kwargs): class ScheduleEmailReminderTest(BaseTest): + @classmethod + def setUpClass(cls): + if not DJANGO_Q_AVAILABLE: + import unittest + raise unittest.SkipTest("Django-Q is not available") + super().setUpClass() + def setUp(self): super().setUp() self.factory = RequestFactory() @@ -217,6 +265,7 @@ def setUp(self): def tearDown(self): Appointment.objects.all().delete() AppointmentRequest.objects.all().delete() + super().tearDown() def test_schedule_email_reminder_cluster_running(self): with patch('appointment.settings.check_q_cluster', return_value=True), \ @@ -233,7 +282,14 @@ def test_schedule_email_reminder_cluster_not_running(self): "Django-Q cluster is not running. Email reminder will not be scheduled.") -class UpdateAppointmentReminderTest(BaseTest): +class UpdateAppointmentReminderTest(BaseTest, TestCase): + @classmethod + def setUpClass(cls): + if not DJANGO_Q_AVAILABLE: + import unittest + raise unittest.SkipTest("Django-Q is not available") + super().setUpClass() + def setUp(self): super().setUp() self.factory = RequestFactory() @@ -243,6 +299,7 @@ def setUp(self): def tearDown(self): Appointment.objects.all().delete() AppointmentRequest.objects.all().delete() + super().tearDown() def test_update_appointment_reminder_date_time_changed(self): appointment = self.create_appt_for_sm1() @@ -267,7 +324,7 @@ def test_update_appointment_reminder_no_change(self): mock_cancel_existing_reminder.assert_not_called() mock_schedule_email_reminder.assert_not_called() - @patch('appointment.utils.db_helpers.logger') # Adjust the import path as necessary + @patch('appointment.utils.db_helpers.logger') def test_reminder_not_scheduled_due_to_user_preference(self, mock_logger): # Scenario where user does not want a reminder want_reminder = False @@ -281,7 +338,7 @@ def test_reminder_not_scheduled_due_to_user_preference(self, mock_logger): f"Reminder for appointment {self.appointment.id} is not scheduled per user's preference or past datetime." ) - @patch('appointment.utils.db_helpers.logger') # Adjust the import path as necessary + @patch('appointment.utils.db_helpers.logger') def test_reminder_not_scheduled_due_to_past_datetime(self, mock_logger): # Scenario where the new datetime is in the past want_reminder = True @@ -371,6 +428,8 @@ def test_staff_change_not_allowed(self, mock_config_first): class CancelExistingReminderTest(BaseTest): def test_cancel_existing_reminder(self): + if not DJANGO_Q_AVAILABLE: + return appointment = self.create_appt_for_sm1() Schedule.objects.create(func='appointment.tasks.send_email_reminder', name=f"reminder_{appointment.id_request}") diff --git a/appointment/utils/db_helpers.py b/appointment/utils/db_helpers.py index 328d6bb..5920b1f 100644 --- a/appointment/utils/db_helpers.py +++ b/appointment/utils/db_helpers.py @@ -11,13 +11,12 @@ from urllib.parse import urlparse from django.apps import apps +from django.conf import settings from django.contrib.auth import get_user_model from django.core.cache import cache from django.core.exceptions import FieldDoesNotExist from django.urls import reverse from django.utils import timezone -from django_q.models import Schedule -from django_q.tasks import schedule from appointment.logger_config import get_logger from appointment.settings import ( @@ -26,6 +25,23 @@ ) from appointment.utils.date_time import combine_date_and_time, get_weekday_num +logger = get_logger(__name__) + +# Check if django-q is installed in settings +DJANGO_Q_AVAILABLE = 'django_q' in settings.INSTALLED_APPS + +# Check if django-q is installed as a dependency +try: + from django_q.models import Schedule + from django_q.tasks import schedule + + DJANGO_Q_AVAILABLE = True +except ImportError: + DJANGO_Q_AVAILABLE = False + Schedule = None + schedule = None + logger.warning("django-q is not installed. Email reminders will not be scheduled.") + Appointment = apps.get_model('appointment', 'Appointment') AppointmentRequest = apps.get_model('appointment', 'AppointmentRequest') WorkingHours = apps.get_model('appointment', 'WorkingHours') @@ -37,8 +53,6 @@ EmailVerificationCode = apps.get_model('appointment', 'EmailVerificationCode') AppointmentRescheduleHistory = apps.get_model('appointment', 'AppointmentRescheduleHistory') -logger = get_logger(__name__) - def calculate_slots(start_time, end_time, buffer_time, slot_duration): """Calculate the available slots between the given start and end times using the given buffer time and slot duration @@ -112,16 +126,17 @@ def create_and_save_appointment(ar, client_data: dict, appointment_data: dict, r logger.info(f"New appointment created: {appointment.to_dict()}") if appointment.want_reminder: logger.info(f"User wants a reminder for appointment {appointment.id}, scheduling it...") - schedule_email_reminder(appointment, request) + if DJANGO_Q_AVAILABLE: + schedule_email_reminder(appointment, request) + else: + logger.warning(f"Email reminder requested for appointment {appointment.id}, but django-q is not available.") return appointment def schedule_email_reminder(appointment, request, appointment_datetime=None): """Schedule an email reminder for the given appointment.""" - # Check if the Django-Q cluster is running - from appointment.settings import check_q_cluster - if not check_q_cluster(): - logger.warning("Django-Q cluster is not running. Email reminder will not be scheduled.") + if not DJANGO_Q_AVAILABLE: + logger.warning("Django-Q is not available. Email reminder will not be scheduled.") return # Calculate reminder datetime if not provided @@ -155,6 +170,9 @@ def update_appointment_reminder(appointment, new_date, new_start_time, request, Updates or cancels the appointment reminder based on changes to the start time or date, and the user's preference for receiving a reminder. """ + if not DJANGO_Q_AVAILABLE: + logger.warning("Django-Q is not available. Appointment reminder cannot be updated.") + return # Convert new date and time strings to datetime objects for comparison new_datetime = combine_date_and_time(new_date, new_start_time) @@ -195,6 +213,9 @@ def cancel_existing_reminder(appointment_id_request): """ Cancels any existing reminder for the appointment. """ + if not DJANGO_Q_AVAILABLE: + logger.warning("Django-Q is not available. Appointment reminder cannot be updated.") + return task_name = f"reminder_{appointment_id_request}" Schedule.objects.filter(name=task_name).delete() diff --git a/appointments/settings.py b/appointments/settings.py index c0d87aa..0822778 100644 --- a/appointments/settings.py +++ b/appointments/settings.py @@ -42,7 +42,6 @@ "django.contrib.messages", "django.contrib.staticfiles", 'appointment.apps.AppointmentConfig', - 'django_q', ] MIDDLEWARE = [ @@ -150,13 +149,3 @@ ADMINS = [ (os.getenv('ADMIN_NAME'), os.getenv('ADMIN_EMAIL')), ] - -Q_CLUSTER = { - 'name': 'DjangORM', - 'workers': 4, - 'timeout': 90, - 'retry': 120, - 'queue_limit': 50, - 'bulk': 10, - 'orm': 'default', -} diff --git a/setup.cfg b/setup.cfg index 91a9732..fd663eb 100644 --- a/setup.cfg +++ b/setup.cfg @@ -27,5 +27,4 @@ install_requires = phonenumbers==8.13.42 django-phonenumber-field==8.0.0 babel==2.15.0 - django-q2==1.6.2 colorama~=0.4.6