forked from extern/django-helpdesk
Merge remote-tracking branch 'upstream/master'
This commit is contained in:
commit
3b4a9891c0
1
.gitignore
vendored
1
.gitignore
vendored
@ -11,6 +11,7 @@ docs/doctrees/*
|
||||
.pydevproject
|
||||
.directory
|
||||
*.swp
|
||||
.idea
|
||||
|
||||
# ignore demo attachments that user might have added
|
||||
helpdesk/attachments/
|
||||
|
@ -7,9 +7,9 @@
|
||||
{"model": "helpdesk.followup", "pk": 2, "fields": {"ticket": 2, "date": "2017-03-20T04:54:53.031Z", "title": "Ticket Opened", "comment": "Something else with some other product. Not a big deal.", "public": true, "user": 1, "new_status": null}},
|
||||
{"model": "helpdesk.ticket", "pk": 3, "fields": {"title": "Something with an attachment", "queue": 1, "created": "2017-03-20T05:14:36.320Z", "modified": "2017-03-20T05:28:28.695Z", "submitter_email": "helpdesk@example.com", "assigned_to": null, "status": 1, "on_hold": false, "description": "WHOA!", "resolution": null, "priority": 1, "due_date": null, "last_escalation": null}},
|
||||
{"model": "helpdesk.followup", "pk": 3, "fields": {"ticket": 3, "date": "2017-03-20T05:14:36.345Z", "title": "Ticket Opened", "comment": "WHOA!", "public": true, "user": 1, "new_status": null}},
|
||||
{"model": "helpdesk.attachment", "pk": 1, "fields": {"followup": 3, "file": "helpdesk/attachments/DH-3/3/someinfo.txt", "filename": "someinfo.txt", "mime_type": "text/plain", "size": 56}},
|
||||
{"model": "helpdesk.followupattachment", "pk": 1, "fields": {"followup": 3, "file": "helpdesk/attachments/DH-3/3/someinfo.txt", "filename": "someinfo.txt", "mime_type": "text/plain", "size": 56}},
|
||||
{"model": "helpdesk.followup", "pk": 4, "fields": {"ticket": 3, "date": "2017-03-20T05:28:28.458Z", "title": "Comment", "comment": "An image attachment goes here!", "public": true, "user": 1, "new_status": null}},
|
||||
{"model": "helpdesk.attachment", "pk": 2, "fields": {"followup": 4, "file": "helpdesk/attachments/DH-3/4/helpdesk.png", "filename": "helpdesk.png", "mime_type": "image/png", "size": 30229}},
|
||||
{"model": "helpdesk.followupattachment", "pk": 2, "fields": {"followup": 4, "file": "helpdesk/attachments/DH-3/4/helpdesk.png", "filename": "helpdesk.png", "mime_type": "image/png", "size": 30229}},
|
||||
{"model": "helpdesk.kbcategory", "pk": 1, "fields": {"title": "KB Cat 1", "slug": "kbcat1", "description": "Some category of KB info"}},
|
||||
{"model": "helpdesk.kbcategory", "pk": 2, "fields": {"title": "KB Cat 2", "slug": "kbcat2", "description": "Here is another category. Enjoy!"}},
|
||||
{"model": "helpdesk.kbitem", "pk": 1, "fields": {"category": 1, "title": "Django-Helpdesk", "question": "What is Django-Helpdesk?", "answer": "An open source helpdesk written in python using the awesome django framework.", "votes": 0, "recommendations": 0, "last_updated": "2017-04-02T19:02:17.213Z"}},
|
||||
|
@ -15,6 +15,8 @@ Contents
|
||||
settings
|
||||
spam
|
||||
custom_fields
|
||||
integration
|
||||
teams
|
||||
contributing
|
||||
license
|
||||
|
||||
|
@ -58,7 +58,11 @@ errors with trying to create User settings.
|
||||
'django.contrib.admin', # Required for helpdesk admin/maintenance
|
||||
'django.contrib.humanize', # Required for elapsed time formatting
|
||||
'bootstrap4form', # Required for nicer formatting of forms with the default templates
|
||||
'account', # Required by pinax-teams
|
||||
'pinax.inviations', # required by pinax-teams
|
||||
'pinax.teams', # team support
|
||||
'helpdesk', # This is us!
|
||||
'reversion', # required by pinax-teams
|
||||
)
|
||||
|
||||
Your ``settings.py`` file should also define a ``SITE_ID`` that allows multiple projects to share
|
||||
|
@ -7,5 +7,12 @@ Django-helpdesk associates an email address with each submitted ticket. If you i
|
||||
- `title`
|
||||
- `body`
|
||||
- `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.
|
||||
|
14
docs/teams.rst
Normal file
14
docs/teams.rst
Normal file
@ -0,0 +1,14 @@
|
||||
Working with teams and larger organizations
|
||||
===========================================
|
||||
|
||||
If you only have one or two people working on tickets, basic Queue setup is enough to get you going. You can now assign tickets to teams for better ticket filtering, reducing noise and improving organization efficiency.
|
||||
|
||||
Rather than assigning tickets to teams directly, django-helpdesk allows you assign tickets to knowledge-base items and then assign knowledge base items to teams.
|
||||
|
||||
Knowledge-base items can be in either public or private knowledge-base categories, so this organizational structure need not have any influence on the external appearance of your public helpdesk web portal.
|
||||
|
||||
You can visit the 'Pinax Teams' page in your django admin in order to create a team and add team members.
|
||||
|
||||
You can assign a knowledge-base item to a team on the Helpdesk admin page.
|
||||
|
||||
Once you have set up teams. Unassigned tickets which are associated with a knowledge-base item will only be shown on the dashboard to those users who are members of the team which is associated with that knowledge-base item.
|
@ -70,9 +70,9 @@ class FollowUpAdmin(admin.ModelAdmin):
|
||||
|
||||
@admin.register(KBItem)
|
||||
class KBItemAdmin(admin.ModelAdmin):
|
||||
list_display = ('category', 'title', 'last_updated',)
|
||||
list_display = ('category', 'title', 'last_updated', 'team', 'order', 'enabled')
|
||||
inlines = [KBIAttachmentInline]
|
||||
readonly_fields = ('voted_by',)
|
||||
readonly_fields = ('voted_by', 'downvoted_by')
|
||||
|
||||
list_display_links = ('title',)
|
||||
|
||||
@ -93,6 +93,10 @@ class IgnoreEmailAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'queue_list', 'email_address', 'keep_in_mailbox')
|
||||
|
||||
|
||||
@admin.register(KBCategory)
|
||||
class KBCategoryAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'title', 'slug', 'public')
|
||||
|
||||
|
||||
admin.site.register(PreSetReply)
|
||||
admin.site.register(EscalationExclusion)
|
||||
admin.site.register(KBCategory)
|
||||
|
@ -487,13 +487,18 @@ def object_from_message(message, queue, logger):
|
||||
body.encode('utf-8')
|
||||
logger.debug("Discovered plain text MIME part")
|
||||
else:
|
||||
try:
|
||||
email_body = encoding.smart_text(part.get_payload(decode=True))
|
||||
except UnicodeDecodeError:
|
||||
email_body = encoding.smart_text(part.get_payload(decode=False))
|
||||
|
||||
payload = """
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
</head>
|
||||
%s
|
||||
</html>""" % encoding.smart_text(part.get_payload(decode=True))
|
||||
</html>""" % email_body
|
||||
files.append(
|
||||
SimpleUploadedFile(_("email_html_body.html"), payload.encode("utf-8"), 'text/html')
|
||||
)
|
||||
|
@ -6,11 +6,10 @@ django-helpdesk - A Django powered ticket tracker for small enterprise.
|
||||
forms.py - Definitions of newforms-based forms for creating and maintaining
|
||||
tickets.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django import forms
|
||||
from django.forms import widgets
|
||||
from django.conf import settings
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.contrib.auth import get_user_model
|
||||
@ -18,9 +17,10 @@ from django.utils import timezone
|
||||
|
||||
from helpdesk.lib import safe_template_context, process_attachments
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
User = get_user_model()
|
||||
|
||||
CUSTOMFIELD_TO_FIELD_DICT = {
|
||||
@ -158,7 +158,7 @@ class AbstractTicketForm(CustomFieldMixin, forms.Form):
|
||||
widget=forms.Select(attrs={'class': 'form-control'}),
|
||||
choices=Ticket.PRIORITY_CHOICES,
|
||||
required=True,
|
||||
initial='3',
|
||||
initial=getattr(settings, 'HELPDESK_PUBLIC_TICKET_PRIORITY', '3'),
|
||||
label=_('Priority'),
|
||||
help_text=_("Please select a priority carefully. If unsure, leave it as '3'."),
|
||||
)
|
||||
@ -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.'),
|
||||
)
|
||||
|
||||
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=_('Knowledge Base Item'),
|
||||
choices=[(kbi.pk, kbi.title) for kbi in KBItem.objects.filter(category=kbcategory.pk, enabled=True)],
|
||||
)
|
||||
|
||||
def _add_form_custom_fields(self, staff_only_filter=None):
|
||||
if staff_only_filter is None:
|
||||
queryset = CustomField.objects.all()
|
||||
@ -192,18 +202,33 @@ class AbstractTicketForm(CustomFieldMixin, forms.Form):
|
||||
|
||||
self.customfield_to_field(field, instanceargs)
|
||||
|
||||
def _create_ticket(self):
|
||||
queue = Queue.objects.get(id=int(self.cleaned_data['queue']))
|
||||
def _get_queue(self):
|
||||
# this procedure is re-defined for public submission form
|
||||
return Queue.objects.get(id=int(self.cleaned_data['queue']))
|
||||
|
||||
ticket = Ticket(title=self.cleaned_data['title'],
|
||||
submitter_email=self.cleaned_data['submitter_email'],
|
||||
created=timezone.now(),
|
||||
status=Ticket.OPEN_STATUS,
|
||||
queue=queue,
|
||||
description=self.cleaned_data['body'],
|
||||
priority=self.cleaned_data['priority'],
|
||||
due_date=self.cleaned_data['due_date'],
|
||||
)
|
||||
def _create_ticket(self):
|
||||
queue = self._get_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'],
|
||||
submitter_email=self.cleaned_data['submitter_email'],
|
||||
created=timezone.now(),
|
||||
status=Ticket.OPEN_STATUS,
|
||||
queue=queue,
|
||||
description=self.cleaned_data['body'],
|
||||
priority=self.cleaned_data.get(
|
||||
'priority',
|
||||
getattr(settings, "HELPDESK_PUBLIC_TICKET_PRIORITY", "3")
|
||||
),
|
||||
due_date=self.cleaned_data.get(
|
||||
'due_date',
|
||||
getattr(settings, "HELPDESK_PUBLIC_TICKET_DUE_DATE", None)
|
||||
) or None,
|
||||
kbitem=kbitem,
|
||||
)
|
||||
|
||||
return ticket, queue
|
||||
|
||||
@ -263,7 +288,11 @@ class TicketForm(AbstractTicketForm):
|
||||
'updates to this ticket.'),
|
||||
)
|
||||
assigned_to = forms.ChoiceField(
|
||||
widget=forms.Select(attrs={'class': 'form-control'}) if not helpdesk_settings.HELPDESK_CREATE_TICKET_HIDE_ASSIGNED_TO else forms.HiddenInput(),
|
||||
widget=(
|
||||
forms.Select(attrs={'class': 'form-control'})
|
||||
if not helpdesk_settings.HELPDESK_CREATE_TICKET_HIDE_ASSIGNED_TO
|
||||
else forms.HiddenInput()
|
||||
),
|
||||
required=False,
|
||||
label=_('Case owner'),
|
||||
help_text=_('If you select an owner other than yourself, they\'ll be '
|
||||
@ -333,20 +362,55 @@ class PublicTicketForm(AbstractTicketForm):
|
||||
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
|
||||
"""
|
||||
super(PublicTicketForm, self).__init__(*args, **kwargs)
|
||||
self._add_form_custom_fields(False)
|
||||
|
||||
if hasattr(settings, 'HELPDESK_PUBLIC_TICKET_QUEUE'):
|
||||
self.fields['queue'].widget = forms.HiddenInput()
|
||||
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()
|
||||
self.fields['queue'].choices = [('', '--------')] + [
|
||||
(q.id, q.title) for q in Queue.objects.filter(allow_public_submission=True)]
|
||||
field_hide_table = {
|
||||
'queue': 'HELPDESK_PUBLIC_TICKET_QUEUE',
|
||||
'priority': 'HELPDESK_PUBLIC_TICKET_PRIORITY',
|
||||
'due_date': 'HELPDESK_PUBLIC_TICKET_DUE_DATE',
|
||||
}
|
||||
|
||||
for field_name, field_setting_key in field_hide_table.items():
|
||||
has_settings_default_value = getattr(settings, field_setting_key, None)
|
||||
if has_settings_default_value is not None:
|
||||
hidden_fields += (field_name,)
|
||||
|
||||
for field in hidden_fields:
|
||||
if field in self.fields:
|
||||
del self.fields[field]
|
||||
|
||||
public_queues = Queue.objects.filter(allow_public_submission=True)
|
||||
|
||||
if len(public_queues) == 0:
|
||||
logger.warning(
|
||||
"There are no public queues defined - public ticket creation is impossible"
|
||||
)
|
||||
|
||||
if 'queue' in self.fields:
|
||||
self.fields['queue'].choices = [('', '--------')] + [
|
||||
(q.id, q.title) for q in public_queues]
|
||||
|
||||
def _get_queue(self):
|
||||
if getattr(settings, 'HELPDESK_PUBLIC_TICKET_QUEUE', None) is not None:
|
||||
# force queue to be the pre-defined one
|
||||
# (only for public submissions)
|
||||
public_queue = Queue.objects.filter(
|
||||
slug=settings.HELPDESK_PUBLIC_TICKET_QUEUE
|
||||
).first()
|
||||
if not public_queue:
|
||||
logger.fatal(
|
||||
"Public queue '%s' is configured as default but can't be found",
|
||||
settings.HELPDESK_PUBLIC_TICKET_QUEUE
|
||||
)
|
||||
return public_queue
|
||||
else:
|
||||
# get the queue user entered
|
||||
return Queue.objects.get(id=int(self.cleaned_data['queue']))
|
||||
|
||||
def save(self):
|
||||
"""
|
||||
|
@ -161,9 +161,9 @@ def format_time_spent(time_spent):
|
||||
"""
|
||||
|
||||
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)
|
||||
time_spent = "{0:02d}h:{1:02d}m".format(
|
||||
time_spent.seconds // 3600,
|
||||
time_spent.seconds // 60
|
||||
)
|
||||
else:
|
||||
time_spent = ""
|
||||
|
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@ -105,7 +105,7 @@ def escalate_tickets(queues, verbose):
|
||||
t.send(
|
||||
{'submitter': ('escalated_submitter', context),
|
||||
'ticket_cc': ('escalated_cc', context),
|
||||
'assigned_to': ('escalated_owner', context)}
|
||||
'assigned_to': ('escalated_owner', context)},
|
||||
fail_silently=True,
|
||||
)
|
||||
|
||||
|
71
helpdesk/migrations/0027_auto_20200107_1221.py
Normal file
71
helpdesk/migrations/0027_auto_20200107_1221.py
Normal 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'),
|
||||
),
|
||||
]
|
20
helpdesk/migrations/0028_kbitem_team.py
Normal file
20
helpdesk/migrations/0028_kbitem_team.py
Normal file
@ -0,0 +1,20 @@
|
||||
# Generated by Django 2.2.9 on 2020-01-27 15:01
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pinax_teams', '0004_auto_20170511_0856'),
|
||||
('helpdesk', '0027_auto_20200107_1221'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='kbitem',
|
||||
name='team',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pinax_teams.Team', verbose_name='Team'),
|
||||
),
|
||||
]
|
18
helpdesk/migrations/0029_kbcategory_public.py
Normal file
18
helpdesk/migrations/0029_kbcategory_public.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 2.2.9 on 2020-01-27 16:00
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('helpdesk', '0028_kbitem_team'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='kbcategory',
|
||||
name='public',
|
||||
field=models.BooleanField(default=True, verbose_name='Is KBCategory publicly visible?'),
|
||||
),
|
||||
]
|
33
helpdesk/migrations/0030_add_kbcategory_name.py
Normal file
33
helpdesk/migrations/0030_add_kbcategory_name.py
Normal file
@ -0,0 +1,33 @@
|
||||
# Generated by Django 2.2.10 on 2020-02-25 11:21
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
def copy_title(apps, schema_editor):
|
||||
KBCategory = apps.get_model("helpdesk", "KBCategory")
|
||||
KBCategory.objects.update(name=models.F('title'))
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('helpdesk', '0029_kbcategory_public'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='kbcategory',
|
||||
name='name',
|
||||
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Name of the category'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='kbcategory',
|
||||
name='title',
|
||||
field=models.CharField(max_length=100, verbose_name='Title on knowledgebase page'),
|
||||
),
|
||||
migrations.RunPython(copy_title, migrations.RunPython.noop),
|
||||
migrations.AlterField(
|
||||
model_name='kbcategory',
|
||||
name='name',
|
||||
field=models.CharField(blank=False, max_length=100, null=False, verbose_name='Name of the category'),
|
||||
),
|
||||
]
|
22
helpdesk/migrations/0031_auto_20200225_1440.py
Normal file
22
helpdesk/migrations/0031_auto_20200225_1440.py
Normal file
@ -0,0 +1,22 @@
|
||||
# Generated by Django 2.2.10 on 2020-02-25 13:40
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('helpdesk', '0030_add_kbcategory_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='kbitem',
|
||||
options={'ordering': ('order', 'title'), 'verbose_name': 'Knowledge base item', 'verbose_name_plural': 'Knowledge base items'},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='kbitem',
|
||||
name='order',
|
||||
field=models.PositiveIntegerField(blank=True, null=True, verbose_name='Order'),
|
||||
),
|
||||
]
|
18
helpdesk/migrations/0032_kbitem_enabled.py
Normal file
18
helpdesk/migrations/0032_kbitem_enabled.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 2.2.10 on 2020-02-25 13:44
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('helpdesk', '0031_auto_20200225_1440'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='kbitem',
|
||||
name='enabled',
|
||||
field=models.BooleanField(default=True, verbose_name='Enabled to display to users'),
|
||||
),
|
||||
]
|
@ -25,6 +25,8 @@ from django.utils.safestring import mark_safe
|
||||
from markdown import markdown
|
||||
from markdown.extensions import Extension
|
||||
|
||||
import pinax.teams.models
|
||||
|
||||
|
||||
import uuid
|
||||
|
||||
@ -35,9 +37,9 @@ from .templated_email import send_templated_mail
|
||||
|
||||
def format_time_spent(time_spent):
|
||||
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)
|
||||
time_spent = "{0:02d}h:{1:02d}m".format(
|
||||
time_spent.seconds // 3600,
|
||||
time_spent.seconds // 60
|
||||
)
|
||||
else:
|
||||
time_spent = ""
|
||||
@ -559,6 +561,14 @@ class Ticket(models.Model):
|
||||
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
|
||||
def time_spent(self):
|
||||
"""Return back total time spent on the ticket. This is calculated value
|
||||
@ -605,6 +615,8 @@ class Ticket(models.Model):
|
||||
if dont_send_to is not None:
|
||||
recipients.update(dont_send_to)
|
||||
|
||||
recipients.add(self.queue.email_address)
|
||||
|
||||
def should_receive(email):
|
||||
return email and email not in recipients
|
||||
|
||||
@ -1203,8 +1215,13 @@ class KBCategory(models.Model):
|
||||
listing of questions & answers.
|
||||
"""
|
||||
|
||||
name = models.CharField(
|
||||
_('Name of the category'),
|
||||
max_length=100,
|
||||
)
|
||||
|
||||
title = models.CharField(
|
||||
_('Title'),
|
||||
_('Title on knowledgebase page'),
|
||||
max_length=100,
|
||||
)
|
||||
|
||||
@ -1216,8 +1233,21 @@ class KBCategory(models.Model):
|
||||
_('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.'),
|
||||
)
|
||||
|
||||
public = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name=_("Is KBCategory publicly visible?")
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return '%s' % self.title
|
||||
return '%s' % self.name
|
||||
|
||||
class Meta:
|
||||
ordering = ('title',)
|
||||
@ -1234,7 +1264,14 @@ class KBItem(models.Model):
|
||||
An item within the knowledgebase. Very straightforward question/answer
|
||||
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(
|
||||
KBCategory,
|
||||
on_delete=models.CASCADE,
|
||||
@ -1272,6 +1309,25 @@ class KBItem(models.Model):
|
||||
blank=True,
|
||||
)
|
||||
|
||||
team = models.ForeignKey(
|
||||
pinax.teams.models.Team,
|
||||
on_delete=models.CASCADE,
|
||||
verbose_name=_('Team'),
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
|
||||
order = models.PositiveIntegerField(
|
||||
_('Order'),
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
|
||||
enabled = models.BooleanField(
|
||||
_('Enabled to display to users'),
|
||||
default=True,
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.last_updated:
|
||||
self.last_updated = timezone.now()
|
||||
@ -1285,16 +1341,26 @@ class KBItem(models.Model):
|
||||
score = property(_score)
|
||||
|
||||
def __str__(self):
|
||||
return '%s' % self.title
|
||||
return '%s: %s' % (self.category.title, self.title)
|
||||
|
||||
class Meta:
|
||||
ordering = ('title',)
|
||||
ordering = ('order', 'title',)
|
||||
verbose_name = _('Knowledge base item')
|
||||
verbose_name_plural = _('Knowledge base items')
|
||||
|
||||
def get_absolute_url(self):
|
||||
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 unassigned_tickets(self):
|
||||
return Ticket.objects.filter(kbitem=self, status__in=(1, 2), assigned_to__isnull=True)
|
||||
|
||||
def get_markdown(self):
|
||||
return get_markdown(self.answer)
|
||||
|
@ -1,12 +1,16 @@
|
||||
from django.db.models import Q
|
||||
from django.core.cache import cache
|
||||
|
||||
from model_utils import Choices
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
from base64 import b64encode
|
||||
from base64 import b64decode
|
||||
import json
|
||||
|
||||
from model_utils import Choices
|
||||
|
||||
from helpdesk.serializers import DatatablesTicketSerializer
|
||||
|
||||
|
||||
def query_to_base64(query):
|
||||
"""
|
||||
@ -47,110 +51,173 @@ def query_to_dict(results, descriptions):
|
||||
return output
|
||||
|
||||
|
||||
def apply_query(queryset, params):
|
||||
"""
|
||||
Apply a dict-based set of filters & parameters to a queryset.
|
||||
def get_search_filter_args(search):
|
||||
if search.startswith('queue:'):
|
||||
return Q(queue__title__icontains=search[len('queue:'):])
|
||||
if search.startswith('priority:'):
|
||||
return Q(priority__icontains=search[len('priority:'):])
|
||||
filter = Q()
|
||||
for subsearch in search.split("OR"):
|
||||
subsearch = subsearch.strip()
|
||||
filter = (
|
||||
filter |
|
||||
Q(id__icontains=subsearch) |
|
||||
Q(title__icontains=subsearch) |
|
||||
Q(description__icontains=subsearch) |
|
||||
Q(priority__icontains=subsearch) |
|
||||
Q(resolution__icontains=subsearch) |
|
||||
Q(submitter_email__icontains=subsearch) |
|
||||
Q(assigned_to__email__icontains=subsearch) |
|
||||
Q(ticketcustomfieldvalue__value__icontains=subsearch) |
|
||||
Q(created__icontains=subsearch) |
|
||||
Q(due_date__icontains=subsearch)
|
||||
)
|
||||
return filter
|
||||
|
||||
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:
|
||||
DATATABLES_ORDER_COLUMN_CHOICES = Choices(
|
||||
('0', 'id'),
|
||||
('1', 'title'),
|
||||
('2', 'priority'),
|
||||
('3', 'queue'),
|
||||
('4', 'status'),
|
||||
('5', 'created'),
|
||||
('6', 'due_date'),
|
||||
('7', 'assigned_to'),
|
||||
('8', 'submitter_email'),
|
||||
# ('9', 'time_spent'),
|
||||
('10', 'kbitem'),
|
||||
)
|
||||
|
||||
|
||||
def get_query_class():
|
||||
from django.conf import settings
|
||||
|
||||
def _get_query_class():
|
||||
return __Query__
|
||||
return getattr(settings,
|
||||
'HELPDESK_QUERY_CLASS',
|
||||
_get_query_class)()
|
||||
|
||||
|
||||
class __Query__:
|
||||
def __init__(self, huser, base64query=None, query_params=None):
|
||||
self.huser = huser
|
||||
self.params = query_params if query_params else query_from_base64(base64query)
|
||||
self.base64 = base64query if base64query else query_to_base64(query_params)
|
||||
self.result = None
|
||||
|
||||
def get_search_filter_args(self):
|
||||
search = self.params.get('search_string', '')
|
||||
return get_search_filter_args(search)
|
||||
|
||||
def __run__(self, queryset):
|
||||
"""
|
||||
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)
|
||||
"""
|
||||
filter = self.params.get('filtering', {})
|
||||
filter_or = self.params.get('filtering_or', {})
|
||||
queryset = queryset.filter((Q(**filter) | Q(**filter_or)) & 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
|
||||
|
||||
search = params.get('search_string', '')
|
||||
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)
|
||||
)
|
||||
def get_cache_key(self):
|
||||
return str(self.huser.user.pk) + ":" + self.base64
|
||||
|
||||
queryset = queryset.filter(qset)
|
||||
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
|
||||
|
||||
sorting = params.get('sorting', None)
|
||||
if sorting:
|
||||
sortreverse = params.get('sortreverse', None)
|
||||
if sortreverse:
|
||||
sorting = "-%s" % sorting
|
||||
queryset = queryset.order_by(sorting)
|
||||
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()
|
||||
|
||||
return queryset
|
||||
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', [0])[0])
|
||||
length = int(kwargs.get('length', [25])[0])
|
||||
start = int(kwargs.get('start', [0])[0])
|
||||
search_value = kwargs.get('search[value]', [""])[0]
|
||||
order_column = kwargs.get('order[0][column]', ['5'])[0]
|
||||
order = kwargs.get('order[0][dir]', ["asc"])[0]
|
||||
|
||||
order_column = DATATABLES_ORDER_COLUMN_CHOICES[order_column]
|
||||
# django orm '-' -> desc
|
||||
if order == 'desc':
|
||||
order_column = '-' + order_column
|
||||
|
||||
def get_query(query, huser):
|
||||
# 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
|
||||
queryset = objects.all().order_by(order_by)
|
||||
total = queryset.count()
|
||||
|
||||
if search_value: # Dead code currently
|
||||
queryset = queryset.filter(get_search_filter_args(search_value))
|
||||
|
||||
ORDER_COLUMN_CHOICES = Choices(
|
||||
('0', 'id'),
|
||||
('2', 'priority'),
|
||||
('3', 'title'),
|
||||
('4', 'queue'),
|
||||
('5', 'status'),
|
||||
('6', 'created'),
|
||||
('7', 'due_date'),
|
||||
('8', 'assigned_to')
|
||||
)
|
||||
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 = []
|
||||
|
||||
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 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]
|
||||
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)
|
||||
|
||||
order_column = ORDER_COLUMN_CHOICES[order_column]
|
||||
# django orm '-' -> desc
|
||||
if order == 'desc':
|
||||
order_column = '-' + order_column
|
||||
return {
|
||||
'events': events,
|
||||
}
|
||||
|
||||
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 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,
|
||||
}
|
||||
|
@ -15,19 +15,21 @@ datatables for ticket_list.html. Called from staff.datatables_ticket_list.
|
||||
class DatatablesTicketSerializer(serializers.ModelSerializer):
|
||||
ticket = serializers.SerializerMethodField()
|
||||
assigned_to = serializers.SerializerMethodField()
|
||||
submitter = serializers.SerializerMethodField()
|
||||
created = serializers.SerializerMethodField()
|
||||
due_date = serializers.SerializerMethodField()
|
||||
status = serializers.SerializerMethodField()
|
||||
row_class = serializers.SerializerMethodField()
|
||||
time_spent = serializers.SerializerMethodField()
|
||||
queue = serializers.SerializerMethodField()
|
||||
kbitem = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Ticket
|
||||
# fields = '__all__'
|
||||
fields = ('ticket', 'id', 'priority', 'title', 'queue', 'status',
|
||||
'created', 'due_date', 'assigned_to', 'row_class',
|
||||
'time_spent')
|
||||
'created', 'due_date', 'assigned_to', 'submitter', 'row_class',
|
||||
'time_spent', 'kbitem')
|
||||
|
||||
def get_queue(self, obj):
|
||||
return ({"title": obj.queue.title, "id": obj.queue.id})
|
||||
@ -46,15 +48,21 @@ class DatatablesTicketSerializer(serializers.ModelSerializer):
|
||||
|
||||
def get_assigned_to(self, obj):
|
||||
if obj.assigned_to:
|
||||
if obj.assigned_to.first_name:
|
||||
return (obj.assigned_to.first_name)
|
||||
if obj.assigned_to.get_full_name():
|
||||
return (obj.assigned_to.get_full_name())
|
||||
else:
|
||||
return (obj.assigned_to.email)
|
||||
else:
|
||||
return ("None")
|
||||
|
||||
def get_submitter(self, obj):
|
||||
return obj.submitter_email
|
||||
|
||||
def get_time_spent(self, obj):
|
||||
return format_time_spent(obj.time_spent)
|
||||
|
||||
def get_row_class(self, obj):
|
||||
return (obj.get_priority_css_class)
|
||||
|
||||
def get_kbitem(self, obj):
|
||||
return obj.kbitem.title if obj.kbitem else ""
|
||||
|
@ -242,6 +242,10 @@ body.fixed-nav.sidebar-toggled #content-wrapper {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.card-text {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.card-body-icon {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
|
380
helpdesk/static/helpdesk/vendor/datatables/css/buttons.dataTables.css
vendored
Normal file
380
helpdesk/static/helpdesk/vendor/datatables/css/buttons.dataTables.css
vendored
Normal file
@ -0,0 +1,380 @@
|
||||
@keyframes dtb-spinner {
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@-o-keyframes dtb-spinner {
|
||||
100% {
|
||||
-o-transform: rotate(360deg);
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@-ms-keyframes dtb-spinner {
|
||||
100% {
|
||||
-ms-transform: rotate(360deg);
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@-webkit-keyframes dtb-spinner {
|
||||
100% {
|
||||
-webkit-transform: rotate(360deg);
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@-moz-keyframes dtb-spinner {
|
||||
100% {
|
||||
-moz-transform: rotate(360deg);
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
div.dt-button-info {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 400px;
|
||||
margin-top: -100px;
|
||||
margin-left: -200px;
|
||||
background-color: white;
|
||||
border: 2px solid #111;
|
||||
box-shadow: 3px 3px 8px rgba(0, 0, 0, 0.3);
|
||||
border-radius: 3px;
|
||||
text-align: center;
|
||||
z-index: 21;
|
||||
}
|
||||
div.dt-button-info h2 {
|
||||
padding: 0.5em;
|
||||
margin: 0;
|
||||
font-weight: normal;
|
||||
border-bottom: 1px solid #ddd;
|
||||
background-color: #f3f3f3;
|
||||
}
|
||||
div.dt-button-info > div {
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
div.dt-button-collection-title {
|
||||
text-align: center;
|
||||
padding: 0.3em 0 0.5em;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
div.dt-button-collection-title:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
button.dt-button,
|
||||
div.dt-button,
|
||||
a.dt-button {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
margin-right: 0.333em;
|
||||
margin-bottom: 0.333em;
|
||||
padding: 0.5em 1em;
|
||||
border: 1px solid #999;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
font-size: 0.88em;
|
||||
line-height: 1.6em;
|
||||
color: black;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
background-color: #e9e9e9;
|
||||
/* Fallback */
|
||||
background-image: -webkit-linear-gradient(top, white 0%, #e9e9e9 100%);
|
||||
/* Chrome 10+, Saf5.1+, iOS 5+ */
|
||||
background-image: -moz-linear-gradient(top, white 0%, #e9e9e9 100%);
|
||||
/* FF3.6 */
|
||||
background-image: -ms-linear-gradient(top, white 0%, #e9e9e9 100%);
|
||||
/* IE10 */
|
||||
background-image: -o-linear-gradient(top, white 0%, #e9e9e9 100%);
|
||||
/* Opera 11.10+ */
|
||||
background-image: linear-gradient(to bottom, white 0%, #e9e9e9 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(GradientType=0,StartColorStr='white', EndColorStr='#e9e9e9');
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
text-decoration: none;
|
||||
outline: none;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
button.dt-button.disabled,
|
||||
div.dt-button.disabled,
|
||||
a.dt-button.disabled {
|
||||
color: #999;
|
||||
border: 1px solid #d0d0d0;
|
||||
cursor: default;
|
||||
background-color: #f9f9f9;
|
||||
/* Fallback */
|
||||
background-image: -webkit-linear-gradient(top, #ffffff 0%, #f9f9f9 100%);
|
||||
/* Chrome 10+, Saf5.1+, iOS 5+ */
|
||||
background-image: -moz-linear-gradient(top, #ffffff 0%, #f9f9f9 100%);
|
||||
/* FF3.6 */
|
||||
background-image: -ms-linear-gradient(top, #ffffff 0%, #f9f9f9 100%);
|
||||
/* IE10 */
|
||||
background-image: -o-linear-gradient(top, #ffffff 0%, #f9f9f9 100%);
|
||||
/* Opera 11.10+ */
|
||||
background-image: linear-gradient(to bottom, #ffffff 0%, #f9f9f9 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(GradientType=0,StartColorStr='#ffffff', EndColorStr='#f9f9f9');
|
||||
}
|
||||
button.dt-button:active:not(.disabled), button.dt-button.active:not(.disabled),
|
||||
div.dt-button:active:not(.disabled),
|
||||
div.dt-button.active:not(.disabled),
|
||||
a.dt-button:active:not(.disabled),
|
||||
a.dt-button.active:not(.disabled) {
|
||||
background-color: #e2e2e2;
|
||||
/* Fallback */
|
||||
background-image: -webkit-linear-gradient(top, #f3f3f3 0%, #e2e2e2 100%);
|
||||
/* Chrome 10+, Saf5.1+, iOS 5+ */
|
||||
background-image: -moz-linear-gradient(top, #f3f3f3 0%, #e2e2e2 100%);
|
||||
/* FF3.6 */
|
||||
background-image: -ms-linear-gradient(top, #f3f3f3 0%, #e2e2e2 100%);
|
||||
/* IE10 */
|
||||
background-image: -o-linear-gradient(top, #f3f3f3 0%, #e2e2e2 100%);
|
||||
/* Opera 11.10+ */
|
||||
background-image: linear-gradient(to bottom, #f3f3f3 0%, #e2e2e2 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(GradientType=0,StartColorStr='#f3f3f3', EndColorStr='#e2e2e2');
|
||||
box-shadow: inset 1px 1px 3px #999999;
|
||||
}
|
||||
button.dt-button:active:not(.disabled):hover:not(.disabled), button.dt-button.active:not(.disabled):hover:not(.disabled),
|
||||
div.dt-button:active:not(.disabled):hover:not(.disabled),
|
||||
div.dt-button.active:not(.disabled):hover:not(.disabled),
|
||||
a.dt-button:active:not(.disabled):hover:not(.disabled),
|
||||
a.dt-button.active:not(.disabled):hover:not(.disabled) {
|
||||
box-shadow: inset 1px 1px 3px #999999;
|
||||
background-color: #cccccc;
|
||||
/* Fallback */
|
||||
background-image: -webkit-linear-gradient(top, #eaeaea 0%, #cccccc 100%);
|
||||
/* Chrome 10+, Saf5.1+, iOS 5+ */
|
||||
background-image: -moz-linear-gradient(top, #eaeaea 0%, #cccccc 100%);
|
||||
/* FF3.6 */
|
||||
background-image: -ms-linear-gradient(top, #eaeaea 0%, #cccccc 100%);
|
||||
/* IE10 */
|
||||
background-image: -o-linear-gradient(top, #eaeaea 0%, #cccccc 100%);
|
||||
/* Opera 11.10+ */
|
||||
background-image: linear-gradient(to bottom, #eaeaea 0%, #cccccc 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(GradientType=0,StartColorStr='#eaeaea', EndColorStr='#cccccc');
|
||||
}
|
||||
button.dt-button:hover,
|
||||
div.dt-button:hover,
|
||||
a.dt-button:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
button.dt-button:hover:not(.disabled),
|
||||
div.dt-button:hover:not(.disabled),
|
||||
a.dt-button:hover:not(.disabled) {
|
||||
border: 1px solid #666;
|
||||
background-color: #e0e0e0;
|
||||
/* Fallback */
|
||||
background-image: -webkit-linear-gradient(top, #f9f9f9 0%, #e0e0e0 100%);
|
||||
/* Chrome 10+, Saf5.1+, iOS 5+ */
|
||||
background-image: -moz-linear-gradient(top, #f9f9f9 0%, #e0e0e0 100%);
|
||||
/* FF3.6 */
|
||||
background-image: -ms-linear-gradient(top, #f9f9f9 0%, #e0e0e0 100%);
|
||||
/* IE10 */
|
||||
background-image: -o-linear-gradient(top, #f9f9f9 0%, #e0e0e0 100%);
|
||||
/* Opera 11.10+ */
|
||||
background-image: linear-gradient(to bottom, #f9f9f9 0%, #e0e0e0 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(GradientType=0,StartColorStr='#f9f9f9', EndColorStr='#e0e0e0');
|
||||
}
|
||||
button.dt-button:focus:not(.disabled),
|
||||
div.dt-button:focus:not(.disabled),
|
||||
a.dt-button:focus:not(.disabled) {
|
||||
border: 1px solid #426c9e;
|
||||
text-shadow: 0 1px 0 #c4def1;
|
||||
outline: none;
|
||||
background-color: #79ace9;
|
||||
/* Fallback */
|
||||
background-image: -webkit-linear-gradient(top, #bddef4 0%, #79ace9 100%);
|
||||
/* Chrome 10+, Saf5.1+, iOS 5+ */
|
||||
background-image: -moz-linear-gradient(top, #bddef4 0%, #79ace9 100%);
|
||||
/* FF3.6 */
|
||||
background-image: -ms-linear-gradient(top, #bddef4 0%, #79ace9 100%);
|
||||
/* IE10 */
|
||||
background-image: -o-linear-gradient(top, #bddef4 0%, #79ace9 100%);
|
||||
/* Opera 11.10+ */
|
||||
background-image: linear-gradient(to bottom, #bddef4 0%, #79ace9 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(GradientType=0,StartColorStr='#bddef4', EndColorStr='#79ace9');
|
||||
}
|
||||
|
||||
.dt-button embed {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
div.dt-buttons {
|
||||
position: relative;
|
||||
float: left;
|
||||
}
|
||||
div.dt-buttons.buttons-right {
|
||||
float: right;
|
||||
}
|
||||
|
||||
div.dt-button-collection {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 150px;
|
||||
margin-top: 3px;
|
||||
padding: 8px 8px 4px 8px;
|
||||
border: 1px solid #ccc;
|
||||
border: 1px solid rgba(0, 0, 0, 0.4);
|
||||
background-color: white;
|
||||
overflow: hidden;
|
||||
z-index: 2002;
|
||||
border-radius: 5px;
|
||||
box-shadow: 3px 3px 5px rgba(0, 0, 0, 0.3);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
div.dt-button-collection button.dt-button,
|
||||
div.dt-button-collection div.dt-button,
|
||||
div.dt-button-collection a.dt-button {
|
||||
position: relative;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
display: block;
|
||||
float: none;
|
||||
margin-bottom: 4px;
|
||||
margin-right: 0;
|
||||
}
|
||||
div.dt-button-collection button.dt-button:active:not(.disabled), div.dt-button-collection button.dt-button.active:not(.disabled),
|
||||
div.dt-button-collection div.dt-button:active:not(.disabled),
|
||||
div.dt-button-collection div.dt-button.active:not(.disabled),
|
||||
div.dt-button-collection a.dt-button:active:not(.disabled),
|
||||
div.dt-button-collection a.dt-button.active:not(.disabled) {
|
||||
background-color: #dadada;
|
||||
/* Fallback */
|
||||
background-image: -webkit-linear-gradient(top, #f0f0f0 0%, #dadada 100%);
|
||||
/* Chrome 10+, Saf5.1+, iOS 5+ */
|
||||
background-image: -moz-linear-gradient(top, #f0f0f0 0%, #dadada 100%);
|
||||
/* FF3.6 */
|
||||
background-image: -ms-linear-gradient(top, #f0f0f0 0%, #dadada 100%);
|
||||
/* IE10 */
|
||||
background-image: -o-linear-gradient(top, #f0f0f0 0%, #dadada 100%);
|
||||
/* Opera 11.10+ */
|
||||
background-image: linear-gradient(to bottom, #f0f0f0 0%, #dadada 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(GradientType=0,StartColorStr='#f0f0f0', EndColorStr='#dadada');
|
||||
box-shadow: inset 1px 1px 3px #666;
|
||||
}
|
||||
div.dt-button-collection.fixed {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
margin-left: -75px;
|
||||
border-radius: 0;
|
||||
}
|
||||
div.dt-button-collection.fixed.two-column {
|
||||
margin-left: -200px;
|
||||
}
|
||||
div.dt-button-collection.fixed.three-column {
|
||||
margin-left: -225px;
|
||||
}
|
||||
div.dt-button-collection.fixed.four-column {
|
||||
margin-left: -300px;
|
||||
}
|
||||
div.dt-button-collection > :last-child {
|
||||
display: block !important;
|
||||
-webkit-column-gap: 8px;
|
||||
-moz-column-gap: 8px;
|
||||
-ms-column-gap: 8px;
|
||||
-o-column-gap: 8px;
|
||||
column-gap: 8px;
|
||||
}
|
||||
div.dt-button-collection > :last-child > * {
|
||||
-webkit-column-break-inside: avoid;
|
||||
break-inside: avoid;
|
||||
}
|
||||
div.dt-button-collection.two-column {
|
||||
width: 400px;
|
||||
}
|
||||
div.dt-button-collection.two-column > :last-child {
|
||||
padding-bottom: 1px;
|
||||
-webkit-column-count: 2;
|
||||
-moz-column-count: 2;
|
||||
-ms-column-count: 2;
|
||||
-o-column-count: 2;
|
||||
column-count: 2;
|
||||
}
|
||||
div.dt-button-collection.three-column {
|
||||
width: 450px;
|
||||
}
|
||||
div.dt-button-collection.three-column > :last-child {
|
||||
padding-bottom: 1px;
|
||||
-webkit-column-count: 3;
|
||||
-moz-column-count: 3;
|
||||
-ms-column-count: 3;
|
||||
-o-column-count: 3;
|
||||
column-count: 3;
|
||||
}
|
||||
div.dt-button-collection.four-column {
|
||||
width: 600px;
|
||||
}
|
||||
div.dt-button-collection.four-column > :last-child {
|
||||
padding-bottom: 1px;
|
||||
-webkit-column-count: 4;
|
||||
-moz-column-count: 4;
|
||||
-ms-column-count: 4;
|
||||
-o-column-count: 4;
|
||||
column-count: 4;
|
||||
}
|
||||
div.dt-button-collection .dt-button {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
div.dt-button-background {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
/* Fallback */
|
||||
background: -ms-radial-gradient(center, ellipse farthest-corner, rgba(0, 0, 0, 0.3) 0%, rgba(0, 0, 0, 0.7) 100%);
|
||||
/* IE10 Consumer Preview */
|
||||
background: -moz-radial-gradient(center, ellipse farthest-corner, rgba(0, 0, 0, 0.3) 0%, rgba(0, 0, 0, 0.7) 100%);
|
||||
/* Firefox */
|
||||
background: -o-radial-gradient(center, ellipse farthest-corner, rgba(0, 0, 0, 0.3) 0%, rgba(0, 0, 0, 0.7) 100%);
|
||||
/* Opera */
|
||||
background: -webkit-gradient(radial, center center, 0, center center, 497, color-stop(0, rgba(0, 0, 0, 0.3)), color-stop(1, rgba(0, 0, 0, 0.7)));
|
||||
/* Webkit (Safari/Chrome 10) */
|
||||
background: -webkit-radial-gradient(center, ellipse farthest-corner, rgba(0, 0, 0, 0.3) 0%, rgba(0, 0, 0, 0.7) 100%);
|
||||
/* Webkit (Chrome 11+) */
|
||||
background: radial-gradient(ellipse farthest-corner at center, rgba(0, 0, 0, 0.3) 0%, rgba(0, 0, 0, 0.7) 100%);
|
||||
/* W3C Markup, IE10 Release Preview */
|
||||
z-index: 2001;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 640px) {
|
||||
div.dt-buttons {
|
||||
float: none !important;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
button.dt-button.processing,
|
||||
div.dt-button.processing,
|
||||
a.dt-button.processing {
|
||||
color: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
button.dt-button.processing:after,
|
||||
div.dt-button.processing:after,
|
||||
a.dt-button.processing:after {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: -8px 0 0 -8px;
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
content: ' ';
|
||||
border: 2px solid #282828;
|
||||
border-radius: 50%;
|
||||
border-left-color: transparent;
|
||||
border-right-color: transparent;
|
||||
animation: dtb-spinner 1500ms infinite linear;
|
||||
-o-animation: dtb-spinner 1500ms infinite linear;
|
||||
-ms-animation: dtb-spinner 1500ms infinite linear;
|
||||
-webkit-animation: dtb-spinner 1500ms infinite linear;
|
||||
-moz-animation: dtb-spinner 1500ms infinite linear;
|
||||
}
|
206
helpdesk/static/helpdesk/vendor/datatables/js/buttons.colVis.js
vendored
Normal file
206
helpdesk/static/helpdesk/vendor/datatables/js/buttons.colVis.js
vendored
Normal file
@ -0,0 +1,206 @@
|
||||
/*!
|
||||
* Column visibility buttons for Buttons and DataTables.
|
||||
* 2016 SpryMedia Ltd - datatables.net/license
|
||||
*/
|
||||
|
||||
(function( factory ){
|
||||
if ( typeof define === 'function' && define.amd ) {
|
||||
// AMD
|
||||
define( ['jquery', 'datatables.net', 'datatables.net-buttons'], function ( $ ) {
|
||||
return factory( $, window, document );
|
||||
} );
|
||||
}
|
||||
else if ( typeof exports === 'object' ) {
|
||||
// CommonJS
|
||||
module.exports = function (root, $) {
|
||||
if ( ! root ) {
|
||||
root = window;
|
||||
}
|
||||
|
||||
if ( ! $ || ! $.fn.dataTable ) {
|
||||
$ = require('datatables.net')(root, $).$;
|
||||
}
|
||||
|
||||
if ( ! $.fn.dataTable.Buttons ) {
|
||||
require('datatables.net-buttons')(root, $);
|
||||
}
|
||||
|
||||
return factory( $, root, root.document );
|
||||
};
|
||||
}
|
||||
else {
|
||||
// Browser
|
||||
factory( jQuery, window, document );
|
||||
}
|
||||
}(function( $, window, document, undefined ) {
|
||||
'use strict';
|
||||
var DataTable = $.fn.dataTable;
|
||||
|
||||
|
||||
$.extend( DataTable.ext.buttons, {
|
||||
// A collection of column visibility buttons
|
||||
colvis: function ( dt, conf ) {
|
||||
return {
|
||||
extend: 'collection',
|
||||
text: function ( dt ) {
|
||||
return dt.i18n( 'buttons.colvis', 'Column visibility' );
|
||||
},
|
||||
className: 'buttons-colvis',
|
||||
buttons: [ {
|
||||
extend: 'columnsToggle',
|
||||
columns: conf.columns,
|
||||
columnText: conf.columnText
|
||||
} ]
|
||||
};
|
||||
},
|
||||
|
||||
// Selected columns with individual buttons - toggle column visibility
|
||||
columnsToggle: function ( dt, conf ) {
|
||||
var columns = dt.columns( conf.columns ).indexes().map( function ( idx ) {
|
||||
return {
|
||||
extend: 'columnToggle',
|
||||
columns: idx,
|
||||
columnText: conf.columnText
|
||||
};
|
||||
} ).toArray();
|
||||
|
||||
return columns;
|
||||
},
|
||||
|
||||
// Single button to toggle column visibility
|
||||
columnToggle: function ( dt, conf ) {
|
||||
return {
|
||||
extend: 'columnVisibility',
|
||||
columns: conf.columns,
|
||||
columnText: conf.columnText
|
||||
};
|
||||
},
|
||||
|
||||
// Selected columns with individual buttons - set column visibility
|
||||
columnsVisibility: function ( dt, conf ) {
|
||||
var columns = dt.columns( conf.columns ).indexes().map( function ( idx ) {
|
||||
return {
|
||||
extend: 'columnVisibility',
|
||||
columns: idx,
|
||||
visibility: conf.visibility,
|
||||
columnText: conf.columnText
|
||||
};
|
||||
} ).toArray();
|
||||
|
||||
return columns;
|
||||
},
|
||||
|
||||
// Single button to set column visibility
|
||||
columnVisibility: {
|
||||
columns: undefined, // column selector
|
||||
text: function ( dt, button, conf ) {
|
||||
return conf._columnText( dt, conf );
|
||||
},
|
||||
className: 'buttons-columnVisibility',
|
||||
action: function ( e, dt, button, conf ) {
|
||||
var col = dt.columns( conf.columns );
|
||||
var curr = col.visible();
|
||||
|
||||
col.visible( conf.visibility !== undefined ?
|
||||
conf.visibility :
|
||||
! (curr.length ? curr[0] : false )
|
||||
);
|
||||
},
|
||||
init: function ( dt, button, conf ) {
|
||||
var that = this;
|
||||
button.attr( 'data-cv-idx', conf.columns );
|
||||
|
||||
dt
|
||||
.on( 'column-visibility.dt'+conf.namespace, function (e, settings) {
|
||||
if ( ! settings.bDestroying && settings.nTable == dt.settings()[0].nTable ) {
|
||||
that.active( dt.column( conf.columns ).visible() );
|
||||
}
|
||||
} )
|
||||
.on( 'column-reorder.dt'+conf.namespace, function (e, settings, details) {
|
||||
if ( dt.columns( conf.columns ).count() !== 1 ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// This button controls the same column index but the text for the column has
|
||||
// changed
|
||||
button.text( conf._columnText( dt, conf ) );
|
||||
|
||||
// Since its a different column, we need to check its visibility
|
||||
that.active( dt.column( conf.columns ).visible() );
|
||||
} );
|
||||
|
||||
this.active( dt.column( conf.columns ).visible() );
|
||||
},
|
||||
destroy: function ( dt, button, conf ) {
|
||||
dt
|
||||
.off( 'column-visibility.dt'+conf.namespace )
|
||||
.off( 'column-reorder.dt'+conf.namespace );
|
||||
},
|
||||
|
||||
_columnText: function ( dt, conf ) {
|
||||
// Use DataTables' internal data structure until this is presented
|
||||
// is a public API. The other option is to use
|
||||
// `$( column(col).node() ).text()` but the node might not have been
|
||||
// populated when Buttons is constructed.
|
||||
var idx = dt.column( conf.columns ).index();
|
||||
var title = dt.settings()[0].aoColumns[ idx ].sTitle
|
||||
.replace(/\n/g," ") // remove new lines
|
||||
.replace(/<br\s*\/?>/gi, " ") // replace line breaks with spaces
|
||||
.replace(/<select(.*?)<\/select>/g, "") // remove select tags, including options text
|
||||
.replace(/<!\-\-.*?\-\->/g, "") // strip HTML comments
|
||||
.replace(/<.*?>/g, "") // strip HTML
|
||||
.replace(/^\s+|\s+$/g,""); // trim
|
||||
|
||||
return conf.columnText ?
|
||||
conf.columnText( dt, idx, title ) :
|
||||
title;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
colvisRestore: {
|
||||
className: 'buttons-colvisRestore',
|
||||
|
||||
text: function ( dt ) {
|
||||
return dt.i18n( 'buttons.colvisRestore', 'Restore visibility' );
|
||||
},
|
||||
|
||||
init: function ( dt, button, conf ) {
|
||||
conf._visOriginal = dt.columns().indexes().map( function ( idx ) {
|
||||
return dt.column( idx ).visible();
|
||||
} ).toArray();
|
||||
},
|
||||
|
||||
action: function ( e, dt, button, conf ) {
|
||||
dt.columns().every( function ( i ) {
|
||||
// Take into account that ColReorder might have disrupted our
|
||||
// indexes
|
||||
var idx = dt.colReorder && dt.colReorder.transpose ?
|
||||
dt.colReorder.transpose( i, 'toOriginal' ) :
|
||||
i;
|
||||
|
||||
this.visible( conf._visOriginal[ idx ] );
|
||||
} );
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
colvisGroup: {
|
||||
className: 'buttons-colvisGroup',
|
||||
|
||||
action: function ( e, dt, button, conf ) {
|
||||
dt.columns( conf.show ).visible( true, false );
|
||||
dt.columns( conf.hide ).visible( false, false );
|
||||
|
||||
dt.columns.adjust();
|
||||
},
|
||||
|
||||
show: [],
|
||||
|
||||
hide: []
|
||||
}
|
||||
} );
|
||||
|
||||
|
||||
return DataTable.Buttons;
|
||||
}));
|
2015
helpdesk/static/helpdesk/vendor/datatables/js/dataTables.buttons.js
vendored
Normal file
2015
helpdesk/static/helpdesk/vendor/datatables/js/dataTables.buttons.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
75
helpdesk/templates/helpdesk/base-head.html
Normal file
75
helpdesk/templates/helpdesk/base-head.html
Normal file
@ -0,0 +1,75 @@
|
||||
{% 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">
|
||||
<link href="{% static 'helpdesk/vendor/datatables/css/buttons.dataTables.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 %}
|
@ -9,69 +9,7 @@
|
||||
|
||||
<head>
|
||||
|
||||
<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" >
|
||||
|
||||
<!-- 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>
|
||||
|
||||
{% include 'helpdesk/base-head.html' %}
|
||||
{% block helpdesk_head %}{% endblock %}
|
||||
|
||||
</head>
|
||||
@ -79,10 +17,10 @@
|
||||
<body id="bg-dark">
|
||||
|
||||
{% include "helpdesk/navigation-header.html" %}
|
||||
|
||||
|
||||
<div id="wrapper">
|
||||
{% include "helpdesk/navigation-sidebar.html" %}
|
||||
|
||||
|
||||
<div id="content-wrapper">
|
||||
|
||||
<div class="container-fluid">
|
||||
@ -105,35 +43,9 @@
|
||||
<!-- /#wrapper -->
|
||||
|
||||
{% 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 -->
|
||||
<script src="{% static 'helpdesk/vendor/metisMenu/metisMenu.min.js' %}"></script>
|
||||
|
||||
<!-- Custom Theme JavaScript -->
|
||||
<script src="{% static 'helpdesk/js/sb-admin.js' %}"></script>
|
||||
|
||||
{% include 'helpdesk/base_js.html' %}
|
||||
{% block helpdesk_js %}{% endblock %}
|
||||
|
||||
</body>
|
||||
|
32
helpdesk/templates/helpdesk/base_js.html
Normal file
32
helpdesk/templates/helpdesk/base_js.html
Normal file
@ -0,0 +1,32 @@
|
||||
{% 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>
|
||||
<script src="{% static 'helpdesk/vendor/datatables/js/dataTables.buttons.js' %}"></script>
|
||||
<script src="{% static 'helpdesk/vendor/datatables/js/buttons.colVis.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 js_bottom %}
|
||||
{% endblock %}
|
24
helpdesk/templates/helpdesk/filters/kbitems.html
Normal file
24
helpdesk/templates/helpdesk/filters/kbitems.html
Normal file
@ -0,0 +1,24 @@
|
||||
{% 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'>
|
||||
{% with magic_number=-1 %}
|
||||
<option value='{{magic_number}}'{% if magic_number|in_list:query_params.filtering.kbitem__in %} selected='selected'{% endif %}>
|
||||
{% trans "Uncategorized" %}
|
||||
</option>
|
||||
{% endwith %}
|
||||
{% 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>
|
@ -9,5 +9,5 @@
|
||||
<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>
|
||||
<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>
|
||||
|
@ -7,6 +7,11 @@
|
||||
</div>
|
||||
<div class="col col-sm-3">
|
||||
<select id='id_owners' name='assigned_to' multiple='selected' size='5'>
|
||||
{% with magic_number=-1 %}
|
||||
<option value='{{magic_number}}'{% if magic_number|in_list:query_params.filtering.assigned_to__id__in %} selected='selected'{% endif %}>
|
||||
{% trans "Unassigned" %}
|
||||
</option>
|
||||
{% endwith %}
|
||||
{% for u in user_choices %}
|
||||
<option value='{{ u.id }}'{% if u.id|in_list:query_params.filtering.assigned_to__id__in %} selected='selected'{% endif %}>
|
||||
{{ u.get_username }}{% ifequal u user %} {% trans "(ME)" %}{% endifequal %}
|
||||
|
@ -1,6 +1,5 @@
|
||||
{% load i18n humanize %}
|
||||
|
||||
<!-- DataTables Example -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-table"></i>
|
||||
@ -40,3 +39,43 @@
|
||||
<div class="card-footer small text-muted">Listing {{ unassigned_tickets|length }} ticket(s).</div>
|
||||
</div>
|
||||
|
||||
{% for kbitem in kbitems %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-table"></i>
|
||||
{% trans "KBItem:" %} {{kbitem.title}} {% trans "Team:" %} {{kbitem.team.name}} {% trans "(pick up a ticket if you start to work on it)" %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered table-sm table-striped" id="dataTable" width="100%" cellspacing="0">
|
||||
<thead class="thead-light">
|
||||
<tr>
|
||||
<th>{% trans "Ticket" %}</th>
|
||||
<th>{% trans "Prority" %}</th>
|
||||
<th>{% trans "Queue" %}</th>
|
||||
<th>{% trans "Created" %}</th>
|
||||
<th>{% trans "Actions" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ticket in kbitem.unassigned_tickets %}
|
||||
<tr class="{{ ticket.get_priority_css_class }}">
|
||||
<td class="tickettitle"><a href='{{ ticket.get_absolute_url }}'>{{ ticket.id }}. {{ ticket.title }} </a></td>
|
||||
<td>{{ ticket.priority }}</td>
|
||||
<td>{{ ticket.queue }}</td>
|
||||
<td><span title='{{ ticket.created|date:"r" }}'>{{ ticket.created|naturaltime }}</span></td>
|
||||
<td class="text-center">
|
||||
<a href='{{ ticket.get_absolute_url }}?take'><button class='btn btn-primary btn-sm'><i class="fas fa-hand-paper"></i> {% trans "Take" %}</button></a>
|
||||
<a href='{% url 'helpdesk:delete' ticket.id %}'><button class='btn btn-danger btn-sm'><i class="fas fa-trash"></i> {% trans "Delete" %}</button></a>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan='6'>{% trans "There are no unassigned tickets." %}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer small text-muted">Listing {{ kbitem.unassigned_tickets|length }} ticket(s).</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
@ -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 %}
|
||||
<li class="breadcrumb-item">
|
||||
@ -8,38 +11,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block helpdesk_body %}
|
||||
<h2>{% trans 'Knowledgebase Category' %}:{% blocktrans with category.title as kbcat %}{{ kbcat }}{% endblocktrans %}</h2>
|
||||
|
||||
<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>
|
||||
{% include 'helpdesk/kb_category_base.html' %}
|
||||
</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 %}
|
||||
|
63
helpdesk/templates/helpdesk/kb_category_base.html
Normal file
63
helpdesk/templates/helpdesk/kb_category_base.html
Normal file
@ -0,0 +1,63 @@
|
||||
{% load i18n %}
|
||||
{% block header %}
|
||||
<h2>{% blocktrans with category.title as kbcat %}{{ kbcat }}{% endblocktrans %}</h2>
|
||||
{{ category.description|linebreaks }}
|
||||
{% endblock %}
|
||||
|
||||
{% block item_list %}
|
||||
<div id="accordion">
|
||||
{% for item in items %}
|
||||
<div class="card mb-3">
|
||||
<div class="btn btn-link" data-toggle="collapse" data-target="#collapse{{item.id}}" role="region" aria-expanded="true" aria-controls="collapse{{item.id}}">
|
||||
<div class="card-header" id="header{{item.id}}">
|
||||
<h5 class="mb-0">
|
||||
{{ item.title }}
|
||||
</h5>
|
||||
</div>
|
||||
</div>
|
||||
<div id="collapse{{item.id}}" class="collapse {% if item.id == selected_item %}show{% endif %}" role="region" aria-labelledby="header{{item.id}}" data-parent="#accordion">
|
||||
{% block card_body %}
|
||||
<div class="card-body">
|
||||
<p class="card-text">{{ item.question }}</p>
|
||||
<div class="card-answer">
|
||||
{{ item.get_markdown }}
|
||||
</div>
|
||||
<div class="row">
|
||||
{% if request.user.pk %}
|
||||
<div class="col-sm">
|
||||
<a href='{% url "helpdesk:kb_vote" item.pk %}?vote=up'><div class="btn btn-success btn-circle btn-xl"><i class="fa fa-thumbs-up fa-lg"></i></div></a>
|
||||
<a href='{% url "helpdesk:kb_vote" item.pk %}?vote=down'><div class="btn btn-danger btn-circle btn-xl"><i class="fa fa-thumbs-down fa-lg"></i></div></a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if staff %}
|
||||
<a href='{% url 'helpdesk:list' %}?kbitem={{item.id}}' class="col-sm">
|
||||
<div 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' %}</div>
|
||||
</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">
|
||||
<div class="btn btn-success btn-circle btn-xl float-right"><i class="fa fa-envelope fa-lg"></i> {% trans 'Contact a human' %}</div>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
{% if item.votes != 0 %}
|
||||
{% blocktrans with recommendations=item.recommendations votes=item.votes %}{{ recommendations }} people found this answer useful of {{votes}}{% endblocktrans %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block footer %}
|
||||
{% endblock %}
|
||||
{% 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}}'>
|
||||
{% block submit_button %}
|
||||
<div class="btn btn-danger btn-circle btn-xl float-right"><i class="fa fa-envelope fa-lg"></i> {% trans 'Contact a human' %}</div>
|
||||
{% endblock %}
|
||||
</a>
|
||||
{% endif %}
|
14
helpdesk/templates/helpdesk/kb_category_iframe.html
Normal file
14
helpdesk/templates/helpdesk/kb_category_iframe.html
Normal file
@ -0,0 +1,14 @@
|
||||
{% load i18n %}
|
||||
{% load saved_queries %}
|
||||
<!DOCTYPE html>
|
||||
<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>
|
@ -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 %}
|
@ -1,47 +1,13 @@
|
||||
{% load i18n %}
|
||||
{% load load_helpdesk_settings %}
|
||||
{% load static from staticfiles %}
|
||||
{% load load_helpdesk_settings %}
|
||||
{% with request|load_helpdesk_settings as helpdesk_settings %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
|
||||
<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 %}{% 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]-->
|
||||
|
||||
{% include 'helpdesk/base-head.html' %}
|
||||
{% block helpdesk_head %}{% endblock %}
|
||||
|
||||
</head>
|
||||
@ -49,62 +15,35 @@
|
||||
<body id="bg-dark">
|
||||
|
||||
{% include "helpdesk/navigation-header.html" %}
|
||||
|
||||
|
||||
<div id="wrapper">
|
||||
{% include "helpdesk/navigation-sidebar.html" %}
|
||||
|
||||
|
||||
<div id="content-wrapper">
|
||||
|
||||
|
||||
<div class="container-fluid">
|
||||
|
||||
<!-- Breadcrumbs-->
|
||||
<ol class="breadcrumb">
|
||||
{% block helpdesk_breadcrumb %}{% endblock %}
|
||||
</ol>
|
||||
|
||||
|
||||
{% block helpdesk_body %}{% endblock %}
|
||||
|
||||
|
||||
</div>
|
||||
<!-- /.container-fluid -->
|
||||
|
||||
|
||||
{% include "helpdesk/attribution.html" %}
|
||||
</div>
|
||||
<!-- /.content-wrapper -->
|
||||
|
||||
</div>
|
||||
<!-- /#wrapper -->
|
||||
|
||||
|
||||
{% 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 -->
|
||||
<script src="{% static 'helpdesk/vendor/metisMenu/metisMenu.min.js' %}"></script>
|
||||
|
||||
<!-- Custom Theme JavaScript -->
|
||||
<script src="{% static 'helpdesk/js/sb-admin.js' %}"></script>
|
||||
|
||||
{% include 'helpdesk/base_js.html' %}
|
||||
{% block helpdesk_js %}{% endblock %}
|
||||
|
||||
</body>
|
||||
</html>
|
||||
{% endwith %}
|
||||
|
@ -1,5 +1,5 @@
|
||||
{% extends "helpdesk/public_base.html" %}
|
||||
{% load i18n bootstrap4form %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block helpdesk_title %}{% trans "Create Ticket" %}{% endblock %}
|
||||
|
||||
@ -11,20 +11,13 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block helpdesk_body %}
|
||||
{% if helpdesk_settings.HELPDESK_SUBMIT_A_TICKET_PUBLIC %}
|
||||
<div class="container">
|
||||
<div class="card card-register mx-auto mt-5">
|
||||
<div class="container">
|
||||
<div class="card card-register mx-auto mt-5">
|
||||
<div class="card-header">{% trans "Submit a Ticket" %}</div>
|
||||
<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>
|
||||
<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> {% trans "Submit Ticket" %}</button>
|
||||
{% csrf_token %}</form>
|
||||
{% include 'helpdesk/public_create_ticket_base.html' %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<h2>{% trans "Public ticket submission is disabled. Please contact the administrator for assistance." %}</h2>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
16
helpdesk/templates/helpdesk/public_create_ticket_base.html
Normal file
16
helpdesk/templates/helpdesk/public_create_ticket_base.html
Normal file
@ -0,0 +1,16 @@
|
||||
{% load i18n bootstrap4form %}
|
||||
{% load load_helpdesk_settings %}
|
||||
{% with request|load_helpdesk_settings as helpdesk_settings %}
|
||||
|
||||
{% if helpdesk_settings.HELPDESK_SUBMIT_A_TICKET_PUBLIC %}
|
||||
{% block form_header %}
|
||||
<p>{% trans "Unless otherwise stated, all fields are required." %} {% trans "Please provide as descriptive a title and description as possible." %}</p>
|
||||
{% endblock %}
|
||||
<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> {% trans "Submit Ticket" %}</button>
|
||||
{% csrf_token %}</form>
|
||||
{% else %}
|
||||
<h2>{% trans "Public ticket submission is disabled. Please contact the administrator for assistance." %}</h2>
|
||||
{% endif %}
|
||||
{% endwith %}
|
12
helpdesk/templates/helpdesk/public_create_ticket_iframe.html
Normal file
12
helpdesk/templates/helpdesk/public_create_ticket_iframe.html
Normal file
@ -0,0 +1,12 @@
|
||||
{% load i18n %}
|
||||
{% load saved_queries %}
|
||||
<!DOCTYPE html>
|
||||
<head>
|
||||
{% include 'helpdesk/base-head.html' %}
|
||||
</head>
|
||||
|
||||
<body>
|
||||
{% block helpdesk_body %}
|
||||
{% include 'helpdesk/public_create_ticket_base.html' %}
|
||||
{% endblock %}
|
||||
</body>
|
4
helpdesk/templates/helpdesk/success_iframe.html
Normal file
4
helpdesk/templates/helpdesk/success_iframe.html
Normal 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>
|
@ -34,7 +34,7 @@
|
||||
|
||||
<!-- Tab panes -->
|
||||
<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>
|
||||
<form method='post' action='./'>
|
||||
<fieldset>
|
||||
|
@ -37,8 +37,10 @@
|
||||
</strong>{% endifequal %}
|
||||
</td>
|
||||
<th class="table-active">{% trans "Submitter E-Mail" %}</th>
|
||||
<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 %}
|
||||
<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 %}
|
||||
<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 }}'>
|
||||
<button type="button" class="btn btn-warning btn-sm float-right"><i class="fas fa-eye-slash"></i></button></a></strong>{% endif %}
|
||||
</td>
|
||||
@ -66,6 +68,12 @@
|
||||
<th class="table-active">{% trans "Total time spent" %}</th>
|
||||
<td>{{ ticket.time_spent_formated }}</td>
|
||||
</tr>
|
||||
{% if ticket.kbitem %}
|
||||
<tr>
|
||||
<th class="table-active">{% trans "Knowlegebase item" %}</th>
|
||||
<td> <a href ="{{ticket.kbitem.query_url}}"> {{ticket.kbitem}} </a> </td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<th class="table-active">{% trans "Attachments" %}</th>
|
||||
<td colspan="3">
|
||||
|
@ -30,6 +30,121 @@
|
||||
{% block helpdesk_body %}
|
||||
{% 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> </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>
|
||||
<th>{% trans "KB item" %}</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> {% trans "All" %}
|
||||
</button>
|
||||
|
||||
<button id='select_none_btn' type="button" class="btn btn-primary btn-sm"><i class="fas fa-times-circle"></i> {% trans "None" %}</button>
|
||||
|
||||
<button id='select_inverse_btn' type="button" class="btn btn-primary btn-sm"><i class="fas fa-expand-arrows-alt"></i> {% 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>
|
||||
<optgroup label='{% trans "Set KB Item" %}'>
|
||||
<option value='kbitem_none'>{% trans "No KB Item" %}</option>
|
||||
{% for kbi in kb_items %}<option value='kbitem_{{ kbi.id }}'>{{kbi.category.title}}: {{ kbi.title }}</option>{% endfor %}
|
||||
</optgroup>
|
||||
</select>
|
||||
<button type="submit" class="btn btn-primary btn-sm"><i class="fas fa-arrow-circle-right"></i> {% 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-header">
|
||||
<i class="fas fa-hand-pointer"></i>
|
||||
@ -62,6 +177,7 @@
|
||||
<option id="filterBuilderSelect-Status" value="Status">{% trans "Status" %}</option>
|
||||
<option id="filterBuilderSelect-Keywords" value="Keywords">{% trans "Keywords" %}</option>
|
||||
<option id="filterBuilderSelect-Dates" value="Dates">{% trans "Date Range" %}</option>
|
||||
<option id="filterBuilderSelect-KBItems" value="KBItems">{% trans "Knowledge base items" %}</option>
|
||||
</select>
|
||||
{% csrf_token %}
|
||||
</form>
|
||||
@ -87,6 +203,9 @@
|
||||
<li class="list-group-item filterBox{% if query_params.search_string %} filterBoxShow{% endif %}" id="filterBoxKeywords">
|
||||
{% include './filters/keywords.html' %}
|
||||
</li>
|
||||
<li class="list-group-item filterBox{% if query_params.filtering.kbitem__in %} filterBoxShow{% endif %}" id="filterBoxKBItems">
|
||||
{% include './filters/kbitems.html' %}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<input class="btn btn-primary btn-sm" type='submit' value='{% trans "Apply Filters" %}' />
|
||||
@ -162,64 +281,6 @@
|
||||
</div>
|
||||
<!-- 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> </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> {% trans "All" %}
|
||||
</button>
|
||||
|
||||
<button id='select_none_btn' type="button" class="btn btn-primary btn-sm"><i class="fas fa-times-circle"></i> {% trans "None" %}</button>
|
||||
|
||||
<button id='select_inverse_btn' type="button" class="btn btn-primary btn-sm"><i class="fas fa-expand-arrows-alt"></i> {% 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> {% trans "Go" %}</button>
|
||||
</p>
|
||||
{% csrf_token %}</form>
|
||||
</div>
|
||||
<!-- /.panel-body -->
|
||||
</div>
|
||||
<!-- /.panel -->
|
||||
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@ -247,6 +308,8 @@
|
||||
{
|
||||
$( row ).addClass(data.row_class);
|
||||
},
|
||||
dom: 'ltBp',
|
||||
buttons: ["colvis"],
|
||||
|
||||
"columns": [
|
||||
{"data": "id",
|
||||
@ -267,8 +330,8 @@
|
||||
var name = data.split(" ")[1];
|
||||
if (type === 'display')
|
||||
{
|
||||
data = '<div class="tickettitle"><a href="' + get_url(row) + '" >' +
|
||||
row.id + '. ' +
|
||||
data = '<div class="tickettitle"><a href="' + get_url(row) + '" >' +
|
||||
row.id + '. ' +
|
||||
row.title + '</a></div>';
|
||||
}
|
||||
return data
|
||||
@ -283,27 +346,31 @@
|
||||
priority = "danger";
|
||||
}
|
||||
return '<p class="text-'+priority+'">'+data+'</p>';
|
||||
}
|
||||
},
|
||||
"visible": false,
|
||||
},
|
||||
{"data": "queue",
|
||||
"render": function(data, type, row, meta) {
|
||||
return data.title;
|
||||
}
|
||||
},
|
||||
"visible": false,
|
||||
},
|
||||
{"data": "status"},
|
||||
{"data": "created"},
|
||||
{"data": "due_date"},
|
||||
{"data": "due_date", "visible": false},
|
||||
{"data": "assigned_to",
|
||||
"render": function(data, type, row, meta) {
|
||||
if (data != "None") {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
else {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
},
|
||||
{"data": "time_spent"},
|
||||
{"data": "submitter"},
|
||||
{"data": "time_spent", "visible": false},
|
||||
{"data": "kbitem"},
|
||||
]
|
||||
});
|
||||
})
|
||||
@ -345,6 +412,9 @@
|
||||
{% if query_params.search_string %}
|
||||
$("#filterBuilderSelect-Keywords")[0].disabled = "disabled";
|
||||
{% endif %}
|
||||
{% if query_params.filtering.kbitem__in %}
|
||||
$("#filterBuilderSelect-KBItems")[0].disabled = "disabled";
|
||||
{% endif %}
|
||||
});
|
||||
|
||||
{% for f in query_params.filtering %}
|
||||
|
@ -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>
|
@ -94,7 +94,11 @@ class AttachmentUnitTests(TestCase):
|
||||
'content-type': 'text/utf8',
|
||||
}
|
||||
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)
|
||||
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'])
|
||||
|
||||
# TODO: FIXME: what's wrong with this test that we get integrity errors?
|
||||
# @mock.patch('helpdesk.lib.FollowUpAttachment', autospec=True)
|
||||
# def test_autofill(self, mock_att_save, mock_queue_save, mock_ticket_save, mock_follow_up_save):
|
||||
# """ check utf-8 data is parsed correctly """
|
||||
# self.follow_up.pk = 100
|
||||
# self.follow_up.save()
|
||||
# obj = models.FollowUpAttachment.objects.create(
|
||||
# followup=self.follow_up,
|
||||
# file=self.test_file
|
||||
# )
|
||||
# self.assertEqual(obj.filename, self.file_attrs['filename'])
|
||||
# self.assertEqual(obj.size, len(self.file_attrs['content']))
|
||||
# self.assertEqual(obj.mime_type, "text/plain")
|
||||
@mock.patch('helpdesk.lib.FollowUpAttachment', autospec=True)
|
||||
def test_autofill(self, mock_att_save, mock_queue_save, mock_ticket_save, mock_follow_up_save):
|
||||
""" check utf-8 data is parsed correctly """
|
||||
obj = models.FollowUpAttachment.objects.create(
|
||||
followup=self.follow_up,
|
||||
file=self.test_file
|
||||
)
|
||||
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):
|
||||
""" check utf-8 data is parsed correctly """
|
||||
|
72
helpdesk/tests/test_files/utf-nondecodable.eml
Normal file
72
helpdesk/tests/test_files/utf-nondecodable.eml
Normal file
@ -0,0 +1,72 @@
|
||||
Delivered-To: helpdesk@example.cz
|
||||
Received: by 2002:a17:90a:f983:0:0:0:0 with SMTP id cq3csp4021504pjb;
|
||||
Tue, 21 Jan 2020 04:28:48 -0800 (PST)
|
||||
X-Received: by 2002:a05:6000:50:: with SMTP id k16mr4730387wrx.145.1579609728626;
|
||||
Tue, 21 Jan 2020 04:28:48 -0800 (PST)
|
||||
X-Received: by 2002:a5d:50d2:: with SMTP id f18mr4914314wrt.366.1579609727642;
|
||||
Tue, 21 Jan 2020 04:28:47 -0800 (PST)
|
||||
Return-Path: <john.smith@example.cz>
|
||||
Received: from [10.0.0.179] (ip-89-176-203-67.net.upcbroadband.cz. [89.176.203.67])
|
||||
by smtp.gmail.com with ESMTPSA id w83sm3724796wmb.42.2020.01.21.04.28.46
|
||||
for <helpdesk@example.cz>
|
||||
(version=TLS1_2 cipher=ECDHE-RSA-AES128-GCM-SHA256 bits=128/128);
|
||||
Tue, 21 Jan 2020 04:28:47 -0800 (PST)
|
||||
Subject: =?UTF-8?Q?Fwd=3a_Cyklozam=c4=9bstnavatel_-_zm=c4=9bna_vyhodnocen?=
|
||||
=?UTF-8?B?w60=?=
|
||||
References: <CAK0Q_=uQY=3V5iUSgEN16GLtYoJ-6oQu-vYjsgQ=jv6DwOkuLQ@mail.gmail.com>
|
||||
To: helpdesk@example.cz
|
||||
From: John Smith <john.smith@example.cz>
|
||||
Openpgp: preference=signencrypt
|
||||
X-Forwarded-Message-Id: <CAK0Q_=uQY=3V5iUSgEN16GLtYoJ-6oQu-vYjsgQ=jv6DwOkuLQ@mail.gmail.com>
|
||||
Message-ID: <00d73ce5-774a-5ea1-6742-af73ef58c01c@example.cz>
|
||||
Date: Tue, 21 Jan 2020 13:28:46 +0100
|
||||
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:60.0) Gecko/20100101
|
||||
Thunderbird/60.9.0
|
||||
MIME-Version: 1.0
|
||||
In-Reply-To: <CAK0Q_=uQY=3V5iUSgEN16GLtYoJ-6oQu-vYjsgQ=jv6DwOkuLQ@mail.gmail.com>
|
||||
Content-Type: multipart/alternative;
|
||||
boundary="------------1E8B96489BB357387CBD04A6"
|
||||
Content-Language: en-US
|
||||
|
||||
This is a multi-part message in MIME format.
|
||||
--------------1E8B96489BB357387CBD04A6
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
Content-Transfer-Encoding: 8bit
|
||||
|
||||
|
||||
|
||||
|
||||
-------- Forwarded Message --------
|
||||
Subject: Cyklozaměstnavatel - změna vyhodnocení
|
||||
Date: Thu, 9 Jan 2020 16:24:28 +0100
|
||||
From: Nikola <nikola@example.cz>
|
||||
To: John Smith <john.smith@example.cz>, Jiří Houdek
|
||||
<jiri.houdek@example.cz>
|
||||
|
||||
|
||||
|
||||
Ahoj Johne,
|
||||
podle domluvy bych Tě poprosila o změnu vyhodnocení soutěže
|
||||
Cyklozaměstnavatel.
|
||||
Poprosím, aby se ve výsledné tabulce pro každé město zobrazoval
|
||||
jednotlivý zaměstnavatel *jen jednou s průměrným výsledkem, *který vyjde
|
||||
po zprůměrování hodnocení všech zaměstnanců tohoto zaměstnavatele.
|
||||
Díky moc!
|
||||
n.
|
||||
|
||||
|
||||
--------------1E8B96489BB357387CBD04A6
|
||||
Content-Type: text/html; charset=utf-8
|
||||
Content-Transfer-Encoding: 8bit
|
||||
|
||||
<body>
|
||||
<p>
|
||||
prosazuje lepší
|
||||
prostředí pro
|
||||
kvalitní život
|
||||
ve městě.
|
||||
</p>
|
||||
</body>
|
||||
|
||||
|
||||
--------------1E8B96489BB357387CBD04A6--
|
@ -84,6 +84,21 @@ class GetEmailCommonTests(TestCase):
|
||||
self.assertEqual(ticket.title, "Testovácí email")
|
||||
self.assertEqual(ticket.description, "íářčšáíéřášč")
|
||||
|
||||
def test_email_with_utf_8_non_decodable_sequences(self):
|
||||
"""
|
||||
Tests that emails with utf-8 non-decodable sequences are parsed correctly
|
||||
"""
|
||||
with open(os.path.join(THIS_DIR, "test_files/utf-nondecodable.eml")) as fd:
|
||||
test_email = fd.read()
|
||||
ticket = helpdesk.email.object_from_message(test_email, self.queue_public, self.logger)
|
||||
self.assertEqual(ticket.title, "Fwd: Cyklozaměstnavatel - změna vyhodnocení")
|
||||
self.assertIn("prosazuje lepší", ticket.description)
|
||||
followups = FollowUp.objects.filter(ticket=ticket)
|
||||
followup = followups[0]
|
||||
attachments = FollowUpAttachment.objects.filter(followup=followup)
|
||||
attachment = attachments[0]
|
||||
self.assertIn('prosazuje lepší', attachment.file.read().decode("utf-8"))
|
||||
|
||||
|
||||
class GetEmailParametricTemplate(object):
|
||||
"""TestCase that checks basic email functionality across methods and socks configs."""
|
||||
|
80
helpdesk/tests/test_kb.py
Normal file
80
helpdesk/tests/test_kb.py
Normal 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&title=lol")
|
@ -6,7 +6,7 @@ from django.test.client import Client
|
||||
|
||||
from helpdesk.models import Queue, Ticket
|
||||
from helpdesk import settings
|
||||
from helpdesk.query import get_query
|
||||
from helpdesk.query import __Query__
|
||||
from helpdesk.user import HelpdeskUser
|
||||
|
||||
|
||||
@ -166,7 +166,7 @@ class PerQueueStaffMembershipTestCase(TestCase):
|
||||
for identifier in self.IDENTIFIERS:
|
||||
self.client.login(username='User_%d' % identifier, password=str(identifier))
|
||||
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(
|
||||
len(tickets),
|
||||
identifier * 2,
|
||||
@ -186,7 +186,7 @@ class PerQueueStaffMembershipTestCase(TestCase):
|
||||
# Superuser
|
||||
self.client.login(username='superuser', password='superuser')
|
||||
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(
|
||||
len(tickets),
|
||||
6,
|
||||
|
106
helpdesk/tests/test_query.py
Normal file
106
helpdesk/tests/test_query.py
Normal file
@ -0,0 +1,106 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from helpdesk.models import KBCategory, KBItem, Queue, Ticket
|
||||
from helpdesk.query import query_to_base64
|
||||
|
||||
from helpdesk.tests.helpers import (get_staff_user, reload_urlconf, User, create_ticket, print_response)
|
||||
|
||||
|
||||
class QueryTests(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.user = get_staff_user()
|
||||
self.ticket1 = Ticket.objects.create(
|
||||
title="unassigned to kbitem",
|
||||
queue=self.queue,
|
||||
description="lol",
|
||||
)
|
||||
self.ticket1.save()
|
||||
self.ticket2 = Ticket.objects.create(
|
||||
title="assigned to kbitem",
|
||||
queue=self.queue,
|
||||
description="lol",
|
||||
kbitem=self.kbitem1,
|
||||
)
|
||||
self.ticket2.save()
|
||||
|
||||
def loginUser(self, is_staff=True):
|
||||
"""Create a staff user and login"""
|
||||
User = get_user_model()
|
||||
self.user = User.objects.create(
|
||||
username='User_1',
|
||||
is_staff=is_staff,
|
||||
)
|
||||
self.user.set_password('pass')
|
||||
self.user.save()
|
||||
self.client.login(username='User_1', password='pass')
|
||||
|
||||
def test_query_basic(self):
|
||||
self.loginUser()
|
||||
query = query_to_base64({})
|
||||
response = self.client.get(reverse('helpdesk:datatables_ticket_list', args=[query]))
|
||||
self.assertEqual(
|
||||
response.json(),
|
||||
{
|
||||
"data":
|
||||
[{"ticket": "1 [test_queue-1]", "id": 1, "priority": 3, "title": "unassigned to kbitem", "queue": {"title": "Test queue", "id": 1}, "status": "Open", "created": "now", "due_date": None, "assigned_to": "None", "submitter": None, "row_class": "", "time_spent": "", "kbitem": ""},
|
||||
{"ticket": "2 [test_queue-2]", "id": 2, "priority": 3, "title": "assigned to kbitem", "queue": {"title": "Test queue", "id": 1}, "status": "Open", "created": "now", "due_date": None, "assigned_to": "None", "submitter": None, "row_class": "", "time_spent": "", "kbitem": "KBItem 1"}],
|
||||
"recordsFiltered": 2,
|
||||
"recordsTotal": 2,
|
||||
"draw": 0,
|
||||
},
|
||||
)
|
||||
|
||||
def test_query_by_kbitem(self):
|
||||
self.loginUser()
|
||||
query = query_to_base64(
|
||||
{'filtering': {'kbitem__in': [self.kbitem1.pk]}}
|
||||
)
|
||||
response = self.client.get(reverse('helpdesk:datatables_ticket_list', args=[query]))
|
||||
self.assertEqual(
|
||||
response.json(),
|
||||
{
|
||||
"data":
|
||||
[{"ticket": "2 [test_queue-2]", "id": 2, "priority": 3, "title": "assigned to kbitem", "queue": {"title": "Test queue", "id": 1}, "status": "Open", "created": "now", "due_date": None, "assigned_to": "None", "submitter": None, "row_class": "", "time_spent": "", "kbitem": "KBItem 1"}],
|
||||
"recordsFiltered": 1,
|
||||
"recordsTotal": 1,
|
||||
"draw": 0,
|
||||
},
|
||||
)
|
||||
|
||||
def test_query_by_no_kbitem(self):
|
||||
self.loginUser()
|
||||
query = query_to_base64(
|
||||
{'filtering_or': {'kbitem__in': [self.kbitem1.pk]}}
|
||||
)
|
||||
response = self.client.get(reverse('helpdesk:datatables_ticket_list', args=[query]))
|
||||
self.assertEqual(
|
||||
response.json(),
|
||||
{
|
||||
"data":
|
||||
[{"ticket": "2 [test_queue-2]", "id": 2, "priority": 3, "title": "assigned to kbitem", "queue": {"title": "Test queue", "id": 1}, "status": "Open", "created": "now", "due_date": None, "assigned_to": "None", "submitter": None, "row_class": "", "time_spent": "", "kbitem": "KBItem 1"}],
|
||||
"recordsFiltered": 1,
|
||||
"recordsTotal": 1,
|
||||
"draw": 0,
|
||||
},
|
||||
)
|
@ -2,7 +2,7 @@
|
||||
import email
|
||||
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.core import mail
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
@ -11,6 +11,7 @@ from django.test.client import Client
|
||||
from django.urls import reverse
|
||||
|
||||
from helpdesk.email import object_from_message, create_ticket_cc
|
||||
from helpdesk.tests.helpers import print_response
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@ -139,6 +140,37 @@ class TicketBasicsTestCase(TestCase):
|
||||
# Ensure only two e-mails were sent - submitter & updated.
|
||||
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):
|
||||
fixtures = ['emailtemplate.json']
|
||||
@ -976,3 +1008,24 @@ class EmailInteractionsTestCase(TestCase):
|
||||
# public_update_queue: +1
|
||||
expected_email_count += 1 + 2 + 1
|
||||
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">')
|
||||
|
@ -151,6 +151,11 @@ urlpatterns = [
|
||||
url(r'^datatables_ticket_list/(?P<query>{})$'.format(base64_pattern),
|
||||
staff.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 += [
|
||||
@ -162,6 +167,14 @@ urlpatterns += [
|
||||
public.create_ticket,
|
||||
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/$',
|
||||
public.view_ticket,
|
||||
name='public_view'),
|
||||
@ -223,17 +236,17 @@ if helpdesk_settings.HELPDESK_KB_ENABLED:
|
||||
kb.index,
|
||||
name='kb_index'),
|
||||
|
||||
url(r'^kb/(?P<item>[0-9]+)/$',
|
||||
kb.item,
|
||||
name='kb_item'),
|
||||
url(r'^kb/(?P<slug>[A-Za-z0-9_-]+)/$',
|
||||
kb.category,
|
||||
name='kb_category'),
|
||||
|
||||
url(r'^kb/(?P<item>[0-9]+)/vote/$',
|
||||
kb.vote,
|
||||
name='kb_vote'),
|
||||
|
||||
url(r'^kb/(?P<slug>[A-Za-z0-9_-]+)/$',
|
||||
kb.category,
|
||||
name='kb_category'),
|
||||
url(r'^kb_iframe/(?P<slug>[A-Za-z0-9_-]+)/$',
|
||||
kb.category_iframe,
|
||||
name='kb_category_iframe'),
|
||||
]
|
||||
|
||||
urlpatterns += [
|
||||
|
@ -1,11 +1,17 @@
|
||||
from helpdesk.models import (
|
||||
Ticket,
|
||||
Queue
|
||||
Queue,
|
||||
KBCategory,
|
||||
KBItem,
|
||||
)
|
||||
|
||||
from helpdesk import settings as helpdesk_settings
|
||||
|
||||
|
||||
def huser_from_request(req):
|
||||
return HelpdeskUser(req.user)
|
||||
|
||||
|
||||
class HelpdeskUser:
|
||||
def __init__(self, user):
|
||||
self.user = user
|
||||
@ -30,9 +36,26 @@ class HelpdeskUser:
|
||||
else:
|
||||
return all_queues
|
||||
|
||||
def get_allowed_kb_categories(self):
|
||||
categories = []
|
||||
for cat in KBCategory.objects.all():
|
||||
if self.can_access_kbcategory(cat):
|
||||
categories.append(cat)
|
||||
return categories
|
||||
|
||||
def get_assigned_kb_items(self):
|
||||
kbitems = []
|
||||
for item in KBItem.objects.all():
|
||||
if item.team and item.team.is_member(self.user):
|
||||
kbitems.append(item)
|
||||
return kbitems
|
||||
|
||||
def get_tickets_in_queues(self):
|
||||
return Ticket.objects.filter(queue__in=self.get_queues())
|
||||
|
||||
def has_full_access(self):
|
||||
return self.user.is_superuser or self.user.is_staff
|
||||
|
||||
def can_access_queue(self, queue):
|
||||
"""Check if a certain user can access a certain queue.
|
||||
|
||||
@ -40,11 +63,10 @@ class HelpdeskUser:
|
||||
:param queue: The django-helpdesk Queue instance
|
||||
:return: True if the user has permission (either by default or explicitly), false otherwise
|
||||
"""
|
||||
user = self.user
|
||||
if user.is_superuser or not helpdesk_settings.HELPDESK_ENABLE_PER_QUEUE_STAFF_PERMISSION:
|
||||
if self.has_full_access():
|
||||
return True
|
||||
else:
|
||||
return user.has_perm(queue.permission_name)
|
||||
return helpdesk_settings.HELPDESK_ENABLE_PER_QUEUE_STAFF_PERMISSION and self.user.has_perm(queue.permission_name)
|
||||
|
||||
def can_access_ticket(self, ticket):
|
||||
"""Check to see if the user has permission to access
|
||||
@ -52,8 +74,13 @@ class HelpdeskUser:
|
||||
user = self.user
|
||||
if self.can_access_queue(ticket.queue):
|
||||
return True
|
||||
elif user.is_superuser or user.is_staff or \
|
||||
elif self.has_full_access() or \
|
||||
(ticket.assigned_to and user.id == ticket.assigned_to.id):
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def can_access_kbcategory(self, category):
|
||||
if category.public:
|
||||
return True
|
||||
return self.has_full_access() or (category.queue and self.can_access_queue(category.queue))
|
||||
|
36
helpdesk/views/abstract_views.py
Normal file
36
helpdesk/views/abstract_views.py
Normal 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
|
@ -8,50 +8,77 @@ views/kb.py - Public-facing knowledgebase views. The knowledgebase is a
|
||||
resolutions to common problems.
|
||||
"""
|
||||
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.http import HttpResponseRedirect, Http404
|
||||
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 user
|
||||
from helpdesk.models import KBCategory, KBItem
|
||||
|
||||
|
||||
def index(request):
|
||||
category_list = KBCategory.objects.all()
|
||||
huser = user.huser_from_request(request)
|
||||
# TODO: It'd be great to have a list of most popular items here.
|
||||
return render(request, 'helpdesk/kb_index.html', {
|
||||
'kb_categories': category_list,
|
||||
'kb_categories': huser.get_allowed_kb_categories(),
|
||||
'helpdesk_settings': helpdesk_settings,
|
||||
})
|
||||
|
||||
|
||||
def category(request, slug):
|
||||
def category(request, slug, iframe=False):
|
||||
category = get_object_or_404(KBCategory, slug__iexact=slug)
|
||||
items = category.kbitem_set.all()
|
||||
return render(request, 'helpdesk/kb_category.html', {
|
||||
if not user.huser_from_request(request).can_access_kbcategory(category):
|
||||
raise Http404
|
||||
items = category.kbitem_set.filter(enabled=True)
|
||||
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,
|
||||
'items': items,
|
||||
'selected_item': selected_item,
|
||||
'query_param_string': qparams.urlencode(),
|
||||
'helpdesk_settings': helpdesk_settings,
|
||||
'iframe': iframe,
|
||||
'staff': staff,
|
||||
})
|
||||
|
||||
|
||||
def item(request, item):
|
||||
item = get_object_or_404(KBItem, pk=item)
|
||||
return render(request, 'helpdesk/kb_item.html', {
|
||||
'category': item.category,
|
||||
'item': item,
|
||||
'helpdesk_settings': helpdesk_settings,
|
||||
})
|
||||
@xframe_options_exempt
|
||||
def category_iframe(request, slug):
|
||||
return category(request, slug, iframe=True)
|
||||
|
||||
|
||||
def vote(request, item):
|
||||
item = get_object_or_404(KBItem, pk=item)
|
||||
vote = request.GET.get('vote', None)
|
||||
if vote in ('up', 'down'):
|
||||
if request.user not in item.voted_by:
|
||||
|
||||
if vote == 'up':
|
||||
if not item.voted_by.filter(pk=request.user.pk):
|
||||
item.votes += 1
|
||||
if vote == 'up':
|
||||
item.recommendations += 1
|
||||
item.save()
|
||||
|
||||
item.voted_by.add(request.user.pk)
|
||||
item.recommendations += 1
|
||||
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())
|
||||
|
@ -6,27 +6,32 @@ django-helpdesk - A Django powered ticket tracker for small enterprise.
|
||||
views/public.py - All public facing views, eg non-staff (no authentication
|
||||
required) views.
|
||||
"""
|
||||
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
|
||||
try:
|
||||
# Django 2.0+
|
||||
from django.urls import reverse
|
||||
except ImportError:
|
||||
# Django < 2
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.http import HttpResponseRedirect, HttpResponse
|
||||
import logging
|
||||
|
||||
from django.core.exceptions import (
|
||||
ObjectDoesNotExist, PermissionDenied, ImproperlyConfigured,
|
||||
)
|
||||
from django.urls import reverse
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.shortcuts import render
|
||||
from django.utils.http import urlquote
|
||||
from django.utils.translation import ugettext as _
|
||||
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.edit import FormView
|
||||
|
||||
from helpdesk import settings as helpdesk_settings
|
||||
from helpdesk.decorators import protect_view, is_helpdesk_staff
|
||||
import helpdesk.views.staff as staff
|
||||
import helpdesk.views.abstract_views as abstract_views
|
||||
from helpdesk.forms import PublicTicketForm
|
||||
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
|
||||
from helpdesk.user import huser_from_request
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_ticket(request, *args, **kwargs):
|
||||
@ -36,8 +41,7 @@ def create_ticket(request, *args, **kwargs):
|
||||
return CreateTicketView.as_view()(request, *args, **kwargs)
|
||||
|
||||
|
||||
class CreateTicketView(FormView):
|
||||
template_name = 'helpdesk/public_create_ticket.html'
|
||||
class BaseCreateTicketView(abstract_views.AbstractCreateTicketMixin, FormView):
|
||||
form_class = PublicTicketForm
|
||||
|
||||
def dispatch(self, *args, **kwargs):
|
||||
@ -57,42 +61,36 @@ class CreateTicketView(FormView):
|
||||
return HttpResponseRedirect(reverse('helpdesk:dashboard'))
|
||||
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):
|
||||
request = self.request
|
||||
initial_data = {}
|
||||
try:
|
||||
queue = Queue.objects.get(slug=request.GET.get('queue', None))
|
||||
except Queue.DoesNotExist:
|
||||
queue = None
|
||||
initial_data = super().get_initial()
|
||||
|
||||
# add pre-defined data for public ticket
|
||||
if hasattr(settings, 'HELPDESK_PUBLIC_TICKET_QUEUE'):
|
||||
# get the requested queue; return an error if queue not found
|
||||
try:
|
||||
queue = Queue.objects.get(slug=settings.HELPDESK_PUBLIC_TICKET_QUEUE)
|
||||
except Queue.DoesNotExist:
|
||||
return HttpResponse(status=500)
|
||||
initial_data['queue'] = Queue.objects.get(
|
||||
slug=settings.HELPDESK_PUBLIC_TICKET_QUEUE,
|
||||
allow_public_submission=True
|
||||
).id
|
||||
except Queue.DoesNotExist as e:
|
||||
logger.fatal(
|
||||
"Public queue '%s' is configured as default but can't be found",
|
||||
settings.HELPDESK_PUBLIC_TICKET_QUEUE
|
||||
)
|
||||
raise ImproperlyConfigured("Wrong public queue configuration") from e
|
||||
if hasattr(settings, 'HELPDESK_PUBLIC_TICKET_PRIORITY'):
|
||||
initial_data['priority'] = settings.HELPDESK_PUBLIC_TICKET_PRIORITY
|
||||
if hasattr(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
|
||||
|
||||
def get_form_kwargs(self, *args, **kwargs):
|
||||
kwargs = super().get_form_kwargs(*args, **kwargs)
|
||||
if '_hide_fields_' in self.request.GET:
|
||||
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):
|
||||
request = self.request
|
||||
if text_is_spam(form.cleaned_data['body'], request):
|
||||
@ -115,9 +113,39 @@ class CreateTicketView(FormView):
|
||||
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):
|
||||
template_name = 'helpdesk/public_homepage.html'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['kb_categories'] = huser_from_request(self.request).get_allowed_kb_categories()
|
||||
return context
|
||||
|
||||
|
||||
def search_for_ticket(request, error_message=None):
|
||||
if hasattr(settings, 'HELPDESK_VIEW_A_TICKET_PUBLIC') and settings.HELPDESK_VIEW_A_TICKET_PUBLIC:
|
||||
|
@ -27,18 +27,14 @@ from django.utils import timezone
|
||||
from django.views.generic.edit import FormView, UpdateView
|
||||
|
||||
from helpdesk.query import (
|
||||
get_query_class,
|
||||
query_to_dict,
|
||||
get_query,
|
||||
apply_query,
|
||||
query_tickets_by_args,
|
||||
query_to_base64,
|
||||
query_from_base64,
|
||||
)
|
||||
|
||||
from helpdesk.user import HelpdeskUser
|
||||
|
||||
from helpdesk.serializers import DatatablesTicketSerializer
|
||||
|
||||
from helpdesk.decorators import (
|
||||
helpdesk_staff_member_required, helpdesk_superuser_required,
|
||||
is_helpdesk_staff
|
||||
@ -56,9 +52,10 @@ from helpdesk.lib import (
|
||||
)
|
||||
from helpdesk.models import (
|
||||
Ticket, Queue, FollowUp, TicketChange, PreSetReply, FollowUpAttachment, SavedSearch,
|
||||
IgnoreEmail, TicketCC, TicketDependency, UserSettings,
|
||||
IgnoreEmail, TicketCC, TicketDependency, UserSettings, KBItem,
|
||||
)
|
||||
from helpdesk import settings as helpdesk_settings
|
||||
import helpdesk.views.abstract_views as abstract_views
|
||||
from helpdesk.views.permissions import MustBeStaffMixin
|
||||
from ..lib import format_time_spent
|
||||
|
||||
@ -71,6 +68,7 @@ import re
|
||||
|
||||
|
||||
User = get_user_model()
|
||||
Query = get_query_class()
|
||||
|
||||
if helpdesk_settings.HELPDESK_ALLOW_NON_STAFF_TICKET_UPDATE:
|
||||
# treat 'normal' users like 'staff'
|
||||
@ -105,6 +103,7 @@ def dashboard(request):
|
||||
showing ticket counts by queue/status, and a list of unassigned tickets
|
||||
with options for them to 'Take' ownership of said tickets.
|
||||
"""
|
||||
huser = HelpdeskUser(request.user)
|
||||
active_tickets = Ticket.objects.select_related('queue').exclude(
|
||||
status__in=[Ticket.CLOSED_STATUS, Ticket.RESOLVED_STATUS],
|
||||
)
|
||||
@ -119,13 +118,16 @@ def dashboard(request):
|
||||
assigned_to=request.user,
|
||||
status__in=[Ticket.CLOSED_STATUS, Ticket.RESOLVED_STATUS])
|
||||
|
||||
user_queues = HelpdeskUser(request.user).get_queues()
|
||||
user_queues = huser.get_queues()
|
||||
|
||||
unassigned_tickets = active_tickets.filter(
|
||||
assigned_to__isnull=True,
|
||||
kbitem__isnull=True,
|
||||
queue__in=user_queues
|
||||
)
|
||||
|
||||
kbitems = huser.get_assigned_kb_items()
|
||||
|
||||
# all tickets, reported by current user
|
||||
all_tickets_reported_by_current_user = ''
|
||||
email_current_user = request.user.email
|
||||
@ -159,6 +161,7 @@ def dashboard(request):
|
||||
'user_tickets': tickets,
|
||||
'user_tickets_closed_resolved': tickets_closed_resolved,
|
||||
'unassigned_tickets': unassigned_tickets,
|
||||
'kbitems': kbitems,
|
||||
'all_tickets_reported_by_current_user': all_tickets_reported_by_current_user,
|
||||
'basic_ticket_stats': basic_ticket_stats,
|
||||
})
|
||||
@ -708,6 +711,13 @@ def mass_update(request):
|
||||
parts = action.split('_')
|
||||
user = User.objects.get(id=parts[1])
|
||||
action = 'assign'
|
||||
if action == 'kbitem_none':
|
||||
kbitem = None
|
||||
action = 'set_kbitem'
|
||||
if action.startswith('kbitem_'):
|
||||
parts = action.split('_')
|
||||
kbitem = KBItem.objects.get(id=parts[1])
|
||||
action = 'set_kbitem'
|
||||
elif action == 'take':
|
||||
user = request.user
|
||||
action = 'assign'
|
||||
@ -737,6 +747,15 @@ def mass_update(request):
|
||||
public=True,
|
||||
user=request.user)
|
||||
f.save()
|
||||
elif action == 'set_kbitem':
|
||||
t.kbitem = kbitem
|
||||
t.save()
|
||||
f = FollowUp(ticket=t,
|
||||
date=timezone.now(),
|
||||
title=_('KBItem set in bulk update'),
|
||||
public=False,
|
||||
user=request.user)
|
||||
f.save()
|
||||
elif action == 'close' and t.status != Ticket.CLOSED_STATUS:
|
||||
t.status = Ticket.CLOSED_STATUS
|
||||
t.save()
|
||||
@ -800,12 +819,15 @@ def ticket_list(request):
|
||||
# a query, to be saved if needed:
|
||||
query_params = {
|
||||
'filtering': {},
|
||||
'filtering_or': {},
|
||||
'sorting': None,
|
||||
'sortreverse': False,
|
||||
'search_string': '',
|
||||
}
|
||||
default_query_params = {
|
||||
'filtering': {'status__in': [1, 2, 3]},
|
||||
'filtering': {
|
||||
'status__in': [1, 2],
|
||||
},
|
||||
'sorting': 'created',
|
||||
'search_string': '',
|
||||
'sortreverse': False,
|
||||
@ -839,7 +861,7 @@ def ticket_list(request):
|
||||
|
||||
if filter:
|
||||
try:
|
||||
ticket = huser.get_tickets_in_queues.get(**filter)
|
||||
ticket = huser.get_tickets_in_queues().get(**filter)
|
||||
return HttpResponseRedirect(ticket.staff_url)
|
||||
except Ticket.DoesNotExist:
|
||||
# Go on to standard keyword searching
|
||||
@ -852,7 +874,7 @@ def ticket_list(request):
|
||||
|
||||
if saved_query:
|
||||
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
|
||||
all_queues = Queue.objects.all()
|
||||
query_params = deepcopy(default_query_params)
|
||||
@ -861,13 +883,23 @@ def ticket_list(request):
|
||||
('queue', 'queue__id__in'),
|
||||
('assigned_to', 'assigned_to__id__in'),
|
||||
('status', 'status__in'),
|
||||
('kbitem', 'kbitem__in'),
|
||||
]
|
||||
|
||||
filter_null_params = dict([
|
||||
('queue', 'queue__id__isnull'),
|
||||
('assigned_to', 'assigned_to__id__isnull'),
|
||||
('status', 'status__isnull'),
|
||||
('kbitem', 'kbitem__isnull'),
|
||||
])
|
||||
for param, filter_command in filter_in_params:
|
||||
patterns = request.GET.getlist(param)
|
||||
if patterns:
|
||||
if not request.GET.get(param) is None:
|
||||
patterns = request.GET.getlist(param)
|
||||
try:
|
||||
pattern_pks = [int(pattern) for pattern in patterns]
|
||||
if -1 in pattern_pks:
|
||||
query_params['filtering_or'][filter_null_params[param]] = True
|
||||
else:
|
||||
query_params['filtering_or'][filter_command] = pattern_pks
|
||||
query_params['filtering'][filter_command] = pattern_pks
|
||||
except ValueError:
|
||||
pass
|
||||
@ -887,7 +919,7 @@ def ticket_list(request):
|
||||
|
||||
# SORTING
|
||||
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'
|
||||
query_params['sorting'] = sort
|
||||
|
||||
@ -896,7 +928,7 @@ def ticket_list(request):
|
||||
|
||||
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))
|
||||
|
||||
@ -910,12 +942,16 @@ def ticket_list(request):
|
||||
'<a href="http://docs.djangoproject.com/en/dev/ref/databases/#sqlite-string-matching">'
|
||||
'Django Documentation on string matching in SQLite</a>.')
|
||||
|
||||
kbitem_choices = [(item.pk, str(item)) for item in KBItem.objects.all()]
|
||||
|
||||
return render(request, 'helpdesk/ticket_list.html', dict(
|
||||
context,
|
||||
default_tickets_per_page=request.user.usersettings_helpdesk.tickets_per_page,
|
||||
user_choices=User.objects.filter(is_active=True, is_staff=True),
|
||||
kb_items=KBItem.objects.all(),
|
||||
queue_choices=huser.get_queues(),
|
||||
status_choices=Ticket.STATUS_CHOICES,
|
||||
kbitem_choices=kbitem_choices,
|
||||
urlsafe_query=urlsafe_query,
|
||||
user_saved_queries=user_saved_queries,
|
||||
query_params=query_params,
|
||||
@ -964,17 +1000,18 @@ def datatables_ticket_list(request, query):
|
||||
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
|
||||
"""
|
||||
objects = get_query(query, HelpdeskUser(request.user))
|
||||
model_object = query_tickets_by_args(objects, '-date_created', **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']
|
||||
query = Query(HelpdeskUser(request.user), base64query=query)
|
||||
result = query.get_datatables_context(**request.query_params)
|
||||
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
|
||||
def edit_ticket(request, ticket_id):
|
||||
ticket = get_object_or_404(Ticket, id=ticket_id)
|
||||
@ -994,17 +1031,12 @@ def edit_ticket(request, ticket_id):
|
||||
edit_ticket = staff_member_required(edit_ticket)
|
||||
|
||||
|
||||
class CreateTicketView(MustBeStaffMixin, FormView):
|
||||
class CreateTicketView(MustBeStaffMixin, abstract_views.AbstractCreateTicketMixin, FormView):
|
||||
template_name = 'helpdesk/create_ticket.html'
|
||||
form_class = TicketForm
|
||||
|
||||
def get_initial(self):
|
||||
initial_data = {}
|
||||
request = self.request
|
||||
if request.user.usersettings_helpdesk.use_email_as_submitter and request.user.email:
|
||||
initial_data['submitter_email'] = request.user.email
|
||||
if 'queue' in request.GET:
|
||||
initial_data['queue'] = request.GET['queue']
|
||||
initial_data = super().get_initial()
|
||||
return initial_data
|
||||
|
||||
def get_form_kwargs(self):
|
||||
|
@ -28,7 +28,11 @@ class QuickDjangoTest(object):
|
||||
'django.contrib.sites',
|
||||
'django.contrib.staticfiles',
|
||||
'bootstrap4form',
|
||||
'account',
|
||||
'pinax.invitations',
|
||||
'pinax.teams',
|
||||
'helpdesk',
|
||||
'reversion',
|
||||
)
|
||||
MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
|
@ -1,4 +1,4 @@
|
||||
Django>=2.2,<3
|
||||
Django>=2.2.9,<3
|
||||
django-bootstrap4-form
|
||||
celery
|
||||
django-celery-beat
|
||||
@ -12,3 +12,4 @@ pytz
|
||||
six
|
||||
djangorestframework
|
||||
django-model-utils
|
||||
pinax-teams @ git+https://github.com/auto-mat/pinax-teams.git@slugify#egg=pinax-teams
|
||||
|
Loading…
Reference in New Issue
Block a user