Skip to content

Commit 3ade721

Browse files
authored
Merge pull request #265 from adamspd/send-ics-file-to-users-on-appointment-creation
Send ics file to users on appointment creation/reschedule
2 parents fac22de + 025350f commit 3ade721

File tree

12 files changed

+478
-237
lines changed

12 files changed

+478
-237
lines changed

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
*.pyc
55
__pycache__/
66
local_settings.py
7-
db.sqlite3
7+
db.sqlite3*
88
db.sqlite3-journal
99
db.sqlite3.init
1010
media

appointment/email_sender/email_sender.py

Lines changed: 187 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,30 @@
11
# email_sender.py
22
# Path: appointment/email_sender/email_sender.py
33
import os
4+
from datetime import datetime, timedelta
5+
from typing import Any, Optional, Tuple
46

5-
from django.core.mail import mail_admins, send_mail
7+
from django.conf import settings
8+
from django.core.mail import send_mail
69
from django.template import loader
10+
from django.utils import timezone
711

812
from appointment.logger_config import get_logger
913
from appointment.settings import APP_DEFAULT_FROM_EMAIL, check_q_cluster
1014

1115
logger = get_logger(__name__)
1216

1317
try:
14-
from django_q.tasks import async_task
18+
from django_q.tasks import async_task, schedule
19+
from django_q.models import Schedule
20+
1521

1622
DJANGO_Q_AVAILABLE = True
1723
except ImportError:
1824
async_task = None
25+
schedule = None
26+
Schedule = None
27+
1928
DJANGO_Q_AVAILABLE = False
2029
logger.warning("django-q is not installed. Email will be sent synchronously.")
2130

@@ -53,49 +62,210 @@ def render_email_template(template_url, context):
5362

5463

5564
def send_email(recipient_list, subject: str, template_url: str = None, context: dict = None, from_email=None,
56-
message: str = None):
65+
message: str = None, attachments=None):
5766
if not has_required_email_settings():
5867
return
5968

6069
from_email = from_email or APP_DEFAULT_FROM_EMAIL
6170
html_message = render_email_template(template_url, context)
6271

6372
if get_use_django_q_for_emails() and check_q_cluster() and DJANGO_Q_AVAILABLE:
64-
# Asynchronously send the email using Django-Q
73+
# Pass only the necessary data to construct the email
6574
async_task(
66-
"appointment.tasks.send_email_task", recipient_list=recipient_list, subject=subject,
67-
message=message, html_message=html_message if template_url else None, from_email=from_email
75+
'appointment.tasks.send_email_task',
76+
recipient_list=recipient_list,
77+
subject=subject,
78+
message=message,
79+
html_message=html_message,
80+
from_email=from_email,
81+
attachments=attachments
6882
)
6983
else:
7084
# Synchronously send the email
7185
try:
7286
send_mail(
73-
subject=subject, message=message if not template_url else "",
74-
html_message=html_message if template_url else None, from_email=from_email,
75-
recipient_list=recipient_list, fail_silently=False,
87+
subject=subject,
88+
message=message if not template_url else "",
89+
html_message=html_message if template_url else None,
90+
from_email=from_email,
91+
recipient_list=recipient_list,
92+
fail_silently=False,
7693
)
7794
except Exception as e:
7895
logger.error(f"Error sending email: {e}")
7996

8097

81-
def notify_admin(subject: str, template_url: str = None, context: dict = None, message: str = None):
98+
def validate_required_fields(recipient_list: list, subject: str) -> Tuple[bool, str]:
99+
if not recipient_list or not subject:
100+
return False, "Recipient list and subject are required."
101+
return True, ""
102+
103+
104+
def validate_and_process_datetime(dt: Optional[datetime], field_name: str) -> Tuple[bool, str, Optional[datetime]]:
105+
if not dt:
106+
return True, "", None
107+
108+
if not isinstance(dt, datetime):
109+
try:
110+
dt = datetime.fromisoformat(dt)
111+
except ValueError:
112+
return False, f"Invalid {field_name} format. Use ISO format or datetime object.", None
113+
114+
if dt.tzinfo is None:
115+
dt = timezone.make_aware(dt)
116+
117+
return True, "", dt
118+
119+
120+
def validate_send_at(send_at: Optional[datetime]) -> tuple[bool, str, None] | tuple[
121+
bool, str, datetime | None | timedelta | Any]:
122+
success, message, processed_send_at = validate_and_process_datetime(send_at, "send_at")
123+
if not success:
124+
return success, message, None
125+
126+
if processed_send_at and processed_send_at <= timezone.now():
127+
return False, "send_at must be in the future.", None
128+
129+
return True, "", processed_send_at or (timezone.now() + timedelta(minutes=1))
130+
131+
132+
def validate_repeat_until(repeat_until: Optional[datetime], send_at: datetime) -> Tuple[bool, str, Optional[datetime]]:
133+
success, message, processed_repeat_until = validate_and_process_datetime(repeat_until, "repeat_until")
134+
if not success:
135+
return success, message, None
136+
137+
if processed_repeat_until and processed_repeat_until <= send_at:
138+
return False, "repeat_until must be after send_at.", None
139+
140+
return True, "", processed_repeat_until
141+
142+
143+
def validate_repeat_option(repeat: Optional[str]) -> Tuple[bool, str, Optional[str]]:
144+
valid_options = ['HOURLY', 'DAILY', 'WEEKLY', 'MONTHLY', 'QUARTERLY', 'YEARLY']
145+
if repeat and repeat not in valid_options:
146+
return False, f"Invalid repeat option. Choose from {', '.join(valid_options)}.", None
147+
return True, "", repeat
148+
149+
150+
def schedule_email_task(
151+
recipient_list: list,
152+
subject: str,
153+
html_message: str,
154+
from_email: str,
155+
attachments: Optional[list],
156+
schedule_type: str,
157+
send_at: datetime,
158+
name: Optional[str],
159+
repeat_until: Optional[datetime]
160+
) -> Tuple[bool, str]:
161+
try:
162+
schedule(
163+
'appointment.tasks.send_email_task',
164+
recipient_list=recipient_list,
165+
subject=subject,
166+
message=None,
167+
html_message=html_message,
168+
from_email=from_email,
169+
attachments=attachments,
170+
schedule_type=schedule_type,
171+
next_run=send_at,
172+
name=name,
173+
repeats=-1 if schedule_type != Schedule.ONCE and not repeat_until else None,
174+
end_date=repeat_until
175+
)
176+
return True, "Email scheduled successfully."
177+
except Exception as e:
178+
logger.error(f"Error scheduling email: {e}")
179+
return False, f"Error scheduling email: {str(e)}"
180+
181+
182+
def schedule_email_sending(
183+
recipient_list: list,
184+
subject: str,
185+
template_url: Optional[str] = None,
186+
context: Optional[dict] = None,
187+
from_email: Optional[str] = None,
188+
attachments: Optional[list] = None,
189+
send_at: Optional[datetime] = None,
190+
name: Optional[str] = None,
191+
repeat: Optional[str] = None,
192+
repeat_until: Optional[datetime] = None
193+
) -> Tuple[bool, str]:
194+
if not has_required_email_settings():
195+
return False, "Email settings are not configured."
196+
197+
if not check_q_cluster() or not DJANGO_Q_AVAILABLE:
198+
return False, "Django-Q is not available."
199+
200+
# Validate required fields
201+
success, message = validate_required_fields(recipient_list, subject)
202+
if not success:
203+
return success, message
204+
205+
# Validate and process send_at
206+
success, message, processed_send_at = validate_send_at(send_at)
207+
if not success:
208+
return success, message
209+
210+
# Validate repeat option
211+
success, message, validated_repeat = validate_repeat_option(repeat)
212+
if not success:
213+
return success, message
214+
215+
# Validate repeat_until
216+
success, message, processed_repeat_until = validate_repeat_until(repeat_until, processed_send_at)
217+
if not success:
218+
return success, message
219+
220+
from_email = from_email or APP_DEFAULT_FROM_EMAIL
221+
html_message = render_email_template(template_url, context)
222+
223+
schedule_type = getattr(Schedule, validated_repeat or 'ONCE')
224+
225+
return schedule_email_task(
226+
recipient_list,
227+
subject,
228+
html_message,
229+
from_email,
230+
attachments,
231+
schedule_type,
232+
processed_send_at,
233+
name,
234+
processed_repeat_until
235+
)
236+
237+
238+
def notify_admin(subject: str, template_url: str = None, context: dict = None, message: str = None,
239+
recipient_email: str = None, attachments=None):
82240
if not has_required_email_settings():
83241
return
84242

85243
html_message = render_email_template(template_url, context)
86244

245+
recipients = [recipient_email] if recipient_email else [email for name, email in settings.ADMINS]
246+
87247
if get_use_django_q_for_emails() and check_q_cluster() and DJANGO_Q_AVAILABLE:
88-
# Enqueue the task to send admin email asynchronously
89-
async_task('appointment.tasks.notify_admin_task', subject=subject, message=message, html_message=html_message)
248+
# Asynchronously send the email using Django-Q
249+
async_task("appointment.tasks.send_email_task",
250+
subject=subject,
251+
message=message,
252+
html_message=html_message,
253+
from_email=settings.DEFAULT_FROM_EMAIL,
254+
recipient_list=recipients,
255+
attachments=attachments)
90256
else:
91-
# Synchronously send admin email
257+
# Synchronously send the email
92258
try:
93-
mail_admins(
94-
subject=subject, message=message if not template_url else "",
95-
html_message=html_message if template_url else None
259+
send_mail(
260+
subject=subject,
261+
message=message if not template_url else "",
262+
html_message=html_message if template_url else None,
263+
from_email=settings.DEFAULT_FROM_EMAIL,
264+
recipient_list=recipients,
265+
fail_silently=False,
96266
)
97267
except Exception as e:
98-
logger.error(f"Error sending email to admin: {e}")
268+
logger.error(f"Error sending email: {e}")
99269

100270

101271
def get_use_django_q_for_emails():

appointment/tasks.py

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
Author: Adams Pierre David
66
Since: 3.1.0
77
"""
8+
from django.core.mail import EmailMessage
89
from django.utils.translation import gettext as _
910

1011
from appointment.email_sender import notify_admin, send_email
@@ -42,18 +43,23 @@ def send_email_reminder(to_email, first_name, reschedule_link, appointment_id):
4243
)
4344

4445

45-
def send_email_task(recipient_list, subject, message, html_message, from_email):
46-
"""
47-
Task function to send an email asynchronously using Django's send_mail function.
48-
This function tries to send an email and logs an error if it fails.
49-
"""
46+
def send_email_task(recipient_list, subject, message, html_message, from_email, attachments=None):
5047
try:
51-
from django.core.mail import send_mail
52-
logger.info(f"Sending email to {recipient_list} with subject: {subject}")
53-
send_mail(
54-
subject=subject, message=message, html_message=html_message, from_email=from_email,
55-
recipient_list=recipient_list, fail_silently=False,
48+
email = EmailMessage(
49+
subject=subject,
50+
body=message if not html_message else html_message,
51+
from_email=from_email,
52+
to=recipient_list
5653
)
54+
55+
if html_message:
56+
email.content_subtype = "html"
57+
58+
if attachments:
59+
for attachment in attachments:
60+
email.attach(*attachment)
61+
62+
email.send(fail_silently=False)
5763
except Exception as e:
5864
logger.error(f"Error sending email from task: {e}")
5965

appointment/templates/email_sender/admin_new_appointment_email.html

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,20 +51,29 @@
5151
<body>
5252
<div class="email-container">
5353
<h1>{% translate 'New Appointment Request' %}</h1>
54-
<p>{% translate 'Dear Admin,' %}</p>
55-
<p>{% translate 'You have received a new appointment request. Here are the details:' %}</p>
54+
<p>{% translate 'Dear' %} {{ recipient_name }},</p>
55+
{% if is_staff_member %}
56+
<p>{% translate 'You have received a new appointment request. Here are the details:' %}</p>
57+
{% else %}
58+
<p>{% translate 'A new appointment request has been received for' %} {{ staff_member_name }}. {% translate 'Here are the details:' %}</p>
59+
{% endif %}
5660

5761
<div class="appointment-details">
5862
<p><strong>{% translate 'Client Name' %}:</strong> {{ client_name }}</p>
5963
<p><strong>{% translate 'Service Requested' %}:</strong> {{ appointment.get_service_name }}</p>
6064
<p><strong>{% translate 'Appointment Date' %}:</strong> {{ appointment.appointment_request.date }}</p>
6165
<p><strong>{% translate 'Time' %}:</strong> {{ appointment.appointment_request.start_time }}
6266
- {{ appointment.appointment_request.end_time }}</p>
63-
<p><strong>{% translate 'Contact Details' %}:</strong> {{ appointment.phone }} | {{ appointment.client.email }}</p>
67+
<p><strong>{% translate 'Contact Details' %}:</strong> {{ appointment.phone }} | {{ appointment.client.email }}
68+
</p>
6469
<p><strong>{% translate 'Additional Info' %}:</strong> {{ appointment.additional_info|default:"N/A" }}</p>
6570
</div>
6671

67-
<p>{% translate 'Please review the appointment request and take the necessary action.' %}</p>
72+
{% if is_staff_member %}
73+
<p>{% translate 'Please review the appointment request and take the necessary action.' %}</p>
74+
{% else %}
75+
<p>{% translate 'Please ensure that' %} {{ staff_member_name }} {% translate 'reviews this appointment request and takes the necessary action.' %}</p>
76+
{% endif %}
6877

6978
<div class="footer">
7079
<p>{% translate 'This is an automated message. Please do not reply directly to this email.' %}</p>

0 commit comments

Comments
 (0)