Merge remote-tracking branch 'upstream/master'

This commit is contained in:
Garret Wassermann 2020-04-15 05:48:11 -04:00
commit 3b4a9891c0
62 changed files with 5589 additions and 1601 deletions

1
.gitignore vendored
View File

@ -11,6 +11,7 @@ docs/doctrees/*
.pydevproject .pydevproject
.directory .directory
*.swp *.swp
.idea
# ignore demo attachments that user might have added # ignore demo attachments that user might have added
helpdesk/attachments/ helpdesk/attachments/

View File

@ -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.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.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.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.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": 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.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"}}, {"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"}},

View File

@ -15,6 +15,8 @@ Contents
settings settings
spam spam
custom_fields custom_fields
integration
teams
contributing contributing
license license

View File

@ -58,7 +58,11 @@ errors with trying to create User settings.
'django.contrib.admin', # Required for helpdesk admin/maintenance 'django.contrib.admin', # Required for helpdesk admin/maintenance
'django.contrib.humanize', # Required for elapsed time formatting 'django.contrib.humanize', # Required for elapsed time formatting
'bootstrap4form', # Required for nicer formatting of forms with the default templates '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! '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 Your ``settings.py`` file should also define a ``SITE_ID`` that allows multiple projects to share

View File

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

14
docs/teams.rst Normal file
View 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.

View File

@ -70,9 +70,9 @@ class FollowUpAdmin(admin.ModelAdmin):
@admin.register(KBItem) @admin.register(KBItem)
class KBItemAdmin(admin.ModelAdmin): class KBItemAdmin(admin.ModelAdmin):
list_display = ('category', 'title', 'last_updated',) list_display = ('category', 'title', 'last_updated', 'team', 'order', 'enabled')
inlines = [KBIAttachmentInline] inlines = [KBIAttachmentInline]
readonly_fields = ('voted_by',) readonly_fields = ('voted_by', 'downvoted_by')
list_display_links = ('title',) list_display_links = ('title',)
@ -93,6 +93,10 @@ class IgnoreEmailAdmin(admin.ModelAdmin):
list_display = ('name', 'queue_list', 'email_address', 'keep_in_mailbox') 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(PreSetReply)
admin.site.register(EscalationExclusion) admin.site.register(EscalationExclusion)
admin.site.register(KBCategory)

View File

@ -487,13 +487,18 @@ def object_from_message(message, queue, logger):
body.encode('utf-8') body.encode('utf-8')
logger.debug("Discovered plain text MIME part") logger.debug("Discovered plain text MIME part")
else: 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 = """ payload = """
<html> <html>
<head> <head>
<meta charset="utf-8"/> <meta charset="utf-8"/>
</head> </head>
%s %s
</html>""" % encoding.smart_text(part.get_payload(decode=True)) </html>""" % email_body
files.append( files.append(
SimpleUploadedFile(_("email_html_body.html"), payload.encode("utf-8"), 'text/html') SimpleUploadedFile(_("email_html_body.html"), payload.encode("utf-8"), 'text/html')
) )

View File

@ -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 forms.py - Definitions of newforms-based forms for creating and maintaining
tickets. tickets.
""" """
import logging
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django import forms from django import forms
from django.forms import widgets
from django.conf import settings from django.conf import settings
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.contrib.auth import get_user_model 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.lib import safe_template_context, process_attachments
from helpdesk.models import (Ticket, Queue, FollowUp, IgnoreEmail, TicketCC, from helpdesk.models import (Ticket, Queue, FollowUp, IgnoreEmail, TicketCC,
CustomField, TicketCustomFieldValue, TicketDependency, UserSettings) CustomField, TicketCustomFieldValue, TicketDependency, UserSettings, KBItem)
from helpdesk import settings as helpdesk_settings from helpdesk import settings as helpdesk_settings
logger = logging.getLogger(__name__)
User = get_user_model() User = get_user_model()
CUSTOMFIELD_TO_FIELD_DICT = { CUSTOMFIELD_TO_FIELD_DICT = {
@ -158,7 +158,7 @@ class AbstractTicketForm(CustomFieldMixin, forms.Form):
widget=forms.Select(attrs={'class': 'form-control'}), widget=forms.Select(attrs={'class': 'form-control'}),
choices=Ticket.PRIORITY_CHOICES, choices=Ticket.PRIORITY_CHOICES,
required=True, required=True,
initial='3', initial=getattr(settings, 'HELPDESK_PUBLIC_TICKET_PRIORITY', '3'),
label=_('Priority'), label=_('Priority'),
help_text=_("Please select a priority carefully. If unsure, leave it as '3'."), 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.'), 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): def _add_form_custom_fields(self, staff_only_filter=None):
if staff_only_filter is None: if staff_only_filter is None:
queryset = CustomField.objects.all() queryset = CustomField.objects.all()
@ -192,18 +202,33 @@ class AbstractTicketForm(CustomFieldMixin, forms.Form):
self.customfield_to_field(field, instanceargs) self.customfield_to_field(field, instanceargs)
def _create_ticket(self): def _get_queue(self):
queue = Queue.objects.get(id=int(self.cleaned_data['queue'])) # 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'], def _create_ticket(self):
submitter_email=self.cleaned_data['submitter_email'], queue = self._get_queue()
created=timezone.now(), kbitem = None
status=Ticket.OPEN_STATUS, if 'kbitem' in self.cleaned_data:
queue=queue, kbitem = KBItem.objects.get(id=int(self.cleaned_data['kbitem']))
description=self.cleaned_data['body'],
priority=self.cleaned_data['priority'], ticket = Ticket(
due_date=self.cleaned_data['due_date'], 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 return ticket, queue
@ -263,7 +288,11 @@ class TicketForm(AbstractTicketForm):
'updates to this ticket.'), 'updates to this ticket.'),
) )
assigned_to = forms.ChoiceField( 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, required=False,
label=_('Case owner'), label=_('Case owner'),
help_text=_('If you select an owner other than yourself, they\'ll be ' 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.'), help_text=_('We will e-mail you when your ticket is updated.'),
) )
def __init__(self, *args, **kwargs): def __init__(self, hidden_fields=(), readonly_fields=(), *args, **kwargs):
""" """
Add any (non-staff) custom fields that are defined to the form Add any (non-staff) custom fields that are defined to the form
""" """
super(PublicTicketForm, self).__init__(*args, **kwargs) super(PublicTicketForm, self).__init__(*args, **kwargs)
self._add_form_custom_fields(False)
if hasattr(settings, 'HELPDESK_PUBLIC_TICKET_QUEUE'): field_hide_table = {
self.fields['queue'].widget = forms.HiddenInput() 'queue': 'HELPDESK_PUBLIC_TICKET_QUEUE',
if hasattr(settings, 'HELPDESK_PUBLIC_TICKET_PRIORITY'): 'priority': 'HELPDESK_PUBLIC_TICKET_PRIORITY',
self.fields['priority'].widget = forms.HiddenInput() 'due_date': 'HELPDESK_PUBLIC_TICKET_DUE_DATE',
if hasattr(settings, 'HELPDESK_PUBLIC_TICKET_DUE_DATE'): }
self.fields['due_date'].widget = forms.HiddenInput()
self.fields['queue'].choices = [('', '--------')] + [ for field_name, field_setting_key in field_hide_table.items():
(q.id, q.title) for q in Queue.objects.filter(allow_public_submission=True)] 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): def save(self):
""" """

View File

@ -161,9 +161,9 @@ def format_time_spent(time_spent):
""" """
if time_spent: if time_spent:
time_spent = "{0:02d}h:{0:02d}m".format( time_spent = "{0:02d}h:{1:02d}m".format(
int(time_spent.total_seconds() // 3600), time_spent.seconds // 3600,
int((time_spent.total_seconds() % 3600) / 60) time_spent.seconds // 60
) )
else: else:
time_spent = "" time_spent = ""

File diff suppressed because it is too large Load Diff

View File

@ -105,7 +105,7 @@ def escalate_tickets(queues, verbose):
t.send( t.send(
{'submitter': ('escalated_submitter', context), {'submitter': ('escalated_submitter', context),
'ticket_cc': ('escalated_cc', context), 'ticket_cc': ('escalated_cc', context),
'assigned_to': ('escalated_owner', context)} 'assigned_to': ('escalated_owner', context)},
fail_silently=True, fail_silently=True,
) )

View File

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

View File

@ -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'),
),
]

View 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?'),
),
]

View 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'),
),
]

View 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'),
),
]

View 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'),
),
]

View File

@ -25,6 +25,8 @@ from django.utils.safestring import mark_safe
from markdown import markdown from markdown import markdown
from markdown.extensions import Extension from markdown.extensions import Extension
import pinax.teams.models
import uuid import uuid
@ -35,9 +37,9 @@ from .templated_email import send_templated_mail
def format_time_spent(time_spent): def format_time_spent(time_spent):
if time_spent: if time_spent:
time_spent = "{0:02d}h:{0:02d}m".format( time_spent = "{0:02d}h:{1:02d}m".format(
int(time_spent.total_seconds() // (3600)), time_spent.seconds // 3600,
int((time_spent.total_seconds() % 3600) / 60) time_spent.seconds // 60
) )
else: else:
time_spent = "" time_spent = ""
@ -559,6 +561,14 @@ class Ticket(models.Model):
default=mk_secret, default=mk_secret,
) )
kbitem = models.ForeignKey(
"KBItem",
blank=True,
null=True,
on_delete=models.CASCADE,
verbose_name=_('Knowledge base item the user was viewing when they created this ticket.'),
)
@property @property
def time_spent(self): def time_spent(self):
"""Return back total time spent on the ticket. This is calculated value """Return back total time spent on the ticket. This is calculated value
@ -605,6 +615,8 @@ class Ticket(models.Model):
if dont_send_to is not None: if dont_send_to is not None:
recipients.update(dont_send_to) recipients.update(dont_send_to)
recipients.add(self.queue.email_address)
def should_receive(email): def should_receive(email):
return email and email not in recipients return email and email not in recipients
@ -1203,8 +1215,13 @@ class KBCategory(models.Model):
listing of questions & answers. listing of questions & answers.
""" """
name = models.CharField(
_('Name of the category'),
max_length=100,
)
title = models.CharField( title = models.CharField(
_('Title'), _('Title on knowledgebase page'),
max_length=100, max_length=100,
) )
@ -1216,8 +1233,21 @@ class KBCategory(models.Model):
_('Description'), _('Description'),
) )
queue = models.ForeignKey(
Queue,
blank=True,
null=True,
on_delete=models.CASCADE,
verbose_name=_('Default queue when creating a ticket after viewing this category.'),
)
public = models.BooleanField(
default=True,
verbose_name=_("Is KBCategory publicly visible?")
)
def __str__(self): def __str__(self):
return '%s' % self.title return '%s' % self.name
class Meta: class Meta:
ordering = ('title',) ordering = ('title',)
@ -1234,7 +1264,14 @@ class KBItem(models.Model):
An item within the knowledgebase. Very straightforward question/answer An item within the knowledgebase. Very straightforward question/answer
style system. style system.
""" """
voted_by = models.ManyToManyField(settings.AUTH_USER_MODEL) voted_by = models.ManyToManyField(
settings.AUTH_USER_MODEL,
related_name='votes',
)
downvoted_by = models.ManyToManyField(
settings.AUTH_USER_MODEL,
related_name='downvotes',
)
category = models.ForeignKey( category = models.ForeignKey(
KBCategory, KBCategory,
on_delete=models.CASCADE, on_delete=models.CASCADE,
@ -1272,6 +1309,25 @@ class KBItem(models.Model):
blank=True, 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): def save(self, *args, **kwargs):
if not self.last_updated: if not self.last_updated:
self.last_updated = timezone.now() self.last_updated = timezone.now()
@ -1285,16 +1341,26 @@ class KBItem(models.Model):
score = property(_score) score = property(_score)
def __str__(self): def __str__(self):
return '%s' % self.title return '%s: %s' % (self.category.title, self.title)
class Meta: class Meta:
ordering = ('title',) ordering = ('order', 'title',)
verbose_name = _('Knowledge base item') verbose_name = _('Knowledge base item')
verbose_name_plural = _('Knowledge base items') verbose_name_plural = _('Knowledge base items')
def get_absolute_url(self): def get_absolute_url(self):
from django.urls import reverse from django.urls import reverse
return reverse('helpdesk:kb_item', args=(self.id,)) return str(reverse('helpdesk:kb_category', args=(self.category.slug,))) + "?kbitem=" + str(self.pk)
def query_url(self):
from django.urls import reverse
return str(reverse('helpdesk:list')) + "?kbitem=" + str(self.pk)
def num_open_tickets(self):
return Ticket.objects.filter(kbitem=self, status__in=(1, 2)).count()
def unassigned_tickets(self):
return Ticket.objects.filter(kbitem=self, status__in=(1, 2), assigned_to__isnull=True)
def get_markdown(self): def get_markdown(self):
return get_markdown(self.answer) return get_markdown(self.answer)

View File

@ -1,12 +1,16 @@
from django.db.models import Q from django.db.models import Q
from django.core.cache import cache from django.core.cache import cache
from django.urls import reverse
from model_utils import Choices from django.utils.translation import ugettext as _
from base64 import b64encode from base64 import b64encode
from base64 import b64decode from base64 import b64decode
import json import json
from model_utils import Choices
from helpdesk.serializers import DatatablesTicketSerializer
def query_to_base64(query): def query_to_base64(query):
""" """
@ -47,110 +51,173 @@ def query_to_dict(results, descriptions):
return output return output
def apply_query(queryset, params): def get_search_filter_args(search):
""" if search.startswith('queue:'):
Apply a dict-based set of filters & parameters to a queryset. return Q(queue__title__icontains=search[len('queue:'):])
if search.startswith('priority:'):
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: DATATABLES_ORDER_COLUMN_CHOICES = Choices(
filtering: A dict of Django ORM filters, eg: ('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'} {'user__id__in': [1, 3, 103], 'title__contains': 'foo'}
search_string: A freetext search string search_string: A freetext search string
sorting: The name of the column to sort by sorting: The name of the column to sort by
""" """
for key in params['filtering'].keys(): filter = self.params.get('filtering', {})
filter = {key: params['filtering'][key]} filter_or = self.params.get('filtering_or', {})
queryset = queryset.filter(**filter) 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', '') def get_cache_key(self):
if search: return str(self.huser.user.pk) + ":" + self.base64
qset = (
Q(title__icontains=search) |
Q(description__icontains=search) |
Q(resolution__icontains=search) |
Q(submitter_email__icontains=search) |
Q(ticketcustomfieldvalue__value__icontains=search)
)
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) def get(self):
if sorting: # Prefilter the allowed tickets
sortreverse = params.get('sortreverse', None) objects = cache.get(self.get_cache_key())
if sortreverse: if objects is not None:
sorting = "-%s" % sorting return objects
queryset = queryset.order_by(sorting) 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): queryset = objects.all().order_by(order_by)
# Prefilter the allowed tickets total = queryset.count()
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
if search_value: # Dead code currently
queryset = queryset.filter(get_search_filter_args(search_value))
ORDER_COLUMN_CHOICES = Choices( count = queryset.count()
('0', 'id'), queryset = queryset.order_by(order_column)[start:start + length]
('2', 'priority'), return {
('3', 'title'), 'data': DatatablesTicketSerializer(queryset, many=True).data,
('4', 'queue'), 'recordsFiltered': count,
('5', 'status'), 'recordsTotal': total,
('6', 'created'), 'draw': draw
('7', 'due_date'), }
('8', 'assigned_to')
)
def get_timeline_context(self):
events = []
def query_tickets_by_args(objects, order_by, **kwargs): for ticket in self.get():
""" for followup in ticket.followup_set.all():
This function takes in a list of ticket objects from the views and throws it event = {
to the datatables on ticket_list.html. If a search string was entered, this 'start_date': self.mk_timeline_date(followup.date),
function filters existing dataset on search string and returns a filtered 'text': {
filtered list. The `draw`, `length` etc parameters are for datatables to 'headline': ticket.title + ' - ' + followup.title,
display meta data on the table contents. The returning queryset is passed 'text': (followup.comment if followup.comment else _('No text')) + '<br/> <a href="%s" class="btn" role="button">%s</a>' %
to a Serializer called DatatablesTicketSerializer in serializers.py. (reverse('helpdesk:view', kwargs={'ticket_id': ticket.pk}), _("View ticket")),
""" },
draw = int(kwargs.get('draw', None)[0]) 'group': _('Messages'),
length = int(kwargs.get('length', None)[0]) }
start = int(kwargs.get('start', None)[0]) events.append(event)
search_value = kwargs.get('search[value]', None)[0]
order_column = kwargs.get('order[0][column]', None)[0]
order = kwargs.get('order[0][dir]', None)[0]
order_column = ORDER_COLUMN_CHOICES[order_column] return {
# django orm '-' -> desc 'events': events,
if order == 'desc': }
order_column = '-' + order_column
queryset = objects.all().order_by(order_by) def mk_timeline_date(self, date):
total = queryset.count() return {
'year': date.year,
if search_value: 'month': date.month,
queryset = queryset.filter(Q(id__icontains=search_value) | 'day': date.day,
Q(priority__icontains=search_value) | 'hour': date.hour,
Q(title__icontains=search_value) | 'minute': date.minute,
Q(queue__title__icontains=search_value) | 'second': date.second,
Q(status__icontains=search_value) | 'second': date.second,
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
}

View File

@ -15,19 +15,21 @@ datatables for ticket_list.html. Called from staff.datatables_ticket_list.
class DatatablesTicketSerializer(serializers.ModelSerializer): class DatatablesTicketSerializer(serializers.ModelSerializer):
ticket = serializers.SerializerMethodField() ticket = serializers.SerializerMethodField()
assigned_to = serializers.SerializerMethodField() assigned_to = serializers.SerializerMethodField()
submitter = serializers.SerializerMethodField()
created = serializers.SerializerMethodField() created = serializers.SerializerMethodField()
due_date = serializers.SerializerMethodField() due_date = serializers.SerializerMethodField()
status = serializers.SerializerMethodField() status = serializers.SerializerMethodField()
row_class = serializers.SerializerMethodField() row_class = serializers.SerializerMethodField()
time_spent = serializers.SerializerMethodField() time_spent = serializers.SerializerMethodField()
queue = serializers.SerializerMethodField() queue = serializers.SerializerMethodField()
kbitem = serializers.SerializerMethodField()
class Meta: class Meta:
model = Ticket model = Ticket
# fields = '__all__' # fields = '__all__'
fields = ('ticket', 'id', 'priority', 'title', 'queue', 'status', fields = ('ticket', 'id', 'priority', 'title', 'queue', 'status',
'created', 'due_date', 'assigned_to', 'row_class', 'created', 'due_date', 'assigned_to', 'submitter', 'row_class',
'time_spent') 'time_spent', 'kbitem')
def get_queue(self, obj): def get_queue(self, obj):
return ({"title": obj.queue.title, "id": obj.queue.id}) return ({"title": obj.queue.title, "id": obj.queue.id})
@ -46,15 +48,21 @@ class DatatablesTicketSerializer(serializers.ModelSerializer):
def get_assigned_to(self, obj): def get_assigned_to(self, obj):
if obj.assigned_to: if obj.assigned_to:
if obj.assigned_to.first_name: if obj.assigned_to.get_full_name():
return (obj.assigned_to.first_name) return (obj.assigned_to.get_full_name())
else: else:
return (obj.assigned_to.email) return (obj.assigned_to.email)
else: else:
return ("None") return ("None")
def get_submitter(self, obj):
return obj.submitter_email
def get_time_spent(self, obj): def get_time_spent(self, obj):
return format_time_spent(obj.time_spent) return format_time_spent(obj.time_spent)
def get_row_class(self, obj): def get_row_class(self, obj):
return (obj.get_priority_css_class) return (obj.get_priority_css_class)
def get_kbitem(self, obj):
return obj.kbitem.title if obj.kbitem else ""

View File

@ -242,6 +242,10 @@ body.fixed-nav.sidebar-toggled #content-wrapper {
overflow-y: auto; overflow-y: auto;
} }
.card-text {
font-weight: bold;
}
.card-body-icon { .card-body-icon {
position: absolute; position: absolute;
z-index: 0; z-index: 0;

View 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;
}

View 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;
}));

File diff suppressed because it is too large Load Diff

View 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 %}

View File

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

View File

@ -0,0 +1,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 %}

View 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>

View File

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

View File

@ -7,6 +7,11 @@
</div> </div>
<div class="col col-sm-3"> <div class="col col-sm-3">
<select id='id_owners' name='assigned_to' multiple='selected' size='5'> <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 %} {% for u in user_choices %}
<option value='{{ u.id }}'{% if u.id|in_list:query_params.filtering.assigned_to__id__in %} selected='selected'{% endif %}> <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 %} {{ u.get_username }}{% ifequal u user %} {% trans "(ME)" %}{% endifequal %}

View File

@ -1,6 +1,5 @@
{% load i18n humanize %} {% load i18n humanize %}
<!-- DataTables Example -->
<div class="card mb-3"> <div class="card mb-3">
<div class="card-header"> <div class="card-header">
<i class="fas fa-table"></i> <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 class="card-footer small text-muted">Listing {{ unassigned_tickets|length }} ticket(s).</div>
</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>&nbsp;{% trans "Take" %}</button></a>
<a href='{% url 'helpdesk:delete' ticket.id %}'><button class='btn btn-danger btn-sm'><i class="fas fa-trash"></i>&nbsp;{% 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 %}

View File

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

View File

@ -0,0 +1,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 %}

View 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>

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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>&nbsp;{% trans "Submit Ticket" %}</button>
{% csrf_token %}</form>
{% else %}
<h2>{% trans "Public ticket submission is disabled. Please contact the administrator for assistance." %}</h2>
{% endif %}
{% endwith %}

View File

@ -0,0 +1,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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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--

View File

@ -84,6 +84,21 @@ class GetEmailCommonTests(TestCase):
self.assertEqual(ticket.title, "Testovácí email") self.assertEqual(ticket.title, "Testovácí email")
self.assertEqual(ticket.description, "íářčšáíéřášč") 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): class GetEmailParametricTemplate(object):
"""TestCase that checks basic email functionality across methods and socks configs.""" """TestCase that checks basic email functionality across methods and socks configs."""

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

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

View File

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

View File

@ -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,
},
)

View File

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

View File

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

View File

@ -1,11 +1,17 @@
from helpdesk.models import ( from helpdesk.models import (
Ticket, Ticket,
Queue Queue,
KBCategory,
KBItem,
) )
from helpdesk import settings as helpdesk_settings from helpdesk import settings as helpdesk_settings
def huser_from_request(req):
return HelpdeskUser(req.user)
class HelpdeskUser: class HelpdeskUser:
def __init__(self, user): def __init__(self, user):
self.user = user self.user = user
@ -30,9 +36,26 @@ class HelpdeskUser:
else: else:
return all_queues 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): def get_tickets_in_queues(self):
return Ticket.objects.filter(queue__in=self.get_queues()) 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): def can_access_queue(self, queue):
"""Check if a certain user can access a certain queue. """Check if a certain user can access a certain queue.
@ -40,11 +63,10 @@ class HelpdeskUser:
:param queue: The django-helpdesk Queue instance :param queue: The django-helpdesk Queue instance
:return: True if the user has permission (either by default or explicitly), false otherwise :return: True if the user has permission (either by default or explicitly), false otherwise
""" """
user = self.user if self.has_full_access():
if user.is_superuser or not helpdesk_settings.HELPDESK_ENABLE_PER_QUEUE_STAFF_PERMISSION:
return True return True
else: 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): def can_access_ticket(self, ticket):
"""Check to see if the user has permission to access """Check to see if the user has permission to access
@ -52,8 +74,13 @@ class HelpdeskUser:
user = self.user user = self.user
if self.can_access_queue(ticket.queue): if self.can_access_queue(ticket.queue):
return True 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): (ticket.assigned_to and user.id == ticket.assigned_to.id):
return True return True
else: else:
return False 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))

View File

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

View File

@ -8,50 +8,77 @@ views/kb.py - Public-facing knowledgebase views. The knowledgebase is a
resolutions to common problems. 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.shortcuts import render, get_object_or_404
from django.views.decorators.clickjacking import xframe_options_exempt
from helpdesk import settings as helpdesk_settings from helpdesk import settings as helpdesk_settings
from helpdesk import user
from helpdesk.models import KBCategory, KBItem from helpdesk.models import KBCategory, KBItem
def index(request): 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. # TODO: It'd be great to have a list of most popular items here.
return render(request, 'helpdesk/kb_index.html', { return render(request, 'helpdesk/kb_index.html', {
'kb_categories': category_list, 'kb_categories': huser.get_allowed_kb_categories(),
'helpdesk_settings': helpdesk_settings, 'helpdesk_settings': helpdesk_settings,
}) })
def category(request, slug): def category(request, slug, iframe=False):
category = get_object_or_404(KBCategory, slug__iexact=slug) category = get_object_or_404(KBCategory, slug__iexact=slug)
items = category.kbitem_set.all() if not user.huser_from_request(request).can_access_kbcategory(category):
return render(request, 'helpdesk/kb_category.html', { 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, 'category': category,
'items': items, 'items': items,
'selected_item': selected_item,
'query_param_string': qparams.urlencode(),
'helpdesk_settings': helpdesk_settings, 'helpdesk_settings': helpdesk_settings,
'iframe': iframe,
'staff': staff,
}) })
def item(request, item): @xframe_options_exempt
item = get_object_or_404(KBItem, pk=item) def category_iframe(request, slug):
return render(request, 'helpdesk/kb_item.html', { return category(request, slug, iframe=True)
'category': item.category,
'item': item,
'helpdesk_settings': helpdesk_settings,
})
def vote(request, item): def vote(request, item):
item = get_object_or_404(KBItem, pk=item) item = get_object_or_404(KBItem, pk=item)
vote = request.GET.get('vote', None) vote = request.GET.get('vote', None)
if vote in ('up', 'down'): if vote == 'up':
if request.user not in item.voted_by: if not item.voted_by.filter(pk=request.user.pk):
item.votes += 1 item.votes += 1
if vote == 'up': item.voted_by.add(request.user.pk)
item.recommendations += 1 item.recommendations += 1
item.save() if item.downvoted_by.filter(pk=request.user.pk):
item.votes -= 1
item.downvoted_by.remove(request.user.pk)
if vote == 'down':
if not item.downvoted_by.filter(pk=request.user.pk):
item.votes += 1
item.downvoted_by.add(request.user.pk)
item.recommendations -= 1
if item.voted_by.filter(pk=request.user.pk):
item.votes -= 1
item.voted_by.remove(request.user.pk)
item.save()
return HttpResponseRedirect(item.get_absolute_url()) return HttpResponseRedirect(item.get_absolute_url())

View File

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

View File

@ -27,18 +27,14 @@ from django.utils import timezone
from django.views.generic.edit import FormView, UpdateView from django.views.generic.edit import FormView, UpdateView
from helpdesk.query import ( from helpdesk.query import (
get_query_class,
query_to_dict, query_to_dict,
get_query,
apply_query,
query_tickets_by_args,
query_to_base64, query_to_base64,
query_from_base64, query_from_base64,
) )
from helpdesk.user import HelpdeskUser from helpdesk.user import HelpdeskUser
from helpdesk.serializers import DatatablesTicketSerializer
from helpdesk.decorators import ( from helpdesk.decorators import (
helpdesk_staff_member_required, helpdesk_superuser_required, helpdesk_staff_member_required, helpdesk_superuser_required,
is_helpdesk_staff is_helpdesk_staff
@ -56,9 +52,10 @@ from helpdesk.lib import (
) )
from helpdesk.models import ( from helpdesk.models import (
Ticket, Queue, FollowUp, TicketChange, PreSetReply, FollowUpAttachment, SavedSearch, Ticket, Queue, FollowUp, TicketChange, PreSetReply, FollowUpAttachment, SavedSearch,
IgnoreEmail, TicketCC, TicketDependency, UserSettings, IgnoreEmail, TicketCC, TicketDependency, UserSettings, KBItem,
) )
from helpdesk import settings as helpdesk_settings from helpdesk import settings as helpdesk_settings
import helpdesk.views.abstract_views as abstract_views
from helpdesk.views.permissions import MustBeStaffMixin from helpdesk.views.permissions import MustBeStaffMixin
from ..lib import format_time_spent from ..lib import format_time_spent
@ -71,6 +68,7 @@ import re
User = get_user_model() User = get_user_model()
Query = get_query_class()
if helpdesk_settings.HELPDESK_ALLOW_NON_STAFF_TICKET_UPDATE: if helpdesk_settings.HELPDESK_ALLOW_NON_STAFF_TICKET_UPDATE:
# treat 'normal' users like 'staff' # treat 'normal' users like 'staff'
@ -105,6 +103,7 @@ def dashboard(request):
showing ticket counts by queue/status, and a list of unassigned tickets showing ticket counts by queue/status, and a list of unassigned tickets
with options for them to 'Take' ownership of said tickets. with options for them to 'Take' ownership of said tickets.
""" """
huser = HelpdeskUser(request.user)
active_tickets = Ticket.objects.select_related('queue').exclude( active_tickets = Ticket.objects.select_related('queue').exclude(
status__in=[Ticket.CLOSED_STATUS, Ticket.RESOLVED_STATUS], status__in=[Ticket.CLOSED_STATUS, Ticket.RESOLVED_STATUS],
) )
@ -119,13 +118,16 @@ def dashboard(request):
assigned_to=request.user, assigned_to=request.user,
status__in=[Ticket.CLOSED_STATUS, Ticket.RESOLVED_STATUS]) 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( unassigned_tickets = active_tickets.filter(
assigned_to__isnull=True, assigned_to__isnull=True,
kbitem__isnull=True,
queue__in=user_queues queue__in=user_queues
) )
kbitems = huser.get_assigned_kb_items()
# all tickets, reported by current user # all tickets, reported by current user
all_tickets_reported_by_current_user = '' all_tickets_reported_by_current_user = ''
email_current_user = request.user.email email_current_user = request.user.email
@ -159,6 +161,7 @@ def dashboard(request):
'user_tickets': tickets, 'user_tickets': tickets,
'user_tickets_closed_resolved': tickets_closed_resolved, 'user_tickets_closed_resolved': tickets_closed_resolved,
'unassigned_tickets': unassigned_tickets, 'unassigned_tickets': unassigned_tickets,
'kbitems': kbitems,
'all_tickets_reported_by_current_user': all_tickets_reported_by_current_user, 'all_tickets_reported_by_current_user': all_tickets_reported_by_current_user,
'basic_ticket_stats': basic_ticket_stats, 'basic_ticket_stats': basic_ticket_stats,
}) })
@ -708,6 +711,13 @@ def mass_update(request):
parts = action.split('_') parts = action.split('_')
user = User.objects.get(id=parts[1]) user = User.objects.get(id=parts[1])
action = 'assign' 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': elif action == 'take':
user = request.user user = request.user
action = 'assign' action = 'assign'
@ -737,6 +747,15 @@ def mass_update(request):
public=True, public=True,
user=request.user) user=request.user)
f.save() 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: elif action == 'close' and t.status != Ticket.CLOSED_STATUS:
t.status = Ticket.CLOSED_STATUS t.status = Ticket.CLOSED_STATUS
t.save() t.save()
@ -800,12 +819,15 @@ def ticket_list(request):
# a query, to be saved if needed: # a query, to be saved if needed:
query_params = { query_params = {
'filtering': {}, 'filtering': {},
'filtering_or': {},
'sorting': None, 'sorting': None,
'sortreverse': False, 'sortreverse': False,
'search_string': '', 'search_string': '',
} }
default_query_params = { default_query_params = {
'filtering': {'status__in': [1, 2, 3]}, 'filtering': {
'status__in': [1, 2],
},
'sorting': 'created', 'sorting': 'created',
'search_string': '', 'search_string': '',
'sortreverse': False, 'sortreverse': False,
@ -839,7 +861,7 @@ def ticket_list(request):
if filter: if filter:
try: try:
ticket = huser.get_tickets_in_queues.get(**filter) ticket = huser.get_tickets_in_queues().get(**filter)
return HttpResponseRedirect(ticket.staff_url) return HttpResponseRedirect(ticket.staff_url)
except Ticket.DoesNotExist: except Ticket.DoesNotExist:
# Go on to standard keyword searching # Go on to standard keyword searching
@ -852,7 +874,7 @@ def ticket_list(request):
if saved_query: if saved_query:
pass pass
elif not {'queue', 'assigned_to', 'status', 'q', 'sort', 'sortreverse'}.intersection(request.GET): elif not {'queue', 'assigned_to', 'status', 'q', 'sort', 'sortreverse', 'kbitem'}.intersection(request.GET):
# Fall-back if no querying is being done # Fall-back if no querying is being done
all_queues = Queue.objects.all() all_queues = Queue.objects.all()
query_params = deepcopy(default_query_params) query_params = deepcopy(default_query_params)
@ -861,13 +883,23 @@ def ticket_list(request):
('queue', 'queue__id__in'), ('queue', 'queue__id__in'),
('assigned_to', 'assigned_to__id__in'), ('assigned_to', 'assigned_to__id__in'),
('status', 'status__in'), ('status', 'status__in'),
('kbitem', 'kbitem__in'),
] ]
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: for param, filter_command in filter_in_params:
patterns = request.GET.getlist(param) if not request.GET.get(param) is None:
if patterns: patterns = request.GET.getlist(param)
try: try:
pattern_pks = [int(pattern) for pattern in patterns] 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 query_params['filtering'][filter_command] = pattern_pks
except ValueError: except ValueError:
pass pass
@ -887,7 +919,7 @@ def ticket_list(request):
# SORTING # SORTING
sort = request.GET.get('sort', None) sort = request.GET.get('sort', None)
if sort not in ('status', 'assigned_to', 'created', 'title', 'queue', 'priority'): if sort not in ('status', 'assigned_to', 'created', 'title', 'queue', 'priority', 'kbitem'):
sort = 'created' sort = 'created'
query_params['sorting'] = sort query_params['sorting'] = sort
@ -896,7 +928,7 @@ def ticket_list(request):
urlsafe_query = query_to_base64(query_params) urlsafe_query = query_to_base64(query_params)
get_query(urlsafe_query, huser) Query(huser, base64query=urlsafe_query).refresh_query()
user_saved_queries = SavedSearch.objects.filter(Q(user=request.user) | Q(shared__exact=True)) user_saved_queries = SavedSearch.objects.filter(Q(user=request.user) | Q(shared__exact=True))
@ -910,12 +942,16 @@ def ticket_list(request):
'<a href="http://docs.djangoproject.com/en/dev/ref/databases/#sqlite-string-matching">' '<a href="http://docs.djangoproject.com/en/dev/ref/databases/#sqlite-string-matching">'
'Django Documentation on string matching in SQLite</a>.') 'Django Documentation on string matching in SQLite</a>.')
kbitem_choices = [(item.pk, str(item)) for item in KBItem.objects.all()]
return render(request, 'helpdesk/ticket_list.html', dict( return render(request, 'helpdesk/ticket_list.html', dict(
context, context,
default_tickets_per_page=request.user.usersettings_helpdesk.tickets_per_page, default_tickets_per_page=request.user.usersettings_helpdesk.tickets_per_page,
user_choices=User.objects.filter(is_active=True, is_staff=True), user_choices=User.objects.filter(is_active=True, is_staff=True),
kb_items=KBItem.objects.all(),
queue_choices=huser.get_queues(), queue_choices=huser.get_queues(),
status_choices=Ticket.STATUS_CHOICES, status_choices=Ticket.STATUS_CHOICES,
kbitem_choices=kbitem_choices,
urlsafe_query=urlsafe_query, urlsafe_query=urlsafe_query,
user_saved_queries=user_saved_queries, user_saved_queries=user_saved_queries,
query_params=query_params, query_params=query_params,
@ -964,17 +1000,18 @@ def datatables_ticket_list(request, query):
on the table. query_tickets_by_args is at lib.py, DatatablesTicketSerializer is in on the table. query_tickets_by_args is at lib.py, DatatablesTicketSerializer is in
serializers.py. The serializers and this view use django-rest_framework methods serializers.py. The serializers and this view use django-rest_framework methods
""" """
objects = get_query(query, HelpdeskUser(request.user)) query = Query(HelpdeskUser(request.user), base64query=query)
model_object = query_tickets_by_args(objects, '-date_created', **request.query_params) result = query.get_datatables_context(**request.query_params)
serializer = DatatablesTicketSerializer(model_object['items'], many=True)
result = dict()
result['data'] = serializer.data
result['draw'] = model_object['draw']
result['recordsTotal'] = model_object['total']
result['recordsFiltered'] = model_object['count']
return (JsonResponse(result, status=status.HTTP_200_OK)) return (JsonResponse(result, status=status.HTTP_200_OK))
@helpdesk_staff_member_required
@api_view(['GET'])
def timeline_ticket_list(request, query):
query = Query(HelpdeskUser(request.user), base64query=query)
return (JsonResponse(query.get_timeline_context(), status=status.HTTP_200_OK))
@helpdesk_staff_member_required @helpdesk_staff_member_required
def edit_ticket(request, ticket_id): def edit_ticket(request, ticket_id):
ticket = get_object_or_404(Ticket, id=ticket_id) ticket = get_object_or_404(Ticket, id=ticket_id)
@ -994,17 +1031,12 @@ def edit_ticket(request, ticket_id):
edit_ticket = staff_member_required(edit_ticket) edit_ticket = staff_member_required(edit_ticket)
class CreateTicketView(MustBeStaffMixin, FormView): class CreateTicketView(MustBeStaffMixin, abstract_views.AbstractCreateTicketMixin, FormView):
template_name = 'helpdesk/create_ticket.html' template_name = 'helpdesk/create_ticket.html'
form_class = TicketForm form_class = TicketForm
def get_initial(self): def get_initial(self):
initial_data = {} initial_data = super().get_initial()
request = self.request
if request.user.usersettings_helpdesk.use_email_as_submitter and request.user.email:
initial_data['submitter_email'] = request.user.email
if 'queue' in request.GET:
initial_data['queue'] = request.GET['queue']
return initial_data return initial_data
def get_form_kwargs(self): def get_form_kwargs(self):

View File

@ -28,7 +28,11 @@ class QuickDjangoTest(object):
'django.contrib.sites', 'django.contrib.sites',
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'bootstrap4form', 'bootstrap4form',
'account',
'pinax.invitations',
'pinax.teams',
'helpdesk', 'helpdesk',
'reversion',
) )
MIDDLEWARE = [ MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware', 'django.middleware.security.SecurityMiddleware',

View File

@ -1,4 +1,4 @@
Django>=2.2,<3 Django>=2.2.9,<3
django-bootstrap4-form django-bootstrap4-form
celery celery
django-celery-beat django-celery-beat
@ -12,3 +12,4 @@ pytz
six six
djangorestframework djangorestframework
django-model-utils django-model-utils
pinax-teams @ git+https://github.com/auto-mat/pinax-teams.git@slugify#egg=pinax-teams

View File

@ -113,7 +113,7 @@ def get_requirements():
def get_long_description(): def get_long_description():
with open(os.path.join(os.path.dirname(__file__), "README.rst")) as f: with open(os.path.join(os.path.dirname(__file__), "README.rst")) as f:
long_desc = f.read() long_desc = f.read()
return long_desc return long_desc