Merge pull request #1041 from martin-marty/unstable

Refactoring to reduce complexity of several email-related functions
This commit is contained in:
Garret Wassermann 2022-07-25 01:47:40 -04:00 committed by GitHub
commit e75e977911
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 561 additions and 378 deletions

View File

@ -2,6 +2,7 @@
max-line-length = 120 max-line-length = 120
exclude = .git,__pycache__,.tox,.eggs,*.egg,node_modules,.venv,migrations,docs,demo,tests,setup.py exclude = .git,__pycache__,.tox,.eggs,*.egg,node_modules,.venv,migrations,docs,demo,tests,setup.py
import-order-style = pep8 import-order-style = pep8
max-complexity = 20
[pycodestyle] [pycodestyle]
max-line-length = 120 max-line-length = 120

View File

@ -28,9 +28,8 @@ jobs:
run: | run: |
pip install flake8 pip install flake8
# stop the build if there are Python syntax errors or undefined names # stop the build if there are Python syntax errors or undefined names
flake8 helpdesk --count --show-source --statistics --exit-zero
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
# flake8 . --count --exit-zero --max-complexity=10 --statistics flake8 helpdesk --count --show-source --statistics --exit-zero --max-complexity=20
- name: Sort style check with 'isort' - name: Sort style check with 'isort'
run: | run: |
isort --line-length=120 --src helpdesk . --check isort --line-length=120 --src helpdesk . --check

View File

@ -21,7 +21,7 @@ from email.utils import getaddresses
from email_reply_parser import EmailReplyParser from email_reply_parser import EmailReplyParser
from helpdesk import settings from helpdesk import settings
from helpdesk.lib import process_attachments, safe_template_context from helpdesk.lib import process_attachments, safe_template_context
from helpdesk.models import FollowUp, IgnoreEmail, Queue, Ticket, TicketCC from helpdesk.models import FollowUp, IgnoreEmail, Queue, Ticket
import imaplib import imaplib
import logging import logging
import mimetypes import mimetypes
@ -33,6 +33,7 @@ import socket
import ssl import ssl
import sys import sys
from time import ctime from time import ctime
import typing
# import User model, which may be a custom model # import User model, which may be a custom model
@ -176,13 +177,13 @@ def imap_sync(q, logger, server):
sys.exit() sys.exit()
try: try:
status, data = server.search(None, 'NOT', 'DELETED') data = server.search(None, 'NOT', 'DELETED')[1]
if data: if data:
msgnums = data[0].split() msgnums = data[0].split()
logger.info("Received %d messages from IMAP server" % len(msgnums)) logger.info("Received %d messages from IMAP server" % len(msgnums))
for num in msgnums: for num in msgnums:
logger.info("Processing message %s" % num) logger.info("Processing message %s" % num)
status, data = server.fetch(num, '(RFC822)') data = server.fetch(num, '(RFC822)')[1]
full_message = encoding.force_str(data[0][1], errors='replace') full_message = encoding.force_str(data[0][1], errors='replace')
try: try:
ticket = object_from_message( ticket = object_from_message(
@ -345,7 +346,7 @@ def create_ticket_cc(ticket, cc_list):
from helpdesk.views.staff import subscribe_to_ticket_updates, User from helpdesk.views.staff import subscribe_to_ticket_updates, User
new_ticket_ccs = [] new_ticket_ccs = []
for cced_name, cced_email in cc_list: for __, cced_email in cc_list:
cced_email = cced_email.strip() cced_email = cced_email.strip()
if cced_email == ticket.queue.email_address: if cced_email == ticket.queue.email_address:
@ -354,7 +355,7 @@ def create_ticket_cc(ticket, cc_list):
user = None user = None
try: try:
user = User.objects.get(email=cced_email) user = User.objects.get(email=cced_email) # @UndefinedVariable
except User.DoesNotExist: except User.DoesNotExist:
pass pass
@ -466,16 +467,6 @@ def create_object_from_email_message(message, ticket_id, payload, files, logger)
new_ticket_ccs = [] new_ticket_ccs = []
new_ticket_ccs.append(create_ticket_cc(ticket, to_list + cc_list)) new_ticket_ccs.append(create_ticket_cc(ticket, to_list + cc_list))
notifications_to_be_sent = [sender_email]
if queue.enable_notifications_on_email_events and len(notifications_to_be_sent):
ticket_cc_list = TicketCC.objects.filter(
ticket=ticket).all().values_list('email', flat=True)
for email_address in ticket_cc_list:
notifications_to_be_sent.append(email_address)
autoreply = is_autoreply(message) autoreply = is_autoreply(message)
if autoreply: if autoreply:
logger.info( logger.info(
@ -516,7 +507,77 @@ def create_object_from_email_message(message, ticket_id, payload, files, logger)
return ticket return ticket
def object_from_message(message, queue, logger): def get_ticket_id_from_subject_slug(
queue_slug: str,
subject: str,
logger: logging.Logger
) -> typing.Optional[int]:
"""Get a ticket id from the subject string
Performs a match on the subject using the queue_slug as reference,
returning the ticket id if a match is found.
"""
matchobj = re.match(r".*\[" + queue_slug + r"-(?P<id>\d+)\]", subject)
ticket_id = None
if matchobj:
# This is a reply or forward.
ticket_id = matchobj.group('id')
logger.info("Matched tracking ID %s-%s" % (queue_slug, ticket_id))
else:
logger.info("No tracking ID matched.")
return ticket_id
def add_file_if_always_save_incoming_email_message(
files_,
message: str
) -> None:
"""When `settings.HELPDESK_ALWAYS_SAVE_INCOMING_EMAIL_MESSAGE` is `True`
add a file to the files_ list"""
if getattr(django_settings, 'HELPDESK_ALWAYS_SAVE_INCOMING_EMAIL_MESSAGE', False):
# save message as attachment in case of some complex markup renders
# wrong
files_.append(
SimpleUploadedFile(
_("original_message.eml").replace(
".eml",
timezone.localtime().strftime("_%d-%m-%Y_%H:%M") + ".eml"
),
str(message).encode("utf-8"),
'text/plain'
)
)
def get_encoded_body(body: str) -> str:
try:
return body.encode('ascii').decode('unicode_escape')
except UnicodeEncodeError:
return body
def get_body_from_fragments(body) -> str:
"""Gets a body from the fragments, joined by a double line break"""
return "\n\n".join(f.content for f in EmailReplyParser.read(body).fragments)
def get_email_body_from_part_payload(part) -> str:
"""Gets an decoded body from the payload part, if the decode fails,
returns without encoding"""
try:
return encoding.smart_str(
part.get_payload(decode=True)
)
except UnicodeDecodeError:
return encoding.smart_str(
part.get_payload(decode=False)
)
def object_from_message(message: str,
queue: Queue,
logger: logging.Logger
) -> Ticket:
# 'message' must be an RFC822 formatted message. # 'message' must be an RFC822 formatted message.
message = email.message_from_string(message) message = email.message_from_string(message)
@ -541,20 +602,15 @@ def object_from_message(message, queue, logger):
for ignore in IgnoreEmail.objects.filter(Q(queues=queue) | Q(queues__isnull=True)): for ignore in IgnoreEmail.objects.filter(Q(queues=queue) | Q(queues__isnull=True)):
if ignore.test(sender_email): if ignore.test(sender_email):
if ignore.keep_in_mailbox:
# By returning 'False' the message will be kept in the mailbox, # By returning 'False' the message will be kept in the mailbox,
# and the 'True' will cause the message to be deleted. # and the 'True' will cause the message to be deleted.
return False return not ignore.keep_in_mailbox
return True
matchobj = re.match(r".*\[" + queue.slug + r"-(?P<id>\d+)\]", subject) ticket_id: typing.Optional[int] = get_ticket_id_from_subject_slug(
if matchobj: queue.slug,
# This is a reply or forward. subject,
ticket = matchobj.group('id') logger
logger.info("Matched tracking ID %s-%s" % (queue.slug, ticket)) )
else:
logger.info("No tracking ID matched.")
ticket = None
body = None body = None
full_body = None full_body = None
@ -578,13 +634,10 @@ def object_from_message(message, queue, logger):
body = decodeUnknown(part.get_content_charset(), body) body = decodeUnknown(part.get_content_charset(), body)
# have to use django_settings here so overwritting it works in tests # have to use django_settings here so overwritting it works in tests
# the default value is False anyway # the default value is False anyway
if ticket is None and getattr(django_settings, 'HELPDESK_FULL_FIRST_MESSAGE_FROM_EMAIL', False): if ticket_id is None and getattr(django_settings, 'HELPDESK_FULL_FIRST_MESSAGE_FROM_EMAIL', False):
# first message in thread, we save full body to avoid # first message in thread, we save full body to avoid
# losing forwards and things like that # losing forwards and things like that
body_parts = [] full_body = get_body_from_fragments(body)
for f in EmailReplyParser.read(body).fragments:
body_parts.append(f.content)
full_body = '\n\n'.join(body_parts)
body = EmailReplyParser.parse_reply(body) body = EmailReplyParser.parse_reply(body)
else: else:
# second and other reply, save only first part of the # second and other reply, save only first part of the
@ -592,18 +645,10 @@ def object_from_message(message, queue, logger):
body = EmailReplyParser.parse_reply(body) body = EmailReplyParser.parse_reply(body)
full_body = body full_body = body
# workaround to get unicode text out rather than escaped text # workaround to get unicode text out rather than escaped text
try: body = get_encoded_body(body)
body = body.encode('ascii').decode('unicode_escape')
except UnicodeEncodeError:
body.encode('utf-8')
logger.debug("Discovered plain text MIME part") logger.debug("Discovered plain text MIME part")
else: else:
try: email_body = get_email_body_from_part_payload(part)
email_body = encoding.smart_str(
part.get_payload(decode=True))
except UnicodeDecodeError:
email_body = encoding.smart_str(
part.get_payload(decode=False))
if not body and not full_body: if not body and not full_body:
# no text has been parsed so far - try such deep parsing # no text has been parsed so far - try such deep parsing
@ -670,19 +715,7 @@ def object_from_message(message, queue, logger):
if not body: if not body:
body = "" body = ""
if getattr(django_settings, 'HELPDESK_ALWAYS_SAVE_INCOMING_EMAIL_MESSAGE', False): add_file_if_always_save_incoming_email_message(files, message)
# save message as attachment in case of some complex markup renders
# wrong
files.append(
SimpleUploadedFile(
_("original_message.eml").replace(
".eml",
timezone.localtime().strftime("_%d-%m-%Y_%H:%M") + ".eml"
),
str(message).encode("utf-8"),
'text/plain'
)
)
smtp_priority = message.get('priority', '') smtp_priority = message.get('priority', '')
smtp_importance = message.get('importance', '') smtp_importance = message.get('importance', '')
@ -700,4 +733,4 @@ def object_from_message(message, queue, logger):
'files': files, 'files': files,
} }
return create_object_from_email_message(message, ticket, payload, files, logger=logger) return create_object_from_email_message(message, ticket_id, payload, files, logger=logger)

View File

@ -1964,6 +1964,10 @@ class TicketCustomFieldValue(models.Model):
def __str__(self): def __str__(self):
return '%s / %s' % (self.ticket, self.field) return '%s / %s' % (self.ticket, self.field)
@property
def default_value(self) -> str:
return _("Not defined")
class Meta: class Meta:
unique_together = (('ticket', 'field'),) unique_together = (('ticket', 'field'),)
verbose_name = _('Ticket custom field value') verbose_name = _('Ticket custom field value')

View File

@ -6,9 +6,9 @@ django-helpdesk - A Django powered ticket tracker for small enterprise.
views/staff.py - The bulk of the application - provides most business logic and views/staff.py - The bulk of the application - provides most business logic and
renders all staff-facing views. renders all staff-facing views.
""" """
from ..lib import format_time_spent from ..lib import format_time_spent
from ..templated_email import send_templated_mail from ..templated_email import send_templated_mail
from collections import defaultdict
from copy import deepcopy from copy import deepcopy
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from django.conf import settings from django.conf import settings
@ -16,6 +16,7 @@ from django.contrib.auth import get_user_model
from django.contrib.auth.decorators import user_passes_test from django.contrib.auth.decorators import user_passes_test
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import PermissionDenied, ValidationError from django.core.exceptions import PermissionDenied, ValidationError
from django.core.handlers.wsgi import WSGIRequest
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
from django.db.models import Q from django.db.models import Q
from django.http import Http404, HttpResponse, HttpResponseRedirect, JsonResponse from django.http import Http404, HttpResponse, HttpResponseRedirect, JsonResponse
@ -70,11 +71,15 @@ import json
import re import re
from rest_framework import status from rest_framework import status
from rest_framework.decorators import api_view from rest_framework.decorators import api_view
import typing
if helpdesk_settings.HELPDESK_KB_ENABLED: if helpdesk_settings.HELPDESK_KB_ENABLED:
from helpdesk.models import KBItem from helpdesk.models import KBItem
DATE_RE: re.Pattern = re.compile(
r'(?P<month>\d{1,2})/(?P<day>\d{1,2})/(?P<year>\d{4})$'
)
User = get_user_model() User = get_user_model()
Query = get_query_class() Query = get_query_class()
@ -268,7 +273,7 @@ def followup_edit(request, ticket_id, followup_id):
'time_spent': format_time_spent(followup.time_spent), 'time_spent': format_time_spent(followup.time_spent),
}) })
ticketcc_string, show_subscribe = \ ticketcc_string, __ = \
return_ticketccstring_and_show_subscribe(request.user, ticket) return_ticketccstring_and_show_subscribe(request.user, ticket)
return render(request, 'helpdesk/followup_edit.html', { return render(request, 'helpdesk/followup_edit.html', {
@ -346,8 +351,10 @@ def view_ticket(request, ticket_id):
if 'subscribe' in request.GET: if 'subscribe' in request.GET:
# Allow the user to subscribe him/herself to the ticket whilst viewing # Allow the user to subscribe him/herself to the ticket whilst viewing
# it. # it.
ticket_cc, show_subscribe = \ show_subscribe = return_ticketccstring_and_show_subscribe(
return_ticketccstring_and_show_subscribe(request.user, ticket) request.user, ticket
)[1]
if show_subscribe: if show_subscribe:
subscribe_staff_member_to_ticket(ticket, request.user) subscribe_staff_member_to_ticket(ticket, request.user)
return HttpResponseRedirect(reverse('helpdesk:view', args=[ticket.id])) return HttpResponseRedirect(reverse('helpdesk:view', args=[ticket.id]))
@ -481,10 +488,20 @@ def subscribe_staff_member_to_ticket(ticket, user, email='', can_view=True, can_
return subscribe_to_ticket_updates(ticket=ticket, user=user, email=email, can_view=can_view, can_update=can_update) 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): def get_ticket_from_request_with_authorisation(
request: WSGIRequest,
ticket_id: str,
public: bool
) -> typing.Union[
Ticket, typing.NoReturn
]:
"""Gets a ticket from the public status and if the user is authenticated and
has permissions to update tickets
ticket = None Raises:
Http404 when the ticket can not be found or the user lacks permission
"""
if not (public or ( if not (public or (
request.user.is_authenticated and request.user.is_authenticated and
request.user.is_active and ( request.user.is_active and (
@ -506,41 +523,29 @@ def update_ticket(request, ticket_id, public=False):
'%s?next=%s' % (reverse('helpdesk:login'), request.path) '%s?next=%s' % (reverse('helpdesk:login'), request.path)
) )
if not ticket: return get_object_or_404(Ticket, id=ticket_id)
ticket = get_object_or_404(Ticket, id=ticket_id)
date_re = re.compile(
r'(?P<month>\d{1,2})/(?P<day>\d{1,2})/(?P<year>\d{4})$'
)
comment = request.POST.get('comment', '') def get_due_date_from_request_or_ticket(
new_status = int(request.POST.get('new_status', ticket.status)) request: WSGIRequest,
title = request.POST.get('title', '') ticket: Ticket
public = request.POST.get('public', False) ) -> typing.Optional[datetime.date]:
owner = int(request.POST.get('owner', -1)) """Tries to locate the due date for a ticket from the `request.POST`
priority = int(request.POST.get('priority', ticket.priority)) 'due_date' parameter or the `due_date_*` paramaters.
due_date_year = int(request.POST.get('due_date_year', 0)) """
due_date_month = int(request.POST.get('due_date_month', 0))
due_date_day = int(request.POST.get('due_date_day', 0))
if request.POST.get("time_spent"):
(hours, minutes) = [int(f)
for f in request.POST.get("time_spent").split(":")]
time_spent = timedelta(hours=hours, minutes=minutes)
else:
time_spent = None
# NOTE: jQuery's default for dates is mm/dd/yy
# very US-centric but for now that's the only format supported
# until we clean up code to internationalize a little more
due_date = request.POST.get('due_date', None) or None due_date = request.POST.get('due_date', None) or None
if due_date is not None: if due_date is not None:
# based on Django code to parse dates: # based on Django code to parse dates:
# https://docs.djangoproject.com/en/2.0/_modules/django/utils/dateparse/ # https://docs.djangoproject.com/en/2.0/_modules/django/utils/dateparse/
match = date_re.match(due_date) match = DATE_RE.match(due_date)
if match: if match:
kw = {k: int(v) for k, v in match.groupdict().items()} kw = {k: int(v) for k, v in match.groupdict().items()}
due_date = date(**kw) due_date = date(**kw)
else: else:
due_date_year = int(request.POST.get('due_date_year', 0))
due_date_month = int(request.POST.get('due_date_month', 0))
due_date_day = int(request.POST.get('due_date_day', 0))
# old way, probably deprecated? # old way, probably deprecated?
if not (due_date_year and due_date_month and due_date_day): if not (due_date_year and due_date_month and due_date_day):
due_date = ticket.due_date due_date = ticket.due_date
@ -553,7 +558,144 @@ def update_ticket(request, ticket_id, public=False):
due_date = timezone.now() due_date = timezone.now()
due_date = due_date.replace( due_date = due_date.replace(
due_date_year, due_date_month, due_date_day) due_date_year, due_date_month, due_date_day)
return due_date
def get_and_set_ticket_status(
new_status: str,
ticket: Ticket,
follow_up: FollowUp
) -> typing.Tuple[str, str]:
"""Performs comparision on previous status to new status,
updating the title as required.
Returns:
The old status as a display string, old status code string
"""
old_status_str = ticket.get_status_display()
old_status = ticket.status
if new_status != ticket.status:
ticket.status = new_status
ticket.save()
follow_up.new_status = new_status
if follow_up.title:
follow_up.title += ' and %s' % ticket.get_status_display()
else:
follow_up.title = '%s' % ticket.get_status_display()
if not follow_up.title:
if follow_up.comment:
follow_up.title = _('Comment')
else:
follow_up.title = _('Updated')
follow_up.save()
return (old_status_str, old_status)
def get_time_spent_from_request(request: WSGIRequest) -> typing.Optional[timedelta]:
if request.POST.get("time_spent"):
(hours, minutes) = [int(f)
for f in request.POST.get("time_spent").split(":")]
return timedelta(hours=hours, minutes=minutes)
return None
def update_messages_sent_to_by_public_and_status(
public: bool,
ticket: Ticket,
follow_up: FollowUp,
context: str,
messages_sent_to: typing.List[str],
files: typing.List[typing.Tuple[str, str]]
) -> Ticket:
"""Sets the status of the ticket"""
if public and (
follow_up.comment or (
follow_up.new_status in (
Ticket.RESOLVED_STATUS,
Ticket.CLOSED_STATUS
)
)
):
if follow_up.new_status == Ticket.RESOLVED_STATUS:
template = 'resolved_'
elif follow_up.new_status == Ticket.CLOSED_STATUS:
template = 'closed_'
else:
template = 'updated_'
roles = {
'submitter': (template + 'submitter', context),
'ticket_cc': (template + 'cc', context),
}
if ticket.assigned_to and ticket.assigned_to.usersettings_helpdesk.email_on_ticket_change:
roles['assigned_to'] = (template + 'cc', context)
messages_sent_to.update(
ticket.send(
roles,
dont_send_to=messages_sent_to,
fail_silently=True,
files=files
)
)
return ticket
def add_staff_subscription(
request: WSGIRequest,
ticket: Ticket
) -> None:
"""Auto subscribe the staff member if that's what the settigs say and the
user is authenticated and a staff member"""
if helpdesk_settings.HELPDESK_AUTO_SUBSCRIBE_ON_TICKET_RESPONSE and request.user.is_authenticated:
SHOW_SUBSCRIBE = return_ticketccstring_and_show_subscribe(
request.user, ticket
)[1]
if SHOW_SUBSCRIBE:
subscribe_staff_member_to_ticket(ticket, request.user)
def get_template_staff_and_template_cc(
reassigned, follow_up: FollowUp
) -> typing.Tuple[str, str]:
if reassigned:
template_staff = 'assigned_owner'
elif follow_up.new_status == Ticket.RESOLVED_STATUS:
template_staff = 'resolved_owner'
elif follow_up.new_status == Ticket.CLOSED_STATUS:
template_staff = 'closed_owner'
else:
template_staff = 'updated_owner'
if reassigned:
template_cc = 'assigned_cc'
elif follow_up.new_status == Ticket.RESOLVED_STATUS:
template_cc = 'resolved_cc'
elif follow_up.new_status == Ticket.CLOSED_STATUS:
template_cc = 'closed_cc'
else:
template_cc = 'updated_cc'
return template_staff, template_cc
def update_ticket(request, ticket_id, public=False):
ticket = get_ticket_from_request_with_authorisation(request, ticket_id, public)
comment = request.POST.get('comment', '')
new_status = int(request.POST.get('new_status', ticket.status))
title = request.POST.get('title', '')
public = request.POST.get('public', False)
owner = int(request.POST.get('owner', -1))
priority = int(request.POST.get('priority', ticket.priority))
time_spent = get_time_spent_from_request(request)
# NOTE: jQuery's default for dates is mm/dd/yy
# very US-centric but for now that's the only format supported
# until we clean up code to internationalize a little more
due_date = get_due_date_from_request_or_ticket(request, ticket)
no_changes = all([ no_changes = all([
not request.FILES, not request.FILES,
not comment, not comment,
@ -613,28 +755,9 @@ def update_ticket(request, ticket_id, public=False):
f.title = _('Unassigned') f.title = _('Unassigned')
ticket.assigned_to = None ticket.assigned_to = None
old_status_str = ticket.get_status_display() old_status_str, old_status = get_and_set_ticket_status(new_status, ticket, f)
old_status = ticket.status
if new_status != ticket.status:
ticket.status = new_status
ticket.save()
f.new_status = new_status
if f.title:
f.title += ' and %s' % ticket.get_status_display()
else:
f.title = '%s' % ticket.get_status_display()
if not f.title: files = process_attachments(f, request.FILES.getlist('attachment')) if request.FILES else []
if f.comment:
f.title = _('Comment')
else:
f.title = _('Updated')
f.save()
files = []
if request.FILES:
files = process_attachments(f, request.FILES.getlist('attachment'))
if title and title != ticket.title: if title and title != ticket.title:
c = TicketChange( c = TicketChange(
@ -684,8 +807,11 @@ def update_ticket(request, ticket_id, public=False):
c.save() c.save()
ticket.due_date = due_date ticket.due_date = due_date
if new_status in (Ticket.RESOLVED_STATUS, Ticket.CLOSED_STATUS): if new_status in (
if new_status == Ticket.RESOLVED_STATUS or ticket.resolution is None: Ticket.RESOLVED_STATUS, Ticket.CLOSED_STATUS
) and (
new_status == Ticket.RESOLVED_STATUS or ticket.resolution is None
):
ticket.resolution = comment ticket.resolution = comment
# ticket might have changed above, so we re-instantiate context with the # ticket might have changed above, so we re-instantiate context with the
@ -701,34 +827,16 @@ def update_ticket(request, ticket_id, public=False):
messages_sent_to.add(request.user.email) messages_sent_to.add(request.user.email)
except AttributeError: except AttributeError:
pass pass
if public and (f.comment or ( ticket = update_messages_sent_to_by_public_and_status(
f.new_status in (Ticket.RESOLVED_STATUS, public,
Ticket.CLOSED_STATUS))): ticket,
if f.new_status == Ticket.RESOLVED_STATUS: f,
template = 'resolved_' context,
elif f.new_status == Ticket.CLOSED_STATUS: messages_sent_to,
template = 'closed_' files
else: )
template = 'updated_'
roles = {
'submitter': (template + 'submitter', context),
'ticket_cc': (template + 'cc', context),
}
if ticket.assigned_to and ticket.assigned_to.usersettings_helpdesk.email_on_ticket_change:
roles['assigned_to'] = (template + 'cc', context)
messages_sent_to.update(ticket.send(
roles, dont_send_to=messages_sent_to, fail_silently=True, files=files,))
if reassigned:
template_staff = 'assigned_owner'
elif f.new_status == Ticket.RESOLVED_STATUS:
template_staff = 'resolved_owner'
elif f.new_status == Ticket.CLOSED_STATUS:
template_staff = 'closed_owner'
else:
template_staff = 'updated_owner'
template_staff, template_cc = get_template_staff_and_template_cc(reassigned, f)
if ticket.assigned_to and ( if ticket.assigned_to and (
ticket.assigned_to.usersettings_helpdesk.email_on_ticket_change ticket.assigned_to.usersettings_helpdesk.email_on_ticket_change
or (reassigned and ticket.assigned_to.usersettings_helpdesk.email_on_ticket_assign) or (reassigned and ticket.assigned_to.usersettings_helpdesk.email_on_ticket_assign)
@ -740,30 +848,16 @@ def update_ticket(request, ticket_id, public=False):
files=files, files=files,
)) ))
if reassigned:
template_cc = 'assigned_cc'
elif f.new_status == Ticket.RESOLVED_STATUS:
template_cc = 'resolved_cc'
elif f.new_status == Ticket.CLOSED_STATUS:
template_cc = 'closed_cc'
else:
template_cc = 'updated_cc'
messages_sent_to.update(ticket.send( messages_sent_to.update(ticket.send(
{'ticket_cc': (template_cc, context)}, {'ticket_cc': (template_cc, context)},
dont_send_to=messages_sent_to, dont_send_to=messages_sent_to,
fail_silently=True, fail_silently=True,
files=files, files=files,
)) ))
ticket.save() ticket.save()
# auto subscribe user if enabled # auto subscribe user if enabled
if helpdesk_settings.HELPDESK_AUTO_SUBSCRIBE_ON_TICKET_RESPONSE and request.user.is_authenticated: add_staff_subscription(request, ticket)
ticketcc_string, SHOW_SUBSCRIBE = return_ticketccstring_and_show_subscribe(
request.user, ticket)
if SHOW_SUBSCRIBE:
subscribe_staff_member_to_ticket(ticket, request.user)
return return_to_ticket(request.user, helpdesk_settings, ticket) return return_to_ticket(request.user, helpdesk_settings, ticket)
@ -895,7 +989,7 @@ mass_update = staff_member_required(mass_update)
# Prepare ticket attributes which will be displayed in the table to choose # Prepare ticket attributes which will be displayed in the table to choose
# which value to keep when merging # which value to keep when merging
ticket_attributes = ( TICKET_ATTRIBUTES = (
('created', _('Created date')), ('created', _('Created date')),
('due_date', _('Due on')), ('due_date', _('Due on')),
('get_status_display', _('Status')), ('get_status_display', _('Status')),
@ -906,30 +1000,20 @@ ticket_attributes = (
) )
@staff_member_required def merge_ticket_values(
def merge_tickets(request): request: WSGIRequest,
""" tickets: typing.List[Ticket],
An intermediate view to merge up to 3 tickets in one main ticket. custom_fields
The user has to first select which ticket will receive the other tickets information and can also choose which ) -> None:
data to keep per attributes as well as custom fields.
Follow-ups and ticketCC will be moved to the main ticket and other tickets won't be able to receive new answers.
"""
ticket_select_form = MultipleTicketSelectForm(request.GET or None)
tickets = custom_fields = None
if ticket_select_form.is_valid():
tickets = ticket_select_form.cleaned_data.get('tickets')
custom_fields = CustomField.objects.all()
default = _('Not defined')
for ticket in tickets: for ticket in tickets:
ticket.values = {} ticket.values = {}
# Prepare the value for each attributes of this ticket # Prepare the value for each attributes of this ticket
for attribute, display_name in ticket_attributes: for attribute, __ in TICKET_ATTRIBUTES:
value = getattr(ticket, attribute, default) value = getattr(ticket, attribute, TicketCustomFieldValue.default_value)
# Check if attr is a get_FIELD_display # Check if attr is a get_FIELD_display
if attribute.startswith('get_') and attribute.endswith('_display'): if attribute.startswith('get_') and attribute.endswith('_display'):
# Hack to call methods like get_FIELD_display() # Hack to call methods like get_FIELD_display()
value = getattr(ticket, attribute, default)() value = getattr(ticket, attribute, TicketCustomFieldValue.default_value)()
ticket.values[attribute] = { ticket.values[attribute] = {
'value': value, 'value': value,
'checked': str(ticket.id) == request.POST.get(attribute) 'checked': str(ticket.id) == request.POST.get(attribute)
@ -940,26 +1024,21 @@ def merge_tickets(request):
value = ticket.ticketcustomfieldvalue_set.get( value = ticket.ticketcustomfieldvalue_set.get(
field=custom_field).value field=custom_field).value
except (TicketCustomFieldValue.DoesNotExist, ValueError): except (TicketCustomFieldValue.DoesNotExist, ValueError):
value = default value = TicketCustomFieldValue.default_value
ticket.values[custom_field.name] = { ticket.values[custom_field.name] = {
'value': value, 'value': value,
'checked': str(ticket.id) == request.POST.get(custom_field.name) 'checked': str(ticket.id) == request.POST.get(custom_field.name)
} }
if request.method == 'POST':
# Find which ticket has been chosen to be the main one def redirect_from_chosen_ticket(
try: request,
chosen_ticket = tickets.get( chosen_ticket,
id=request.POST.get('chosen_ticket')) tickets,
except Ticket.DoesNotExist: custom_fields
ticket_select_form.add_error( ) -> HttpResponseRedirect:
field='tickets',
error=_(
'Please choose a ticket in which the others will be merged into.')
)
else:
# Save ticket fields values # Save ticket fields values
for attribute, display_name in ticket_attributes: for attribute, __ in TICKET_ATTRIBUTES:
id_for_attribute = request.POST.get(attribute) id_for_attribute = request.POST.get(attribute)
if id_for_attribute != chosen_ticket.id: if id_for_attribute != chosen_ticket.id:
try: try:
@ -1047,14 +1126,88 @@ def merge_tickets(request):
ticketcc=ticketcc) ticketcc=ticketcc)
return redirect(chosen_ticket) return redirect(chosen_ticket)
@staff_member_required
def merge_tickets(request):
"""
An intermediate view to merge up to 3 tickets in one main ticket.
The user has to first select which ticket will receive the other tickets information and can also choose which
data to keep per attributes as well as custom fields.
Follow-ups and ticketCC will be moved to the main ticket and other tickets won't be able to receive new answers.
"""
ticket_select_form = MultipleTicketSelectForm(request.GET or None)
tickets = custom_fields = None
if ticket_select_form.is_valid():
tickets = ticket_select_form.cleaned_data.get('tickets')
custom_fields = CustomField.objects.all()
merge_ticket_values(request, tickets, custom_fields)
if request.method == 'POST':
# Find which ticket has been chosen to be the main one
try:
chosen_ticket = tickets.get(
id=request.POST.get('chosen_ticket'))
except Ticket.DoesNotExist:
ticket_select_form.add_error(
field='tickets',
error=_(
'Please choose a ticket in which the others will be merged into.')
)
else:
return redirect_from_chosen_ticket(
request,
chosen_ticket,
tickets,
custom_fields
)
return render(request, 'helpdesk/ticket_merge.html', { return render(request, 'helpdesk/ticket_merge.html', {
'tickets': tickets, 'tickets': tickets,
'ticket_attributes': ticket_attributes, 'ticket_attributes': TICKET_ATTRIBUTES,
'custom_fields': custom_fields, 'custom_fields': custom_fields,
'ticket_select_form': ticket_select_form 'ticket_select_form': ticket_select_form
}) })
def check_redirect_on_user_query(request, huser):
"""If the user is coming from the header/navigation search box, lets' first
look at their query to see if they have entered a valid ticket number. If
they have, just redirect to that ticket number. Otherwise, we treat it as
a keyword search.
"""
if request.GET.get('search_type', None) == 'header':
query = request.GET.get('q')
filter_ = None
if query.find('-') > 0:
try:
queue, id_ = Ticket.queue_and_id_from_query(query)
id_ = int(id)
except ValueError:
id_ = None
if id_:
filter_ = {'queue__slug': queue, 'id': id_}
else:
try:
query = int(query)
except ValueError:
query = None
if query:
filter_ = {'id': int(query)}
if filter_:
try:
ticket = huser.get_tickets_in_queues().get(**filter_)
return HttpResponseRedirect(ticket.staff_url)
except Ticket.DoesNotExist:
# Go on to standard keyword searching
pass
return None
@helpdesk_staff_member_required @helpdesk_staff_member_required
def ticket_list(request): def ticket_list(request):
context = {} context = {}
@ -1079,40 +1232,10 @@ def ticket_list(request):
'sortreverse': False, 'sortreverse': False,
} }
# If the user is coming from the header/navigation search box, lets' first #: check for a redirect, see function doc for details
# look at their query to see if they have entered a valid ticket number. If redirect = check_redirect_on_user_query(request, huser)
# they have, just redirect to that ticket number. Otherwise, we treat it as if redirect:
# a keyword search. return redirect
if request.GET.get('search_type', None) == 'header':
query = request.GET.get('q')
filter = None
if query.find('-') > 0:
try:
queue, id = Ticket.queue_and_id_from_query(query)
id = int(id)
except ValueError:
id = None
if id:
filter = {'queue__slug': queue, 'id': id}
else:
try:
query = int(query)
except ValueError:
query = None
if query:
filter = {'id': int(query)}
if filter:
try:
ticket = huser.get_tickets_in_queues().get(**filter)
return HttpResponseRedirect(ticket.staff_url)
except Ticket.DoesNotExist:
# Go on to standard keyword searching
pass
try: try:
saved_query, query_params = load_saved_query(request, query_params) saved_query, query_params = load_saved_query(request, query_params)
except QueryLoadError: except QueryLoadError:
@ -1308,15 +1431,15 @@ class CreateTicketView(MustBeStaffMixin, abstract_views.AbstractCreateTicketMixi
@helpdesk_staff_member_required @helpdesk_staff_member_required
def raw_details(request, type): def raw_details(request, type_):
# TODO: This currently only supports spewing out 'PreSetReply' objects, # TODO: This currently only supports spewing out 'PreSetReply' objects,
# in the future it needs to be expanded to include other items. All it # in the future it needs to be expanded to include other items. All it
# does is return a plain-text representation of an object. # does is return a plain-text representation of an object.
if type not in ('preset',): if type_ not in ('preset',):
raise Http404 raise Http404
if type == 'preset' and request.GET.get('id', False): if type_ == 'preset' and request.GET.get('id', False):
try: try:
preset = PreSetReply.objects.get(id=request.GET.get('id')) preset = PreSetReply.objects.get(id=request.GET.get('id'))
return HttpResponse(preset.body) return HttpResponse(preset.body)
@ -1416,12 +1539,18 @@ def report_index(request):
report_index = staff_member_required(report_index) report_index = staff_member_required(report_index)
@helpdesk_staff_member_required def get_report_queryset_or_redirect(request, report):
def run_report(request, report):
if Ticket.objects.all().count() == 0 or report not in ( if Ticket.objects.all().count() == 0 or report not in (
'queuemonth', 'usermonth', 'queuestatus', 'queuepriority', 'userstatus', "queuemonth",
'userpriority', 'userqueue', 'daysuntilticketclosedbymonth'): "usermonth",
return HttpResponseRedirect(reverse("helpdesk:report_index")) "queuestatus",
"queuepriority",
"userstatus",
"userpriority",
"userqueue",
"daysuntilticketclosedbymonth"
):
return None, None, HttpResponseRedirect(reverse("helpdesk:report_index"))
report_queryset = Ticket.objects.all().select_related().filter( report_queryset = Ticket.objects.all().select_related().filter(
queue__in=HelpdeskUser(request.user).get_queues() queue__in=HelpdeskUser(request.user).get_queues()
@ -1430,12 +1559,79 @@ def run_report(request, report):
try: try:
saved_query, query_params = load_saved_query(request) saved_query, query_params = load_saved_query(request)
except QueryLoadError: except QueryLoadError:
return HttpResponseRedirect(reverse('helpdesk:report_index')) return None, HttpResponseRedirect(reverse('helpdesk:report_index'))
return report_queryset, query_params, saved_query, None
def get_report_table_and_totals(header1, summarytable, possible_options):
table = []
totals = {}
for item in header1:
data = []
for hdr in possible_options:
if hdr not in totals.keys():
totals[hdr] = summarytable[item, hdr]
else:
totals[hdr] += summarytable[item, hdr]
data.append(summarytable[item, hdr])
table.append([item] + data)
return table, totals
def update_summary_tables(report_queryset, report, summarytable, summarytable2):
metric3 = False
for ticket in report_queryset:
if report == 'userpriority':
metric1 = u'%s' % ticket.get_assigned_to
metric2 = u'%s' % ticket.get_priority_display()
elif report == 'userqueue':
metric1 = u'%s' % ticket.get_assigned_to
metric2 = u'%s' % ticket.queue.title
elif report == 'userstatus':
metric1 = u'%s' % ticket.get_assigned_to
metric2 = u'%s' % ticket.get_status_display()
elif report == 'usermonth':
metric1 = u'%s' % ticket.get_assigned_to
metric2 = u'%s-%s' % (ticket.created.year, ticket.created.month)
elif report == 'queuepriority':
metric1 = u'%s' % ticket.queue.title
metric2 = u'%s' % ticket.get_priority_display()
elif report == 'queuestatus':
metric1 = u'%s' % ticket.queue.title
metric2 = u'%s' % ticket.get_status_display()
elif report == 'queuemonth':
metric1 = u'%s' % ticket.queue.title
metric2 = u'%s-%s' % (ticket.created.year, ticket.created.month)
elif report == 'daysuntilticketclosedbymonth':
metric1 = u'%s' % ticket.queue.title
metric2 = u'%s-%s' % (ticket.created.year, ticket.created.month)
metric3 = ticket.modified - ticket.created
metric3 = metric3.days
summarytable[metric1, metric2] += 1
if metric3:
if report == 'daysuntilticketclosedbymonth':
summarytable2[metric1, metric2] += metric3
@helpdesk_staff_member_required
def run_report(request, report):
report_queryset, query_params, saved_query, redirect = get_report_queryset_or_redirect(
request, report
)
if redirect:
return redirect
if request.GET.get('saved_query', None): if request.GET.get('saved_query', None):
Query(report_queryset, query_to_base64(query_params)) Query(report_queryset, query_to_base64(query_params))
from collections import defaultdict
summarytable = defaultdict(int) summarytable = defaultdict(int)
# a second table for more complex queries # a second table for more complex queries
summarytable2 = defaultdict(int) summarytable2 = defaultdict(int)
@ -1510,50 +1706,7 @@ def run_report(request, report):
col1heading = _('Queue') col1heading = _('Queue')
possible_options = periods possible_options = periods
charttype = 'date' charttype = 'date'
update_summary_tables(report_queryset, report, summarytable, summarytable2)
metric3 = False
for ticket in report_queryset:
if report == 'userpriority':
metric1 = u'%s' % ticket.get_assigned_to
metric2 = u'%s' % ticket.get_priority_display()
elif report == 'userqueue':
metric1 = u'%s' % ticket.get_assigned_to
metric2 = u'%s' % ticket.queue.title
elif report == 'userstatus':
metric1 = u'%s' % ticket.get_assigned_to
metric2 = u'%s' % ticket.get_status_display()
elif report == 'usermonth':
metric1 = u'%s' % ticket.get_assigned_to
metric2 = u'%s-%s' % (ticket.created.year, ticket.created.month)
elif report == 'queuepriority':
metric1 = u'%s' % ticket.queue.title
metric2 = u'%s' % ticket.get_priority_display()
elif report == 'queuestatus':
metric1 = u'%s' % ticket.queue.title
metric2 = u'%s' % ticket.get_status_display()
elif report == 'queuemonth':
metric1 = u'%s' % ticket.queue.title
metric2 = u'%s-%s' % (ticket.created.year, ticket.created.month)
elif report == 'daysuntilticketclosedbymonth':
metric1 = u'%s' % ticket.queue.title
metric2 = u'%s-%s' % (ticket.created.year, ticket.created.month)
metric3 = ticket.modified - ticket.created
metric3 = metric3.days
summarytable[metric1, metric2] += 1
if metric3:
if report == 'daysuntilticketclosedbymonth':
summarytable2[metric1, metric2] += metric3
table = []
if report == 'daysuntilticketclosedbymonth': if report == 'daysuntilticketclosedbymonth':
for key in summarytable2.keys(): for key in summarytable2.keys():
summarytable[key] = summarytable2[key] / summarytable[key] summarytable[key] = summarytable2[key] / summarytable[key]
@ -1563,18 +1716,9 @@ def run_report(request, report):
column_headings = [col1heading] + possible_options column_headings = [col1heading] + possible_options
# Prepare a dict to store totals for each possible option # Prepare a dict to store totals for each possible option
totals = {} table, totals = get_report_table_and_totals(header1, summarytable, possible_options)
# Pivot the data so that 'header1' fields are always first column # Pivot the data so that 'header1' fields are always first column
# in the row, and 'possible_options' are always the 2nd - nth columns. # in the row, and 'possible_options' are always the 2nd - nth columns.
for item in header1:
data = []
for hdr in possible_options:
if hdr not in totals.keys():
totals[hdr] = summarytable[item, hdr]
else:
totals[hdr] += summarytable[item, hdr]
data.append(summarytable[item, hdr])
table.append([item] + data)
# Zip data and headers together in one list for Morris.js charts # Zip data and headers together in one list for Morris.js charts
# will get a list like [(Header1, Data1), (Header2, Data2)...] # will get a list like [(Header1, Data1), (Header2, Data2)...]
@ -1634,8 +1778,8 @@ save_query = staff_member_required(save_query)
@helpdesk_staff_member_required @helpdesk_staff_member_required
def delete_saved_query(request, id): def delete_saved_query(request, pk):
query = get_object_or_404(SavedSearch, id=id, user=request.user) query = get_object_or_404(SavedSearch, id=pk, user=request.user)
if request.method == 'POST': if request.method == 'POST':
query.delete() query.delete()
@ -1684,8 +1828,8 @@ email_ignore_add = superuser_required(email_ignore_add)
@helpdesk_superuser_required @helpdesk_superuser_required
def email_ignore_del(request, id): def email_ignore_del(request, pk):
ignore = get_object_or_404(IgnoreEmail, id=id) ignore = get_object_or_404(IgnoreEmail, id=pk)
if request.method == 'POST': if request.method == 'POST':
ignore.delete() ignore.delete()
return HttpResponseRedirect(reverse('helpdesk:email_ignore')) return HttpResponseRedirect(reverse('helpdesk:email_ignore'))

View File

@ -14,7 +14,7 @@ import os
import sys import sys
class QuickDjangoTest(object): class QuickDjangoTest:
""" """
A quick way to run the Django test suite without a fully-configured project. A quick way to run the Django test suite without a fully-configured project.
@ -78,6 +78,7 @@ class QuickDjangoTest(object):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.tests = args self.tests = args
self.kwargs = kwargs or {"verbosity": 1}
self._tests() self._tests()
def _tests(self): def _tests(self):
@ -112,7 +113,7 @@ class QuickDjangoTest(object):
) )
from django.test.runner import DiscoverRunner from django.test.runner import DiscoverRunner
test_runner = DiscoverRunner(verbosity=1) test_runner = DiscoverRunner(verbosity=self.kwargs["verbosity"])
django.setup() django.setup()
failures = test_runner.run_tests(self.tests) failures = test_runner.run_tests(self.tests)
@ -134,7 +135,8 @@ if __name__ == '__main__':
description="Run Django tests." description="Run Django tests."
) )
parser.add_argument('tests', nargs="*", type=str) parser.add_argument('tests', nargs="*", type=str)
parser.add_argument("--verbosity", "-v", nargs="?", type=int, default=1)
args = parser.parse_args() args = parser.parse_args()
if not args.tests: if not args.tests:
args.tests = ['helpdesk'] args.tests = ['helpdesk']
QuickDjangoTest(*args.tests) QuickDjangoTest(*args.tests, verbosity=args.verbosity)