forked from extern/django-helpdesk
309 lines
9.5 KiB
Python
309 lines
9.5 KiB
Python
"""
|
|
django-helpdesk - A Django powered ticket tracker for small enterprise.
|
|
|
|
(c) Copyright 2008 Jutda. All Rights Reserved. See LICENSE for details.
|
|
|
|
lib.py - Common functions (eg multipart e-mail)
|
|
"""
|
|
|
|
import logging
|
|
import mimetypes
|
|
import os
|
|
|
|
from django.conf import settings
|
|
from django.db.models import Q
|
|
from django.utils.encoding import smart_text, smart_str
|
|
from django.utils.safestring import mark_safe
|
|
|
|
from helpdesk.models import FollowUpAttachment, EmailTemplate
|
|
|
|
from model_utils import Choices
|
|
|
|
from base64 import b64encode
|
|
from base64 import b64decode
|
|
|
|
import json
|
|
|
|
logger = logging.getLogger('helpdesk')
|
|
|
|
|
|
def query_to_base64(query):
|
|
"""
|
|
Converts a query dict object to a base64-encoded bytes object.
|
|
"""
|
|
return b64encode(json.dumps(query).encode('UTF-8'))
|
|
|
|
|
|
def query_from_base64(b64data):
|
|
"""
|
|
Converts base64-encoded bytes object back to a query dict object.
|
|
"""
|
|
return json.loads(b64decode(b64data).decode('utf-8'))
|
|
|
|
|
|
def query_to_dict(results, descriptions):
|
|
"""
|
|
Replacement method for cursor.dictfetchall() as that method no longer
|
|
exists in psycopg2, and I'm guessing in other backends too.
|
|
|
|
Converts the results of a raw SQL query into a list of dictionaries, suitable
|
|
for use in templates etc.
|
|
"""
|
|
|
|
output = []
|
|
for data in results:
|
|
row = {}
|
|
i = 0
|
|
for column in descriptions:
|
|
row[column[0]] = data[i]
|
|
i += 1
|
|
|
|
output.append(row)
|
|
return output
|
|
|
|
|
|
def apply_query(queryset, params):
|
|
"""
|
|
Apply a dict-based set of filters & parameters to a queryset.
|
|
|
|
queryset is a Django queryset, eg MyModel.objects.all() or
|
|
MyModel.objects.filter(user=request.user)
|
|
|
|
params is a dictionary that contains the following:
|
|
filtering: A dict of Django ORM filters, eg:
|
|
{'user__id__in': [1, 3, 103], 'title__contains': 'foo'}
|
|
|
|
search_string: A freetext search string
|
|
|
|
sorting: The name of the column to sort by
|
|
"""
|
|
for key in params['filtering'].keys():
|
|
filter = {key: params['filtering'][key]}
|
|
queryset = queryset.filter(**filter)
|
|
|
|
search = params.get('search_string', None)
|
|
if search:
|
|
qset = (
|
|
Q(title__icontains=search) |
|
|
Q(description__icontains=search) |
|
|
Q(resolution__icontains=search) |
|
|
Q(submitter_email__icontains=search)
|
|
)
|
|
|
|
queryset = queryset.filter(qset)
|
|
|
|
sorting = params.get('sorting', None)
|
|
if sorting:
|
|
sortreverse = params.get('sortreverse', None)
|
|
if sortreverse:
|
|
sorting = "-%s" % sorting
|
|
queryset = queryset.order_by(sorting)
|
|
|
|
return queryset
|
|
|
|
|
|
def ticket_template_context(ticket):
|
|
context = {}
|
|
|
|
for field in ('title', 'created', 'modified', 'submitter_email',
|
|
'status', 'get_status_display', 'on_hold', 'description',
|
|
'resolution', 'priority', 'get_priority_display',
|
|
'last_escalation', 'ticket', 'ticket_for_url',
|
|
'get_status', 'ticket_url', 'staff_url', '_get_assigned_to'
|
|
):
|
|
attr = getattr(ticket, field, None)
|
|
if callable(attr):
|
|
context[field] = '%s' % attr()
|
|
else:
|
|
context[field] = attr
|
|
context['assigned_to'] = context['_get_assigned_to']
|
|
|
|
return context
|
|
|
|
|
|
def queue_template_context(queue):
|
|
context = {}
|
|
|
|
for field in ('title', 'slug', 'email_address', 'from_address', 'locale'):
|
|
attr = getattr(queue, field, None)
|
|
if callable(attr):
|
|
context[field] = attr()
|
|
else:
|
|
context[field] = attr
|
|
|
|
return context
|
|
|
|
|
|
def safe_template_context(ticket):
|
|
"""
|
|
Return a dictionary that can be used as a template context to render
|
|
comments and other details with ticket or queue parameters. Note that
|
|
we don't just provide the Ticket & Queue objects to the template as
|
|
they could reveal confidential information. Just imagine these two options:
|
|
* {{ ticket.queue.email_box_password }}
|
|
* {{ ticket.assigned_to.password }}
|
|
|
|
Ouch!
|
|
|
|
The downside to this is that if we make changes to the model, we will also
|
|
have to update this code. Perhaps we can find a better way in the future.
|
|
"""
|
|
|
|
context = {
|
|
'queue': queue_template_context(ticket.queue),
|
|
'ticket': ticket_template_context(ticket),
|
|
}
|
|
context['ticket']['queue'] = context['queue']
|
|
|
|
return context
|
|
|
|
|
|
def text_is_spam(text, request):
|
|
# Based on a blog post by 'sciyoshi':
|
|
# http://sciyoshi.com/blog/2008/aug/27/using-akismet-djangos-new-comments-framework/
|
|
# This will return 'True' is the given text is deemed to be spam, or
|
|
# False if it is not spam. If it cannot be checked for some reason, we
|
|
# assume it isn't spam.
|
|
from django.contrib.sites.models import Site
|
|
from django.core.exceptions import ImproperlyConfigured
|
|
try:
|
|
from akismet import Akismet
|
|
except ImportError:
|
|
return False
|
|
try:
|
|
site = Site.objects.get_current()
|
|
except ImproperlyConfigured:
|
|
site = Site(domain='configure-django-sites.com')
|
|
|
|
# see https://akismet.readthedocs.io/en/latest/overview.html#using-akismet
|
|
|
|
apikey = None
|
|
|
|
if hasattr(settings, 'TYPEPAD_ANTISPAM_API_KEY'):
|
|
apikey = settings.TYPEPAD_ANTISPAM_API_KEY
|
|
ak.baseurl = 'api.antispam.typepad.com/1.1/'
|
|
elif hasattr(settings, 'PYTHON_AKISMET_API_KEY'):
|
|
# new env var expected by python-akismet package
|
|
apikey = settings.PYTHON_AKISMET_API_KEY
|
|
elif hasattr(settings, 'AKISMET_API_KEY'):
|
|
# deprecated, but kept for backward compatibility
|
|
apikey = settings.AKISMET_API_KEY
|
|
else:
|
|
return False
|
|
|
|
ak = Akismet(
|
|
blog_url='http://%s/' % site.domain,
|
|
key=apikey,
|
|
)
|
|
|
|
if ak.verify_key():
|
|
ak_data = {
|
|
'user_ip': request.META.get('REMOTE_ADDR', '127.0.0.1'),
|
|
'user_agent': request.META.get('HTTP_USER_AGENT', ''),
|
|
'referrer': request.META.get('HTTP_REFERER', ''),
|
|
'comment_type': 'comment',
|
|
'comment_author': '',
|
|
}
|
|
|
|
return ak.comment_check(smart_text(text), data=ak_data)
|
|
|
|
return False
|
|
|
|
|
|
def process_attachments(followup, attached_files):
|
|
max_email_attachment_size = getattr(settings, 'MAX_EMAIL_ATTACHMENT_SIZE', 512000)
|
|
attachments = []
|
|
|
|
for attached in attached_files:
|
|
|
|
if attached.size:
|
|
filename = smart_text(attached.name)
|
|
att = FollowUpAttachment(
|
|
followup=followup,
|
|
file=attached,
|
|
filename=filename,
|
|
mime_type=attached.content_type or
|
|
mimetypes.guess_type(filename, strict=False)[0] or
|
|
'application/octet-stream',
|
|
size=attached.size,
|
|
)
|
|
att.save()
|
|
|
|
if attached.size < max_email_attachment_size:
|
|
# Only files smaller than 512kb (or as defined in
|
|
# settings.MAX_EMAIL_ATTACHMENT_SIZE) are sent via email.
|
|
attachments.append([filename, att.file])
|
|
|
|
return attachments
|
|
|
|
|
|
ORDER_COLUMN_CHOICES = Choices(
|
|
('0', 'id'),
|
|
('2', 'priority'),
|
|
('3', 'title'),
|
|
('4', 'queue'),
|
|
('5', 'status'),
|
|
('6', 'created'),
|
|
('7', 'due_date'),
|
|
('8', 'assigned_to')
|
|
)
|
|
|
|
|
|
def query_tickets_by_args(objects, order_by, **kwargs):
|
|
"""
|
|
This function takes in a list of ticket objects from the views and throws it
|
|
to the datatables on ticket_list.html. If a search string was entered, this
|
|
function filters existing dataset on search string and returns a filtered
|
|
filtered list. The `draw`, `length` etc parameters are for datatables to
|
|
display meta data on the table contents. The returning queryset is passed
|
|
to a Serializer called TicketSerializer in serializers.py.
|
|
"""
|
|
draw = int(kwargs.get('draw', None)[0])
|
|
length = int(kwargs.get('length', None)[0])
|
|
start = int(kwargs.get('start', None)[0])
|
|
search_value = kwargs.get('search[value]', None)[0]
|
|
order_column = kwargs.get('order[0][column]', None)[0]
|
|
order = kwargs.get('order[0][dir]', None)[0]
|
|
|
|
order_column = ORDER_COLUMN_CHOICES[order_column]
|
|
# django orm '-' -> desc
|
|
if order == 'desc':
|
|
order_column = '-' + order_column
|
|
|
|
queryset = objects.all().order_by(order_by)
|
|
total = queryset.count()
|
|
|
|
if search_value:
|
|
queryset = queryset.filter(Q(id__icontains=search_value) |
|
|
Q(priority__icontains=search_value) |
|
|
Q(title__icontains=search_value) |
|
|
Q(queue__title__icontains=search_value) |
|
|
Q(status__icontains=search_value) |
|
|
Q(created__icontains=search_value) |
|
|
Q(due_date__icontains=search_value) |
|
|
Q(assigned_to__email__icontains=search_value))
|
|
|
|
count = queryset.count()
|
|
queryset = queryset.order_by(order_column)[start:start + length]
|
|
return {
|
|
'items': queryset,
|
|
'count': count,
|
|
'total': total,
|
|
'draw': draw
|
|
}
|
|
|
|
|
|
def format_time_spent(time_spent):
|
|
"""Format time_spent attribute to "[H]HHh:MMm" text string to be allign in
|
|
all graphical outputs
|
|
"""
|
|
|
|
if time_spent:
|
|
time_spent = "{0:02d}h:{0:02d}m".format(
|
|
int(time_spent.total_seconds() // 3600),
|
|
int((time_spent.total_seconds() % 3600) / 60)
|
|
)
|
|
else:
|
|
time_spent = ""
|
|
return time_spent
|