Merge pull request #797 from auto-mat/iframe_submit

Iframe submit, redesigned KB, various bug fixes
This commit is contained in:
Garret Wassermann 2020-01-20 15:34:20 -05:00 committed by GitHub
commit 6e5e8cb21f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 2341 additions and 1557 deletions

View File

@ -7,5 +7,12 @@ Django-helpdesk associates an email address with each submitted ticket. If you i
- `title` - `title`
- `body` - `body`
- `submitter_email` - `submitter_email`
- `custom_<custom-field-slug>`
There is also a page under the url `/tickets/submit_iframe/` with the same behavior.
Fields may be hidden by adding them to a comma separated `_hide_fieds_` query parameter.
Here is an example url to get you started: `http://localhost:8000/desk/tickets/submit_iframe/?queue=1;custom_dpnk-user=http://lol.cz;submitter_email=foo@bar.cz;title=lol;_hide_fields_=title,queue,submitter_email`. This url sets the queue to 1, sets the custom field `dpnk-url` to `http://lol.cz` and submitter_email to `lol@baz.cz` and hides the title, queue, and submitter_email fields. Note that hidden fields should be set to a default.
Note that these fields will continue to be user-editable despite being pre-filled. Note that these fields will continue to be user-editable despite being pre-filled.

View File

@ -72,7 +72,7 @@ class FollowUpAdmin(admin.ModelAdmin):
class KBItemAdmin(admin.ModelAdmin): class KBItemAdmin(admin.ModelAdmin):
list_display = ('category', 'title', 'last_updated',) list_display = ('category', 'title', 'last_updated',)
inlines = [KBIAttachmentInline] inlines = [KBIAttachmentInline]
readonly_fields = ('voted_by',) readonly_fields = ('voted_by', 'downvoted_by')
list_display_links = ('title',) list_display_links = ('title',)

View File

@ -18,7 +18,7 @@ from django.utils import timezone
from helpdesk.lib import safe_template_context, process_attachments from helpdesk.lib import safe_template_context, process_attachments
from helpdesk.models import (Ticket, Queue, FollowUp, IgnoreEmail, TicketCC, from helpdesk.models import (Ticket, Queue, FollowUp, IgnoreEmail, TicketCC,
CustomField, TicketCustomFieldValue, TicketDependency, UserSettings) CustomField, TicketCustomFieldValue, TicketDependency, UserSettings, KBItem)
from helpdesk import settings as helpdesk_settings from helpdesk import settings as helpdesk_settings
User = get_user_model() User = get_user_model()
@ -177,6 +177,16 @@ class AbstractTicketForm(CustomFieldMixin, forms.Form):
help_text=_('You can attach a file such as a document or screenshot to this ticket.'), help_text=_('You can attach a file such as a document or screenshot to this ticket.'),
) )
def __init__(self, kbcategory=None, *args, **kwargs):
super().__init__(*args, **kwargs)
if kbcategory:
self.fields['kbitem'] = forms.ChoiceField(
widget=forms.Select(attrs={'class': 'form-control'}),
required=False,
label=_('Knowedge Base Item'),
choices=[(kbi.pk, kbi.title) for kbi in KBItem.objects.filter(category=kbcategory.pk)],
)
def _add_form_custom_fields(self, staff_only_filter=None): def _add_form_custom_fields(self, staff_only_filter=None):
if staff_only_filter is None: if staff_only_filter is None:
queryset = CustomField.objects.all() queryset = CustomField.objects.all()
@ -197,7 +207,10 @@ class AbstractTicketForm(CustomFieldMixin, forms.Form):
return Queue.objects.get(id=int(self.cleaned_data['queue'])) return Queue.objects.get(id=int(self.cleaned_data['queue']))
def _create_ticket(self): def _create_ticket(self):
queue = self._get_queue() queue = Queue.objects.get(id=int(self.cleaned_data['queue']))
kbitem = None
if 'kbitem' in self.cleaned_data:
kbitem = KBItem.objects.get(id=int(self.cleaned_data['kbitem']))
ticket = Ticket(title=self.cleaned_data['title'], ticket = Ticket(title=self.cleaned_data['title'],
submitter_email=self.cleaned_data['submitter_email'], submitter_email=self.cleaned_data['submitter_email'],
@ -207,6 +220,7 @@ class AbstractTicketForm(CustomFieldMixin, forms.Form):
description=self.cleaned_data['body'], description=self.cleaned_data['body'],
priority=self.cleaned_data['priority'], priority=self.cleaned_data['priority'],
due_date=self.cleaned_data['due_date'], due_date=self.cleaned_data['due_date'],
kbitem=kbitem,
) )
return ticket, queue return ticket, queue
@ -337,34 +351,28 @@ class PublicTicketForm(AbstractTicketForm):
help_text=_('We will e-mail you when your ticket is updated.'), help_text=_('We will e-mail you when your ticket is updated.'),
) )
def __init__(self, *args, **kwargs): def __init__(self, hidden_fields=(), readonly_fields=(), *args, **kwargs):
""" """
Add any (non-staff) custom fields that are defined to the form Add any (non-staff) custom fields that are defined to the form
""" """
super(PublicTicketForm, self).__init__(*args, **kwargs) super(PublicTicketForm, self).__init__(*args, **kwargs)
if hasattr(settings, 'HELPDESK_PUBLIC_TICKET_QUEUE'): self._add_form_custom_fields(False)
del self.fields['queue']
else:
self.fields['queue'].choices = [
('', '--------')
] + [
(q.id, q.title) for q in Queue.objects.filter(allow_public_submission=True)
]
if hasattr(settings, 'HELPDESK_PUBLIC_TICKET_PRIORITY'):
self.fields['priority'].widget = forms.HiddenInput()
if hasattr(settings, 'HELPDESK_PUBLIC_TICKET_DUE_DATE'):
self.fields['due_date'].widget = forms.HiddenInput()
def _get_queue(self): field_hide_table = {
if getattr(settings, 'HELPDESK_PUBLIC_TICKET_QUEUE', None): 'queue': 'HELPDESK_PUBLIC_TICKET_QUEUE',
# force queue to be the pre-defined one 'priority': 'HELPDESK_PUBLIC_TICKET_PRIORITY',
# (only for anon submissions) 'due_date': 'HELPDESK_PUBLIC_TICKET_DUE_DATE',
return Queue.objects.filter( }
slug=settings.HELPDESK_PUBLIC_TICKET_QUEUE
).first() for field in self.fields.keys():
else: setting = field_hide_table.get(field, None)
# get the queue user entered if (setting and hasattr(settings, setting)) or field in hidden_fields:
return Queue.objects.get(id=int(self.cleaned_data['queue'])) self.fields[field].widget = forms.HiddenInput()
if field in readonly_fields:
self.fields[field].disabled = True
self.fields['queue'].choices = [('', '--------')] + [
(q.id, q.title) for q in Queue.objects.filter(allow_public_submission=True)]
def save(self): def save(self):
""" """

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,71 @@
# Generated by Django 2.2.6 on 2020-01-07 12:21
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('helpdesk', '0026_kbitem_attachments'),
]
operations = [
migrations.AddField(
model_name='kbcategory',
name='queue',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='helpdesk.Queue', verbose_name='Default queue when creating a ticket after viewing this category.'),
),
migrations.AddField(
model_name='kbitem',
name='downvoted_by',
field=models.ManyToManyField(related_name='downvotes', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='ticket',
name='kbitem',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='helpdesk.KBItem', verbose_name='Knowledge base item the user was viewing when they created this ticket.'),
),
migrations.AlterField(
model_name='followupattachment',
name='filename',
field=models.CharField(blank=True, max_length=1000, verbose_name='Filename'),
),
migrations.AlterField(
model_name='followupattachment',
name='mime_type',
field=models.CharField(blank=True, max_length=255, verbose_name='MIME Type'),
),
migrations.AlterField(
model_name='followupattachment',
name='size',
field=models.IntegerField(blank=True, help_text='Size of this file in bytes', verbose_name='Size'),
),
migrations.AlterField(
model_name='kbiattachment',
name='filename',
field=models.CharField(blank=True, max_length=1000, verbose_name='Filename'),
),
migrations.AlterField(
model_name='kbiattachment',
name='mime_type',
field=models.CharField(blank=True, max_length=255, verbose_name='MIME Type'),
),
migrations.AlterField(
model_name='kbiattachment',
name='size',
field=models.IntegerField(blank=True, help_text='Size of this file in bytes', verbose_name='Size'),
),
migrations.AlterField(
model_name='kbitem',
name='voted_by',
field=models.ManyToManyField(related_name='votes', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='queue',
name='enable_notifications_on_email_events',
field=models.BooleanField(blank=True, default=False, help_text='When an email arrives to either create a ticket or to interact with an existing discussion. Should email notifications be sent ? Note: the new_ticket_cc and updated_ticket_cc work independently of this feature', verbose_name='Notify contacts when email updates arrive'),
),
]

View File

@ -559,6 +559,14 @@ class Ticket(models.Model):
default=mk_secret, default=mk_secret,
) )
kbitem = models.ForeignKey(
"KBItem",
blank=True,
null=True,
on_delete=models.CASCADE,
verbose_name=_('Knowledge base item the user was viewing when they created this ticket.'),
)
@property @property
def time_spent(self): def time_spent(self):
"""Return back total time spent on the ticket. This is calculated value """Return back total time spent on the ticket. This is calculated value
@ -605,6 +613,8 @@ class Ticket(models.Model):
if dont_send_to is not None: if dont_send_to is not None:
recipients.update(dont_send_to) recipients.update(dont_send_to)
recipients.add(self.queue.email_address)
def should_receive(email): def should_receive(email):
return email and email not in recipients return email and email not in recipients
@ -1216,6 +1226,14 @@ class KBCategory(models.Model):
_('Description'), _('Description'),
) )
queue = models.ForeignKey(
Queue,
blank=True,
null=True,
on_delete=models.CASCADE,
verbose_name=_('Default queue when creating a ticket after viewing this category.'),
)
def __str__(self): def __str__(self):
return '%s' % self.title return '%s' % self.title
@ -1234,7 +1252,14 @@ class KBItem(models.Model):
An item within the knowledgebase. Very straightforward question/answer An item within the knowledgebase. Very straightforward question/answer
style system. style system.
""" """
voted_by = models.ManyToManyField(settings.AUTH_USER_MODEL) voted_by = models.ManyToManyField(
settings.AUTH_USER_MODEL,
related_name='votes',
)
downvoted_by = models.ManyToManyField(
settings.AUTH_USER_MODEL,
related_name='downvotes',
)
category = models.ForeignKey( category = models.ForeignKey(
KBCategory, KBCategory,
on_delete=models.CASCADE, on_delete=models.CASCADE,
@ -1294,7 +1319,14 @@ class KBItem(models.Model):
def get_absolute_url(self): def get_absolute_url(self):
from django.urls import reverse from django.urls import reverse
return reverse('helpdesk:kb_item', args=(self.id,)) return str(reverse('helpdesk:kb_category', args=(self.category.slug,))) + "?kbitem=" + str(self.pk)
def query_url(self):
from django.urls import reverse
return str(reverse('helpdesk:list')) + "?kbitem=" + str(self.pk)
def num_open_tickets(self):
return Ticket.objects.filter(kbitem=self, status__in=(1, 2)).count()
def get_markdown(self): def get_markdown(self):
return get_markdown(self.answer) return get_markdown(self.answer)

View File

@ -1,12 +1,16 @@
from django.db.models import Q from django.db.models import Q
from django.core.cache import cache from django.core.cache import cache
from django.urls import reverse
from model_utils import Choices from django.utils.translation import ugettext as _
from base64 import b64encode from base64 import b64encode
from base64 import b64decode from base64 import b64decode
import json import json
from model_utils import Choices
from helpdesk.serializers import DatatablesTicketSerializer
def query_to_base64(query): def query_to_base64(query):
""" """
@ -47,60 +51,31 @@ def query_to_dict(results, descriptions):
return output return output
def apply_query(queryset, params): def get_search_filter_args(search):
""" if search.startswith('queue:'):
Apply a dict-based set of filters & parameters to a queryset. return Q(queue__title__icontains=search[len('queue:'):])
if search.startswith('priority:'):
queryset is a Django queryset, eg MyModel.objects.all() or return Q(priority__icontains=search[len('priority:'):])
MyModel.objects.filter(user=request.user) filter = Q()
for subsearch in search.split("OR"):
params is a dictionary that contains the following: subsearch = subsearch.strip()
filtering: A dict of Django ORM filters, eg: filter = (
{'user__id__in': [1, 3, 103], 'title__contains': 'foo'} filter |
Q(id__icontains=subsearch) |
search_string: A freetext search string Q(title__icontains=subsearch) |
Q(description__icontains=subsearch) |
sorting: The name of the column to sort by Q(priority__icontains=subsearch) |
""" Q(resolution__icontains=subsearch) |
for key in params['filtering'].keys(): Q(submitter_email__icontains=subsearch) |
filter = {key: params['filtering'][key]} Q(assigned_to__email__icontains=subsearch) |
queryset = queryset.filter(**filter) Q(ticketcustomfieldvalue__value__icontains=subsearch) |
Q(created__icontains=subsearch) |
search = params.get('search_string', '') Q(due_date__icontains=subsearch)
if search:
qset = (
Q(title__icontains=search) |
Q(description__icontains=search) |
Q(resolution__icontains=search) |
Q(submitter_email__icontains=search) |
Q(ticketcustomfieldvalue__value__icontains=search)
) )
return filter
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 get_query(query, huser): DATATABLES_ORDER_COLUMN_CHOICES = Choices(
# Prefilter the allowed tickets
objects = cache.get(huser.user.email + query)
if objects is not None:
return objects
tickets = huser.get_tickets_in_queues().select_related()
query_params = query_from_base64(query)
ticket_qs = apply_query(tickets, query_params)
cache.set(huser.user.email + query, ticket_qs, timeout=3600)
return ticket_qs
ORDER_COLUMN_CHOICES = Choices(
('0', 'id'), ('0', 'id'),
('2', 'priority'), ('2', 'priority'),
('3', 'title'), ('3', 'title'),
@ -112,45 +87,135 @@ ORDER_COLUMN_CHOICES = Choices(
) )
def query_tickets_by_args(objects, order_by, **kwargs): def get_query_class():
""" from django.conf import settings
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 DatatablesTicketSerializer 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] def _get_query_class():
# django orm '-' -> desc return __Query__
if order == 'desc': return getattr(settings,
order_column = '-' + order_column 'HELPDESK_QUERY_CLASS',
_get_query_class)()
queryset = objects.all().order_by(order_by)
total = queryset.count()
if search_value: class __Query__:
queryset = queryset.filter(Q(id__icontains=search_value) | def __init__(self, huser, base64query=None, query_params=None):
Q(priority__icontains=search_value) | self.huser = huser
Q(title__icontains=search_value) | self.params = query_params if query_params else query_from_base64(base64query)
Q(queue__title__icontains=search_value) | self.base64 = base64query if base64query else query_to_base64(query_params)
Q(status__icontains=search_value) | self.result = None
Q(created__icontains=search_value) |
Q(due_date__icontains=search_value) |
Q(assigned_to__email__icontains=search_value))
count = queryset.count() def get_search_filter_args(self):
queryset = queryset.order_by(order_column)[start:start + length] search = self.params.get('search_string', '')
return { return get_search_filter_args(search)
'items': queryset,
'count': count, def __run__(self, queryset):
'total': total, """
'draw': draw 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 self.params.get('filtering', {}).keys():
filter = {key: self.params['filtering'][key]}
queryset = queryset.filter(**filter)
queryset = queryset.filter(self.get_search_filter_args())
sorting = self.params.get('sorting', None)
if sorting:
sortreverse = self.params.get('sortreverse', None)
if sortreverse:
sorting = "-%s" % sorting
queryset = queryset.order_by(sorting)
return queryset.distinct() # https://stackoverflow.com/questions/30487056/django-queryset-contains-duplicate-entries
def get_cache_key(self):
return str(self.huser.user.pk) + ":" + self.base64
def refresh_query(self):
tickets = self.huser.get_tickets_in_queues().select_related()
ticket_qs = self.__run__(tickets)
cache.set(self.get_cache_key(), ticket_qs, timeout=3600)
return ticket_qs
def get(self):
# Prefilter the allowed tickets
objects = cache.get(self.get_cache_key())
if objects is not None:
return objects
return self.refresh_query()
def get_datatables_context(self, **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 DatatablesTicketSerializer in serializers.py.
"""
objects = self.get()
order_by = '-date_created'
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 = DATATABLES_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(get_search_filter_args(search_value))
count = queryset.count()
queryset = queryset.order_by(order_column)[start:start + length]
return {
'data': DatatablesTicketSerializer(queryset, many=True).data,
'recordsFiltered': count,
'recordsTotal': total,
'draw': draw
}
def get_timeline_context(self):
events = []
for ticket in self.get():
for followup in ticket.followup_set.all():
event = {
'start_date': self.mk_timeline_date(followup.date),
'text': {
'headline': ticket.title + ' - ' + followup.title,
'text': (followup.comment if followup.comment else _('No text')) + '<br/> <a href="%s" class="btn" role="button">%s</a>' %
(reverse('helpdesk:view', kwargs={'ticket_id': ticket.pk}), _("View ticket")),
},
'group': _('Messages'),
}
events.append(event)
return {
'events': events,
}
def mk_timeline_date(self, date):
return {
'year': date.year,
'month': date.month,
'day': date.day,
'hour': date.hour,
'minute': date.minute,
'second': date.second,
'second': date.second,
}

View File

@ -15,6 +15,7 @@ datatables for ticket_list.html. Called from staff.datatables_ticket_list.
class DatatablesTicketSerializer(serializers.ModelSerializer): class DatatablesTicketSerializer(serializers.ModelSerializer):
ticket = serializers.SerializerMethodField() ticket = serializers.SerializerMethodField()
assigned_to = serializers.SerializerMethodField() assigned_to = serializers.SerializerMethodField()
submitter = serializers.SerializerMethodField()
created = serializers.SerializerMethodField() created = serializers.SerializerMethodField()
due_date = serializers.SerializerMethodField() due_date = serializers.SerializerMethodField()
status = serializers.SerializerMethodField() status = serializers.SerializerMethodField()
@ -26,7 +27,7 @@ class DatatablesTicketSerializer(serializers.ModelSerializer):
model = Ticket model = Ticket
# fields = '__all__' # fields = '__all__'
fields = ('ticket', 'id', 'priority', 'title', 'queue', 'status', fields = ('ticket', 'id', 'priority', 'title', 'queue', 'status',
'created', 'due_date', 'assigned_to', 'row_class', 'created', 'due_date', 'assigned_to', 'submitter', 'row_class',
'time_spent') 'time_spent')
def get_queue(self, obj): def get_queue(self, obj):
@ -53,6 +54,9 @@ class DatatablesTicketSerializer(serializers.ModelSerializer):
else: else:
return ("None") return ("None")
def get_submitter(self, obj):
return obj.submitter_email
def get_time_spent(self, obj): def get_time_spent(self, obj):
return format_time_spent(obj.time_spent) return format_time_spent(obj.time_spent)

View File

@ -0,0 +1,74 @@
{% load i18n %}
{% load saved_queries %}
{% load load_helpdesk_settings %}
{% load static from staticfiles %}
{% with request|load_helpdesk_settings as helpdesk_settings %}
{% with user|saved_queries as user_saved_queries_ %}
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="description" content="">
<meta name="author" content="">
<title>{% block helpdesk_title %}Helpdesk{% endblock %} :: {% trans "Powered by django-helpdesk" %}</title>
<!-- Bootstrap Core CSS -->
{% if helpdesk_settings.HELPDESK_USE_CDN %}
<link href="https://stackpath.bootstrapcdn.com/bootstrap/4.2.1/css/bootstrap.min.css" rel="stylesheet">
{% else %}
<link href="{% static 'helpdesk/vendor/bootstrap/css/bootstrap.min.css' %}" rel="stylesheet">
{% endif %}
<!-- Font Awesome -->
<link href="{% static 'helpdesk/vendor/fontawesome-free/css/all.min.css' %}" rel="stylesheet" type="text/css">
<!-- DataTables CSS-->
<link href="{% static 'helpdesk/vendor/datatables/css/dataTables.bootstrap4.css' %}" rel="stylesheet">
<!-- MetisMenu CSS -->
<link href="{% static 'helpdesk/vendor/metisMenu/metisMenu.min.css' %}" rel="stylesheet">
<!-- Morris Charts CSS -->
{% if helpdesk_settings.HELPDESK_USE_CDN %}
<link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/morris.js/0.5.1/morris.css">
{% else %}
<link href="{% static 'helpdesk/vendor/morrisjs/morris.css' %}" rel="stylesheet">
{% endif %}
<!-- Custom CSS -->
<link href="{% static 'helpdesk/css/sb-admin.css' %}" rel="stylesheet">
<link rel='stylesheet' href='{% static "helpdesk/helpdesk-extend.css" %}' type='text/css' media="screen" >
{% if user.id %}
<!-- RSS -->
<link rel='alternate' href='{% url 'helpdesk:rss_user' user.get_username %}' type='application/rss+xml' title='{% trans "My Open Tickets" %}' />
<link rel='alternate' href='{% url 'helpdesk:rss_activity' %}' type='application/rss+xml' title='{% trans "All Recent Activity" %}' />
<link rel='alternate' href='{% url 'helpdesk:rss_unassigned' %}' type='application/rss+xml' title='{% trans "Unassigned Tickets" %}' />
<style type="text/css">
/* hide google translate top bar */
.goog-te-banner-frame {display: none !important;}
.goog-te-balloon-frame {display: none !important;}
/* hide google translate tooltips (generated for every translated item) */
.goog-tooltip {display: none !important; }
</style>
<style type="text/css">
/* header */
#dropdown li.headerlink { width: auto; float: left; text-align: center; }
/* query list */
#dropdown li.headerlink ul { display: none;
text-align: left;
position: absolute;
padding: 5px;
z-index: 2; }
/* query entries */
#dropdown li.headerlink:hover ul { display: block; width: auto; }
#dropdown li.headerlink:hover ul li { padding: 5px; margin: 1px; float: none; display: block; }
</style>
{% endif %}
{% endwith %}
{% endwith %}

View File

@ -9,69 +9,7 @@
<head> <head>
<meta charset="utf-8"> {% include 'helpdesk/base-head.html' %}
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="description" content="">
<meta name="author" content="">
<title>{% block helpdesk_title %}Helpdesk{% endblock %} :: {% trans "Powered by django-helpdesk" %}</title>
<!-- Bootstrap Core CSS -->
{% if helpdesk_settings.HELPDESK_USE_CDN %}
<link href="https://stackpath.bootstrapcdn.com/bootstrap/4.2.1/css/bootstrap.min.css" rel="stylesheet">
{% else %}
<link href="{% static 'helpdesk/vendor/bootstrap/css/bootstrap.min.css' %}" rel="stylesheet">
{% endif %}
<!-- Font Awesome -->
<link href="{% static 'helpdesk/vendor/fontawesome-free/css/all.min.css' %}" rel="stylesheet" type="text/css">
<!-- DataTables CSS-->
<link href="{% static 'helpdesk/vendor/datatables/css/dataTables.bootstrap4.css' %}" rel="stylesheet">
<!-- MetisMenu CSS -->
<link href="{% static 'helpdesk/vendor/metisMenu/metisMenu.min.css' %}" rel="stylesheet">
<!-- Morris Charts CSS -->
{% if helpdesk_settings.HELPDESK_USE_CDN %}
<link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/morris.js/0.5.1/morris.css">
{% else %}
<link href="{% static 'helpdesk/vendor/morrisjs/morris.css' %}" rel="stylesheet">
{% endif %}
<!-- Custom CSS -->
<link href="{% static 'helpdesk/css/sb-admin.css' %}" rel="stylesheet">
<link rel='stylesheet' href='{% static "helpdesk/helpdesk-extend.css" %}' type='text/css' media="screen" >
<!-- RSS -->
<link rel='alternate' href='{% url 'helpdesk:rss_user' user.get_username %}' type='application/rss+xml' title='{% trans "My Open Tickets" %}' />
<link rel='alternate' href='{% url 'helpdesk:rss_activity' %}' type='application/rss+xml' title='{% trans "All Recent Activity" %}' />
<link rel='alternate' href='{% url 'helpdesk:rss_unassigned' %}' type='application/rss+xml' title='{% trans "Unassigned Tickets" %}' />
<style type="text/css">
/* hide google translate top bar */
.goog-te-banner-frame {display: none !important;}
.goog-te-balloon-frame {display: none !important;}
/* hide google translate tooltips (generated for every translated item) */
.goog-tooltip {display: none !important; }
</style>
<style type="text/css">
/* header */
#dropdown li.headerlink { width: auto; float: left; text-align: center; }
/* query list */
#dropdown li.headerlink ul { display: none;
text-align: left;
position: absolute;
padding: 5px;
z-index: 2; }
/* query entries */
#dropdown li.headerlink:hover ul { display: block; width: auto; }
#dropdown li.headerlink:hover ul li { padding: 5px; margin: 1px; float: none; display: block; }
</style>
{% block helpdesk_head %}{% endblock %} {% block helpdesk_head %}{% endblock %}
</head> </head>
@ -79,10 +17,10 @@
<body id="bg-dark"> <body id="bg-dark">
{% include "helpdesk/navigation-header.html" %} {% include "helpdesk/navigation-header.html" %}
<div id="wrapper"> <div id="wrapper">
{% include "helpdesk/navigation-sidebar.html" %} {% include "helpdesk/navigation-sidebar.html" %}
<div id="content-wrapper"> <div id="content-wrapper">
<div class="container-fluid"> <div class="container-fluid">
@ -105,35 +43,9 @@
<!-- /#wrapper --> <!-- /#wrapper -->
{% include "helpdesk/debug.html" %} {% include "helpdesk/debug.html" %}
<!-- jQuery and Bootstrap Core -->
{% if helpdesk_settings.HELPDESK_USE_CDN %}
<script src="//ajax.googleapis.com/ajax/libs/jquery/1.9.0/jquery.min.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.2.1/js/bootstrap.min.js"></script>
{% else %}
<script src="{% static 'helpdesk/vendor/jquery/jquery.min.js' %}"></script>
<script src="{% static 'helpdesk/vendor/bootstrap/js/bootstrap.bundle.min.js' %}"></script>
{% endif %}
<!-- Core plugin JavaScript-->
<script src="{% static 'helpdesk/vendor/jquery-easing/jquery.easing.min.js' %}"></script>
<!-- Page level plugin JavaScript-->
<script src="{% static 'helpdesk/vendor/chart.js/Chart.min.js' %}"></script>
<script src="{% static 'helpdesk/vendor/datatables/js/jquery.dataTables.js' %}"></script>
<script src="{% static 'helpdesk/vendor/datatables/js/dataTables.bootstrap4.js' %}"></script>
<!-- jQuery UI DatePicker -->
<script src='{% static "helpdesk/vendor/jquery-ui/jquery-ui.min.js" %}' type='text/javascript' language='javascript'></script>
<link href="{% static 'helpdesk/vendor/jquery-ui/jquery-ui.css' %}" rel="stylesheet">
<!-- Metis Menu Plugin JavaScript --> {% include 'helpdesk/base_js.html' %}
<script src="{% static 'helpdesk/vendor/metisMenu/metisMenu.min.js' %}"></script>
<!-- Custom Theme JavaScript -->
<script src="{% static 'helpdesk/js/sb-admin.js' %}"></script>
{% block helpdesk_js %}{% endblock %} {% block helpdesk_js %}{% endblock %}
</body> </body>

View File

@ -0,0 +1,27 @@
{% load static from staticfiles %}
<!-- jQuery and Bootstrap Core -->
{% if helpdesk_settings.HELPDESK_USE_CDN %}
<script src="//ajax.googleapis.com/ajax/libs/jquery/1.9.0/jquery.min.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.2.1/js/bootstrap.min.js"></script>
{% else %}
<script src="{% static 'helpdesk/vendor/jquery/jquery.min.js' %}"></script>
<script src="{% static 'helpdesk/vendor/bootstrap/js/bootstrap.bundle.min.js' %}"></script>
{% endif %}
<!-- Core plugin JavaScript-->
<script src="{% static 'helpdesk/vendor/jquery-easing/jquery.easing.min.js' %}"></script>
<!-- Page level plugin JavaScript-->
<script src="{% static 'helpdesk/vendor/chart.js/Chart.min.js' %}"></script>
<script src="{% static 'helpdesk/vendor/datatables/js/jquery.dataTables.js' %}"></script>
<script src="{% static 'helpdesk/vendor/datatables/js/dataTables.bootstrap4.js' %}"></script>
<!-- jQuery UI DatePicker -->
<script src='{% static "helpdesk/vendor/jquery-ui/jquery-ui.min.js" %}' type='text/javascript' language='javascript'></script>
<link href="{% static 'helpdesk/vendor/jquery-ui/jquery-ui.css' %}" rel="stylesheet">
<!-- Metis Menu Plugin JavaScript -->
<script src="{% static 'helpdesk/vendor/metisMenu/metisMenu.min.js' %}"></script>
<!-- Custom Theme JavaScript -->
<script src="{% static 'helpdesk/js/sb-admin.js' %}"></script>

View File

@ -0,0 +1,15 @@
{% load i18n humanize %}
{% load static from staticfiles %}
{% load in_list %}
<div class="form-row">
<div class="col col-sm-3">
<label for='id_statuses'>{% trans "Knowledge base item(s)" %}:</label>
</div>
<div class="col col-sm-3">
<select id='id_kbitems' name='kbitem' multiple='selected' size='5'>{% for s in kbitem_choices %}<option value='{{ s.0 }}'{% if s.0|in_list:query_params.filtering.kbitem__in %} selected='selected'{% endif %}>{{ s.1 }}</option>{% endfor %}</select>
</div>
<div class="col col-sm-6">
<button class="filterBuilderRemove btn btn-danger btn-sm float-right"><i class="fas fa-trash-alt"></i></button>
</div>
<div class='form-row filterHelp'>{% trans "Ctrl-click to select multiple options" %}</div>
</div>

View File

@ -9,5 +9,5 @@
<div class="col col-sm-6"> <div class="col col-sm-6">
<button class='filterBuilderRemove btn btn-danger btn-sm float-right'><i class="fas fa-trash-alt"></i></button> <button class='filterBuilderRemove btn btn-danger btn-sm float-right'><i class="fas fa-trash-alt"></i></button>
</div> </div>
<p class='filterHelp'>{% trans "Keywords are case-insensitive, and will be looked for in the title, body and submitter fields." %}</p> <p class='filterHelp'>{% trans "Keywords are case-insensitive, and will be looked for pretty much everywhere possible. Prepend with 'queue:' or 'priority:' to search by queue or priority. You can also use the keyword OR to combine multiple searches." %}</p>
</div> </div>

View File

@ -1,4 +1,7 @@
{% extends "helpdesk/public_base.html" %}{% load i18n humanize %} {% extends "helpdesk/public_base.html" %}
{% load i18n %}
{% block helpdesk_title %}{% blocktrans with category.title as kbcat %}{{ kbcat }}{% endblocktrans %}{% endblock %}
{% block helpdesk_breadcrumb %} {% block helpdesk_breadcrumb %}
<li class="breadcrumb-item"> <li class="breadcrumb-item">
@ -8,38 +11,7 @@
{% endblock %} {% endblock %}
{% block helpdesk_body %} {% block helpdesk_body %}
<h2>{% trans 'Knowledgebase Category' %}:{% blocktrans with category.title as kbcat %}{{ kbcat }}{% endblocktrans %}</h2> {% include 'helpdesk/kb_category_base.html' %}
<div class="card mb-3">
<div class="card-header">
<i class="fas fa-info-circle"></i>
{% blocktrans with category.title as kbcat %}You are viewing all items in the {{ kbcat }} category.{% endblocktrans %}
</div>
<div class="card-body">
<p>{{ category.description }}</p>
</div>
</div> </div>
{% for item in items %}
{% cycle 'one' 'two' 'three' as itemnumperrow silent %}
{% ifequal itemnumperrow 'one' %}<div class="card-deck">{% endifequal %}
<div class="card">
<div class="card-header">
<h5 class="card-title">{{ item.title }}</h5>
</div>
<div class="card-body">
<p class="card-text">{{ item.question }}</p>
<p class="card-text">
{% blocktrans with item.get_absolute_url as url %}<a href='{{ url }}' class="btn btn-primary"> Go to answer <i class="fa fa-share"></i></a>{% endblocktrans %}
</p>
<div class="well well-sm">
<p>{% trans 'Rating' %}: {{ item.score }}</p>
<p>{% trans 'Last Update' %}: {{ item.last_updated|naturaltime }}</p>
</div>
</p>
</div>
</div>
{% ifequal itemnumperrow 'three' %}</div>{% endifequal %}
{% endfor %}
{% endblock %} {% endblock %}

View File

@ -0,0 +1,48 @@
{% load i18n %}
<h2>{% trans 'Knowledgebase Category' %}:{% blocktrans with category.title as kbcat %}{{ kbcat }}{% endblocktrans %}</h2>
<p>{{ category.description }}</p>
<div id="accordion">
{% for item in items %}
<div class="card mb-3">
<button class="btn btn-link" data-toggle="collapse" data-target="#collapse{{item.id}}" aria-expanded="true" aria-controls="collapse{{item.id}}">
<div class="card-header" id="header{{item.id}}">
<h5 class="mb-0">
{{ item.title }}
</h5>
</div>
</button>
<div id="collapse{{item.id}}" class="collapse {% if item.id == selected_item %}show{% endif %}" aria-labelledby="header{{item.id}}" data-parent="#accordion">
<div class="card-body">
<p class="card-text">{{ item.question }}</p>
<p>{{ item.get_markdown }}</p>
<div class="row">
{% if request.user.pk %}
<div class="col-sm">
<a href='{% url "helpdesk:kb_vote" item.pk %}?vote=up'><button type="button" class="btn btn-success btn-circle btn-xl"><i class="fa fa-thumbs-up fa-lg"></i></button></a>
<a href='{% url "helpdesk:kb_vote" item.pk %}?vote=down'><button type="button" class="btn btn-danger btn-circle btn-xl"><i class="fa fa-thumbs-down fa-lg"></i></button></a>
</div>
{% endif %}
{% if staff %}
<a href='{% url 'helpdesk:list' %}?kbitem={{item.id}}' class="col-sm">
<button type="button" class="btn btn-success btn-circle btn-xl float-right"><i class="fa fa-search fa-lg"></i> {{item.num_open_tickets}} {% trans 'open tickets' %}</button>
</a>
{% endif %}
<a href='{% if iframe %}{% url 'helpdesk:submit_iframe' %}{% else %}{% url 'helpdesk:submit' %}{%endif%}?{% if category.queue %}queue={{category.queue.pk}};_readonly_fields_=queue;{%endif%}kbitem={{item.id}};{{query_param_string}}' class="col-sm">
<button type="button" class="btn btn-success btn-circle btn-xl float-right"><i class="fa fa-envelope fa-lg"></i> {% trans 'Contact a human' %}</button>
</a>
</div>
<div>
{% blocktrans with recommendations=item.recommendations votes=item.votes %}{{ recommendations }} people found this answer useful of {{votes}}{% endblocktrans %}
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% if category.queue %}
<a href='{% if iframe %}{% url 'helpdesk:submit_iframe' %}{% else %}{% url 'helpdesk:submit' %}{%endif%}?queue={{category.queue.pk}};_readonly_fields_=queue;{{query_param_string}}'>
<button type="button" class="btn btn-danger btn-circle btn-xl float-right"><i class="fa fa-envelope fa-lg"></i> {% trans 'Contact a human' %}</button>
</a>
{% endif %}

View File

@ -0,0 +1,13 @@
{% load i18n %}
{% load saved_queries %}
<head>
{% include 'helpdesk/base-head.html' %}
{% block helpdesk_head %}{% endblock %}
</head>
<body>
{% block helpdesk_body %}
{% include 'helpdesk/kb_category_base.html' %}
{% endblock %}
{% include 'helpdesk/base_js.html' %}
</body>

View File

@ -1,51 +0,0 @@
{% extends "helpdesk/public_base.html" %}{% load i18n %}
{% block helpdesk_breadcrumb %}
<li class="breadcrumb-item">
<a href="{% url 'helpdesk:kb_index' %}">{% trans "Knowledgebase" %}</a>
</li>
<li class="breadcrumb-item">
<a href="{{ category.get_absolute_url }}">{% blocktrans with category.title as kbcat %}{{ kbcat }}{% endblocktrans %}</a>
</li>
<li class="breadcrumb-item active">Overview</li>
{% endblock %}
{% block helpdesk_body %}
<h2>{% trans 'Knowledgebase' %}: {% blocktrans with item.title as item %}{{ item }}{% endblocktrans %}</h2>
<div class="card mb-3">
<div class="card-header">
<i class="fas fa-question-circle"></i>
{{ item.question }}
</div>
<div class="card-body">
<p>{{ item.get_markdown }}</p>
</div>
<div class="card-footer">
<div class="row">
<div class="col-lg-2">
<p>{% trans "Did you find this article useful?" %}</p>
<div class="row">
<div class="col-lg-6">
<a href='vote/?vote=up'><button type="button" class="btn btn-success btn-circle btn-xl"><i class="fa fa-thumbs-up fa-lg"></i></button></a>
</div>
<div class="col-lg-6">
<a href='vote/?vote=down'><button type="button" class="btn btn-danger btn-circle btn-xl"><i class="fa fa-thumbs-down fa-lg"></i></button></a>
</div>
</div>
</div>
<div class="col-lg-10">
<p>{% trans "The results of voting by other readers of this article are below:" %}</p>
<ul>
<li>{% blocktrans with item.recommendations as recommendations %}Recommendations: {{ recommendations }}{% endblocktrans %}</li>
<li>{% blocktrans with item.votes as votes %}Votes: {{ votes }}{% endblocktrans %}</li>
<li>{% blocktrans with item.score as score %}Overall Rating: {{ score }}{% endblocktrans %}</li>
</ul>
</div>
</div>
</div>
</div>
<p>{% blocktrans with item.category.title as category_title and item.category.get_absolute_url as category_url %}View <a href='{{ category_url }}'>other <em>{{ category_title }}</em> articles</a>, or continue <a href='../'>viewing other knowledgebase articles</a>.{% endblocktrans %}</p>
{% endblock %}

View File

@ -1,47 +1,13 @@
{% load i18n %} {% load i18n %}
{% load load_helpdesk_settings %}
{% load static from staticfiles %} {% load static from staticfiles %}
{% load load_helpdesk_settings %}
{% with request|load_helpdesk_settings as helpdesk_settings %} {% with request|load_helpdesk_settings as helpdesk_settings %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> {% include 'helpdesk/base-head.html' %}
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="description" content="">
<meta name="author" content="">
<title>{% block helpdesk_title %}{% trans 'Helpdesk' %}{% endblock %} :: {% trans "Powered by django-helpdesk" %}</title>
<!-- Bootstrap Core CSS -->
{% if helpdesk_settings.HELPDESK_USE_CDN %}
<link href="https://stackpath.bootstrapcdn.com/bootstrap/4.2.1/css/bootstrap.min.css" rel="stylesheet">
{% else %}
<link href="{% static 'helpdesk/vendor/bootstrap/css/bootstrap.min.css' %}" rel="stylesheet">
{% endif %}
<!-- Font Awesome -->
<link href="{% static 'helpdesk/vendor/fontawesome-free/css/all.min.css' %}" rel="stylesheet" type="text/css">
<!-- DataTables CSS-->
<link href="{% static 'helpdesk/vendor/datatables/css/dataTables.bootstrap4.css' %}" rel="stylesheet">
<!-- MetisMenu CSS -->
<link href="{% static 'helpdesk/vendor/metisMenu/metisMenu.min.css' %}" rel="stylesheet">
<!-- Custom CSS -->
<link href="{% static 'helpdesk/css/sb-admin.css' %}" rel="stylesheet">
<link rel='stylesheet' href='{% static "helpdesk/helpdesk-extend.css" %}' type='text/css' media="screen" >
<!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
<!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
<!--[if lt IE 9]>
<script src="https://oss.maxcdn.com/libs/html5shiv/3.7.0/html5shiv.js"></script>
<script src="https://oss.maxcdn.com/libs/respond.js/1.4.2/respond.min.js"></script>
<![endif]-->
{% block helpdesk_head %}{% endblock %} {% block helpdesk_head %}{% endblock %}
</head> </head>
@ -49,62 +15,35 @@
<body id="bg-dark"> <body id="bg-dark">
{% include "helpdesk/navigation-header.html" %} {% include "helpdesk/navigation-header.html" %}
<div id="wrapper"> <div id="wrapper">
{% include "helpdesk/navigation-sidebar.html" %} {% include "helpdesk/navigation-sidebar.html" %}
<div id="content-wrapper"> <div id="content-wrapper">
<div class="container-fluid"> <div class="container-fluid">
<!-- Breadcrumbs--> <!-- Breadcrumbs-->
<ol class="breadcrumb"> <ol class="breadcrumb">
{% block helpdesk_breadcrumb %}{% endblock %} {% block helpdesk_breadcrumb %}{% endblock %}
</ol> </ol>
{% block helpdesk_body %}{% endblock %} {% block helpdesk_body %}{% endblock %}
</div> </div>
<!-- /.container-fluid --> <!-- /.container-fluid -->
{% include "helpdesk/attribution.html" %} {% include "helpdesk/attribution.html" %}
</div> </div>
<!-- /.content-wrapper --> <!-- /.content-wrapper -->
</div> </div>
<!-- /#wrapper --> <!-- /#wrapper -->
{% include "helpdesk/debug.html" %} {% include "helpdesk/debug.html" %}
<!-- jQuery and Bootstrap Core --> {% include 'helpdesk/base_js.html' %}
{% if helpdesk_settings.HELPDESK_USE_CDN %}
<script src="//ajax.googleapis.com/ajax/libs/jquery/1.9.0/jquery.min.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.2.1/js/bootstrap.min.js"></script>
{% else %}
<script src="{% static 'helpdesk/vendor/jquery/jquery.min.js' %}"></script>
<script src="{% static 'helpdesk/vendor/bootstrap/js/bootstrap.bundle.min.js' %}"></script>
{% endif %}
<!-- Core plugin JavaScript-->
<script src="{% static 'helpdesk/vendor/jquery-easing/jquery.easing.min.js' %}"></script>
<!-- Page level plugin JavaScript-->
<script src="{% static 'helpdesk/vendor/chart.js/Chart.min.js' %}"></script>
<script src="{% static 'helpdesk/vendor/datatables/js/jquery.dataTables.js' %}"></script>
<script src="{% static 'helpdesk/vendor/datatables/js/dataTables.bootstrap4.js' %}"></script>
<!-- jQuery UI DatePicker -->
<script src='{% static "helpdesk/vendor/jquery-ui/jquery-ui.min.js" %}' type='text/javascript' language='javascript'></script>
<link href="{% static 'helpdesk/vendor/jquery-ui/jquery-ui.css' %}" rel="stylesheet">
<!-- Metis Menu Plugin JavaScript -->
<script src="{% static 'helpdesk/vendor/metisMenu/metisMenu.min.js' %}"></script>
<!-- Custom Theme JavaScript -->
<script src="{% static 'helpdesk/js/sb-admin.js' %}"></script>
{% block helpdesk_js %}{% endblock %} {% block helpdesk_js %}{% endblock %}
</body> </body>
</html> </html>
{% endwith %} {% endwith %}

View File

@ -1,5 +1,5 @@
{% extends "helpdesk/public_base.html" %} {% extends "helpdesk/public_base.html" %}
{% load i18n bootstrap4form %} {% load i18n %}
{% block helpdesk_title %}{% trans "Create Ticket" %}{% endblock %} {% block helpdesk_title %}{% trans "Create Ticket" %}{% endblock %}
@ -11,20 +11,13 @@
{% endblock %} {% endblock %}
{% block helpdesk_body %} {% block helpdesk_body %}
{% if helpdesk_settings.HELPDESK_SUBMIT_A_TICKET_PUBLIC %} <div class="container">
<div class="container"> <div class="card card-register mx-auto mt-5">
<div class="card card-register mx-auto mt-5">
<div class="card-header">{% trans "Submit a Ticket" %}</div> <div class="card-header">{% trans "Submit a Ticket" %}</div>
<div class="card-body"> <div class="card-body">
<p>{% trans "Unless otherwise stated, all fields are required." %} {% trans "Please provide as descriptive a title and description as possible." %}</p> {% include 'helpdesk/public_create_ticket_base.html' %}
<form method='post' action='./#submit' enctype='multipart/form-data'>
{{ form|bootstrap4form }}
<button type="submit" class="btn btn-primary btn-lg btn-block"><i class="fa fa-send"></i>&nbsp;{% trans "Submit Ticket" %}</button>
{% csrf_token %}</form>
</div> </div>
</div>
</div> </div>
{% else %} </div>
<h2>{% trans "Public ticket submission is disabled. Please contact the administrator for assistance." %}</h2>
{% endif %}
{% endblock %} {% endblock %}

View File

@ -0,0 +1,14 @@
{% load i18n bootstrap4form %}
{% load load_helpdesk_settings %}
{% with request|load_helpdesk_settings as helpdesk_settings %}
{% if helpdesk_settings.HELPDESK_SUBMIT_A_TICKET_PUBLIC %}
<p>{% trans "Unless otherwise stated, all fields are required." %} {% trans "Please provide as descriptive a title and description as possible." %}</p>
<form method='post' enctype='multipart/form-data'>
{{ form|bootstrap4form }}
<button type="submit" class="btn btn-primary btn-lg btn-block"><i class="fa fa-send"></i>&nbsp;{% trans "Submit Ticket" %}</button>
{% csrf_token %}</form>
{% else %}
<h2>{% trans "Public ticket submission is disabled. Please contact the administrator for assistance." %}</h2>
{% endif %}
{% endwith %}

View File

@ -0,0 +1,11 @@
{% load i18n %}
{% load saved_queries %}
<head>
{% include 'helpdesk/base-head.html' %}
</head>
<body>
{% block helpdesk_body %}
{% include 'helpdesk/public_create_ticket_base.html' %}
{% endblock %}
</body>

View File

@ -0,0 +1,4 @@
{% load i18n %}
<h1>
{% trans "Ticket submitted successfully! We will reply via email as soon as we get the chance." %}
</h1>

View File

@ -34,7 +34,7 @@
<!-- Tab panes --> <!-- Tab panes -->
<div class="tab-content"> <div class="tab-content">
<div class="tab-pane fade in active" id="EmailCC"> <div class="tab-pane in active" id="EmailCC">
<h4>{% trans 'Add Email' %}</h4> <h4>{% trans 'Add Email' %}</h4>
<form method='post' action='./'> <form method='post' action='./'>
<fieldset> <fieldset>

View File

@ -37,8 +37,10 @@
</strong>{% endifequal %} </strong>{% endifequal %}
</td> </td>
<th class="table-active">{% trans "Submitter E-Mail" %}</th> <th class="table-active">{% trans "Submitter E-Mail" %}</th>
<td>{{ ticket.submitter_email }} <td> {{ ticket.submitter_email }}
{% if user.is_superuser %} {% if submitter_userprofile_url %}<strong><a href='{{submitter_userprofile_url}}'><button type="button" class="btn btn-primary btn-sm"><i class="fas fa-address-book"></i></button></a></strong>{% endif %} {% if user.is_superuser %} {% if submitter_userprofile_url %}<strong><a href='{{submitter_userprofile_url}}'><button type="button" class="btn btn-primary btn-sm"><i class="fas fa-address-book"></i></button></a></strong>{% endif %}
<strong><a href ="{% url 'helpdesk:list'%}?q={{ticket.submitter_email}}">
<button type="button" class="btn btn-primary btn-sm"><i class="fas fa-search"></i></button></a></strong>
<strong><a href='{% url 'helpdesk:email_ignore_add' %}?email={{ ticket.submitter_email }}'> <strong><a href='{% url 'helpdesk:email_ignore_add' %}?email={{ ticket.submitter_email }}'>
<button type="button" class="btn btn-warning btn-sm float-right"><i class="fas fa-eye-slash"></i></button></a></strong>{% endif %} <button type="button" class="btn btn-warning btn-sm float-right"><i class="fas fa-eye-slash"></i></button></a></strong>{% endif %}
</td> </td>
@ -66,6 +68,12 @@
<th class="table-active">{% trans "Total time spent" %}</th> <th class="table-active">{% trans "Total time spent" %}</th>
<td>{{ ticket.time_spent_formated }}</td> <td>{{ ticket.time_spent_formated }}</td>
</tr> </tr>
{% if ticket.kbitem %}
<tr>
<th class="table-active">{% trans "Knowlegebase item" %}</th>
<td> <a href ="{{ticket.kbitem.query_url}}"> {{ticket.kbitem.title}} </a> </td>
</tr>
{% endif %}
<tr> <tr>
<th class="table-active">{% trans "Attachments" %}</th> <th class="table-active">{% trans "Attachments" %}</th>
<td colspan="3"> <td colspan="3">

View File

@ -30,6 +30,116 @@
{% block helpdesk_body %} {% block helpdesk_body %}
{% load in_list %} {% load in_list %}
<div class="card">
<div class="card-header">
<ul class="nav nav-tabs">
<li class="nav-item" style="width: 200px;">
{% trans "Query Results" %}:
</li>
<li class="nav-item"">
<a class="nav-link active" href="#datatabletabcontents" id="datatabletabcontents-tab" data-toggle="tab" role="tab" aria-controls="datatabletabcontents" aria-selected=true>
<i class="fas fa-th-list"></i>
{% trans "Table" %}
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#timelinetabcontents" id="timelinetabcontents-tab" data-toggle="tab" role="tab" aria-controls="timelinetabcontents" aria-selected=false>
<i class="fas fa-history"></i>
{% trans "Timeline" %}
</a>
</li>
</ul>
</div>
<div class="card-body">
{{ search_message|safe }}
<div class="tab-content" id="myTabContent">
<div class="tab-pane fade show active" id="datatabletabcontents" role="tabpanel" aria-labelledby="datatabletabcontents-tab">
<form method='post' action='{% url 'helpdesk:mass_update' %}' id="ticket_mass_update">
<table width="100%" class="table table-sm table-striped table-bordered table-hover" id="ticketTable" data-page-length='{{ default_tickets_per_page }}'>
<thead class="thead-light">
<tr>
<th>&nbsp;</th>
<th>{% trans "Ticket" %}</th>
<th>{% trans "Prority" %}</th>
<th>{% trans "Queue" %}</th>
<th>{% trans "Status" %}</th>
<th>{% trans "Created" %}</th>
<th>{% trans "Due Date" %}</th>
<th>{% trans "Owner" %}</th>
<th>{% trans "Submitter" %}</th>
<th>{% trans "Time Spent" %}</th>
</tr>
</thead>
</table>
<p><label>{% trans "Select:" %} </label>
<button id="select_all_btn" type="button" class="btn btn-primary btn-sm" />
<i class="fas fa-check-circle"></i>&nbsp;{% trans "All" %}
</button>
<button id='select_none_btn' type="button" class="btn btn-primary btn-sm"><i class="fas fa-times-circle"></i>&nbsp;{% trans "None" %}</button>
<button id='select_inverse_btn' type="button" class="btn btn-primary btn-sm"><i class="fas fa-expand-arrows-alt"></i>&nbsp;{% trans "Invert" %}</button>
</p>
<p>
<label for='id_mass_action'>{% trans "With Selected Tickets:" %}</label>
<select name='action' id='id_mass_action'>
<option value='take'>{% trans "Take (Assign to me)" %}</option>
<option value='delete'>{% trans "Delete" %}</option>
<optgroup label='{% trans "Close" %}'>
<option value='close'>{% trans "Close (Don't Send E-Mail)" %}</option>
<option value='close_public'>{% trans "Close (Send E-Mail)" %}</option>
</optgroup>
<optgroup label='{% trans "Assign To" %}'>
<option value='unassign'>{% trans "Nobody (Unassign)" %}</option>
{% for u in user_choices %}<option value='assign_{{ u.id }}'>{{ u.get_username }}</option>{% endfor %}
</optgroup>
</select>
<button type="submit" class="btn btn-primary btn-sm"><i class="fas fa-arrow-circle-right"></i>&nbsp;{% trans "Go" %}</button>
</p>
{% csrf_token %}</form>
</div>
<div class="tab-pane fade" id="timelinetabcontents" role="tabpanel" aria-labelledby="timelinetabcontents-tab">
<div id='timeline-embed' style="width: 100%; height: 80vh"></div>
<!-- 1 -->
<link title="timeline-styles" rel="stylesheet" href="https://cdn.knightlab.com/libs/timeline3/latest/css/timeline.css">
<!-- 2 -->
<script src="https://cdn.knightlab.com/libs/timeline3/latest/js/timeline.js"></script>
<!-- 3 -->
<script type="text/javascript">
// The TL.Timeline constructor takes at least two arguments:
// the id of the Timeline container (no '#'), and
// the URL to your JSON data file or Google spreadsheet.
// the id must refer to an element "above" this code,
// and the element must have CSS styling to give it width and height
// optionally, a third argument with configuration options can be passed.
// See below for more about options.
var timeline_loaded = false;
$(function () {
$('#timelinetabcontents-tab').on('shown.bs.tab', function (e) {
if(!timeline_loaded){
timeline = new TL.Timeline(
'timeline-embed',
'{% url 'helpdesk:timeline_ticket_list' urlsafe_query %}'
);
timeline_loaded = true;
}
});
})
</script>
</div>
</div>
</div>
<!-- /.panel-body -->
</div>
<!-- /.panel -->
<div class="card mb-3"> <div class="card mb-3">
<div class="card-header"> <div class="card-header">
<i class="fas fa-hand-pointer"></i> <i class="fas fa-hand-pointer"></i>
@ -62,6 +172,7 @@
<option id="filterBuilderSelect-Status" value="Status">{% trans "Status" %}</option> <option id="filterBuilderSelect-Status" value="Status">{% trans "Status" %}</option>
<option id="filterBuilderSelect-Keywords" value="Keywords">{% trans "Keywords" %}</option> <option id="filterBuilderSelect-Keywords" value="Keywords">{% trans "Keywords" %}</option>
<option id="filterBuilderSelect-Dates" value="Dates">{% trans "Date Range" %}</option> <option id="filterBuilderSelect-Dates" value="Dates">{% trans "Date Range" %}</option>
<option id="filterBuilderSelect-KBItems" value="KBItems">{% trans "Knowledge base items" %}</option>
</select> </select>
{% csrf_token %} {% csrf_token %}
</form> </form>
@ -87,6 +198,9 @@
<li class="list-group-item filterBox{% if query_params.search_string %} filterBoxShow{% endif %}" id="filterBoxKeywords"> <li class="list-group-item filterBox{% if query_params.search_string %} filterBoxShow{% endif %}" id="filterBoxKeywords">
{% include './filters/keywords.html' %} {% include './filters/keywords.html' %}
</li> </li>
<li class="list-group-item filterBox{% if query_params.filtering.kbitem__in %} filterBoxShow{% endif %}" id="filterBoxKBItems">
{% include './filters/kbitems.html' %}
</li>
</ul> </ul>
<input class="btn btn-primary btn-sm" type='submit' value='{% trans "Apply Filters" %}' /> <input class="btn btn-primary btn-sm" type='submit' value='{% trans "Apply Filters" %}' />
@ -162,64 +276,6 @@
</div> </div>
<!-- end top card --> <!-- end top card -->
<div class="card mb-3">
<div class="card-header">
<i class="fas fa-table"></i>
{% trans "Query Results" %}
</div>
<div class="card-body">
{{ search_message|safe }}
<form method='post' action='{% url 'helpdesk:mass_update' %}' id="ticket_mass_update">
<table width="100%" class="table table-sm table-striped table-bordered table-hover" id="ticketTable" data-page-length='{{ default_tickets_per_page }}'>
<thead class="thead-light">
<tr>
<th>&nbsp;</th>
<th>{% trans "Ticket" %}</th>
<th>{% trans "Prority" %}</th>
<th>{% trans "Queue" %}</th>
<th>{% trans "Status" %}</th>
<th>{% trans "Created" %}</th>
<th>{% trans "Due Date" %}</th>
<th>{% trans "Owner" %}</th>
<th>{% trans "Time Spent" %}</th>
</tr>
</thead>
</table>
<p><label>{% trans "Select:" %} </label>
<button id="select_all_btn" type="button" class="btn btn-primary btn-sm" />
<i class="fas fa-check-circle"></i>&nbsp;{% trans "All" %}
</button>
<button id='select_none_btn' type="button" class="btn btn-primary btn-sm"><i class="fas fa-times-circle"></i>&nbsp;{% trans "None" %}</button>
<button id='select_inverse_btn' type="button" class="btn btn-primary btn-sm"><i class="fas fa-expand-arrows-alt"></i>&nbsp;{% trans "Invert" %}</button>
</p>
<p>
<label for='id_mass_action'>{% trans "With Selected Tickets:" %}</label>
<select name='action' id='id_mass_action'>
<option value='take'>{% trans "Take (Assign to me)" %}</option>
<option value='delete'>{% trans "Delete" %}</option>
<optgroup label='{% trans "Close" %}'>
<option value='close'>{% trans "Close (Don't Send E-Mail)" %}</option>
<option value='close_public'>{% trans "Close (Send E-Mail)" %}</option>
</optgroup>
<optgroup label='{% trans "Assign To" %}'>
<option value='unassign'>{% trans "Nobody (Unassign)" %}</option>
{% for u in user_choices %}<option value='assign_{{ u.id }}'>{{ u.get_username }}</option>{% endfor %}
</optgroup>
</select>
<button type="submit" class="btn btn-primary btn-sm"><i class="fas fa-arrow-circle-right"></i>&nbsp;{% trans "Go" %}</button>
</p>
{% csrf_token %}</form>
</div>
<!-- /.panel-body -->
</div>
<!-- /.panel -->
{% endblock %} {% endblock %}
@ -267,8 +323,8 @@
var name = data.split(" ")[1]; var name = data.split(" ")[1];
if (type === 'display') if (type === 'display')
{ {
data = '<div class="tickettitle"><a href="' + get_url(row) + '" >' + data = '<div class="tickettitle"><a href="' + get_url(row) + '" >' +
row.id + '. ' + row.id + '. ' +
row.title + '</a></div>'; row.title + '</a></div>';
} }
return data return data
@ -297,12 +353,13 @@
"render": function(data, type, row, meta) { "render": function(data, type, row, meta) {
if (data != "None") { if (data != "None") {
return data; return data;
} }
else { else {
return ""; return "";
} }
} }
}, },
{"data": "submitter"},
{"data": "time_spent"}, {"data": "time_spent"},
] ]
}); });
@ -345,6 +402,9 @@
{% if query_params.search_string %} {% if query_params.search_string %}
$("#filterBuilderSelect-Keywords")[0].disabled = "disabled"; $("#filterBuilderSelect-Keywords")[0].disabled = "disabled";
{% endif %} {% endif %}
{% if query_params.filtering.kbitem__in %}
$("#filterBuilderSelect-KBItems")[0].disabled = "disabled";
{% endif %}
}); });
{% for f in query_params.filtering %} {% for f in query_params.filtering %}

View File

@ -1,18 +0,0 @@
{% load i18n humanize %}
<tbody>
{% for ticket in tickets %}
<tr class="{{ ticket.get_priority_css_class }}">
<th><a href='{{ ticket.get_absolute_url }}'>{{ ticket.ticket }}</a></th>
<td><input type='checkbox' name='ticket_id' value='{{ ticket.id }}' class='ticket_multi_select' /></td>
<td>{{ ticket.priority }}|||||</td>
<th><a href='{{ ticket.get_absolute_url }}'>{{ ticket.title }}</a></th>
<td>{{ ticket.queue }}</td>
<td>{{ ticket.get_status }}</td>
<td data-order='{{ ticket.created|date:"U" }}'><span title='{{ ticket.created|date:"r" }}'>{{ ticket.created|naturaltime }}</span></td>
<td data-order='{{ ticket.due_date|date:"U" }}'><span title='{{ ticket.due_date|date:"r" }}'>{{ ticket.due_date|naturaltime }}</span></td>
<td>{{ ticket.get_assigned_to }}</td>
<td>{{ ticket.time_spent_formated }}</td>
</tr>
{% endfor %}
</tbody>

View File

@ -94,7 +94,11 @@ class AttachmentUnitTests(TestCase):
'content-type': 'text/utf8', 'content-type': 'text/utf8',
} }
self.test_file = SimpleUploadedFile.from_dict(self.file_attrs) self.test_file = SimpleUploadedFile.from_dict(self.file_attrs)
self.follow_up = models.FollowUp(ticket=models.Ticket(queue=models.Queue())) self.follow_up = models.FollowUp.objects.create(
ticket=models.Ticket.objects.create(
queue=models.Queue.objects.create()
)
)
@mock.patch('helpdesk.lib.FollowUpAttachment', autospec=True) @mock.patch('helpdesk.lib.FollowUpAttachment', autospec=True)
def test_unicode_attachment_filename(self, mock_att_save, mock_queue_save, mock_ticket_save, mock_follow_up_save): def test_unicode_attachment_filename(self, mock_att_save, mock_queue_save, mock_ticket_save, mock_follow_up_save):
@ -109,19 +113,16 @@ class AttachmentUnitTests(TestCase):
) )
self.assertEqual(filename, self.file_attrs['filename']) self.assertEqual(filename, self.file_attrs['filename'])
# TODO: FIXME: what's wrong with this test that we get integrity errors? @mock.patch('helpdesk.lib.FollowUpAttachment', autospec=True)
# @mock.patch('helpdesk.lib.FollowUpAttachment', autospec=True) def test_autofill(self, mock_att_save, mock_queue_save, mock_ticket_save, mock_follow_up_save):
# def test_autofill(self, mock_att_save, mock_queue_save, mock_ticket_save, mock_follow_up_save): """ check utf-8 data is parsed correctly """
# """ check utf-8 data is parsed correctly """ obj = models.FollowUpAttachment.objects.create(
# self.follow_up.pk = 100 followup=self.follow_up,
# self.follow_up.save() file=self.test_file
# obj = models.FollowUpAttachment.objects.create( )
# followup=self.follow_up, self.assertEqual(obj.filename, self.file_attrs['filename'])
# file=self.test_file self.assertEqual(obj.size, len(self.file_attrs['content']))
# ) self.assertEqual(obj.mime_type, "text/plain")
# self.assertEqual(obj.filename, self.file_attrs['filename'])
# self.assertEqual(obj.size, len(self.file_attrs['content']))
# self.assertEqual(obj.mime_type, "text/plain")
def test_kbi_attachment(self, mock_att_save, mock_queue_save, mock_ticket_save): def test_kbi_attachment(self, mock_att_save, mock_queue_save, mock_ticket_save):
""" check utf-8 data is parsed correctly """ """ check utf-8 data is parsed correctly """

80
helpdesk/tests/test_kb.py Normal file
View File

@ -0,0 +1,80 @@
# -*- coding: utf-8 -*-
from django.urls import reverse
from django.test import TestCase
from helpdesk.models import KBCategory, KBItem, Queue, Ticket
from helpdesk.tests.helpers import (get_staff_user, reload_urlconf, User, create_ticket, print_response)
class KBTests(TestCase):
def setUp(self):
self.queue = Queue.objects.create(
title="Test queue",
slug="test_queue",
allow_public_submission=True,
)
self.queue.save()
cat = KBCategory.objects.create(
title="Test Cat",
slug="test_cat",
description="This is a test category",
queue=self.queue,
)
cat.save()
self.kbitem1 = KBItem.objects.create(
category=cat,
title="KBItem 1",
question="What?",
answer="A KB Item",
)
self.kbitem1.save()
self.kbitem2 = KBItem.objects.create(
category=cat,
title="KBItem 2",
question="When?",
answer="Now",
)
self.kbitem2.save()
self.user = get_staff_user()
def test_kb_index(self):
response = self.client.get(reverse('helpdesk:kb_index'))
self.assertContains(response, 'This is a test category')
def test_kb_category(self):
response = self.client.get(reverse('helpdesk:kb_category', args=("test_cat", )))
self.assertContains(response, 'This is a test category')
self.assertContains(response, 'KBItem 1')
self.assertContains(response, 'KBItem 2')
self.assertContains(response, 'Contact a human')
self.client.login(username=self.user.get_username(), password='password')
response = self.client.get(reverse('helpdesk:kb_category', args=("test_cat", )))
self.assertContains(response, '<i class="fa fa-thumbs-up fa-lg"></i>')
self.assertContains(response, '0 open tickets')
ticket = Ticket.objects.create(
title="Test ticket",
queue=self.queue,
kbitem=self.kbitem1,
)
ticket.save()
response = self.client.get(reverse('helpdesk:kb_category', args=("test_cat",)))
self.assertContains(response, '1 open tickets')
def test_kb_vote(self):
self.client.login(username=self.user.get_username(), password='password')
response = self.client.get(reverse('helpdesk:kb_vote', args=(self.kbitem1.pk,)) + "?vote=up")
cat_url = reverse('helpdesk:kb_category', args=("test_cat",)) + "?kbitem=1"
self.assertRedirects(response, cat_url)
response = self.client.get(cat_url)
self.assertContains(response, '1 people found this answer useful of 1')
response = self.client.get(reverse('helpdesk:kb_vote', args=(self.kbitem1.pk,)) + "?vote=down")
self.assertRedirects(response, cat_url)
response = self.client.get(cat_url)
self.assertContains(response, '0 people found this answer useful of 1')
def test_kb_category_iframe(self):
cat_url = reverse('helpdesk:kb_category', args=("test_cat",)) + "?kbitem=1;submitter_email=foo@bar.cz;title=lol;"
response = self.client.get(cat_url)
# Assert that query params are passed on to ticket submit form
self.assertContains(response, "'/helpdesk/tickets/submit/?queue=1;_readonly_fields_=queue;kbitem=1;submitter_email=foo%40bar.cz&amp;title=lol")

View File

@ -6,7 +6,7 @@ from django.test.client import Client
from helpdesk.models import Queue, Ticket from helpdesk.models import Queue, Ticket
from helpdesk import settings from helpdesk import settings
from helpdesk.query import get_query from helpdesk.query import __Query__
from helpdesk.user import HelpdeskUser from helpdesk.user import HelpdeskUser
@ -166,7 +166,7 @@ class PerQueueStaffMembershipTestCase(TestCase):
for identifier in self.IDENTIFIERS: for identifier in self.IDENTIFIERS:
self.client.login(username='User_%d' % identifier, password=str(identifier)) self.client.login(username='User_%d' % identifier, password=str(identifier))
response = self.client.get(reverse('helpdesk:list')) response = self.client.get(reverse('helpdesk:list'))
tickets = get_query(response.context['urlsafe_query'], HelpdeskUser(self.identifier_users[identifier])) tickets = __Query__(HelpdeskUser(self.identifier_users[identifier]), base64query=response.context['urlsafe_query']).get()
self.assertEqual( self.assertEqual(
len(tickets), len(tickets),
identifier * 2, identifier * 2,
@ -186,7 +186,7 @@ class PerQueueStaffMembershipTestCase(TestCase):
# Superuser # Superuser
self.client.login(username='superuser', password='superuser') self.client.login(username='superuser', password='superuser')
response = self.client.get(reverse('helpdesk:list')) response = self.client.get(reverse('helpdesk:list'))
tickets = get_query(response.context['urlsafe_query'], HelpdeskUser(self.superuser)) tickets = __Query__(HelpdeskUser(self.superuser), base64query=response.context['urlsafe_query']).get()
self.assertEqual( self.assertEqual(
len(tickets), len(tickets),
6, 6,

View File

@ -2,7 +2,7 @@
import email import email
import uuid import uuid
from helpdesk.models import Queue, CustomField, FollowUp, Ticket, TicketCC from helpdesk.models import Queue, CustomField, FollowUp, Ticket, TicketCC, KBCategory, KBItem
from django.test import TestCase from django.test import TestCase
from django.core import mail from django.core import mail
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
@ -11,6 +11,7 @@ from django.test.client import Client
from django.urls import reverse from django.urls import reverse
from helpdesk.email import object_from_message, create_ticket_cc from helpdesk.email import object_from_message, create_ticket_cc
from helpdesk.tests.helpers import print_response
from urllib.parse import urlparse from urllib.parse import urlparse
@ -139,6 +140,37 @@ class TicketBasicsTestCase(TestCase):
# Ensure only two e-mails were sent - submitter & updated. # Ensure only two e-mails were sent - submitter & updated.
self.assertEqual(email_count + 2, len(mail.outbox)) self.assertEqual(email_count + 2, len(mail.outbox))
def test_create_ticket_public_no_loopback(self):
"""
Don't send emails to the queue's own inbox. It'll create a loop.
"""
email_count = len(mail.outbox)
self.queue_public.email_address = "queue@example.com"
self.queue_public.save()
post_data = {
'title': 'Test ticket title',
'queue': self.queue_public.id,
'submitter_email': 'queue@example.com',
'body': 'Test ticket body',
'priority': 3,
}
response = self.client.post(reverse('helpdesk:home'), post_data, follow=True)
last_redirect = response.redirect_chain[-1]
last_redirect_url = last_redirect[0]
# last_redirect_status = last_redirect[1]
# Ensure we landed on the "View" page.
# Django 1.9 compatible way of testing this
# https://docs.djangoproject.com/en/1.9/releases/1.9/#http-redirects-no-longer-forced-to-absolute-uris
urlparts = urlparse(last_redirect_url)
self.assertEqual(urlparts.path, reverse('helpdesk:public_view'))
# Ensure submitter, new-queue + update-queue were all emailed.
self.assertEqual(email_count + 2, len(mail.outbox))
class EmailInteractionsTestCase(TestCase): class EmailInteractionsTestCase(TestCase):
fixtures = ['emailtemplate.json'] fixtures = ['emailtemplate.json']
@ -976,3 +1008,24 @@ class EmailInteractionsTestCase(TestCase):
# public_update_queue: +1 # public_update_queue: +1
expected_email_count += 1 + 2 + 1 expected_email_count += 1 + 2 + 1
self.assertEqual(expected_email_count, len(mail.outbox)) self.assertEqual(expected_email_count, len(mail.outbox))
def test_ticket_field_autofill(self):
cat = KBCategory.objects.create(
title="Test Cat",
slug="test_cat",
description="This is a test category",
queue=self.queue_public,
)
cat.save()
self.kbitem1 = KBItem.objects.create(
category=cat,
title="KBItem 1",
question="What?",
answer="A KB Item",
)
self.kbitem1.save()
cat_url = reverse('helpdesk:submit') + "?kbitem=1;submitter_email=foo@bar.cz;title=lol;"
response = self.client.get(cat_url)
self.assertContains(response, '<option value="1" selected>KBItem 1</option>')
self.assertContains(response, '<input type="email" name="submitter_email" value="foo@bar.cz" class="form-control form-control" required id="id_submitter_email">')
self.assertContains(response, '<input type="text" name="title" value="lol" class="form-control form-control" maxlength="100" required id="id_title">')

View File

@ -151,6 +151,11 @@ urlpatterns = [
url(r'^datatables_ticket_list/(?P<query>{})$'.format(base64_pattern), url(r'^datatables_ticket_list/(?P<query>{})$'.format(base64_pattern),
staff.datatables_ticket_list, staff.datatables_ticket_list,
name="datatables_ticket_list"), name="datatables_ticket_list"),
url(r'^timeline_ticket_list/(?P<query>{})$'.format(base64_pattern),
staff.timeline_ticket_list,
name="timeline_ticket_list"),
] ]
urlpatterns += [ urlpatterns += [
@ -162,6 +167,14 @@ urlpatterns += [
public.create_ticket, public.create_ticket,
name='submit'), name='submit'),
url(r'^tickets/submit_iframe/$',
public.CreateTicketIframeView.as_view(),
name='submit_iframe'),
url(r'^tickets/success_iframe/$', # Ticket was submitted successfully
public.SuccessIframeView.as_view(),
name='success_iframe'),
url(r'^view/$', url(r'^view/$',
public.view_ticket, public.view_ticket,
name='public_view'), name='public_view'),
@ -223,17 +236,17 @@ if helpdesk_settings.HELPDESK_KB_ENABLED:
kb.index, kb.index,
name='kb_index'), name='kb_index'),
url(r'^kb/(?P<item>[0-9]+)/$', url(r'^kb/(?P<slug>[A-Za-z0-9_-]+)/$',
kb.item, kb.category,
name='kb_item'), name='kb_category'),
url(r'^kb/(?P<item>[0-9]+)/vote/$', url(r'^kb/(?P<item>[0-9]+)/vote/$',
kb.vote, kb.vote,
name='kb_vote'), name='kb_vote'),
url(r'^kb/(?P<slug>[A-Za-z0-9_-]+)/$', url(r'^kb_iframe/(?P<slug>[A-Za-z0-9_-]+)/$',
kb.category, kb.category_iframe,
name='kb_category'), name='kb_category_iframe'),
] ]
urlpatterns += [ urlpatterns += [

View File

@ -0,0 +1,36 @@
from django.views.generic.edit import FormView
from helpdesk.models import CustomField, KBItem, Queue
class AbstractCreateTicketMixin():
def get_initial(self):
initial_data = {}
request = self.request
try:
initial_data['queue'] = Queue.objects.get(slug=request.GET.get('queue', None)).id
except Queue.DoesNotExist:
pass
if request.user.is_authenticated and request.user.usersettings_helpdesk.use_email_as_submitter and request.user.email:
initial_data['submitter_email'] = request.user.email
query_param_fields = ['submitter_email', 'title', 'body', 'queue', 'kbitem']
custom_fields = ["custom_%s" % f.name for f in CustomField.objects.filter(staff_only=False)]
query_param_fields += custom_fields
for qpf in query_param_fields:
initial_data[qpf] = request.GET.get(qpf, initial_data.get(qpf, ""))
return initial_data
def get_form_kwargs(self, *args, **kwargs):
kwargs = super().get_form_kwargs(*args, **kwargs)
kbitem = self.request.GET.get(
'kbitem',
self.request.POST.get('kbitem', None),
)
if kbitem:
try:
kwargs['kbcategory'] = KBItem.objects.get(pk=int(kbitem)).category
except (ValueError, KBItem.DoesNotExist):
pass
return kwargs

View File

@ -10,6 +10,7 @@ views/kb.py - Public-facing knowledgebase views. The knowledgebase is a
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.shortcuts import render, get_object_or_404 from django.shortcuts import render, get_object_or_404
from django.views.decorators.clickjacking import xframe_options_exempt
from helpdesk import settings as helpdesk_settings from helpdesk import settings as helpdesk_settings
from helpdesk.models import KBCategory, KBItem from helpdesk.models import KBCategory, KBItem
@ -24,34 +25,57 @@ def index(request):
}) })
def category(request, slug): def category(request, slug, iframe=False):
category = get_object_or_404(KBCategory, slug__iexact=slug) category = get_object_or_404(KBCategory, slug__iexact=slug)
items = category.kbitem_set.all() items = category.kbitem_set.all()
return render(request, 'helpdesk/kb_category.html', { selected_item = request.GET.get('kbitem', None)
try:
selected_item = int(selected_item)
except TypeError:
pass
qparams = request.GET.copy()
try:
del qparams['kbitem']
except KeyError:
pass
template = 'helpdesk/kb_category.html'
if iframe:
template = 'helpdesk/kb_category_iframe.html'
staff = request.user.is_authenticated and request.user.is_staff
return render(request, template, {
'category': category, 'category': category,
'items': items, 'items': items,
'selected_item': selected_item,
'query_param_string': qparams.urlencode(),
'helpdesk_settings': helpdesk_settings, 'helpdesk_settings': helpdesk_settings,
'iframe': iframe,
'staff': staff,
}) })
def item(request, item): @xframe_options_exempt
item = get_object_or_404(KBItem, pk=item) def category_iframe(request, slug):
return render(request, 'helpdesk/kb_item.html', { return category(request, slug, iframe=True)
'category': item.category,
'item': item,
'helpdesk_settings': helpdesk_settings,
})
def vote(request, item): def vote(request, item):
item = get_object_or_404(KBItem, pk=item) item = get_object_or_404(KBItem, pk=item)
vote = request.GET.get('vote', None) vote = request.GET.get('vote', None)
if vote in ('up', 'down'): if vote == 'up':
if request.user not in item.voted_by: if not item.voted_by.filter(pk=request.user.pk):
item.votes += 1 item.votes += 1
if vote == 'up': item.voted_by.add(request.user.pk)
item.recommendations += 1 item.recommendations += 1
item.save() if item.downvoted_by.filter(pk=request.user.pk):
item.votes -= 1
item.downvoted_by.remove(request.user.pk)
if vote == 'down':
if not item.downvoted_by.filter(pk=request.user.pk):
item.votes += 1
item.downvoted_by.add(request.user.pk)
item.recommendations -= 1
if item.voted_by.filter(pk=request.user.pk):
item.votes -= 1
item.voted_by.remove(request.user.pk)
item.save()
return HttpResponseRedirect(item.get_absolute_url()) return HttpResponseRedirect(item.get_absolute_url())

View File

@ -7,26 +7,24 @@ views/public.py - All public facing views, eg non-staff (no authentication
required) views. required) views.
""" """
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
try: from django.urls import reverse
# Django 2.0+
from django.urls import reverse
except ImportError:
# Django < 2
from django.core.urlresolvers import reverse
from django.http import HttpResponseRedirect, HttpResponse from django.http import HttpResponseRedirect, HttpResponse
from django.shortcuts import render from django.shortcuts import render
from django.utils.http import urlquote from django.utils.http import urlquote
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.conf import settings from django.conf import settings
from django.views.decorators.clickjacking import xframe_options_exempt
from django.views.decorators.csrf import csrf_exempt
from django.views.generic.base import TemplateView from django.views.generic.base import TemplateView
from django.views.generic.edit import FormView from django.views.generic.edit import FormView
from helpdesk import settings as helpdesk_settings from helpdesk import settings as helpdesk_settings
from helpdesk.decorators import protect_view, is_helpdesk_staff from helpdesk.decorators import protect_view, is_helpdesk_staff
import helpdesk.views.staff as staff import helpdesk.views.staff as staff
import helpdesk.views.abstract_views as abstract_views
from helpdesk.forms import PublicTicketForm from helpdesk.forms import PublicTicketForm
from helpdesk.lib import text_is_spam from helpdesk.lib import text_is_spam
from helpdesk.models import Ticket, Queue, UserSettings, KBCategory from helpdesk.models import CustomField, Ticket, Queue, UserSettings, KBCategory, KBItem
def create_ticket(request, *args, **kwargs): def create_ticket(request, *args, **kwargs):
@ -36,8 +34,7 @@ def create_ticket(request, *args, **kwargs):
return CreateTicketView.as_view()(request, *args, **kwargs) return CreateTicketView.as_view()(request, *args, **kwargs)
class CreateTicketView(FormView): class BaseCreateTicketView(abstract_views.AbstractCreateTicketMixin, FormView):
template_name = 'helpdesk/public_create_ticket.html'
form_class = PublicTicketForm form_class = PublicTicketForm
def dispatch(self, *args, **kwargs): def dispatch(self, *args, **kwargs):
@ -57,42 +54,29 @@ class CreateTicketView(FormView):
return HttpResponseRedirect(reverse('helpdesk:dashboard')) return HttpResponseRedirect(reverse('helpdesk:dashboard'))
return super().dispatch(*args, **kwargs) return super().dispatch(*args, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['kb_categories'] = KBCategory.objects.all()
return context
def get_initial(self): def get_initial(self):
request = self.request request = self.request
initial_data = {} initial_data = super().get_initial()
try:
queue = Queue.objects.get(slug=request.GET.get('queue', None))
except Queue.DoesNotExist:
queue = None
# add pre-defined data for public ticket # add pre-defined data for public ticket
if hasattr(settings, 'HELPDESK_PUBLIC_TICKET_QUEUE'): if hasattr(settings, 'HELPDESK_PUBLIC_TICKET_QUEUE'):
# get the requested queue; return an error if queue not found # get the requested queue; return an error if queue not found
try: try:
queue = Queue.objects.get(slug=settings.HELPDESK_PUBLIC_TICKET_QUEUE) initial_data['queue'] = Queue.objects.get(slug=settings.HELPDESK_PUBLIC_TICKET_QUEUE).id
except Queue.DoesNotExist: except Queue.DoesNotExist:
return HttpResponse(status=500) return HttpResponse(status=500)
if hasattr(settings, 'HELPDESK_PUBLIC_TICKET_PRIORITY'): if hasattr(settings, 'HELPDESK_PUBLIC_TICKET_PRIORITY'):
initial_data['priority'] = settings.HELPDESK_PUBLIC_TICKET_PRIORITY initial_data['priority'] = settings.HELPDESK_PUBLIC_TICKET_PRIORITY
if hasattr(settings, 'HELPDESK_PUBLIC_TICKET_DUE_DATE'): if hasattr(settings, 'HELPDESK_PUBLIC_TICKET_DUE_DATE'):
initial_data['due_date'] = settings.HELPDESK_PUBLIC_TICKET_DUE_DATE initial_data['due_date'] = settings.HELPDESK_PUBLIC_TICKET_DUE_DATE
if queue:
initial_data['queue'] = queue.id
if request.user.is_authenticated and request.user.email:
initial_data['submitter_email'] = request.user.email
query_param_fields = ['submitter_email', 'title', 'body']
for qpf in query_param_fields:
initial_data[qpf] = request.GET.get(qpf, initial_data.get(qpf, ""))
return initial_data return initial_data
def get_form_kwargs(self, *args, **kwargs):
kwargs = super().get_form_kwargs(*args, **kwargs)
kwargs['hidden_fields'] = self.request.GET.get('_hide_fields_', '').split(',')
kwargs['readonly_fields'] = self.request.GET.get('_readonly_fields_', '').split(',')
return kwargs
def form_valid(self, form): def form_valid(self, form):
request = self.request request = self.request
if text_is_spam(form.cleaned_data['body'], request): if text_is_spam(form.cleaned_data['body'], request):
@ -115,9 +99,39 @@ class CreateTicketView(FormView):
request = self.request request = self.request
class CreateTicketIframeView(BaseCreateTicketView):
template_name = 'helpdesk/public_create_ticket_iframe.html'
@csrf_exempt
@xframe_options_exempt
def dispatch(self, *args, **kwargs):
return super().dispatch(*args, **kwargs)
def form_valid(self, form):
if super().form_valid(form).status_code == 302:
return HttpResponseRedirect(reverse('helpdesk:success_iframe'))
class SuccessIframeView(TemplateView):
template_name = 'helpdesk/success_iframe.html'
@xframe_options_exempt
def dispatch(self, *args, **kwargs):
return super().dispatch(*args, **kwargs)
class CreateTicketView(BaseCreateTicketView):
template_name = 'helpdesk/public_create_ticket.html'
class Homepage(CreateTicketView): class Homepage(CreateTicketView):
template_name = 'helpdesk/public_homepage.html' template_name = 'helpdesk/public_homepage.html'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['kb_categories'] = KBCategory.objects.all()
return context
def search_for_ticket(request, error_message=None): def search_for_ticket(request, error_message=None):
if hasattr(settings, 'HELPDESK_VIEW_A_TICKET_PUBLIC') and settings.HELPDESK_VIEW_A_TICKET_PUBLIC: if hasattr(settings, 'HELPDESK_VIEW_A_TICKET_PUBLIC') and settings.HELPDESK_VIEW_A_TICKET_PUBLIC:

View File

@ -27,18 +27,14 @@ from django.utils import timezone
from django.views.generic.edit import FormView, UpdateView from django.views.generic.edit import FormView, UpdateView
from helpdesk.query import ( from helpdesk.query import (
get_query_class,
query_to_dict, query_to_dict,
get_query,
apply_query,
query_tickets_by_args,
query_to_base64, query_to_base64,
query_from_base64, query_from_base64,
) )
from helpdesk.user import HelpdeskUser from helpdesk.user import HelpdeskUser
from helpdesk.serializers import DatatablesTicketSerializer
from helpdesk.decorators import ( from helpdesk.decorators import (
helpdesk_staff_member_required, helpdesk_superuser_required, helpdesk_staff_member_required, helpdesk_superuser_required,
is_helpdesk_staff is_helpdesk_staff
@ -56,9 +52,10 @@ from helpdesk.lib import (
) )
from helpdesk.models import ( from helpdesk.models import (
Ticket, Queue, FollowUp, TicketChange, PreSetReply, FollowUpAttachment, SavedSearch, Ticket, Queue, FollowUp, TicketChange, PreSetReply, FollowUpAttachment, SavedSearch,
IgnoreEmail, TicketCC, TicketDependency, UserSettings, IgnoreEmail, TicketCC, TicketDependency, UserSettings, KBItem,
) )
from helpdesk import settings as helpdesk_settings from helpdesk import settings as helpdesk_settings
import helpdesk.views.abstract_views as abstract_views
from helpdesk.views.permissions import MustBeStaffMixin from helpdesk.views.permissions import MustBeStaffMixin
from ..lib import format_time_spent from ..lib import format_time_spent
@ -71,6 +68,7 @@ import re
User = get_user_model() User = get_user_model()
Query = get_query_class()
if helpdesk_settings.HELPDESK_ALLOW_NON_STAFF_TICKET_UPDATE: if helpdesk_settings.HELPDESK_ALLOW_NON_STAFF_TICKET_UPDATE:
# treat 'normal' users like 'staff' # treat 'normal' users like 'staff'
@ -805,7 +803,9 @@ def ticket_list(request):
'search_string': '', 'search_string': '',
} }
default_query_params = { default_query_params = {
'filtering': {'status__in': [1, 2, 3]}, 'filtering': {
'status__in': [1, 2, 3],
},
'sorting': 'created', 'sorting': 'created',
'search_string': '', 'search_string': '',
'sortreverse': False, 'sortreverse': False,
@ -852,7 +852,7 @@ def ticket_list(request):
if saved_query: if saved_query:
pass pass
elif not {'queue', 'assigned_to', 'status', 'q', 'sort', 'sortreverse'}.intersection(request.GET): elif not {'queue', 'assigned_to', 'status', 'q', 'sort', 'sortreverse', 'kbitem'}.intersection(request.GET):
# Fall-back if no querying is being done # Fall-back if no querying is being done
all_queues = Queue.objects.all() all_queues = Queue.objects.all()
query_params = deepcopy(default_query_params) query_params = deepcopy(default_query_params)
@ -861,6 +861,7 @@ def ticket_list(request):
('queue', 'queue__id__in'), ('queue', 'queue__id__in'),
('assigned_to', 'assigned_to__id__in'), ('assigned_to', 'assigned_to__id__in'),
('status', 'status__in'), ('status', 'status__in'),
('kbitem', 'kbitem__in'),
] ]
for param, filter_command in filter_in_params: for param, filter_command in filter_in_params:
@ -887,7 +888,7 @@ def ticket_list(request):
# SORTING # SORTING
sort = request.GET.get('sort', None) sort = request.GET.get('sort', None)
if sort not in ('status', 'assigned_to', 'created', 'title', 'queue', 'priority'): if sort not in ('status', 'assigned_to', 'created', 'title', 'queue', 'priority', 'kbitem'):
sort = 'created' sort = 'created'
query_params['sorting'] = sort query_params['sorting'] = sort
@ -896,7 +897,7 @@ def ticket_list(request):
urlsafe_query = query_to_base64(query_params) urlsafe_query = query_to_base64(query_params)
get_query(urlsafe_query, huser) Query(huser, base64query=urlsafe_query).refresh_query()
user_saved_queries = SavedSearch.objects.filter(Q(user=request.user) | Q(shared__exact=True)) user_saved_queries = SavedSearch.objects.filter(Q(user=request.user) | Q(shared__exact=True))
@ -910,12 +911,15 @@ def ticket_list(request):
'<a href="http://docs.djangoproject.com/en/dev/ref/databases/#sqlite-string-matching">' '<a href="http://docs.djangoproject.com/en/dev/ref/databases/#sqlite-string-matching">'
'Django Documentation on string matching in SQLite</a>.') 'Django Documentation on string matching in SQLite</a>.')
kbitem_choices = [(item.pk, item.title) for item in KBItem.objects.all()]
return render(request, 'helpdesk/ticket_list.html', dict( return render(request, 'helpdesk/ticket_list.html', dict(
context, context,
default_tickets_per_page=request.user.usersettings_helpdesk.tickets_per_page, default_tickets_per_page=request.user.usersettings_helpdesk.tickets_per_page,
user_choices=User.objects.filter(is_active=True, is_staff=True), user_choices=User.objects.filter(is_active=True, is_staff=True),
queue_choices=huser.get_queues(), queue_choices=huser.get_queues(),
status_choices=Ticket.STATUS_CHOICES, status_choices=Ticket.STATUS_CHOICES,
kbitem_choices=kbitem_choices,
urlsafe_query=urlsafe_query, urlsafe_query=urlsafe_query,
user_saved_queries=user_saved_queries, user_saved_queries=user_saved_queries,
query_params=query_params, query_params=query_params,
@ -964,17 +968,18 @@ def datatables_ticket_list(request, query):
on the table. query_tickets_by_args is at lib.py, DatatablesTicketSerializer is in on the table. query_tickets_by_args is at lib.py, DatatablesTicketSerializer is in
serializers.py. The serializers and this view use django-rest_framework methods serializers.py. The serializers and this view use django-rest_framework methods
""" """
objects = get_query(query, HelpdeskUser(request.user)) query = Query(HelpdeskUser(request.user), base64query=query)
model_object = query_tickets_by_args(objects, '-date_created', **request.query_params) result = query.get_datatables_context(**request.query_params)
serializer = DatatablesTicketSerializer(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 (JsonResponse(result, status=status.HTTP_200_OK)) return (JsonResponse(result, status=status.HTTP_200_OK))
@helpdesk_staff_member_required
@api_view(['GET'])
def timeline_ticket_list(request, query):
query = Query(HelpdeskUser(request.user), base64query=query)
return (JsonResponse(query.get_timeline_context(), status=status.HTTP_200_OK))
@helpdesk_staff_member_required @helpdesk_staff_member_required
def edit_ticket(request, ticket_id): def edit_ticket(request, ticket_id):
ticket = get_object_or_404(Ticket, id=ticket_id) ticket = get_object_or_404(Ticket, id=ticket_id)
@ -994,17 +999,12 @@ def edit_ticket(request, ticket_id):
edit_ticket = staff_member_required(edit_ticket) edit_ticket = staff_member_required(edit_ticket)
class CreateTicketView(MustBeStaffMixin, FormView): class CreateTicketView(MustBeStaffMixin, abstract_views.AbstractCreateTicketMixin, FormView):
template_name = 'helpdesk/create_ticket.html' template_name = 'helpdesk/create_ticket.html'
form_class = TicketForm form_class = TicketForm
def get_initial(self): def get_initial(self):
initial_data = {} initial_data = super().get_initial()
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 return initial_data
def get_form_kwargs(self): def get_form_kwargs(self):