""" django-helpdesk - A Django powered ticket tracker for small enterprise. (c) Copyright 2008 Jutda. All Rights Reserved. See LICENSE for details. views/staff.py - The bulk of the application - provides most business logic and renders all staff-facing views. """ from django import VERSION as DJANGO_VERSION from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.auth.decorators import user_passes_test from django.contrib.contenttypes.models import ContentType from django.urls import reverse, reverse_lazy from django.core.exceptions import ValidationError, PermissionDenied from django.db import connection from django.db.models import Q from django.http import HttpResponseRedirect, Http404, HttpResponse from django.shortcuts import render, get_object_or_404 from django.utils.dates import MONTHS_3 from django.utils.translation import ugettext as _ from django.utils.html import escape from django import forms from django.utils import timezone from django.views.generic.edit import FormView, UpdateView # For datatables serverside from django.core.cache import cache from helpdesk.lib import query_tickets_by_args from helpdesk.serializers import TicketSerializer from helpdesk.decorators import ( helpdesk_staff_member_required, helpdesk_superuser_required, is_helpdesk_staff ) from helpdesk.forms import ( TicketForm, UserSettingsForm, EmailIgnoreForm, EditTicketForm, TicketCCForm, TicketCCEmailForm, TicketCCUserForm, EditFollowUpForm, TicketDependencyForm ) from helpdesk.lib import ( query_to_dict, apply_query, safe_template_context, process_attachments, queue_template_context, ) from helpdesk.models import ( Ticket, Queue, FollowUp, TicketChange, PreSetReply, Attachment, SavedSearch, IgnoreEmail, TicketCC, TicketDependency, UserSettings, ) from helpdesk import settings as helpdesk_settings from helpdesk.views.permissions import MustBeStaffMixin from rest_framework import viewsets, status from rest_framework.response import Response from rest_framework.decorators import api_view from datetime import date, datetime, timedelta import re User = get_user_model() if helpdesk_settings.HELPDESK_ALLOW_NON_STAFF_TICKET_UPDATE: # treat 'normal' users like 'staff' staff_member_required = user_passes_test( lambda u: u.is_authenticated and u.is_active) else: staff_member_required = user_passes_test( lambda u: u.is_authenticated and u.is_active and u.is_staff) superuser_required = user_passes_test( lambda u: u.is_authenticated and u.is_active and u.is_superuser) def _get_user_queues(user): """Return the list of Queues the user can access. :param user: The User (the class should have the has_perm method) :return: A Python list of Queues """ all_queues = Queue.objects.all() limit_queues_by_user = \ helpdesk_settings.HELPDESK_ENABLE_PER_QUEUE_STAFF_PERMISSION \ and not user.is_superuser if limit_queues_by_user: id_list = [q.pk for q in all_queues if user.has_perm(q.permission_name)] return all_queues.filter(pk__in=id_list) else: return all_queues def _has_access_to_queue(user, queue): """Check if a certain user can access a certain queue. :param user: The User (the class should have the has_perm method) :param queue: The django-helpdesk Queue instance :return: True if the user has permission (either by default or explicitly), false otherwise """ if user.is_superuser or not helpdesk_settings.HELPDESK_ENABLE_PER_QUEUE_STAFF_PERMISSION: return True else: return user.has_perm(queue.permission_name) def _is_my_ticket(user, ticket): """Check to see if the user has permission to access a ticket. If not then deny access.""" if user.is_superuser or user.is_staff or user.id == ticket.assigned_to.id: return True else: return False @helpdesk_staff_member_required def dashboard(request): """ A quick summary overview for users: A list of their own tickets, a table showing ticket counts by queue/status, and a list of unassigned tickets with options for them to 'Take' ownership of said tickets. """ # open & reopened tickets, assigned to current user tickets = Ticket.objects.select_related('queue').filter( assigned_to=request.user, ).exclude( status__in=[Ticket.CLOSED_STATUS, Ticket.RESOLVED_STATUS], ) # closed & resolved tickets, assigned to current user tickets_closed_resolved = Ticket.objects.select_related('queue').filter( assigned_to=request.user, status__in=[Ticket.CLOSED_STATUS, Ticket.RESOLVED_STATUS]) user_queues = _get_user_queues(request.user) unassigned_tickets = Ticket.objects.select_related('queue').filter( assigned_to__isnull=True, queue__in=user_queues ).exclude( status=Ticket.CLOSED_STATUS, ) # all tickets, reported by current user all_tickets_reported_by_current_user = '' email_current_user = request.user.email if email_current_user: all_tickets_reported_by_current_user = Ticket.objects.select_related('queue').filter( submitter_email=email_current_user, ).order_by('status') tickets_in_queues = Ticket.objects.filter( queue__in=user_queues, ) basic_ticket_stats = calc_basic_ticket_stats(tickets_in_queues) # The following query builds a grid of queues & ticket statuses, # to be displayed to the user. EG: # Open Resolved # Queue 1 10 4 # Queue 2 4 12 queues = _get_user_queues(request.user).values_list('id', flat=True) from_clause = """FROM helpdesk_ticket t, helpdesk_queue q""" if queues: where_clause = """WHERE q.id = t.queue_id AND q.id IN (%s)""" % (",".join(("%d" % pk for pk in queues))) else: where_clause = """WHERE q.id = t.queue_id""" return render(request, 'helpdesk/dashboard.html', { 'user_tickets': tickets, 'user_tickets_closed_resolved': tickets_closed_resolved, 'unassigned_tickets': unassigned_tickets, 'all_tickets_reported_by_current_user': all_tickets_reported_by_current_user, 'basic_ticket_stats': basic_ticket_stats, }) dashboard = staff_member_required(dashboard) @helpdesk_staff_member_required def delete_ticket(request, ticket_id): ticket = get_object_or_404(Ticket, id=ticket_id) if not _has_access_to_queue(request.user, ticket.queue): raise PermissionDenied() if not _is_my_ticket(request.user, ticket): raise PermissionDenied() if request.method == 'GET': return render(request, 'helpdesk/delete_ticket.html', { 'ticket': ticket, }) else: ticket.delete() return HttpResponseRedirect(reverse('helpdesk:home')) delete_ticket = staff_member_required(delete_ticket) @helpdesk_staff_member_required def followup_edit(request, ticket_id, followup_id): """Edit followup options with an ability to change the ticket.""" followup = get_object_or_404(FollowUp, id=followup_id) ticket = get_object_or_404(Ticket, id=ticket_id) if not _has_access_to_queue(request.user, ticket.queue): raise PermissionDenied() if not _is_my_ticket(request.user, ticket): raise PermissionDenied() if request.method == 'GET': form = EditFollowUpForm(initial={ 'title': escape(followup.title), 'ticket': followup.ticket, 'comment': escape(followup.comment), 'public': followup.public, 'new_status': followup.new_status, }) ticketcc_string, show_subscribe = \ return_ticketccstring_and_show_subscribe(request.user, ticket) return render(request, 'helpdesk/followup_edit.html', { 'followup': followup, 'ticket': ticket, 'form': form, 'ticketcc_string': ticketcc_string, }) elif request.method == 'POST': form = EditFollowUpForm(request.POST) if form.is_valid(): title = form.cleaned_data['title'] _ticket = form.cleaned_data['ticket'] comment = form.cleaned_data['comment'] public = form.cleaned_data['public'] new_status = form.cleaned_data['new_status'] # will save previous date old_date = followup.date new_followup = FollowUp(title=title, date=old_date, ticket=_ticket, comment=comment, public=public, new_status=new_status, ) # keep old user if one did exist before. if followup.user: new_followup.user = followup.user new_followup.save() # get list of old attachments & link them to new_followup attachments = Attachment.objects.filter(followup=followup) for attachment in attachments: attachment.followup = new_followup attachment.save() # delete old followup followup.delete() return HttpResponseRedirect(reverse('helpdesk:view', args=[ticket.id])) followup_edit = staff_member_required(followup_edit) @helpdesk_staff_member_required def followup_delete(request, ticket_id, followup_id): """followup delete for superuser""" ticket = get_object_or_404(Ticket, id=ticket_id) if not request.user.is_superuser: return HttpResponseRedirect(reverse('helpdesk:view', args=[ticket.id])) followup = get_object_or_404(FollowUp, id=followup_id) followup.delete() return HttpResponseRedirect(reverse('helpdesk:view', args=[ticket.id])) followup_delete = staff_member_required(followup_delete) @helpdesk_staff_member_required def view_ticket(request, ticket_id): ticket = get_object_or_404(Ticket, id=ticket_id) if not _has_access_to_queue(request.user, ticket.queue): raise PermissionDenied() if not _is_my_ticket(request.user, ticket): raise PermissionDenied() if 'take' in request.GET: # Allow the user to assign the ticket to themselves whilst viewing it. # Trick the update_ticket() view into thinking it's being called with # a valid POST. request.POST = { 'owner': request.user.id, 'public': 1, 'title': ticket.title, 'comment': '' } return update_ticket(request, ticket_id) if 'subscribe' in request.GET: # Allow the user to subscribe him/herself to the ticket whilst viewing it. ticket_cc, show_subscribe = \ return_ticketccstring_and_show_subscribe(request.user, ticket) if show_subscribe: subscribe_staff_member_to_ticket(ticket, request.user) return HttpResponseRedirect(reverse('helpdesk:view', args=[ticket.id])) if 'close' in request.GET and ticket.status == Ticket.RESOLVED_STATUS: if not ticket.assigned_to: owner = 0 else: owner = ticket.assigned_to.id # Trick the update_ticket() view into thinking it's being called with # a valid POST. request.POST = { 'new_status': Ticket.CLOSED_STATUS, 'public': 1, 'owner': owner, 'title': ticket.title, 'comment': _('Accepted resolution and closed ticket'), } return update_ticket(request, ticket_id) if helpdesk_settings.HELPDESK_STAFF_ONLY_TICKET_OWNERS: users = User.objects.filter(is_active=True, is_staff=True).order_by(User.USERNAME_FIELD) else: users = User.objects.filter(is_active=True).order_by(User.USERNAME_FIELD) # TODO: shouldn't this template get a form to begin with? form = TicketForm(initial={'due_date': ticket.due_date}) ticketcc_string, show_subscribe = \ return_ticketccstring_and_show_subscribe(request.user, ticket) submitter_userprofile = ticket.get_submitter_userprofile() if submitter_userprofile is not None: content_type = ContentType.objects.get_for_model(submitter_userprofile) submitter_userprofile_url = reverse( 'admin:{app}_{model}_change'.format(app=content_type.app_label, model=content_type.model), kwargs={'object_id': submitter_userprofile.id} ) else: submitter_userprofile_url = None return render(request, 'helpdesk/ticket.html', { 'ticket': ticket, 'submitter_userprofile_url': submitter_userprofile_url, 'form': form, 'active_users': users, 'priorities': Ticket.PRIORITY_CHOICES, 'preset_replies': PreSetReply.objects.filter( Q(queues=ticket.queue) | Q(queues__isnull=True)), 'ticketcc_string': ticketcc_string, 'SHOW_SUBSCRIBE': show_subscribe, }) view_ticket = staff_member_required(view_ticket) def return_ticketccstring_and_show_subscribe(user, ticket): """used in view_ticket() and followup_edit()""" # create the ticketcc_string and check whether current user is already # subscribed username = user.get_username().upper() useremail = user.email.upper() strings_to_check = list() strings_to_check.append(username) strings_to_check.append(useremail) ticketcc_string = '' all_ticketcc = ticket.ticketcc_set.all() counter_all_ticketcc = len(all_ticketcc) - 1 show_subscribe = True for i, ticketcc in enumerate(all_ticketcc): ticketcc_this_entry = str(ticketcc.display) ticketcc_string += ticketcc_this_entry if i < counter_all_ticketcc: ticketcc_string += ', ' if strings_to_check.__contains__(ticketcc_this_entry.upper()): show_subscribe = False # check whether current user is a submitter or assigned to ticket assignedto_username = str(ticket.assigned_to).upper() strings_to_check = list() if ticket.submitter_email is not None: submitter_email = ticket.submitter_email.upper() strings_to_check.append(submitter_email) strings_to_check.append(assignedto_username) if strings_to_check.__contains__(username) or strings_to_check.__contains__(useremail): show_subscribe = False return ticketcc_string, show_subscribe def subscribe_staff_member_to_ticket(ticket, user): """used in view_ticket() and update_ticket()""" ticketcc = TicketCC( ticket=ticket, user=user, can_view=True, can_update=True, ) ticketcc.save() def update_ticket(request, ticket_id, public=False): if not (public or ( request.user.is_authenticated and request.user.is_active and ( is_helpdesk_staff(request.user) or helpdesk_settings.HELPDESK_ALLOW_NON_STAFF_TICKET_UPDATE))): return HttpResponseRedirect('%s?next=%s' % (reverse('helpdesk:login'), request.path)) ticket = get_object_or_404(Ticket, id=ticket_id) date_re = re.compile( r'(?P\d{1,2})/(?P\d{1,2})/(?P\d{4})$' ) 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)) 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)) # 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 if due_date is not None: # based on Django code to parse dates: # https://docs.djangoproject.com/en/2.0/_modules/django/utils/dateparse/ match = date_re.match(due_date) if match: kw = {k: int(v) for k, v in match.groupdict().items()} due_date = date(**kw) else: # old way, probably deprecated? if not (due_date_year and due_date_month and due_date_day): due_date = ticket.due_date else: # NOTE: must be an easier way to create a new date than doing it this way? if ticket.due_date: due_date = ticket.due_date else: due_date = timezone.now() due_date = due_date.replace(due_date_year, due_date_month, due_date_day) no_changes = all([ not request.FILES, not comment, new_status == ticket.status, title == ticket.title, priority == int(ticket.priority), due_date == ticket.due_date, (owner == -1) or (not owner and not ticket.assigned_to) or (owner and User.objects.get(id=owner) == ticket.assigned_to), ]) if no_changes: return return_to_ticket(request.user, helpdesk_settings, ticket) # We need to allow the 'ticket' and 'queue' contexts to be applied to the # comment. context = safe_template_context(ticket) from django.template import engines template_func = engines['django'].from_string # this prevents system from trying to render any template tags # broken into two stages to prevent changes from first replace being themselves # changed by the second replace due to conflicting syntax comment = comment.replace('{%', 'X-HELPDESK-COMMENT-VERBATIM').replace('%}', 'X-HELPDESK-COMMENT-ENDVERBATIM') comment = comment.replace('X-HELPDESK-COMMENT-VERBATIM', '{% verbatim %}{%').replace('X-HELPDESK-COMMENT-ENDVERBATIM', '%}{% endverbatim %}') # render the neutralized template comment = template_func(comment).render(context) if owner is -1 and ticket.assigned_to: owner = ticket.assigned_to.id f = FollowUp(ticket=ticket, date=timezone.now(), comment=comment) if is_helpdesk_staff(request.user): f.user = request.user f.public = public reassigned = False old_owner = ticket.assigned_to if owner is not -1: if owner != 0 and ((ticket.assigned_to and owner != ticket.assigned_to.id) or not ticket.assigned_to): new_user = User.objects.get(id=owner) f.title = _('Assigned to %(username)s') % { 'username': new_user.get_username(), } ticket.assigned_to = new_user reassigned = True # user changed owner to 'unassign' elif owner == 0 and ticket.assigned_to is not None: f.title = _('Unassigned') ticket.assigned_to = None old_status_str = ticket.get_status_display() old_status = ticket.status if new_status != ticket.status: ticket.status = new_status ticket.save() f.new_status = new_status ticket_status_changed = True if f.title: f.title += ' and %s' % ticket.get_status_display() else: f.title = '%s' % ticket.get_status_display() if not f.title: 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: c = TicketChange( followup=f, field=_('Title'), old_value=ticket.title, new_value=title, ) c.save() ticket.title = title if new_status != old_status: c = TicketChange( followup=f, field=_('Status'), old_value=old_status_str, new_value=ticket.get_status_display(), ) c.save() if ticket.assigned_to != old_owner: c = TicketChange( followup=f, field=_('Owner'), old_value=old_owner, new_value=ticket.assigned_to, ) c.save() if priority != ticket.priority: c = TicketChange( followup=f, field=_('Priority'), old_value=ticket.priority, new_value=priority, ) c.save() ticket.priority = priority if due_date != ticket.due_date: c = TicketChange( followup=f, field=_('Due on'), old_value=ticket.due_date, new_value=due_date, ) c.save() ticket.due_date = due_date if new_status in (Ticket.RESOLVED_STATUS, Ticket.CLOSED_STATUS): if new_status == Ticket.RESOLVED_STATUS or ticket.resolution is None: ticket.resolution = comment # ticket might have changed above, so we re-instantiate context with the # (possibly) updated ticket. context = safe_template_context(ticket) context.update( resolution=ticket.resolution, comment=f.comment, ) messages_sent_to = set() try: messages_sent_to.add(request.user.email) except AttributeError: pass if public and (f.comment or ( f.new_status in (Ticket.RESOLVED_STATUS, Ticket.CLOSED_STATUS))): if f.new_status == Ticket.RESOLVED_STATUS: template = 'resolved_' elif f.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,)) 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' if ticket.assigned_to and (ticket.assigned_to.usersettings_helpdesk.email_on_ticket_change or (reassigned and ticket.assigned_to.usersettings_helpdesk.email_on_ticket_assigned)): messages_sent_to.update(ticket.send( {'assigned_to': (template_staff, context)}, dont_send_to=messages_sent_to, fail_silently=True, 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( {'ticket_cc': (template_cc, context)}, dont_send_to=messages_sent_to, fail_silently=True, files=files, )) ticket.save() # auto subscribe user if enabled if helpdesk_settings.HELPDESK_AUTO_SUBSCRIBE_ON_TICKET_RESPONSE and request.user.is_authenticated: 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) def return_to_ticket(user, helpdesk_settings, ticket): """Helper function for update_ticket""" if is_helpdesk_staff(user): return HttpResponseRedirect(ticket.get_absolute_url()) else: return HttpResponseRedirect(ticket.ticket_url) @helpdesk_staff_member_required def mass_update(request): tickets = request.POST.getlist('ticket_id') action = request.POST.get('action', None) if not (tickets and action): return HttpResponseRedirect(reverse('helpdesk:list')) if action.startswith('assign_'): parts = action.split('_') user = User.objects.get(id=parts[1]) action = 'assign' elif action == 'take': user = request.user action = 'assign' for t in Ticket.objects.filter(id__in=tickets): if not _has_access_to_queue(request.user, t.queue): continue if action == 'assign' and t.assigned_to != user: t.assigned_to = user t.save() f = FollowUp(ticket=t, date=timezone.now(), title=_('Assigned to %(username)s in bulk update' % { 'username': user.get_username() }), public=True, user=request.user) f.save() elif action == 'unassign' and t.assigned_to is not None: t.assigned_to = None t.save() f = FollowUp(ticket=t, date=timezone.now(), title=_('Unassigned in bulk update'), public=True, user=request.user) f.save() elif action == 'close' and t.status != Ticket.CLOSED_STATUS: t.status = Ticket.CLOSED_STATUS t.save() f = FollowUp(ticket=t, date=timezone.now(), title=_('Closed in bulk update'), public=False, user=request.user, new_status=Ticket.CLOSED_STATUS) f.save() elif action == 'close_public' and t.status != Ticket.CLOSED_STATUS: t.status = Ticket.CLOSED_STATUS t.save() f = FollowUp(ticket=t, date=timezone.now(), title=_('Closed in bulk update'), public=True, user=request.user, new_status=Ticket.CLOSED_STATUS) f.save() # Send email to Submitter, Owner, Queue CC context = safe_template_context(t) context.update(resolution=t.resolution, queue=queue_template_context(t.queue)) messages_sent_to = set() try: messages_sent_to.add(request.user.email) except AttributeError: pass roles = { 'submitter': ('closed_submitter', context), 'ticket_cc': ('closed_cc', context), } if ticket.assigned_to and ticket.assigned_to.usersettings_helpdesk.email_on_ticket_change: roles['assigned_to'] = ('closed_owner', context), messages_sent_to.update(t.send( roles, dont_send_to=messages_sent_to, fail_silently=True, )) elif action == 'delete': t.delete() return HttpResponseRedirect(reverse('helpdesk:list')) mass_update = staff_member_required(mass_update) @helpdesk_staff_member_required def ticket_list(request): context = {} user_queues = _get_user_queues(request.user) # Prefilter the allowed tickets base_tickets = Ticket.objects.filter(queue__in=user_queues) # Query_params will hold a dictionary of parameters relating to # a query, to be saved if needed: query_params = { 'filtering': {}, 'sorting': None, 'sortreverse': False, 'keyword': None, 'search_string': None, } from_saved_query = False # 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 = base_tickets.get(**filter) return HttpResponseRedirect(ticket.staff_url) except Ticket.DoesNotExist: # Go on to standard keyword searching pass saved_query = None if request.GET.get('saved_query', None): from_saved_query = True try: saved_query = SavedSearch.objects.get(pk=request.GET.get('saved_query')) except SavedSearch.DoesNotExist: return HttpResponseRedirect(reverse('helpdesk:list')) if not (saved_query.shared or saved_query.user == request.user): return HttpResponseRedirect(reverse('helpdesk:list')) import json from helpdesk.lib import query_from_base64 try: # we get a string like: b'stuff' # so leave of the first two chars (b') and last (') b64query = saved_query.query[2:-1] query_params = query_from_base64(b64query) except ValueError: # Query deserialization failed. (E.g. was a pickled query) return HttpResponseRedirect(reverse('helpdesk:list')) elif not ('queue' in request.GET or 'assigned_to' in request.GET or 'status' in request.GET or 'q' in request.GET or 'sort' in request.GET or 'sortreverse' in request.GET): # Fall-back if no querying is being done, force the list to only # show open/reopened/resolved (not closed) cases sorted by creation # date. query_params = { 'filtering': {'status__in': [1, 2, 3]}, 'sorting': 'created', } else: queues = request.GET.getlist('queue') if queues: try: queues = [int(q) for q in queues] query_params['filtering']['queue__id__in'] = queues except ValueError: pass owners = request.GET.getlist('assigned_to') if owners: try: owners = [int(u) for u in owners] query_params['filtering']['assigned_to__id__in'] = owners except ValueError: pass statuses = request.GET.getlist('status') if statuses: try: statuses = [int(s) for s in statuses] query_params['filtering']['status__in'] = statuses except ValueError: pass date_from = request.GET.get('date_from') if date_from: query_params['filtering']['created__gte'] = date_from date_to = request.GET.get('date_to') if date_to: query_params['filtering']['created__lte'] = date_to # KEYWORD SEARCHING q = request.GET.get('q', None) if q: context = dict(context, query=q) query_params['search_string'] = q # SORTING sort = request.GET.get('sort', None) if sort not in ('status', 'assigned_to', 'created', 'title', 'queue', 'priority'): sort = 'created' query_params['sorting'] = sort sortreverse = request.GET.get('sortreverse', None) query_params['sortreverse'] = sortreverse tickets = base_tickets.select_related() try: ticket_qs = apply_query(tickets, query_params) except ValidationError: # invalid parameters in query, return default query query_params = { 'filtering': {'status__in': [1, 2, 3]}, 'sorting': 'created', } ticket_qs = apply_query(tickets, query_params) search_message = '' if 'query' in context and settings.DATABASES['default']['ENGINE'].endswith('sqlite'): search_message = _( '

Note: Your keyword search is case sensitive ' 'because of your database. This means the search will not ' 'be accurate. By switching to a different database system you will gain ' 'better searching! For more information, read the ' '' 'Django Documentation on string matching in SQLite.') import json from helpdesk.lib import query_to_base64 urlsafe_query = query_to_base64(query_params) user_saved_queries = SavedSearch.objects.filter(Q(user=request.user) | Q(shared__exact=True)) # Serverside processing on datatables is optional. Set # HELPDESK_USE_SERVERSIDE_PROCESSING to False in settings.py to disable if helpdesk_settings.HELPDESK_USE_SERVERSIDE_PROCESSING: cache.set('ticket_qs', ticket_qs) context['server_side'] = True else: context['server_side'] = False return render(request, 'helpdesk/ticket_list.html', dict( context, tickets=ticket_qs, default_tickets_per_page=request.user.usersettings_helpdesk.tickets_per_page, user_choices=User.objects.filter(is_active=True, is_staff=True), queue_choices=user_queues, status_choices=Ticket.STATUS_CHOICES, urlsafe_query=urlsafe_query, user_saved_queries=user_saved_queries, query_params=query_params, from_saved_query=from_saved_query, saved_query=saved_query, search_message=search_message, )) ticket_list = staff_member_required(ticket_list) @helpdesk_staff_member_required @api_view(['GET', 'POST']) def datatables_ticket_list(request): """ Datatable on ticket_list.html uses this view from to get objects to display on the table. query_tickets_by_args is at lib.py, TicketSerializer is in serializers.py. The serializers and this view use django-rest_framework methods """ try: model_object = query_tickets_by_args(cache.get('ticket_qs'), '-date_created', **request.query_params) serializer = TicketSerializer(model_object['items'], many=True) result = dict() result['data'] = serializer.data result['draw'] = model_object['draw'] result['recordsTotal'] = model_object['total'] result['recordsFiltered'] = model_object['count'] return (Response(result, status=status.HTTP_200_OK, template_name=None, content_type=None)) except TypeError as e: return (Response(e, status=status.HTTP_404_NOT_FOUND, template_name=None, content_type=None)) @helpdesk_staff_member_required def edit_ticket(request, ticket_id): ticket = get_object_or_404(Ticket, id=ticket_id) if not _has_access_to_queue(request.user, ticket.queue): raise PermissionDenied() if not _is_my_ticket(request.user, ticket): raise PermissionDenied() if request.method == 'POST': form = EditTicketForm(request.POST, instance=ticket) if form.is_valid(): ticket = form.save() return HttpResponseRedirect(ticket.get_absolute_url()) else: form = EditTicketForm(instance=ticket) return render(request, 'helpdesk/edit_ticket.html', {'form': form, 'ticket': ticket}) edit_ticket = staff_member_required(edit_ticket) class CreateTicketView(MustBeStaffMixin, FormView): template_name = 'helpdesk/create_ticket.html' form_class = TicketForm def get_initial(self): initial_data = {} request = self.request if request.user.usersettings_helpdesk.use_email_as_submitter and request.user.email: initial_data['submitter_email'] = request.user.email if 'queue' in request.GET: initial_data['queue'] = request.GET['queue'] return initial_data def form_valid(self, form): self.ticket = form.save() return super().form_valid(form) def get_success_url(self): request = self.request if _has_access_to_queue(request.user, self.ticket.queue): return self.ticket.get_absolute_url() else: return reverse('helpdesk:dashboard') @helpdesk_staff_member_required def raw_details(request, type): # TODO: This currently only supports spewing out 'PreSetReply' objects, # in the future it needs to be expanded to include other items. All it # does is return a plain-text representation of an object. if type not in ('preset',): raise Http404 if type == 'preset' and request.GET.get('id', False): try: preset = PreSetReply.objects.get(id=request.GET.get('id')) return HttpResponse(preset.body) except PreSetReply.DoesNotExist: raise Http404 raise Http404 raw_details = staff_member_required(raw_details) @helpdesk_staff_member_required def hold_ticket(request, ticket_id, unhold=False): ticket = get_object_or_404(Ticket, id=ticket_id) if not _has_access_to_queue(request.user, ticket.queue): raise PermissionDenied() if not _is_my_ticket(request.user, ticket): raise PermissionDenied() if unhold: ticket.on_hold = False title = _('Ticket taken off hold') else: ticket.on_hold = True title = _('Ticket placed on hold') f = FollowUp( ticket=ticket, user=request.user, title=title, date=timezone.now(), public=True, ) f.save() ticket.save() return HttpResponseRedirect(ticket.get_absolute_url()) hold_ticket = staff_member_required(hold_ticket) @helpdesk_staff_member_required def unhold_ticket(request, ticket_id): return hold_ticket(request, ticket_id, unhold=True) unhold_ticket = staff_member_required(unhold_ticket) @helpdesk_staff_member_required def rss_list(request): return render(request, 'helpdesk/rss_list.html', {'queues': Queue.objects.all()}) rss_list = staff_member_required(rss_list) @helpdesk_staff_member_required def report_index(request): number_tickets = Ticket.objects.all().count() saved_query = request.GET.get('saved_query', None) user_queues = _get_user_queues(request.user) Tickets = Ticket.objects.filter(queue__in=user_queues) basic_ticket_stats = calc_basic_ticket_stats(Tickets) # The following query builds a grid of queues & ticket statuses, # to be displayed to the user. EG: # Open Resolved # Queue 1 10 4 # Queue 2 4 12 Queues = user_queues if user_queues else Queue.objects.all() dash_tickets = [] for queue in Queues: dash_ticket = { 'queue': queue.id, 'name': queue.title, 'open': queue.ticket_set.filter(status__in=[1, 2]).count(), 'resolved': queue.ticket_set.filter(status=3).count(), 'closed': queue.ticket_set.filter(status=4).count(), } dash_tickets.append(dash_ticket) return render(request, 'helpdesk/report_index.html', { 'number_tickets': number_tickets, 'saved_query': saved_query, 'basic_ticket_stats': basic_ticket_stats, 'dash_tickets': dash_tickets, }) report_index = staff_member_required(report_index) @helpdesk_staff_member_required def run_report(request, report): if Ticket.objects.all().count() == 0 or report not in ( 'queuemonth', 'usermonth', 'queuestatus', 'queuepriority', 'userstatus', 'userpriority', 'userqueue', 'daysuntilticketclosedbymonth'): return HttpResponseRedirect(reverse("helpdesk:report_index")) report_queryset = Ticket.objects.all().select_related().filter( queue__in=_get_user_queues(request.user) ) from_saved_query = False saved_query = None if request.GET.get('saved_query', None): from_saved_query = True try: saved_query = SavedSearch.objects.get(pk=request.GET.get('saved_query')) except SavedSearch.DoesNotExist: return HttpResponseRedirect(reverse('helpdesk:report_index')) if not (saved_query.shared or saved_query.user == request.user): return HttpResponseRedirect(reverse('helpdesk:report_index')) import json from helpdesk.lib import query_from_base64 try: # we get a string like: b'stuff' # so leave of the first two chars (b') and last (') b64query = saved_query.query[2:-1] query_params = query_from_base64(b64query) except json.JSONDecodeError: return HttpResponseRedirect(reverse('helpdesk:report_index')) report_queryset = apply_query(report_queryset, query_params) from collections import defaultdict summarytable = defaultdict(int) # a second table for more complex queries summarytable2 = defaultdict(int) def month_name(m): MONTHS_3[m].title() first_ticket = Ticket.objects.all().order_by('created')[0] first_month = first_ticket.created.month first_year = first_ticket.created.year last_ticket = Ticket.objects.all().order_by('-created')[0] last_month = last_ticket.created.month last_year = last_ticket.created.year periods = [] year, month = first_year, first_month working = True periods.append("%s-%s" % (year, month)) while working: month += 1 if month > 12: year += 1 month = 1 if (year > last_year) or (month > last_month and year >= last_year): working = False periods.append("%s-%s" % (year, month)) if report == 'userpriority': title = _('User by Priority') col1heading = _('User') possible_options = [t[1].title() for t in Ticket.PRIORITY_CHOICES] charttype = 'bar' elif report == 'userqueue': title = _('User by Queue') col1heading = _('User') queue_options = _get_user_queues(request.user) possible_options = [q.title for q in queue_options] charttype = 'bar' elif report == 'userstatus': title = _('User by Status') col1heading = _('User') possible_options = [s[1].title() for s in Ticket.STATUS_CHOICES] charttype = 'bar' elif report == 'usermonth': title = _('User by Month') col1heading = _('User') possible_options = periods charttype = 'date' elif report == 'queuepriority': title = _('Queue by Priority') col1heading = _('Queue') possible_options = [t[1].title() for t in Ticket.PRIORITY_CHOICES] charttype = 'bar' elif report == 'queuestatus': title = _('Queue by Status') col1heading = _('Queue') possible_options = [s[1].title() for s in Ticket.STATUS_CHOICES] charttype = 'bar' elif report == 'queuemonth': title = _('Queue by Month') col1heading = _('Queue') possible_options = periods charttype = 'date' elif report == 'daysuntilticketclosedbymonth': title = _('Days until ticket closed by Month') col1heading = _('Queue') possible_options = periods charttype = 'date' 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': for key in summarytable2.keys(): summarytable[key] = summarytable2[key] / summarytable[key] header1 = sorted(set(list(i for i, _ in summarytable.keys()))) column_headings = [col1heading] + possible_options # Pivot the data so that 'header1' fields are always first column # in the row, and 'possible_options' are always the 2nd - nth columns. for item in header1: data = [] for hdr in possible_options: data.append(summarytable[item, hdr]) table.append([item] + data) # Zip data and headers together in one list for Morris.js charts # will get a list like [(Header1, Data1), (Header2, Data2)...] seriesnum = 0 morrisjs_data = [] for label in column_headings[1:]: seriesnum += 1 datadict = {"x": label} for n in range(0, len(table)): datadict[n] = table[n][seriesnum] morrisjs_data.append(datadict) series_names = [] for series in table: series_names.append(series[0]) return render(request, 'helpdesk/report_output.html', { 'title': title, 'charttype': charttype, 'data': table, 'headings': column_headings, 'series_names': series_names, 'morrisjs_data': morrisjs_data, 'from_saved_query': from_saved_query, 'saved_query': saved_query, }) run_report = staff_member_required(run_report) @helpdesk_staff_member_required def save_query(request): title = request.POST.get('title', None) shared = request.POST.get('shared', False) if shared == 'on': # django only translates '1', 'true', 't' into True shared = True query_encoded = request.POST.get('query_encoded', None) if not title or not query_encoded: return HttpResponseRedirect(reverse('helpdesk:list')) query = SavedSearch(title=title, shared=shared, query=query_encoded, user=request.user) query.save() return HttpResponseRedirect('%s?saved_query=%s' % (reverse('helpdesk:list'), query.id)) save_query = staff_member_required(save_query) @helpdesk_staff_member_required def delete_saved_query(request, id): query = get_object_or_404(SavedSearch, id=id, user=request.user) if request.method == 'POST': query.delete() return HttpResponseRedirect(reverse('helpdesk:list')) else: return render(request, 'helpdesk/confirm_delete_saved_query.html', {'query': query}) delete_saved_query = staff_member_required(delete_saved_query) class EditUserSettingsView(MustBeStaffMixin, UpdateView): template_name = 'helpdesk/user_settings.html' form_class = UserSettingsForm model = UserSettings success_url = reverse_lazy('helpdesk:dashboard') def get_object(self): return UserSettings.objects.get_or_create(user=self.request.user)[0] @helpdesk_superuser_required def email_ignore(request): return render(request, 'helpdesk/email_ignore_list.html', { 'ignore_list': IgnoreEmail.objects.all(), }) email_ignore = superuser_required(email_ignore) @helpdesk_superuser_required def email_ignore_add(request): if request.method == 'POST': form = EmailIgnoreForm(request.POST) if form.is_valid(): form.save() return HttpResponseRedirect(reverse('helpdesk:email_ignore')) else: form = EmailIgnoreForm(request.GET) return render(request, 'helpdesk/email_ignore_add.html', {'form': form}) email_ignore_add = superuser_required(email_ignore_add) @helpdesk_superuser_required def email_ignore_del(request, id): ignore = get_object_or_404(IgnoreEmail, id=id) if request.method == 'POST': ignore.delete() return HttpResponseRedirect(reverse('helpdesk:email_ignore')) else: return render(request, 'helpdesk/email_ignore_del.html', {'ignore': ignore}) email_ignore_del = superuser_required(email_ignore_del) @helpdesk_staff_member_required def ticket_cc(request, ticket_id): ticket = get_object_or_404(Ticket, id=ticket_id) if not _has_access_to_queue(request.user, ticket.queue): raise PermissionDenied() if not _is_my_ticket(request.user, ticket): raise PermissionDenied() copies_to = ticket.ticketcc_set.all() return render(request, 'helpdesk/ticket_cc_list.html', { 'copies_to': copies_to, 'ticket': ticket, }) ticket_cc = staff_member_required(ticket_cc) @helpdesk_staff_member_required def ticket_cc_add(request, ticket_id): ticket = get_object_or_404(Ticket, id=ticket_id) if not _has_access_to_queue(request.user, ticket.queue): raise PermissionDenied() if not _is_my_ticket(request.user, ticket): raise PermissionDenied() if request.method == 'POST': form = TicketCCForm(request.POST) if form.is_valid(): ticketcc = form.save(commit=False) ticketcc.ticket = ticket ticketcc.save() return HttpResponseRedirect(reverse('helpdesk:ticket_cc', kwargs={'ticket_id': ticket.id})) else: form_email = TicketCCEmailForm() form_user = TicketCCUserForm() return render(request, 'helpdesk/ticket_cc_add.html', { 'ticket': ticket, 'form_email': form_email, 'form_user': form_user, }) ticket_cc_add = staff_member_required(ticket_cc_add) @helpdesk_staff_member_required def ticket_cc_del(request, ticket_id, cc_id): cc = get_object_or_404(TicketCC, ticket__id=ticket_id, id=cc_id) if request.method == 'POST': cc.delete() return HttpResponseRedirect(reverse('helpdesk:ticket_cc', kwargs={'ticket_id': cc.ticket.id})) return render(request, 'helpdesk/ticket_cc_del.html', {'cc': cc}) ticket_cc_del = staff_member_required(ticket_cc_del) @helpdesk_staff_member_required def ticket_dependency_add(request, ticket_id): ticket = get_object_or_404(Ticket, id=ticket_id) if not _has_access_to_queue(request.user, ticket.queue): raise PermissionDenied() if not _is_my_ticket(request.user, ticket): raise PermissionDenied() if request.method == 'POST': form = TicketDependencyForm(request.POST) if form.is_valid(): ticketdependency = form.save(commit=False) ticketdependency.ticket = ticket if ticketdependency.ticket != ticketdependency.depends_on: ticketdependency.save() return HttpResponseRedirect(reverse('helpdesk:view', args=[ticket.id])) else: form = TicketDependencyForm() return render(request, 'helpdesk/ticket_dependency_add.html', { 'ticket': ticket, 'form': form, }) ticket_dependency_add = staff_member_required(ticket_dependency_add) @helpdesk_staff_member_required def ticket_dependency_del(request, ticket_id, dependency_id): dependency = get_object_or_404(TicketDependency, ticket__id=ticket_id, id=dependency_id) if request.method == 'POST': dependency.delete() return HttpResponseRedirect(reverse('helpdesk:view', args=[ticket_id])) return render(request, 'helpdesk/ticket_dependency_del.html', {'dependency': dependency}) ticket_dependency_del = staff_member_required(ticket_dependency_del) @helpdesk_staff_member_required def attachment_del(request, ticket_id, attachment_id): ticket = get_object_or_404(Ticket, id=ticket_id) if not _has_access_to_queue(request.user, ticket.queue): raise PermissionDenied() if not _is_my_ticket(request.user, ticket): raise PermissionDenied() attachment = get_object_or_404(Attachment, id=attachment_id) if request.method == 'POST': attachment.delete() return HttpResponseRedirect(reverse('helpdesk:view', args=[ticket_id])) return render(request, 'helpdesk/ticket_attachment_del.html', { 'attachment': attachment, 'filename': attachment.filename, }) attachment_del = staff_member_required(attachment_del) def calc_average_nbr_days_until_ticket_resolved(Tickets): nbr_closed_tickets = len(Tickets) days_per_ticket = 0 days_each_ticket = list() for ticket in Tickets: time_ticket_open = ticket.modified - ticket.created days_this_ticket = time_ticket_open.days days_per_ticket += days_this_ticket days_each_ticket.append(days_this_ticket) if nbr_closed_tickets > 0: mean_per_ticket = days_per_ticket / nbr_closed_tickets else: mean_per_ticket = 0 return mean_per_ticket def calc_basic_ticket_stats(Tickets): # all not closed tickets (open, reopened, resolved,) - independent of user all_open_tickets = Tickets.exclude(status=Ticket.CLOSED_STATUS) today = datetime.today() date_30 = date_rel_to_today(today, 30) date_60 = date_rel_to_today(today, 60) date_30_str = date_30.strftime('%Y-%m-%d') date_60_str = date_60.strftime('%Y-%m-%d') # > 0 & <= 30 ota_le_30 = all_open_tickets.filter(created__gte=date_30_str) N_ota_le_30 = len(ota_le_30) # >= 30 & <= 60 ota_le_60_ge_30 = all_open_tickets.filter(created__gte=date_60_str, created__lte=date_30_str) N_ota_le_60_ge_30 = len(ota_le_60_ge_30) # >= 60 ota_ge_60 = all_open_tickets.filter(created__lte=date_60_str) N_ota_ge_60 = len(ota_ge_60) # (O)pen (T)icket (S)tats ots = list() # label, number entries, color, sort_string ots.append(['Tickets < 30 days', N_ota_le_30, 'success', sort_string(date_30_str, ''), ]) ots.append(['Tickets 30 - 60 days', N_ota_le_60_ge_30, 'success' if N_ota_le_60_ge_30 == 0 else 'warning', sort_string(date_60_str, date_30_str), ]) ots.append(['Tickets > 60 days', N_ota_ge_60, 'success' if N_ota_ge_60 == 0 else 'danger', sort_string('', date_60_str), ]) # all closed tickets - independent of user. all_closed_tickets = Tickets.filter(status=Ticket.CLOSED_STATUS) average_nbr_days_until_ticket_closed = \ calc_average_nbr_days_until_ticket_resolved(all_closed_tickets) # all closed tickets that were opened in the last 60 days. all_closed_last_60_days = all_closed_tickets.filter(created__gte=date_60_str) average_nbr_days_until_ticket_closed_last_60_days = \ calc_average_nbr_days_until_ticket_resolved(all_closed_last_60_days) # put together basic stats basic_ticket_stats = { 'average_nbr_days_until_ticket_closed': average_nbr_days_until_ticket_closed, 'average_nbr_days_until_ticket_closed_last_60_days': average_nbr_days_until_ticket_closed_last_60_days, 'open_ticket_stats': ots, } return basic_ticket_stats def get_color_for_nbr_days(nbr_days): if nbr_days < 5: color_string = 'green' elif nbr_days < 10: color_string = 'orange' else: # more than 10 days color_string = 'red' return color_string def days_since_created(today, ticket): return (today - ticket.created).days def date_rel_to_today(today, offset): return today - timedelta(days=offset) def sort_string(begin, end): return 'sort=created&date_from=%s&date_to=%s&status=%s&status=%s&status=%s' % ( begin, end, Ticket.OPEN_STATUS, Ticket.REOPENED_STATUS, Ticket.RESOLVED_STATUS)