Merge pull request #803 from auto-mat/teams

Add Teams functionality, using pinax-teams
This commit is contained in:
Garret Wassermann 2020-03-04 01:02:40 -05:00 committed by GitHub
commit 0e9358e61b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 3103 additions and 61 deletions

View File

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

View File

@ -58,7 +58,11 @@ errors with trying to create User settings.
'django.contrib.admin', # Required for helpdesk admin/maintenance
'django.contrib.humanize', # Required for elapsed time formatting
'bootstrap4form', # Required for nicer formatting of forms with the default templates
'account', # Required by pinax-teams
'pinax.inviations', # required by pinax-teams
'pinax.teams', # team support
'helpdesk', # This is us!
'reversion', # required by pinax-teams
)
Your ``settings.py`` file should also define a ``SITE_ID`` that allows multiple projects to share

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

View File

@ -183,8 +183,8 @@ class AbstractTicketForm(CustomFieldMixin, forms.Form):
self.fields['kbitem'] = forms.ChoiceField(
widget=forms.Select(attrs={'class': 'form-control'}),
required=False,
label=_('Knowedge Base Item'),
choices=[(kbi.pk, kbi.title) for kbi in KBItem.objects.filter(category=kbcategory.pk)],
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):

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.extensions import Extension
import pinax.teams.models
import uuid
@ -1213,8 +1215,13 @@ class KBCategory(models.Model):
listing of questions & answers.
"""
name = models.CharField(
_('Name of the category'),
max_length=100,
)
title = models.CharField(
_('Title'),
_('Title on knowledgebase page'),
max_length=100,
)
@ -1234,8 +1241,13 @@ class KBCategory(models.Model):
verbose_name=_('Default queue when creating a ticket after viewing this category.'),
)
public = models.BooleanField(
default=True,
verbose_name=_("Is KBCategory publicly visible?")
)
def __str__(self):
return '%s' % self.title
return '%s' % self.name
class Meta:
ordering = ('title',)
@ -1297,6 +1309,25 @@ class KBItem(models.Model):
blank=True,
)
team = models.ForeignKey(
pinax.teams.models.Team,
on_delete=models.CASCADE,
verbose_name=_('Team'),
blank=True,
null=True,
)
order = models.PositiveIntegerField(
_('Order'),
blank=True,
null=True,
)
enabled = models.BooleanField(
_('Enabled to display to users'),
default=True,
)
def save(self, *args, **kwargs):
if not self.last_updated:
self.last_updated = timezone.now()
@ -1310,10 +1341,10 @@ class KBItem(models.Model):
score = property(_score)
def __str__(self):
return '%s' % self.title
return '%s: %s' % (self.category.title, self.title)
class Meta:
ordering = ('title',)
ordering = ('order', 'title',)
verbose_name = _('Knowledge base item')
verbose_name_plural = _('Knowledge base items')
@ -1328,6 +1359,9 @@ class KBItem(models.Model):
def num_open_tickets(self):
return Ticket.objects.filter(kbitem=self, status__in=(1, 2)).count()
def unassigned_tickets(self):
return Ticket.objects.filter(kbitem=self, status__in=(1, 2), assigned_to__isnull=True)
def get_markdown(self):
return get_markdown(self.answer)

View File

@ -77,13 +77,16 @@ def get_search_filter_args(search):
DATATABLES_ORDER_COLUMN_CHOICES = Choices(
('0', 'id'),
('1', 'title'),
('2', 'priority'),
('3', 'title'),
('4', 'queue'),
('5', 'status'),
('6', 'created'),
('7', 'due_date'),
('8', 'assigned_to')
('3', 'queue'),
('4', 'status'),
('5', 'created'),
('6', 'due_date'),
('7', 'assigned_to'),
('8', 'submitter_email'),
# ('9', 'time_spent'),
('10', 'kbitem'),
)
@ -123,10 +126,9 @@ class __Query__:
sorting: The name of the column to sort by
"""
for key in self.params.get('filtering', {}).keys():
filter = {key: self.params['filtering'][key]}
queryset = queryset.filter(**filter)
queryset = queryset.filter(self.get_search_filter_args())
filter = self.params.get('filtering', {})
filter_or = self.params.get('filtering_or', {})
queryset = queryset.filter((Q(**filter) | Q(**filter_or)) & self.get_search_filter_args())
sorting = self.params.get('sorting', None)
if sorting:
sortreverse = self.params.get('sortreverse', None)
@ -162,12 +164,12 @@ class __Query__:
"""
objects = self.get()
order_by = '-date_created'
draw = int(kwargs.get('draw', None)[0])
length = int(kwargs.get('length', None)[0])
start = int(kwargs.get('start', None)[0])
search_value = kwargs.get('search[value]', None)[0]
order_column = kwargs.get('order[0][column]', None)[0]
order = kwargs.get('order[0][dir]', None)[0]
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
@ -177,7 +179,7 @@ class __Query__:
queryset = objects.all().order_by(order_by)
total = queryset.count()
if search_value:
if search_value: # Dead code currently
queryset = queryset.filter(get_search_filter_args(search_value))
count = queryset.count()

View File

@ -22,13 +22,14 @@ class DatatablesTicketSerializer(serializers.ModelSerializer):
row_class = serializers.SerializerMethodField()
time_spent = serializers.SerializerMethodField()
queue = serializers.SerializerMethodField()
kbitem = serializers.SerializerMethodField()
class Meta:
model = Ticket
# fields = '__all__'
fields = ('ticket', 'id', 'priority', 'title', 'queue', 'status',
'created', 'due_date', 'assigned_to', 'submitter', 'row_class',
'time_spent')
'time_spent', 'kbitem')
def get_queue(self, obj):
return ({"title": obj.queue.title, "id": obj.queue.id})
@ -62,3 +63,6 @@ class DatatablesTicketSerializer(serializers.ModelSerializer):
def get_row_class(self, obj):
return (obj.get_priority_css_class)
def get_kbitem(self, obj):
return obj.kbitem.title if obj.kbitem else ""

View File

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

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

@ -26,6 +26,7 @@
<!-- 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">

View File

@ -15,6 +15,8 @@
<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>
@ -25,3 +27,6 @@
<!-- Custom Theme JavaScript -->
<script src="{% static 'helpdesk/js/sb-admin.js' %}"></script>
{% block js_bottom %}
{% endblock %}

View File

@ -6,7 +6,16 @@
<label for='id_statuses'>{% trans "Knowledge base item(s)" %}:</label>
</div>
<div class="col col-sm-3">
<select id='id_kbitems' name='kbitem' multiple='selected' size='5'>{% for s in kbitem_choices %}<option value='{{ s.0 }}'{% if s.0|in_list:query_params.filtering.kbitem__in %} selected='selected'{% endif %}>{{ s.1 }}</option>{% endfor %}</select>
<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>

View File

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

View File

@ -1,6 +1,5 @@
{% load i18n humanize %}
<!-- DataTables Example -->
<div class="card mb-3">
<div class="card-header">
<i class="fas fa-table"></i>
@ -40,3 +39,43 @@
<div class="card-footer small text-muted">Listing {{ unassigned_tickets|length }} ticket(s).</div>
</div>
{% for kbitem in kbitems %}
<div class="card mb-3">
<div class="card-header">
<i class="fas fa-table"></i>
{% trans "KBItem:" %} {{kbitem.title}} {% trans "Team:" %} {{kbitem.team.name}} {% trans "(pick up a ticket if you start to work on it)" %}
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-bordered table-sm table-striped" id="dataTable" width="100%" cellspacing="0">
<thead class="thead-light">
<tr>
<th>{% trans "Ticket" %}</th>
<th>{% trans "Prority" %}</th>
<th>{% trans "Queue" %}</th>
<th>{% trans "Created" %}</th>
<th>{% trans "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for ticket in kbitem.unassigned_tickets %}
<tr class="{{ ticket.get_priority_css_class }}">
<td class="tickettitle"><a href='{{ ticket.get_absolute_url }}'>{{ ticket.id }}. {{ ticket.title }} </a></td>
<td>{{ ticket.priority }}</td>
<td>{{ ticket.queue }}</td>
<td><span title='{{ ticket.created|date:"r" }}'>{{ ticket.created|naturaltime }}</span></td>
<td class="text-center">
<a href='{{ ticket.get_absolute_url }}?take'><button class='btn btn-primary btn-sm'><i class="fas fa-hand-paper"></i>&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,48 +1,63 @@
{% load i18n %}
<h2>{% trans 'Knowledgebase Category' %}:{% blocktrans with category.title as kbcat %}{{ kbcat }}{% endblocktrans %}</h2>
<p>{{ category.description }}</p>
{% 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">
<button class="btn btn-link" data-toggle="collapse" data-target="#collapse{{item.id}}" aria-expanded="true" aria-controls="collapse{{item.id}}">
<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>
</button>
<div id="collapse{{item.id}}" class="collapse {% if item.id == selected_item %}show{% endif %}" aria-labelledby="header{{item.id}}" data-parent="#accordion">
</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>
<p>{{ item.get_markdown }}</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'><button type="button" class="btn btn-success btn-circle btn-xl"><i class="fa fa-thumbs-up fa-lg"></i></button></a>
<a href='{% url "helpdesk:kb_vote" item.pk %}?vote=down'><button type="button" class="btn btn-danger btn-circle btn-xl"><i class="fa fa-thumbs-down fa-lg"></i></button></a>
<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">
<button type="button" class="btn btn-success btn-circle btn-xl float-right"><i class="fa fa-search fa-lg"></i> {{item.num_open_tickets}} {% trans 'open tickets' %}</button>
<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">
<button type="button" class="btn btn-success btn-circle btn-xl float-right"><i class="fa fa-envelope fa-lg"></i> {% trans 'Contact a human' %}</button>
<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}}'>
<button type="button" class="btn btn-danger btn-circle btn-xl float-right"><i class="fa fa-envelope fa-lg"></i> {% trans 'Contact a human' %}</button>
{% 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

@ -1,5 +1,6 @@
{% load i18n %}
{% load saved_queries %}
<!DOCTYPE html>
<head>
{% include 'helpdesk/base-head.html' %}
{% block helpdesk_head %}{% endblock %}

View File

@ -3,7 +3,9 @@
{% 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>

View File

@ -1,5 +1,6 @@
{% load i18n %}
{% load saved_queries %}
<!DOCTYPE html>
<head>
{% include 'helpdesk/base-head.html' %}
</head>

View File

@ -71,7 +71,7 @@
{% if ticket.kbitem %}
<tr>
<th class="table-active">{% trans "Knowlegebase item" %}</th>
<td> <a href ="{{ticket.kbitem.query_url}}"> {{ticket.kbitem.title}} </a> </td>
<td> <a href ="{{ticket.kbitem.query_url}}"> {{ticket.kbitem}} </a> </td>
</tr>
{% endif %}
<tr>

View File

@ -68,6 +68,7 @@
<th>{% trans "Owner" %}</th>
<th>{% trans "Submitter" %}</th>
<th>{% trans "Time Spent" %}</th>
<th>{% trans "KB item" %}</th>
</tr>
</thead>
</table>
@ -96,6 +97,10 @@
<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>
@ -303,6 +308,8 @@
{
$( row ).addClass(data.row_class);
},
dom: 'ltBp',
buttons: ["colvis"],
"columns": [
{"data": "id",
@ -339,16 +346,18 @@
priority = "danger";
}
return '<p class="text-'+priority+'">'+data+'</p>';
}
},
"visible": false,
},
{"data": "queue",
"render": function(data, type, row, meta) {
return data.title;
}
},
"visible": false,
},
{"data": "status"},
{"data": "created"},
{"data": "due_date"},
{"data": "due_date", "visible": false},
{"data": "assigned_to",
"render": function(data, type, row, meta) {
if (data != "None") {
@ -360,7 +369,8 @@
}
},
{"data": "submitter"},
{"data": "time_spent"},
{"data": "time_spent", "visible": false},
{"data": "kbitem"},
]
});
})

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

@ -1,11 +1,17 @@
from helpdesk.models import (
Ticket,
Queue
Queue,
KBCategory,
KBItem,
)
from helpdesk import settings as helpdesk_settings
def huser_from_request(req):
return HelpdeskUser(req.user)
class HelpdeskUser:
def __init__(self, user):
self.user = user
@ -30,9 +36,26 @@ class HelpdeskUser:
else:
return all_queues
def get_allowed_kb_categories(self):
categories = []
for cat in KBCategory.objects.all():
if self.can_access_kbcategory(cat):
categories.append(cat)
return categories
def get_assigned_kb_items(self):
kbitems = []
for item in KBItem.objects.all():
if item.team and item.team.is_member(self.user):
kbitems.append(item)
return kbitems
def get_tickets_in_queues(self):
return Ticket.objects.filter(queue__in=self.get_queues())
def has_full_access(self):
return self.user.is_superuser or self.user.is_staff
def can_access_queue(self, queue):
"""Check if a certain user can access a certain queue.
@ -40,11 +63,10 @@ class HelpdeskUser:
:param queue: The django-helpdesk Queue instance
:return: True if the user has permission (either by default or explicitly), false otherwise
"""
user = self.user
if user.is_superuser or not helpdesk_settings.HELPDESK_ENABLE_PER_QUEUE_STAFF_PERMISSION:
if self.has_full_access():
return True
else:
return user.has_perm(queue.permission_name)
return helpdesk_settings.HELPDESK_ENABLE_PER_QUEUE_STAFF_PERMISSION and self.user.has_perm(queue.permission_name)
def can_access_ticket(self, ticket):
"""Check to see if the user has permission to access
@ -52,8 +74,13 @@ class HelpdeskUser:
user = self.user
if self.can_access_queue(ticket.queue):
return True
elif user.is_superuser or user.is_staff or \
elif self.has_full_access() or \
(ticket.assigned_to and user.id == ticket.assigned_to.id):
return True
else:
return False
def can_access_kbcategory(self, category):
if category.public:
return True
return self.has_full_access() or (category.queue and self.can_access_queue(category.queue))

View File

@ -8,26 +8,29 @@ views/kb.py - Public-facing knowledgebase views. The knowledgebase is a
resolutions to common problems.
"""
from django.http import HttpResponseRedirect
from django.http import HttpResponseRedirect, Http404
from django.shortcuts import render, get_object_or_404
from django.views.decorators.clickjacking import xframe_options_exempt
from helpdesk import settings as helpdesk_settings
from helpdesk import user
from helpdesk.models import KBCategory, KBItem
def index(request):
category_list = KBCategory.objects.all()
huser = user.huser_from_request(request)
# TODO: It'd be great to have a list of most popular items here.
return render(request, 'helpdesk/kb_index.html', {
'kb_categories': category_list,
'kb_categories': huser.get_allowed_kb_categories(),
'helpdesk_settings': helpdesk_settings,
})
def category(request, slug, iframe=False):
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):
raise Http404
items = category.kbitem_set.filter(enabled=True)
selected_item = request.GET.get('kbitem', None)
try:
selected_item = int(selected_item)

View File

@ -29,6 +29,7 @@ import helpdesk.views.abstract_views as abstract_views
from helpdesk.forms import PublicTicketForm
from helpdesk.lib import text_is_spam
from helpdesk.models import CustomField, Ticket, Queue, UserSettings, KBCategory, KBItem
from helpdesk.user import huser_from_request
logger = logging.getLogger(__name__)
@ -142,7 +143,7 @@ class Homepage(CreateTicketView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['kb_categories'] = KBCategory.objects.all()
context['kb_categories'] = huser_from_request(self.request).get_allowed_kb_categories()
return context

View File

@ -103,6 +103,7 @@ def dashboard(request):
showing ticket counts by queue/status, and a list of unassigned tickets
with options for them to 'Take' ownership of said tickets.
"""
huser = HelpdeskUser(request.user)
active_tickets = Ticket.objects.select_related('queue').exclude(
status__in=[Ticket.CLOSED_STATUS, Ticket.RESOLVED_STATUS],
)
@ -117,13 +118,16 @@ def dashboard(request):
assigned_to=request.user,
status__in=[Ticket.CLOSED_STATUS, Ticket.RESOLVED_STATUS])
user_queues = HelpdeskUser(request.user).get_queues()
user_queues = huser.get_queues()
unassigned_tickets = active_tickets.filter(
assigned_to__isnull=True,
kbitem__isnull=True,
queue__in=user_queues
)
kbitems = huser.get_assigned_kb_items()
# all tickets, reported by current user
all_tickets_reported_by_current_user = ''
email_current_user = request.user.email
@ -157,6 +161,7 @@ def dashboard(request):
'user_tickets': tickets,
'user_tickets_closed_resolved': tickets_closed_resolved,
'unassigned_tickets': unassigned_tickets,
'kbitems': kbitems,
'all_tickets_reported_by_current_user': all_tickets_reported_by_current_user,
'basic_ticket_stats': basic_ticket_stats,
})
@ -706,6 +711,13 @@ def mass_update(request):
parts = action.split('_')
user = User.objects.get(id=parts[1])
action = 'assign'
if action == 'kbitem_none':
kbitem = None
action = 'set_kbitem'
if action.startswith('kbitem_'):
parts = action.split('_')
kbitem = KBItem.objects.get(id=parts[1])
action = 'set_kbitem'
elif action == 'take':
user = request.user
action = 'assign'
@ -735,6 +747,15 @@ def mass_update(request):
public=True,
user=request.user)
f.save()
elif action == 'set_kbitem':
t.kbitem = kbitem
t.save()
f = FollowUp(ticket=t,
date=timezone.now(),
title=_('KBItem set in bulk update'),
public=False,
user=request.user)
f.save()
elif action == 'close' and t.status != Ticket.CLOSED_STATUS:
t.status = Ticket.CLOSED_STATUS
t.save()
@ -798,13 +819,14 @@ def ticket_list(request):
# a query, to be saved if needed:
query_params = {
'filtering': {},
'filtering_or': {},
'sorting': None,
'sortreverse': False,
'search_string': '',
}
default_query_params = {
'filtering': {
'status__in': [1, 2, 3],
'status__in': [1, 2],
},
'sorting': 'created',
'search_string': '',
@ -863,12 +885,21 @@ def ticket_list(request):
('status', 'status__in'),
('kbitem', 'kbitem__in'),
]
filter_null_params = dict([
('queue', 'queue__id__isnull'),
('assigned_to', 'assigned_to__id__isnull'),
('status', 'status__isnull'),
('kbitem', 'kbitem__isnull'),
])
for param, filter_command in filter_in_params:
patterns = request.GET.getlist(param)
if patterns:
if not request.GET.get(param) is None:
patterns = request.GET.getlist(param)
try:
pattern_pks = [int(pattern) for pattern in patterns]
if -1 in pattern_pks:
query_params['filtering_or'][filter_null_params[param]] = True
else:
query_params['filtering_or'][filter_command] = pattern_pks
query_params['filtering'][filter_command] = pattern_pks
except ValueError:
pass
@ -911,12 +942,13 @@ def ticket_list(request):
'<a href="http://docs.djangoproject.com/en/dev/ref/databases/#sqlite-string-matching">'
'Django Documentation on string matching in SQLite</a>.')
kbitem_choices = [(item.pk, item.title) for item in KBItem.objects.all()]
kbitem_choices = [(item.pk, str(item)) for item in KBItem.objects.all()]
return render(request, 'helpdesk/ticket_list.html', dict(
context,
default_tickets_per_page=request.user.usersettings_helpdesk.tickets_per_page,
user_choices=User.objects.filter(is_active=True, is_staff=True),
kb_items=KBItem.objects.all(),
queue_choices=huser.get_queues(),
status_choices=Ticket.STATUS_CHOICES,
kbitem_choices=kbitem_choices,

View File

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

View File

@ -12,3 +12,4 @@ pytz
six
djangorestframework
django-model-utils
pinax-teams @ git+https://github.com/auto-mat/pinax-teams.git@slugify#egg=pinax-teams

View File

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