diff --git a/README.rst b/README.rst index 680d35d2..5b4c3bd3 100644 --- a/README.rst +++ b/README.rst @@ -28,7 +28,8 @@ Dependencies (pre-flight checklist) 1. Python 2.7 or 3.4+ (3.4+ support is new, please let us know how it goes) 2. Django (1.7 or newer, preferably 1.9 - Django 1.7 is not supported if you are using Python 3.5) 3. An existing WORKING Django project with database etc. If you - cannot log into the Admin, you won't get this product working. + cannot log into the Admin, you won't get this product working. Also, make sure that both + "django.contrib.humanize" and "django.contrib.sites" are installed. 4. `pip install django-bootstrap-form` and add `bootstrapform` to `settings.INSTALLED_APPS` 5. `pip install django-markdown-deux` and add `markdown_deux` to `settings.INSTALLED_APPS` 6. `pip install email-reply-parser` to get smart email reply handling diff --git a/helpdesk/forms.py b/helpdesk/forms.py index b766ceb6..75367e19 100644 --- a/helpdesk/forms.py +++ b/helpdesk/forms.py @@ -540,6 +540,16 @@ class TicketCCForm(forms.ModelForm): model = TicketCC exclude = ('ticket',) + def clean(self): + + cleaned_data = super(TicketCCForm, self).clean() + + user = cleaned_data.get('user', None) + email = cleaned_data.get('email', '') + + if user is None and len(email) == 0: + raise forms.ValidationError(_('When you add somebody on Cc, you must provided either an User or a valid email.')) + class TicketDependencyForm(forms.ModelForm): class Meta: model = TicketDependency diff --git a/helpdesk/lib.py b/helpdesk/lib.py index 97977f24..3f7afd39 100644 --- a/helpdesk/lib.py +++ b/helpdesk/lib.py @@ -22,7 +22,7 @@ logger = logging.getLogger('helpdesk') from django.utils.encoding import smart_str -def send_templated_mail(template_name, email_context, recipients, sender=None, bcc=None, fail_silently=False, files=None): +def send_templated_mail(template_name, email_context, recipients, sender=None, bcc=None, fail_silently=False, files=None, extra_headers={}): """ send_templated_mail() is a warpper around Django's e-mail routines that allows us to easily send multipart (text/plain & text/html) e-mails using @@ -82,7 +82,7 @@ def send_templated_mail(template_name, email_context, recipients, sender=None, b t = EmailTemplate.objects.get(template_name__iexact=template_name, locale__isnull=True) except EmailTemplate.DoesNotExist: logger.warning('template "%s" does not exist, no mail sent' % - template_name) + template_name) return # just ignore if template doesn't exist if not sender: @@ -133,7 +133,9 @@ def send_templated_mail(template_name, email_context, recipients, sender=None, b text_part, sender, recipients, - bcc=bcc) + bcc=bcc, + headers=extra_headers, + ) msg.attach_alternative(html_part, "text/html") if files: diff --git a/helpdesk/management/commands/get_email.py b/helpdesk/management/commands/get_email.py index e6a3b124..14faeedd 100644 --- a/helpdesk/management/commands/get_email.py +++ b/helpdesk/management/commands/get_email.py @@ -36,7 +36,7 @@ except ImportError: from datetime import datetime as timezone from helpdesk.lib import send_templated_mail, safe_template_context -from helpdesk.models import Queue, Ticket, FollowUp, Attachment, IgnoreEmail +from helpdesk.models import Queue, Ticket, TicketCC, FollowUp, Attachment, IgnoreEmail class Command(BaseCommand): @@ -124,7 +124,7 @@ def process_queue(q, quiet=False): msgSize = msg.split(" ")[1] full_message = "\n".join(server.retr(msgNum)[1]) - ticket = ticket_from_message(message=full_message, queue=q, quiet=quiet) + ticket = object_from_message(message=full_message, queue=q, quiet=quiet) if ticket: server.dele(msgNum) @@ -147,7 +147,7 @@ def process_queue(q, quiet=False): msgnums = data[0].split() for num in msgnums: status, data = server.fetch(num, '(RFC822)') - ticket = ticket_from_message(message=data[0][1], queue=q, quiet=quiet) + ticket = object_from_message(message=data[0][1], queue=q, quiet=quiet) if ticket: server.store(num, '+FLAGS', '\\Deleted') @@ -168,10 +168,207 @@ def decode_mail_headers(string): decoded = decode_header(string) return u' '.join([unicode(msg, charset or 'utf-8') for msg, charset in decoded]) -def ticket_from_message(message, queue, quiet): +def create_ticket_cc(ticket, cc_list): + + # Local import to deal with non-defined / circular reference problem + from helpdesk.views.staff import User, subscribe_to_ticket_updates + + new_ticket_ccs = [] + for cced_email in cc_list: + + user = None + + try: + user = User.objects.get(email=cced_email) + except User.DoesNotExist: + pass + + ticket_cc = subscribe_to_ticket_updates(ticket=ticket, user=user, email=cced_email) + new_ticket_ccs.append(ticket_cc) + + return new_ticket_ccs + +def create_object_from_email_message(message, ticket_id, payload, files, quiet): + + ticket, previous_followup, new = None, None, False + now = timezone.now() + + queue = payload['queue'] + sender_email = payload['sender_email'] + + message_id = message.get('Message-Id') + in_reply_to = message.get('In-Reply-To') + cc_list = message.get('Cc') + + if in_reply_to is not None: + try: + queryset = FollowUp.objects.filter(message_id=in_reply_to).order_by('-date') + if queryset.count() > 0: + previous_followup = queryset.first() + ticket = previous_followup.ticket + except FollowUp.DoesNotExist: + pass #play along. The header may be wrong + + if previous_followup is None and ticket_id is not None: + try: + ticket = Ticket.objects.get(id=ticket_id) + new = False + except Ticket.DoesNotExist: + ticket = None + + # New issue, create a new instance + if ticket is None: + ticket = Ticket.objects.create( + title = payload['subject'], + queue = queue, + submitter_email = sender_email, + created = now, + description = payload['body'], + priority = payload['priority'], + ) + ticket.save() + + new = True + update = '' + + # Old issue being re-openned + elif ticket.status == Ticket.CLOSED_STATUS: + ticket.status = Ticket.REOPENED_STATUS + ticket.save() + + f = FollowUp( + ticket = ticket, + title = _('E-Mail Received from %(sender_email)s' % {'sender_email': sender_email}), + date = now, + public = True, + comment = payload['body'], + message_id = message_id, + ) + + if ticket.status == Ticket.REOPENED_STATUS: + f.new_status = Ticket.REOPENED_STATUS + f.title = _('Ticket Re-Opened by E-Mail Received from %(sender_email)s' % {'sender_email': sender_email}) + + f.save() + + if not quiet: + print (" [%s-%s] %s" % (ticket.queue.slug, ticket.id, ticket.title,)).encode('ascii', 'replace') + + for file in files: + if file['content']: + filename = file['filename'].encode('ascii', 'replace').replace(' ', '_') + filename = re.sub('[^a-zA-Z0-9._-]+', '', filename) + a = Attachment( + followup=f, + filename=filename, + mime_type=file['type'], + size=len(file['content']), + ) + a.file.save(filename, ContentFile(file['content']), save=False) + a.save() + if not quiet: + print " - %s" % filename + + + context = safe_template_context(ticket) + + new_ticket_ccs = [] + if cc_list is not None: + new_ticket_ccs = create_ticket_cc(ticket, cc_list.split(',')) + + notification_template = None + + if new: + + notification_template = 'newticket_cc' + + if sender_email: + send_templated_mail( + 'newticket_submitter', + context, + recipients=sender_email, + sender=queue.from_address, + fail_silently=True, + extra_headers={'In-Reply-To': message_id}, + ) + + if queue.new_ticket_cc: + + send_templated_mail( + 'newticket_cc', + context, + recipients=queue.new_ticket_cc, + sender=queue.from_address, + fail_silently=True, + extra_headers={'In-Reply-To': message_id}, + ) + + if queue.updated_ticket_cc and queue.updated_ticket_cc != queue.new_ticket_cc: + send_templated_mail( + 'newticket_cc', + context, + recipients=queue.updated_ticket_cc, + sender=queue.from_address, + fail_silently=True, + extra_headers={'In-Reply-To': message_id}, + ) + + else: + + notification_template = 'updated_cc' + + context.update(comment=f.comment) + + if ticket.status == Ticket.REOPENED_STATUS: + update = _(' (Reopened)') + else: + update = _(' (Updated)') + + if ticket.assigned_to: + send_templated_mail( + 'updated_owner', + context, + recipients=ticket.assigned_to.email, + sender=queue.from_address, + fail_silently=True, + ) + + if queue.updated_ticket_cc: + send_templated_mail( + 'updated_cc', + context, + recipients=queue.updated_ticket_cc, + sender=queue.from_address, + fail_silently=True, + ) + + notifications_to_be_sent = [] + ticket_cc_list = TicketCC.objects.filter(ticket=ticket).all().values_list('email', flat=True) + + for email in ticket_cc_list : + notifications_to_be_sent.append(email) + + if len(notifications_to_be_sent): + + send_templated_mail( + notification_template, + context, + recipients=notifications_to_be_sent, + sender=queue.from_address, + fail_silently=True, + extra_headers={'In-Reply-To': message_id}, + ) + + return ticket + + +def object_from_message(message, queue, quiet): # 'message' must be an RFC822 formatted message. + msg = message + message = email.message_from_string(msg) + subject = message.get('subject', _('Created from e-mail')) subject = decode_mail_headers(decodeUnknown(message.get_charset(), subject)) subject = subject.replace("Re: ", "").replace("Fw: ", "").replace("RE: ", "").replace("FW: ", "").replace("Automatic reply: ", "").strip() @@ -194,9 +391,9 @@ def ticket_from_message(message, queue, quiet): matchobj = re.match(r".*\["+queue.slug+"-(?P\d+)\]", subject) if matchobj: # This is a reply or forward. - ticket = matchobj.group('id') + ticket_id = matchobj.group('id') else: - ticket = None + ticket_id = None counter = 0 files = [] @@ -239,14 +436,7 @@ def ticket_from_message(message, queue, quiet): 'type': 'text/html', }) - now = timezone.now() - - if ticket: - try: - t = Ticket.objects.get(id=ticket) - new = False - except Ticket.DoesNotExist: - ticket = None + priority = 3 @@ -258,115 +448,17 @@ def ticket_from_message(message, queue, quiet): if smtp_priority in high_priority_types or smtp_importance in high_priority_types: priority = 2 - if ticket == None: - t = Ticket( - title=subject, - queue=queue, - submitter_email=sender_email, - created=now, - description=body, - priority=priority, - ) - t.save() - new = True - update = '' - - elif t.status == Ticket.CLOSED_STATUS: - t.status = Ticket.REOPENED_STATUS - t.save() - - f = FollowUp( - ticket = t, - title = _('E-Mail Received from %(sender_email)s' % {'sender_email': sender_email}), - date = timezone.now(), - public = True, - comment = body, - ) - - if t.status == Ticket.REOPENED_STATUS: - f.new_status = Ticket.REOPENED_STATUS - f.title = _('Ticket Re-Opened by E-Mail Received from %(sender_email)s' % {'sender_email': sender_email}) - - f.save() - - if not quiet: - print (" [%s-%s] %s" % (t.queue.slug, t.id, t.title,)).encode('ascii', 'replace') - - for file in files: - if file['content']: - filename = file['filename'].encode('ascii', 'replace').replace(' ', '_') - filename = re.sub('[^a-zA-Z0-9._-]+', '', filename) - a = Attachment( - followup=f, - filename=filename, - mime_type=file['type'], - size=len(file['content']), - ) - a.file.save(filename, ContentFile(file['content']), save=False) - a.save() - if not quiet: - print " - %s" % filename + payload = { + 'body': body, + 'subject': subject, + 'queue': queue, + 'sender_email': sender_email, + 'priority': priority, + 'files': files, + } - context = safe_template_context(t) - - if new: - - if sender_email: - send_templated_mail( - 'newticket_submitter', - context, - recipients=sender_email, - sender=queue.from_address, - fail_silently=True, - ) - - if queue.new_ticket_cc: - send_templated_mail( - 'newticket_cc', - context, - recipients=queue.new_ticket_cc, - sender=queue.from_address, - fail_silently=True, - ) - - if queue.updated_ticket_cc and queue.updated_ticket_cc != queue.new_ticket_cc: - send_templated_mail( - 'newticket_cc', - context, - recipients=queue.updated_ticket_cc, - sender=queue.from_address, - fail_silently=True, - ) - - else: - context.update(comment=f.comment) - - if t.status == Ticket.REOPENED_STATUS: - update = _(' (Reopened)') - else: - update = _(' (Updated)') - - if t.assigned_to: - send_templated_mail( - 'updated_owner', - context, - recipients=t.assigned_to.email, - sender=queue.from_address, - fail_silently=True, - ) - - if queue.updated_ticket_cc: - send_templated_mail( - 'updated_cc', - context, - recipients=queue.updated_ticket_cc, - sender=queue.from_address, - fail_silently=True, - ) - - return t - + return create_object_from_email_message(message, ticket_id, payload, files, quiet=quiet) if __name__ == '__main__': process_email() diff --git a/helpdesk/migrations/0012_add_submitter_email_id_field_to_ticket.py b/helpdesk/migrations/0012_add_submitter_email_id_field_to_ticket.py new file mode 100644 index 00000000..beafe939 --- /dev/null +++ b/helpdesk/migrations/0012_add_submitter_email_id_field_to_ticket.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.1 on 2016-02-07 19:51 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('helpdesk', '0011_admin_related_improvements'), + ] + + operations = [ + migrations.AddField( + model_name='ticket', + name='submitter_email_id', + field=models.CharField(blank=True, editable=False, help_text="The Message ID of the submitter's email.", max_length=256, null=True, verbose_name='Submitter E-Mail ID'), + ), + ] diff --git a/helpdesk/migrations/0013_store_message_id_field_into_followup_model.py b/helpdesk/migrations/0013_store_message_id_field_into_followup_model.py new file mode 100644 index 00000000..2c1106c7 --- /dev/null +++ b/helpdesk/migrations/0013_store_message_id_field_into_followup_model.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.1 on 2016-02-16 18:13 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('helpdesk', '0012_add_submitter_email_id_field_to_ticket'), + ] + + operations = [ + migrations.RemoveField( + model_name='ticket', + name='submitter_email_id', + ), + migrations.AddField( + model_name='followup', + name='message_id', + field=models.CharField(blank=True, editable=False, help_text="The Message ID of the submitter's email.", max_length=256, null=True, verbose_name='E-Mail ID'), + ), + ] diff --git a/helpdesk/models.py b/helpdesk/models.py index 6069f6cc..42d7ef2b 100644 --- a/helpdesk/models.py +++ b/helpdesk/models.py @@ -540,7 +540,7 @@ class Ticket(models.Model): def get_absolute_url(self): return ('helpdesk_view', (self.id,)) - get_absolute_url = models.permalink(get_absolute_url) + get_absolute_url = models.permalink(get_absolute_url) def save(self, *args, **kwargs): if not self.id: @@ -623,6 +623,15 @@ class FollowUp(models.Model): help_text=_('If the status was changed, what was it changed to?'), ) + message_id = models.CharField( + _('E-Mail ID'), + max_length=256, + blank=True, + null=True, + help_text=_("The Message ID of the submitter's email."), + editable=False, + ) + objects = FollowUpManager() class Meta: diff --git a/helpdesk/tests/test_ticket_submission.py b/helpdesk/tests/test_ticket_submission.py index e5f9933b..a63dd266 100644 --- a/helpdesk/tests/test_ticket_submission.py +++ b/helpdesk/tests/test_ticket_submission.py @@ -1,9 +1,17 @@ -from helpdesk.models import Queue, CustomField, Ticket + +import email +import uuid + +from helpdesk.models import Queue, CustomField, FollowUp, Ticket, TicketCC from django.test import TestCase from django.core import mail +from django.core.exceptions import ObjectDoesNotExist +from django.forms import ValidationError from django.test.client import Client from django.core.urlresolvers import reverse +from helpdesk.management.commands.get_email import object_from_message, create_ticket_cc + try: # python 3 from urllib.parse import urlparse except ImportError: # python 2 @@ -24,13 +32,385 @@ class TicketBasicsTestCase(TestCase): self.client = Client() - def test_create_ticket_direct(self): + def test_create_ticket_instance_from_payload(self): + + """ + Ensure that a instance is created whenever an email is sent to a public queue. + """ + email_count = len(mail.outbox) ticket_data = dict(queue=self.queue_public, **self.ticket_data) ticket = Ticket.objects.create(**ticket_data) self.assertEqual(ticket.ticket_for_url, "q1-%s" % ticket.id) self.assertEqual(email_count, len(mail.outbox)) + + def test_create_ticket_from_email_with_message_id(self): + + """ + Ensure that a instance is created whenever an email is sent to a public queue. + Also, make sure that the RFC 2822 field "message-id" is stored on the + field. + """ + + msg = email.message.Message() + + message_id = uuid.uuid4().hex + submitter_email = 'foo@bar.py' + + msg.__setitem__('Message-ID', message_id) + msg.__setitem__('Subject', self.ticket_data['title']) + msg.__setitem__('From', submitter_email) + msg.__setitem__('To', self.queue_public.email_address) + msg.__setitem__('Content-Type', 'text/plain;') + msg.set_payload(self.ticket_data['description']) + + email_count = len(mail.outbox) + #print email_count + #for m in mail.outbox: + # print m.to, m.subject + + object_from_message(str(msg), self.queue_public, quiet=True) + + followup = FollowUp.objects.get(message_id=message_id) + ticket = Ticket.objects.get(id=followup.ticket.id) + + self.assertEqual(ticket.ticket_for_url, "q1-%s" % ticket.id) + + # As we have created an Ticket from an email, we notify the sender (+1) + # and the new and update queues (+2) + self.assertEqual(email_count + 1 + 2, len(mail.outbox)) + + def test_create_ticket_from_email_without_message_id(self): + + """ + Ensure that a instance is created whenever an email is sent to a public queue. + Also, make sure that the RFC 2822 field "message-id" is stored on the + field. + """ + + msg = email.message.Message() + submitter_email = 'foo@bar.py' + + msg.__setitem__('Subject', self.ticket_data['title']) + msg.__setitem__('From', submitter_email) + msg.__setitem__('To', self.queue_public.email_address) + msg.__setitem__('Content-Type', 'text/plain;') + msg.set_payload(self.ticket_data['description']) + + email_count = len(mail.outbox) + + object_from_message(str(msg), self.queue_public, quiet=True) + + ticket = Ticket.objects.get(title=self.ticket_data['title'], queue=self.queue_public, submitter_email=submitter_email) + + self.assertEqual(ticket.ticket_for_url, "q1-%s" % ticket.id) + + # As we have created an Ticket from an email, we notify the sender (+1) + # and the new and update queues (+2) + self.assertEqual(email_count + 1 + 2, len(mail.outbox)) + + def test_create_ticket_from_email_with_carbon_copy(self): + + """ + Ensure that an instance of is created for every valid element of the + "rfc_2822_cc" field when creating a instance. + """ + + msg = email.message.Message() + + message_id = uuid.uuid4().hex + submitter_email = 'foo@bar.py' + cc_list = ['bravo@example.net', 'charlie@foobar.com'] + + msg.__setitem__('Message-ID', message_id) + msg.__setitem__('Subject', self.ticket_data['title']) + msg.__setitem__('From', submitter_email) + msg.__setitem__('To', self.queue_public.email_address) + msg.__setitem__('Cc', ','.join(cc_list)) + msg.__setitem__('Content-Type', 'text/plain;') + msg.set_payload(self.ticket_data['description']) + + email_count = len(mail.outbox) + object_from_message(str(msg), self.queue_public, quiet=True) + + followup = FollowUp.objects.get(message_id=message_id) + ticket = Ticket.objects.get(id=followup.ticket.id) + self.assertEqual(ticket.ticket_for_url, "q1-%s" % ticket.id) + + # As we have created an Ticket from an email, we notify the sender (+1), + # the new and update queues (+2) and contacts on the cc_list (+1 as it's + # treated as a list) + self.assertEqual(email_count + 1 + 2 + 1, len(mail.outbox)) + + # Ensure that is created + for cc_email in cc_list: + ticket_cc = TicketCC.objects.get(ticket=ticket, email=cc_email) + self.assertTrue(ticket_cc.ticket, ticket) + self.assertTrue(ticket_cc.email, cc_email) + + def test_create_ticket_from_email_with_invalid_carbon_copy(self): + + """ + Ensure that no instance is created if an invalid element of the + "rfc_2822_cc" field is provided when creating a instance. + """ + + msg = email.message.Message() + + message_id = uuid.uuid4().hex + submitter_email = 'foo@bar.py' + cc_list = ['null@example', 'invalid@foobar'] + + msg.__setitem__('Message-ID', message_id) + msg.__setitem__('Subject', self.ticket_data['title']) + msg.__setitem__('From', submitter_email) + msg.__setitem__('To', self.queue_public.email_address) + msg.__setitem__('Cc', ','.join(cc_list)) + msg.__setitem__('Content-Type', 'text/plain;') + msg.set_payload(self.ticket_data['description']) + + email_count = len(mail.outbox) + + self.assertRaises(ValidationError, object_from_message, str(msg), self.queue_public, quiet=True) + + def test_create_followup_from_email_with_valid_message_id_with_when_no_initial_cc_list(self): + + """ + Ensure that if a message is received with an valid In-Reply-To ID, + the expected instances are created even if the there were + no s so far. + """ + + ### Ticket and TicketCCs creation ### + msg = email.message.Message() + + message_id = uuid.uuid4().hex + submitter_email = 'foo@bar.py' + + msg.__setitem__('Message-ID', message_id) + msg.__setitem__('Subject', self.ticket_data['title']) + msg.__setitem__('From', submitter_email) + msg.__setitem__('To', self.queue_public.email_address) + msg.__setitem__('Content-Type', 'text/plain;') + msg.set_payload(self.ticket_data['description']) + + email_count = len(mail.outbox) + + object_from_message(str(msg), self.queue_public, quiet=True) + + followup = FollowUp.objects.get(message_id=message_id) + ticket = Ticket.objects.get(id=followup.ticket.id) + ### end of the Ticket and TicketCCs creation ### + + # Reply message + reply = email.message.Message() + + reply_message_id = uuid.uuid4().hex + submitter_email = 'foo@bar.py' + cc_list = ['bravo@example.net', 'charlie@foobar.com'] + + reply.__setitem__('Message-ID', reply_message_id) + reply.__setitem__('In-Reply-To', message_id) + reply.__setitem__('Subject', self.ticket_data['title']) + reply.__setitem__('From', submitter_email) + reply.__setitem__('To', self.queue_public.email_address) + reply.__setitem__('Cc', ','.join(cc_list)) + reply.__setitem__('Content-Type', 'text/plain;') + reply.set_payload(self.ticket_data['description']) + + object_from_message(str(reply), self.queue_public, quiet=True) + + followup = FollowUp.objects.get(message_id=message_id) + ticket = Ticket.objects.get(id=followup.ticket.id) + self.assertEqual(ticket.ticket_for_url, "q1-%s" % ticket.id) + + # Ensure that is created + for cc_email in cc_list: + # Even after 2 messages with the same cc_list, MUST return only + # one object + ticket_cc = TicketCC.objects.get(ticket=ticket, email=cc_email) + self.assertTrue(ticket_cc.ticket, ticket) + self.assertTrue(ticket_cc.email, cc_email) + + # As we have created an Ticket from an email, we notify the sender (+1) + # and the new and update queues (+2) + expected_email_count = 1 + 2 + + # As an update was made, we increase the expected_email_count with: + # cc_list: +1 + # public_update_queue: +1 + expected_email_count += 1 + 1 + self.assertEqual(expected_email_count, len(mail.outbox)) + + + def test_create_followup_from_email_with_valid_message_id_with_original_cc_list_included(self): + + """ + Ensure that if a message is received with an valid In-Reply-To ID, + the expected instances are created but if there's any + overlap with the previous Cc list, no duplicates are created. + """ + + ### Ticket and TicketCCs creation ### + msg = email.message.Message() + + message_id = uuid.uuid4().hex + submitter_email = 'foo@bar.py' + cc_list = ['bravo@example.net', 'charlie@foobar.com'] + + msg.__setitem__('Message-ID', message_id) + msg.__setitem__('Subject', self.ticket_data['title']) + msg.__setitem__('From', submitter_email) + msg.__setitem__('To', self.queue_public.email_address) + msg.__setitem__('Cc', ','.join(cc_list)) + msg.__setitem__('Content-Type', 'text/plain;') + msg.set_payload(self.ticket_data['description']) + + email_count = len(mail.outbox) + + object_from_message(str(msg), self.queue_public, quiet=True) + + followup = FollowUp.objects.get(message_id=message_id) + ticket = Ticket.objects.get(id=followup.ticket.id) + + # Ensure that is created + for cc_email in cc_list: + ticket_cc = TicketCC.objects.get(ticket=ticket, email=cc_email) + self.assertTrue(ticket_cc.ticket, ticket) + self.assertTrue(ticket_cc.email, cc_email) + self.assertTrue(ticket_cc.can_view, True) + + # As we have created an Ticket from an email, we notify the sender (+1), + # the new and update queues (+2) and contacts on the cc_list (+1 as it's + # treated as a list) + self.assertEqual(email_count + 1 + 2 + 1, len(mail.outbox)) + ### end of the Ticket and TicketCCs creation ### + + # Reply message + reply = email.message.Message() + + reply_message_id = uuid.uuid4().hex + submitter_email = 'foo@bar.py' + cc_list = ['bravo@example.net', 'charlie@foobar.com'] + + reply.__setitem__('Message-ID', reply_message_id) + reply.__setitem__('In-Reply-To', message_id) + reply.__setitem__('Subject', self.ticket_data['title']) + reply.__setitem__('From', submitter_email) + reply.__setitem__('To', self.queue_public.email_address) + reply.__setitem__('Cc', ','.join(cc_list)) + reply.__setitem__('Content-Type', 'text/plain;') + reply.set_payload(self.ticket_data['description']) + + object_from_message(str(reply), self.queue_public, quiet=True) + + followup = FollowUp.objects.get(message_id=message_id) + ticket = Ticket.objects.get(id=followup.ticket.id) + self.assertEqual(ticket.ticket_for_url, "q1-%s" % ticket.id) + + # Ensure that is created + for cc_email in cc_list: + # Even after 2 messages with the same cc_list, + # MUST return only one object + ticket_cc = TicketCC.objects.get(ticket=ticket, email=cc_email) + self.assertTrue(ticket_cc.ticket, ticket) + self.assertTrue(ticket_cc.email, cc_email) + + # As we have created an Ticket from an email, we notify the sender (+1), + # the new and update queues (+2) and contacts on the cc_list (+1 as it's + # treated as a list) + expected_email_count = 1 + 2 + 1 + + # As an update was made, we increase the expected_email_count with: + # cc_list: +1 + # public_update_queue: +1 + expected_email_count += 1 + 1 + self.assertEqual(expected_email_count, len(mail.outbox)) + + def test_create_followup_from_email_with_invalid_message_id(self): + + """ + Ensure that if a message is received with an invalid In-Reply-To ID and we + can infer the original Ticket ID by the message's subject, the expected + instances are created + """ + + ### Ticket and TicketCCs creation ### + msg = email.message.Message() + + message_id = uuid.uuid4().hex + submitter_email = 'foo@bar.py' + cc_list = ['bravo@example.net', 'charlie@foobar.com'] + + msg.__setitem__('Message-ID', message_id) + msg.__setitem__('Subject', self.ticket_data['title']) + msg.__setitem__('From', submitter_email) + msg.__setitem__('To', self.queue_public.email_address) + msg.__setitem__('Cc', ','.join(cc_list)) + msg.__setitem__('Content-Type', 'text/plain;') + msg.set_payload(self.ticket_data['description']) + + email_count = len(mail.outbox) + + object_from_message(str(msg), self.queue_public, quiet=True) + + followup = FollowUp.objects.get(message_id=message_id) + ticket = Ticket.objects.get(id=followup.ticket.id) + + # Ensure that is created + for cc_email in cc_list: + ticket_cc = TicketCC.objects.get(ticket=ticket, email=cc_email) + self.assertTrue(ticket_cc.ticket, ticket) + self.assertTrue(ticket_cc.email, cc_email) + self.assertTrue(ticket_cc.can_view, True) + + # As we have created an Ticket from an email, we notify the sender (+1), + # the new and update queues (+2) and contacts on the cc_list (+1 as it's + # treated as a list) + self.assertEqual(email_count + 1 + 2 + 1, len(mail.outbox)) + ### end of the Ticket and TicketCCs creation ### + + # Reply message + reply = email.message.Message() + + reply_message_id = uuid.uuid4().hex + submitter_email = 'foo@bar.py' + cc_list = ['bravo@example.net', 'charlie@foobar.com'] + + invalid_message_id = 'INVALID' + reply_subject = 'Re: ' + self.ticket_data['title'] + + reply.__setitem__('Message-ID', reply_message_id) + reply.__setitem__('In-Reply-To', invalid_message_id) + reply.__setitem__('Subject', reply_subject) + reply.__setitem__('From', submitter_email) + reply.__setitem__('To', self.queue_public.email_address) + reply.__setitem__('Cc', ','.join(cc_list)) + reply.__setitem__('Content-Type', 'text/plain;') + reply.set_payload(self.ticket_data['description']) + + email_count = len(mail.outbox) + + object_from_message(str(reply), self.queue_public, quiet=True) + + followup = FollowUp.objects.get(message_id=message_id) + ticket = Ticket.objects.get(id=followup.ticket.id) + self.assertEqual(ticket.ticket_for_url, "q1-%s" % ticket.id) + + # Ensure that is created + for cc_email in cc_list: + # Even after 2 messages with the same cc_list, MUST return only + # one object + ticket_cc = TicketCC.objects.get(ticket=ticket, email=cc_email) + self.assertTrue(ticket_cc.ticket, ticket) + self.assertTrue(ticket_cc.email, cc_email) + + # As we have created an Ticket from an email, we notify the sender (+1), + # the new and update queues (+2) and contacts on the cc_list (+1 as it's + # treated as a list) + self.assertEqual(email_count + 1 + 2 + 1, len(mail.outbox)) + def test_create_ticket_public(self): email_count = len(mail.outbox) diff --git a/helpdesk/views/staff.py b/helpdesk/views/staff.py index 2a00f090..51969dd3 100644 --- a/helpdesk/views/staff.py +++ b/helpdesk/views/staff.py @@ -338,16 +338,39 @@ def return_ticketccstring_and_show_subscribe(user, ticket): return ticketcc_string, SHOW_SUBSCRIBE +def subscribe_to_ticket_updates(ticket, user=None, email=None, can_view=True, can_update=False): + + data = { + 'user': user, + 'email': email, + 'can_view': can_view, + 'can_update': can_update + } + + ticket_cc_form = TicketCCForm(data) + if ticket is not None and ticket_cc_form.is_valid(): + + queryset = TicketCC.objects.filter(ticket=ticket, user=user, email=email) + + if queryset.count() > 0: + return queryset.first() + + ticketcc = ticket_cc_form.save(commit=False) + ticketcc.ticket = ticket + ticketcc.save() + return ticketcc + else: + raise ValidationError( + _('Could not create subscribe contact to ticket updated. Errors: {}'.format(ticket_cc_form.errors)) + ) + + +def subscribe_staff_member_to_ticket(ticket, user, email=''): -def subscribe_staff_member_to_ticket(ticket, user): ''' used in view_ticket() and update_ticket() ''' - ticketcc = TicketCC() - ticketcc.ticket = ticket - ticketcc.user = user - ticketcc.can_view = True - ticketcc.can_update = True - ticketcc.save() + return subscribe_to_ticket_updates(ticket=ticket, user=user, email=email, can_view=can_view, can_update=can_update) + def update_ticket(request, ticket_id, public=False): if not (public or (request.user.is_authenticated() and request.user.is_active and (request.user.is_staff or helpdesk_settings.HELPDESK_ALLOW_NON_STAFF_TICKET_UPDATE))):