2008-08-19 10:50:38 +02:00
|
|
|
"""
|
2011-01-26 00:08:41 +01:00
|
|
|
django-helpdesk - A Django powered ticket tracker for small enterprise.
|
2008-02-06 05:36:07 +01:00
|
|
|
|
|
|
|
(c) Copyright 2008 Jutda. All Rights Reserved. See LICENSE for details.
|
|
|
|
|
|
|
|
lib.py - Common functions (eg multipart e-mail)
|
2008-01-07 21:22:13 +01:00
|
|
|
"""
|
2008-08-19 10:50:38 +02:00
|
|
|
|
2016-10-21 17:14:12 +02:00
|
|
|
import logging
|
2016-11-10 17:23:16 +01:00
|
|
|
import mimetypes
|
|
|
|
import os
|
2017-09-06 17:22:06 +02:00
|
|
|
from smtplib import SMTPException
|
2008-08-19 10:50:38 +02:00
|
|
|
|
2008-11-18 01:14:36 +01:00
|
|
|
try:
|
2017-08-30 16:48:16 +02:00
|
|
|
# Python 2 support
|
2008-11-18 01:14:36 +01:00
|
|
|
from base64 import urlsafe_b64encode as b64encode
|
|
|
|
except ImportError:
|
2017-08-30 16:48:16 +02:00
|
|
|
# Python 3 support
|
|
|
|
from base64 import encodebytes as b64encode
|
2008-11-18 01:14:36 +01:00
|
|
|
try:
|
2017-08-30 16:48:16 +02:00
|
|
|
# Python 2 support
|
2008-11-18 01:14:36 +01:00
|
|
|
from base64 import urlsafe_b64decode as b64decode
|
|
|
|
except ImportError:
|
2017-08-30 16:48:16 +02:00
|
|
|
# Python 3 support
|
|
|
|
from base64 import decodebytes as b64decode
|
2008-08-19 10:50:38 +02:00
|
|
|
|
2016-11-10 17:23:16 +01:00
|
|
|
from django.conf import settings
|
2016-07-04 17:47:53 +02:00
|
|
|
from django.db.models import Q
|
2016-12-16 10:30:50 +01:00
|
|
|
from django.utils import six
|
2016-11-10 17:23:16 +01:00
|
|
|
from django.utils.encoding import smart_text
|
2016-10-21 17:14:12 +02:00
|
|
|
from django.utils.safestring import mark_safe
|
|
|
|
|
2016-11-10 17:23:16 +01:00
|
|
|
from helpdesk.models import Attachment, EmailTemplate
|
|
|
|
|
2016-10-21 17:14:12 +02:00
|
|
|
logger = logging.getLogger('helpdesk')
|
|
|
|
|
2011-07-01 10:13:01 +02:00
|
|
|
|
2016-10-21 17:14:12 +02:00
|
|
|
def send_templated_mail(template_name,
|
2016-11-10 17:23:16 +01:00
|
|
|
context,
|
2016-10-21 17:14:12 +02:00
|
|
|
recipients,
|
|
|
|
sender=None,
|
|
|
|
bcc=None,
|
|
|
|
fail_silently=False,
|
|
|
|
files=None):
|
2008-08-19 10:50:38 +02:00
|
|
|
"""
|
2016-11-10 17:23:16 +01:00
|
|
|
send_templated_mail() is a wrapper around Django's e-mail routines that
|
2008-08-19 10:50:38 +02:00
|
|
|
allows us to easily send multipart (text/plain & text/html) e-mails using
|
|
|
|
templates that are stored in the database. This lets the admin provide
|
|
|
|
both a text and a HTML template for each message.
|
|
|
|
|
|
|
|
template_name is the slug of the template to use for this message (see
|
|
|
|
models.EmailTemplate)
|
|
|
|
|
2016-11-10 17:23:16 +01:00
|
|
|
context is a dictionary to be used when rendering the template
|
2008-08-19 10:50:38 +02:00
|
|
|
|
|
|
|
recipients can be either a string, eg 'a@b.com', or a list of strings.
|
|
|
|
|
|
|
|
sender should contain a string, eg 'My Site <me@z.com>'. If you leave it
|
|
|
|
blank, it'll use settings.DEFAULT_FROM_EMAIL as a fallback.
|
|
|
|
|
|
|
|
bcc is an optional list of addresses that will receive this message as a
|
|
|
|
blind carbon copy.
|
|
|
|
|
|
|
|
fail_silently is passed to Django's mail routine. Set to 'True' to ignore
|
|
|
|
any errors at send time.
|
|
|
|
|
2016-11-10 17:23:16 +01:00
|
|
|
files can be a list of tuples. Each tuple should be a filename to attach,
|
2014-09-02 10:36:00 +02:00
|
|
|
along with the File objects to be read. files can be blank.
|
2008-08-19 10:50:38 +02:00
|
|
|
|
|
|
|
"""
|
2008-04-02 01:26:12 +02:00
|
|
|
from django.core.mail import EmailMultiAlternatives
|
2016-11-10 17:23:16 +01:00
|
|
|
from django.template import engines
|
|
|
|
from_string = engines['django'].from_string
|
2008-08-19 10:50:38 +02:00
|
|
|
|
|
|
|
from helpdesk.models import EmailTemplate
|
2016-10-14 10:04:28 +02:00
|
|
|
from helpdesk.settings import HELPDESK_EMAIL_SUBJECT_TEMPLATE, \
|
|
|
|
HELPDESK_EMAIL_FALLBACK_LOCALE
|
2011-11-19 09:34:07 +01:00
|
|
|
|
2016-11-10 17:23:16 +01:00
|
|
|
locale = context['queue'].get('locale') or HELPDESK_EMAIL_FALLBACK_LOCALE
|
2008-11-18 02:43:50 +01:00
|
|
|
|
2011-03-11 23:30:59 +01:00
|
|
|
try:
|
|
|
|
t = EmailTemplate.objects.get(template_name__iexact=template_name, locale=locale)
|
|
|
|
except EmailTemplate.DoesNotExist:
|
2009-08-04 14:26:35 +02:00
|
|
|
try:
|
2011-03-11 23:30:59 +01:00
|
|
|
t = EmailTemplate.objects.get(template_name__iexact=template_name, locale__isnull=True)
|
2009-08-04 14:26:35 +02:00
|
|
|
except EmailTemplate.DoesNotExist:
|
2016-11-10 17:23:16 +01:00
|
|
|
logger.warning('template "%s" does not exist, no mail sent', template_name)
|
2016-10-21 17:14:12 +02:00
|
|
|
return # just ignore if template doesn't exist
|
2008-04-02 01:26:12 +02:00
|
|
|
|
2016-11-10 17:23:16 +01:00
|
|
|
subject_part = from_string(
|
2017-01-09 00:30:36 +01:00
|
|
|
HELPDESK_EMAIL_SUBJECT_TEMPLATE % {
|
|
|
|
"subject": t.subject
|
|
|
|
}).render(context).replace('\n', '').replace('\r', '')
|
2008-04-02 01:26:12 +02:00
|
|
|
|
2008-11-18 02:43:50 +01:00
|
|
|
footer_file = os.path.join('helpdesk', locale, 'email_text_footer.txt')
|
2016-10-14 10:04:28 +02:00
|
|
|
|
2016-11-10 17:23:16 +01:00
|
|
|
text_part = from_string(
|
2008-11-18 02:43:50 +01:00
|
|
|
"%s{%% include '%s' %%}" % (t.plain_text, footer_file)
|
2016-10-23 22:09:17 +02:00
|
|
|
).render(context)
|
2008-08-19 10:50:38 +02:00
|
|
|
|
2008-11-18 02:43:50 +01:00
|
|
|
email_html_base_file = os.path.join('helpdesk', locale, 'email_html_base.html')
|
2016-10-21 17:14:12 +02:00
|
|
|
# keep new lines in html emails
|
2015-12-16 00:48:27 +01:00
|
|
|
if 'comment' in context:
|
2016-11-10 17:23:16 +01:00
|
|
|
context['comment'] = mark_safe(context['comment'].replace('\r\n', '<br>'))
|
2011-11-05 01:56:53 +01:00
|
|
|
|
2016-11-10 17:23:16 +01:00
|
|
|
html_part = from_string(
|
2016-10-21 17:14:12 +02:00
|
|
|
"{%% extends '%s' %%}{%% block title %%}"
|
|
|
|
"%s"
|
|
|
|
"{%% endblock %%}{%% block content %%}%s{%% endblock %%}" %
|
2016-11-10 17:23:16 +01:00
|
|
|
(email_html_base_file, t.heading, t.html)
|
|
|
|
).render(context)
|
2008-08-19 10:50:38 +02:00
|
|
|
|
2015-11-18 15:07:33 +01:00
|
|
|
if isinstance(recipients, str):
|
2011-03-05 04:29:01 +01:00
|
|
|
if recipients.find(','):
|
|
|
|
recipients = recipients.split(',')
|
|
|
|
elif type(recipients) != list:
|
2016-11-10 17:23:16 +01:00
|
|
|
recipients = [recipients]
|
2008-04-02 01:26:12 +02:00
|
|
|
|
2016-11-10 17:23:16 +01:00
|
|
|
msg = EmailMultiAlternatives(subject_part, text_part,
|
|
|
|
sender or settings.DEFAULT_FROM_EMAIL,
|
|
|
|
recipients, bcc=bcc)
|
2008-04-02 01:26:12 +02:00
|
|
|
msg.attach_alternative(html_part, "text/html")
|
|
|
|
|
|
|
|
if files:
|
2016-11-10 17:23:16 +01:00
|
|
|
for filename, filefield in files:
|
2017-07-17 11:04:25 +02:00
|
|
|
mime = mimetypes.guess_type(filename)
|
|
|
|
if mime[0] is not None and mime[0] == "text/plain":
|
2017-08-30 21:18:22 +02:00
|
|
|
with open(filefield.path, 'r') as attachedfile:
|
|
|
|
content = attachedfile.read()
|
2017-07-17 11:04:25 +02:00
|
|
|
msg.attach(filename, content)
|
|
|
|
else:
|
2017-08-30 21:18:22 +02:00
|
|
|
if six.PY3:
|
|
|
|
msg.attach_file(filefield.path)
|
|
|
|
else:
|
|
|
|
with open(filefield.path, 'rb') as attachedfile:
|
|
|
|
content = attachedfile.read()
|
|
|
|
msg.attach(filename, content)
|
2008-08-19 10:50:38 +02:00
|
|
|
|
2017-09-06 17:22:06 +02:00
|
|
|
logger.debug('Sending email to: {!r}'.format(recipients))
|
|
|
|
|
|
|
|
try:
|
|
|
|
return msg.send()
|
2017-12-16 02:35:41 +01:00
|
|
|
except SMTPException as e:
|
2017-09-06 17:22:06 +02:00
|
|
|
logger.exception('SMTPException raised while sending email to {}'.format(recipients))
|
|
|
|
if not fail_silently:
|
|
|
|
raise e
|
|
|
|
return 0
|
2008-04-02 01:26:12 +02:00
|
|
|
|
2008-01-07 21:22:13 +01:00
|
|
|
|
2008-05-07 11:04:18 +02:00
|
|
|
def query_to_dict(results, descriptions):
|
2008-08-19 10:50:38 +02:00
|
|
|
"""
|
|
|
|
Replacement method for cursor.dictfetchall() as that method no longer
|
2008-05-07 11:04:18 +02:00
|
|
|
exists in psycopg2, and I'm guessing in other backends too.
|
2008-08-19 10:50:38 +02:00
|
|
|
|
|
|
|
Converts the results of a raw SQL query into a list of dictionaries, suitable
|
|
|
|
for use in templates etc.
|
|
|
|
"""
|
|
|
|
|
2008-05-07 11:04:18 +02:00
|
|
|
output = []
|
|
|
|
for data in results:
|
|
|
|
row = {}
|
|
|
|
i = 0
|
|
|
|
for column in descriptions:
|
|
|
|
row[column[0]] = data[i]
|
|
|
|
i += 1
|
|
|
|
|
|
|
|
output.append(row)
|
|
|
|
return output
|
2008-08-28 11:06:24 +02:00
|
|
|
|
|
|
|
|
|
|
|
def apply_query(queryset, params):
|
|
|
|
"""
|
2012-05-11 17:15:46 +02:00
|
|
|
Apply a dict-based set of filters & parameters to a queryset.
|
2008-08-28 11:06:24 +02:00
|
|
|
|
2011-11-05 01:56:53 +01:00
|
|
|
queryset is a Django queryset, eg MyModel.objects.all() or
|
2008-08-28 11:06:24 +02:00
|
|
|
MyModel.objects.filter(user=request.user)
|
|
|
|
|
|
|
|
params is a dictionary that contains the following:
|
|
|
|
filtering: A dict of Django ORM filters, eg:
|
|
|
|
{'user__id__in': [1, 3, 103], 'title__contains': 'foo'}
|
2016-10-14 10:04:28 +02:00
|
|
|
|
2016-07-04 17:47:53 +02:00
|
|
|
search_string: A freetext search string
|
|
|
|
|
2008-08-28 11:06:24 +02:00
|
|
|
sorting: The name of the column to sort by
|
|
|
|
"""
|
|
|
|
for key in params['filtering'].keys():
|
|
|
|
filter = {key: params['filtering'][key]}
|
|
|
|
queryset = queryset.filter(**filter)
|
|
|
|
|
2016-07-04 17:47:53 +02:00
|
|
|
search = params.get('search_string', None)
|
|
|
|
if search:
|
|
|
|
qset = (
|
|
|
|
Q(title__icontains=search) |
|
|
|
|
Q(description__icontains=search) |
|
|
|
|
Q(resolution__icontains=search) |
|
2019-07-31 15:04:31 +02:00
|
|
|
Q(submitter_email__icontains=search) |
|
|
|
|
Q(ticketcustomfieldvalue__value__icontains=search)
|
2016-07-04 17:47:53 +02:00
|
|
|
)
|
|
|
|
|
2019-11-22 08:06:15 +01:00
|
|
|
# Distinct works, when there are multiple custom fields
|
|
|
|
queryset = queryset.filter(qset).distinct()
|
2008-08-28 11:06:24 +02:00
|
|
|
|
2012-01-18 14:39:36 +01:00
|
|
|
sorting = params.get('sorting', None)
|
2012-05-11 17:15:46 +02:00
|
|
|
if sorting:
|
2012-01-18 14:39:36 +01:00
|
|
|
sortreverse = params.get('sortreverse', None)
|
2012-01-18 23:36:58 +01:00
|
|
|
if sortreverse:
|
2012-01-18 14:39:36 +01:00
|
|
|
sorting = "-%s" % sorting
|
|
|
|
queryset = queryset.order_by(sorting)
|
2008-08-28 11:06:24 +02:00
|
|
|
|
|
|
|
return queryset
|
2008-08-29 11:11:02 +02:00
|
|
|
|
|
|
|
|
2017-08-21 01:26:45 +02:00
|
|
|
def ticket_template_context(ticket):
|
|
|
|
context = {}
|
|
|
|
|
|
|
|
for field in ('title', 'created', 'modified', 'submitter_email',
|
|
|
|
'status', 'get_status_display', 'on_hold', 'description',
|
|
|
|
'resolution', 'priority', 'get_priority_display',
|
|
|
|
'last_escalation', 'ticket', 'ticket_for_url',
|
|
|
|
'get_status', 'ticket_url', 'staff_url', '_get_assigned_to'
|
|
|
|
):
|
|
|
|
attr = getattr(ticket, field, None)
|
|
|
|
if callable(attr):
|
|
|
|
context[field] = '%s' % attr()
|
|
|
|
else:
|
|
|
|
context[field] = attr
|
|
|
|
context['assigned_to'] = context['_get_assigned_to']
|
|
|
|
|
|
|
|
return context
|
|
|
|
|
|
|
|
|
|
|
|
def queue_template_context(queue):
|
|
|
|
context = {}
|
|
|
|
|
|
|
|
for field in ('title', 'slug', 'email_address', 'from_address', 'locale'):
|
|
|
|
attr = getattr(queue, field, None)
|
|
|
|
if callable(attr):
|
|
|
|
context[field] = attr()
|
|
|
|
else:
|
|
|
|
context[field] = attr
|
|
|
|
|
|
|
|
return context
|
|
|
|
|
|
|
|
|
2008-08-29 11:11:02 +02:00
|
|
|
def safe_template_context(ticket):
|
|
|
|
"""
|
|
|
|
Return a dictionary that can be used as a template context to render
|
2012-05-11 17:15:46 +02:00
|
|
|
comments and other details with ticket or queue parameters. Note that
|
2011-11-05 01:56:53 +01:00
|
|
|
we don't just provide the Ticket & Queue objects to the template as
|
2008-08-29 11:11:02 +02:00
|
|
|
they could reveal confidential information. Just imagine these two options:
|
|
|
|
* {{ ticket.queue.email_box_password }}
|
|
|
|
* {{ ticket.assigned_to.password }}
|
|
|
|
|
|
|
|
Ouch!
|
|
|
|
|
|
|
|
The downside to this is that if we make changes to the model, we will also
|
|
|
|
have to update this code. Perhaps we can find a better way in the future.
|
|
|
|
"""
|
|
|
|
|
|
|
|
context = {
|
2017-08-21 01:26:45 +02:00
|
|
|
'queue': queue_template_context(ticket.queue),
|
|
|
|
'ticket': ticket_template_context(ticket),
|
2016-10-23 22:09:17 +02:00
|
|
|
}
|
2008-08-29 11:11:02 +02:00
|
|
|
context['ticket']['queue'] = context['queue']
|
|
|
|
|
|
|
|
return context
|
2008-11-18 01:14:36 +01:00
|
|
|
|
2009-06-25 13:22:53 +02:00
|
|
|
|
|
|
|
def text_is_spam(text, request):
|
|
|
|
# Based on a blog post by 'sciyoshi':
|
|
|
|
# http://sciyoshi.com/blog/2008/aug/27/using-akismet-djangos-new-comments-framework/
|
2011-11-05 01:56:53 +01:00
|
|
|
# This will return 'True' is the given text is deemed to be spam, or
|
2009-06-25 13:22:53 +02:00
|
|
|
# False if it is not spam. If it cannot be checked for some reason, we
|
|
|
|
# assume it isn't spam.
|
|
|
|
from django.contrib.sites.models import Site
|
2017-12-06 08:06:07 +01:00
|
|
|
from django.core.exceptions import ImproperlyConfigured
|
2009-06-25 13:22:53 +02:00
|
|
|
try:
|
|
|
|
from helpdesk.akismet import Akismet
|
2017-12-06 08:06:07 +01:00
|
|
|
except ImportError:
|
2009-06-25 13:22:53 +02:00
|
|
|
return False
|
2012-08-08 06:31:51 +02:00
|
|
|
try:
|
|
|
|
site = Site.objects.get_current()
|
2017-12-06 08:06:07 +01:00
|
|
|
except ImproperlyConfigured:
|
2012-08-08 06:31:51 +02:00
|
|
|
site = Site(domain='configure-django-sites.com')
|
2009-06-25 13:22:53 +02:00
|
|
|
|
|
|
|
ak = Akismet(
|
2012-08-08 06:31:51 +02:00
|
|
|
blog_url='http://%s/' % site.domain,
|
2011-01-26 00:08:41 +01:00
|
|
|
agent='django-helpdesk',
|
2009-06-25 13:22:53 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
if hasattr(settings, 'TYPEPAD_ANTISPAM_API_KEY'):
|
2016-10-21 17:14:12 +02:00
|
|
|
ak.setAPIKey(key=settings.TYPEPAD_ANTISPAM_API_KEY)
|
2009-06-25 13:22:53 +02:00
|
|
|
ak.baseurl = 'api.antispam.typepad.com/1.1/'
|
|
|
|
elif hasattr(settings, 'AKISMET_API_KEY'):
|
2016-10-21 17:14:12 +02:00
|
|
|
ak.setAPIKey(key=settings.AKISMET_API_KEY)
|
2009-06-25 13:22:53 +02:00
|
|
|
else:
|
|
|
|
return False
|
|
|
|
|
|
|
|
if ak.verify_key():
|
|
|
|
ak_data = {
|
|
|
|
'user_ip': request.META.get('REMOTE_ADDR', '127.0.0.1'),
|
|
|
|
'user_agent': request.META.get('HTTP_USER_AGENT', ''),
|
|
|
|
'referrer': request.META.get('HTTP_REFERER', ''),
|
|
|
|
'comment_type': 'comment',
|
|
|
|
'comment_author': '',
|
|
|
|
}
|
|
|
|
|
2016-11-10 17:23:16 +01:00
|
|
|
return ak.comment_check(smart_text(text), data=ak_data)
|
2009-06-25 13:22:53 +02:00
|
|
|
|
|
|
|
return False
|
2016-11-10 17:23:16 +01:00
|
|
|
|
|
|
|
|
|
|
|
def process_attachments(followup, attached_files):
|
2020-07-30 08:54:03 +02:00
|
|
|
max_email_attachment_size = getattr(settings, 'HELPDESK_MAX_EMAIL_ATTACHMENT_SIZE', 512000)
|
2016-11-10 17:23:16 +01:00
|
|
|
attachments = []
|
|
|
|
|
|
|
|
for attached in attached_files:
|
|
|
|
if attached.size:
|
|
|
|
filename = smart_text(attached.name)
|
|
|
|
att = Attachment(
|
|
|
|
followup=followup,
|
|
|
|
file=attached,
|
|
|
|
filename=filename,
|
|
|
|
mime_type=attached.content_type or
|
|
|
|
mimetypes.guess_type(filename, strict=False)[0] or
|
|
|
|
'application/octet-stream',
|
|
|
|
size=attached.size,
|
|
|
|
)
|
|
|
|
att.save()
|
|
|
|
|
|
|
|
if attached.size < max_email_attachment_size:
|
|
|
|
# Only files smaller than 512kb (or as defined in
|
2020-07-30 08:54:03 +02:00
|
|
|
# settings.HELPDESK_MAX_EMAIL_ATTACHMENT_SIZE) are sent via email.
|
2016-11-10 17:23:16 +01:00
|
|
|
attachments.append([filename, att.file])
|
|
|
|
|
|
|
|
return attachments
|