mirror of
https://github.com/django-helpdesk/django-helpdesk.git
synced 2025-05-31 23:15:42 +02:00
Merge pull request #778 from auto-mat/datatables_refactor
Datatables refactor - now use Datatables always rather than only when serverside processing is disabled.
This commit is contained in:
commit
406ecf5411
@ -106,9 +106,6 @@ HELPDESK_REDIRECT_TO_LOGIN_BY_DEFAULT = False
|
|||||||
LOGIN_URL = '/login/'
|
LOGIN_URL = '/login/'
|
||||||
LOGIN_REDIRECT_URL = '/login/'
|
LOGIN_REDIRECT_URL = '/login/'
|
||||||
|
|
||||||
# Turn off server-side processing for this local demo
|
|
||||||
HELPDESK_USE_SERVERSIDE_PROCESSING = False
|
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
# - by default, we use SQLite3 for the demo, but you can also
|
# - by default, we use SQLite3 for the demo, but you can also
|
||||||
# configure MySQL or PostgreSQL, see the docs for more:
|
# configure MySQL or PostgreSQL, see the docs for more:
|
||||||
|
@ -90,11 +90,6 @@ These changes are visible throughout django-helpdesk
|
|||||||
|
|
||||||
**Default:** ``HELPDESK_ANON_ACCESS_RAISES_404 = False``
|
**Default:** ``HELPDESK_ANON_ACCESS_RAISES_404 = False``
|
||||||
|
|
||||||
- **HELPDESK_USE_SERVERSIDE_PROCESSING** If True, may improve performance by utilizing server-side processing of the full ticket list whenever performing queries on the ticket list. Set to False to restore the "classic" functionality using javascript.
|
|
||||||
|
|
||||||
**Default:** ``HELPDESK_USE_SERVERSIDE_PROCESSING = True``
|
|
||||||
|
|
||||||
|
|
||||||
Options shown on public pages
|
Options shown on public pages
|
||||||
-----------------------------
|
-----------------------------
|
||||||
|
|
||||||
|
139
helpdesk/lib.py
139
helpdesk/lib.py
@ -11,98 +11,15 @@ import mimetypes
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db.models import Q
|
|
||||||
from django.utils.encoding import smart_text, smart_str
|
from django.utils.encoding import smart_text, smart_str
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
from helpdesk.models import FollowUpAttachment, EmailTemplate
|
from helpdesk.models import FollowUpAttachment, EmailTemplate
|
||||||
|
|
||||||
from model_utils import Choices
|
|
||||||
|
|
||||||
from base64 import b64encode
|
|
||||||
from base64 import b64decode
|
|
||||||
|
|
||||||
import json
|
|
||||||
|
|
||||||
logger = logging.getLogger('helpdesk')
|
logger = logging.getLogger('helpdesk')
|
||||||
|
|
||||||
|
|
||||||
def query_to_base64(query):
|
|
||||||
"""
|
|
||||||
Converts a query dict object to a base64-encoded bytes object.
|
|
||||||
"""
|
|
||||||
return b64encode(json.dumps(query).encode('UTF-8'))
|
|
||||||
|
|
||||||
|
|
||||||
def query_from_base64(b64data):
|
|
||||||
"""
|
|
||||||
Converts base64-encoded bytes object back to a query dict object.
|
|
||||||
"""
|
|
||||||
return json.loads(b64decode(b64data).decode('utf-8'))
|
|
||||||
|
|
||||||
|
|
||||||
def query_to_dict(results, descriptions):
|
|
||||||
"""
|
|
||||||
Replacement method for cursor.dictfetchall() as that method no longer
|
|
||||||
exists in psycopg2, and I'm guessing in other backends too.
|
|
||||||
|
|
||||||
Converts the results of a raw SQL query into a list of dictionaries, suitable
|
|
||||||
for use in templates etc.
|
|
||||||
"""
|
|
||||||
|
|
||||||
output = []
|
|
||||||
for data in results:
|
|
||||||
row = {}
|
|
||||||
i = 0
|
|
||||||
for column in descriptions:
|
|
||||||
row[column[0]] = data[i]
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
output.append(row)
|
|
||||||
return output
|
|
||||||
|
|
||||||
|
|
||||||
def apply_query(queryset, params):
|
|
||||||
"""
|
|
||||||
Apply a dict-based set of filters & parameters to a queryset.
|
|
||||||
|
|
||||||
queryset is a Django queryset, eg MyModel.objects.all() or
|
|
||||||
MyModel.objects.filter(user=request.user)
|
|
||||||
|
|
||||||
params is a dictionary that contains the following:
|
|
||||||
filtering: A dict of Django ORM filters, eg:
|
|
||||||
{'user__id__in': [1, 3, 103], 'title__contains': 'foo'}
|
|
||||||
|
|
||||||
search_string: A freetext search string
|
|
||||||
|
|
||||||
sorting: The name of the column to sort by
|
|
||||||
"""
|
|
||||||
for key in params['filtering'].keys():
|
|
||||||
filter = {key: params['filtering'][key]}
|
|
||||||
queryset = queryset.filter(**filter)
|
|
||||||
|
|
||||||
search = params.get('search_string', None)
|
|
||||||
if search:
|
|
||||||
qset = (
|
|
||||||
Q(title__icontains=search) |
|
|
||||||
Q(description__icontains=search) |
|
|
||||||
Q(resolution__icontains=search) |
|
|
||||||
Q(submitter_email__icontains=search) |
|
|
||||||
Q(ticketcustomfieldvalue__value__icontains=search)
|
|
||||||
)
|
|
||||||
|
|
||||||
queryset = queryset.filter(qset)
|
|
||||||
|
|
||||||
sorting = params.get('sorting', None)
|
|
||||||
if sorting:
|
|
||||||
sortreverse = params.get('sortreverse', None)
|
|
||||||
if sortreverse:
|
|
||||||
sorting = "-%s" % sorting
|
|
||||||
queryset = queryset.order_by(sorting)
|
|
||||||
|
|
||||||
return queryset
|
|
||||||
|
|
||||||
|
|
||||||
def ticket_template_context(ticket):
|
def ticket_template_context(ticket):
|
||||||
context = {}
|
context = {}
|
||||||
|
|
||||||
@ -238,62 +155,6 @@ def process_attachments(followup, attached_files):
|
|||||||
return attachments
|
return attachments
|
||||||
|
|
||||||
|
|
||||||
ORDER_COLUMN_CHOICES = Choices(
|
|
||||||
('0', 'id'),
|
|
||||||
('2', 'priority'),
|
|
||||||
('3', 'title'),
|
|
||||||
('4', 'queue'),
|
|
||||||
('5', 'status'),
|
|
||||||
('6', 'created'),
|
|
||||||
('7', 'due_date'),
|
|
||||||
('8', 'assigned_to')
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def query_tickets_by_args(objects, order_by, **kwargs):
|
|
||||||
"""
|
|
||||||
This function takes in a list of ticket objects from the views and throws it
|
|
||||||
to the datatables on ticket_list.html. If a search string was entered, this
|
|
||||||
function filters existing dataset on search string and returns a filtered
|
|
||||||
filtered list. The `draw`, `length` etc parameters are for datatables to
|
|
||||||
display meta data on the table contents. The returning queryset is passed
|
|
||||||
to a Serializer called TicketSerializer in serializers.py.
|
|
||||||
"""
|
|
||||||
draw = int(kwargs.get('draw', None)[0])
|
|
||||||
length = int(kwargs.get('length', None)[0])
|
|
||||||
start = int(kwargs.get('start', None)[0])
|
|
||||||
search_value = kwargs.get('search[value]', None)[0]
|
|
||||||
order_column = kwargs.get('order[0][column]', None)[0]
|
|
||||||
order = kwargs.get('order[0][dir]', None)[0]
|
|
||||||
|
|
||||||
order_column = ORDER_COLUMN_CHOICES[order_column]
|
|
||||||
# django orm '-' -> desc
|
|
||||||
if order == 'desc':
|
|
||||||
order_column = '-' + order_column
|
|
||||||
|
|
||||||
queryset = objects.all().order_by(order_by)
|
|
||||||
total = queryset.count()
|
|
||||||
|
|
||||||
if search_value:
|
|
||||||
queryset = queryset.filter(Q(id__icontains=search_value) |
|
|
||||||
Q(priority__icontains=search_value) |
|
|
||||||
Q(title__icontains=search_value) |
|
|
||||||
Q(queue__title__icontains=search_value) |
|
|
||||||
Q(status__icontains=search_value) |
|
|
||||||
Q(created__icontains=search_value) |
|
|
||||||
Q(due_date__icontains=search_value) |
|
|
||||||
Q(assigned_to__email__icontains=search_value))
|
|
||||||
|
|
||||||
count = queryset.count()
|
|
||||||
queryset = queryset.order_by(order_column)[start:start + length]
|
|
||||||
return {
|
|
||||||
'items': queryset,
|
|
||||||
'count': count,
|
|
||||||
'total': total,
|
|
||||||
'draw': draw
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def format_time_spent(time_spent):
|
def format_time_spent(time_spent):
|
||||||
"""Format time_spent attribute to "[H]HHh:MMm" text string to be allign in
|
"""Format time_spent attribute to "[H]HHh:MMm" text string to be allign in
|
||||||
all graphical outputs
|
all graphical outputs
|
||||||
|
@ -28,6 +28,8 @@ from markdown.extensions import Extension
|
|||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
from helpdesk import settings as helpdesk_settings
|
||||||
|
|
||||||
from .templated_email import send_templated_mail
|
from .templated_email import send_templated_mail
|
||||||
|
|
||||||
|
|
||||||
@ -685,7 +687,7 @@ class Ticket(models.Model):
|
|||||||
site = Site.objects.get_current()
|
site = Site.objects.get_current()
|
||||||
except ImproperlyConfigured:
|
except ImproperlyConfigured:
|
||||||
site = Site(domain='configure-django-sites.com')
|
site = Site(domain='configure-django-sites.com')
|
||||||
if settings.HELPDESK_USE_HTTPS_IN_EMAIL_LINK:
|
if helpdesk_settings.HELPDESK_USE_HTTPS_IN_EMAIL_LINK:
|
||||||
protocol = 'https'
|
protocol = 'https'
|
||||||
else:
|
else:
|
||||||
protocol = 'http'
|
protocol = 'http'
|
||||||
@ -711,7 +713,7 @@ class Ticket(models.Model):
|
|||||||
site = Site.objects.get_current()
|
site = Site.objects.get_current()
|
||||||
except ImproperlyConfigured:
|
except ImproperlyConfigured:
|
||||||
site = Site(domain='configure-django-sites.com')
|
site = Site(domain='configure-django-sites.com')
|
||||||
if settings.HELPDESK_USE_HTTPS_IN_EMAIL_LINK:
|
if helpdesk_settings.HELPDESK_USE_HTTPS_IN_EMAIL_LINK:
|
||||||
protocol = 'https'
|
protocol = 'https'
|
||||||
else:
|
else:
|
||||||
protocol = 'http'
|
protocol = 'http'
|
||||||
|
156
helpdesk/query.py
Normal file
156
helpdesk/query.py
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
from django.db.models import Q
|
||||||
|
from django.core.cache import cache
|
||||||
|
|
||||||
|
from model_utils import Choices
|
||||||
|
|
||||||
|
from base64 import b64encode
|
||||||
|
from base64 import b64decode
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
def query_to_base64(query):
|
||||||
|
"""
|
||||||
|
Converts a query dict object to a base64-encoded bytes object.
|
||||||
|
"""
|
||||||
|
return b64encode(json.dumps(query).encode('UTF-8')).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def query_from_base64(b64data):
|
||||||
|
"""
|
||||||
|
Converts base64-encoded bytes object back to a query dict object.
|
||||||
|
"""
|
||||||
|
query = {'search_string': ''}
|
||||||
|
query.update(json.loads(b64decode(b64data).decode('utf-8')))
|
||||||
|
if query['search_string'] is None:
|
||||||
|
query['search_string'] = ''
|
||||||
|
return query
|
||||||
|
|
||||||
|
|
||||||
|
def query_to_dict(results, descriptions):
|
||||||
|
"""
|
||||||
|
Replacement method for cursor.dictfetchall() as that method no longer
|
||||||
|
exists in psycopg2, and I'm guessing in other backends too.
|
||||||
|
|
||||||
|
Converts the results of a raw SQL query into a list of dictionaries, suitable
|
||||||
|
for use in templates etc.
|
||||||
|
"""
|
||||||
|
|
||||||
|
output = []
|
||||||
|
for data in results:
|
||||||
|
row = {}
|
||||||
|
i = 0
|
||||||
|
for column in descriptions:
|
||||||
|
row[column[0]] = data[i]
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
output.append(row)
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def apply_query(queryset, params):
|
||||||
|
"""
|
||||||
|
Apply a dict-based set of filters & parameters to a queryset.
|
||||||
|
|
||||||
|
queryset is a Django queryset, eg MyModel.objects.all() or
|
||||||
|
MyModel.objects.filter(user=request.user)
|
||||||
|
|
||||||
|
params is a dictionary that contains the following:
|
||||||
|
filtering: A dict of Django ORM filters, eg:
|
||||||
|
{'user__id__in': [1, 3, 103], 'title__contains': 'foo'}
|
||||||
|
|
||||||
|
search_string: A freetext search string
|
||||||
|
|
||||||
|
sorting: The name of the column to sort by
|
||||||
|
"""
|
||||||
|
for key in params['filtering'].keys():
|
||||||
|
filter = {key: params['filtering'][key]}
|
||||||
|
queryset = queryset.filter(**filter)
|
||||||
|
|
||||||
|
search = params.get('search_string', '')
|
||||||
|
if search:
|
||||||
|
qset = (
|
||||||
|
Q(title__icontains=search) |
|
||||||
|
Q(description__icontains=search) |
|
||||||
|
Q(resolution__icontains=search) |
|
||||||
|
Q(submitter_email__icontains=search) |
|
||||||
|
Q(ticketcustomfieldvalue__value__icontains=search)
|
||||||
|
)
|
||||||
|
|
||||||
|
queryset = queryset.filter(qset)
|
||||||
|
|
||||||
|
sorting = params.get('sorting', None)
|
||||||
|
if sorting:
|
||||||
|
sortreverse = params.get('sortreverse', None)
|
||||||
|
if sortreverse:
|
||||||
|
sorting = "-%s" % sorting
|
||||||
|
queryset = queryset.order_by(sorting)
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
|
def get_query(query, huser):
|
||||||
|
# Prefilter the allowed tickets
|
||||||
|
objects = cache.get(huser.user.email + query)
|
||||||
|
if objects is not None:
|
||||||
|
return objects
|
||||||
|
tickets = huser.get_tickets_in_queues().select_related()
|
||||||
|
query_params = query_from_base64(query)
|
||||||
|
ticket_qs = apply_query(tickets, query_params)
|
||||||
|
cache.set(huser.user.email + query, ticket_qs, timeout=60*60)
|
||||||
|
return ticket_qs
|
||||||
|
|
||||||
|
|
||||||
|
ORDER_COLUMN_CHOICES = Choices(
|
||||||
|
('0', 'id'),
|
||||||
|
('2', 'priority'),
|
||||||
|
('3', 'title'),
|
||||||
|
('4', 'queue'),
|
||||||
|
('5', 'status'),
|
||||||
|
('6', 'created'),
|
||||||
|
('7', 'due_date'),
|
||||||
|
('8', 'assigned_to')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def query_tickets_by_args(objects, order_by, **kwargs):
|
||||||
|
"""
|
||||||
|
This function takes in a list of ticket objects from the views and throws it
|
||||||
|
to the datatables on ticket_list.html. If a search string was entered, this
|
||||||
|
function filters existing dataset on search string and returns a filtered
|
||||||
|
filtered list. The `draw`, `length` etc parameters are for datatables to
|
||||||
|
display meta data on the table contents. The returning queryset is passed
|
||||||
|
to a Serializer called DatatablesTicketSerializer in serializers.py.
|
||||||
|
"""
|
||||||
|
draw = int(kwargs.get('draw', None)[0])
|
||||||
|
length = int(kwargs.get('length', None)[0])
|
||||||
|
start = int(kwargs.get('start', None)[0])
|
||||||
|
search_value = kwargs.get('search[value]', None)[0]
|
||||||
|
order_column = kwargs.get('order[0][column]', None)[0]
|
||||||
|
order = kwargs.get('order[0][dir]', None)[0]
|
||||||
|
|
||||||
|
order_column = ORDER_COLUMN_CHOICES[order_column]
|
||||||
|
# django orm '-' -> desc
|
||||||
|
if order == 'desc':
|
||||||
|
order_column = '-' + order_column
|
||||||
|
|
||||||
|
queryset = objects.all().order_by(order_by)
|
||||||
|
total = queryset.count()
|
||||||
|
|
||||||
|
if search_value:
|
||||||
|
queryset = queryset.filter(Q(id__icontains=search_value) |
|
||||||
|
Q(priority__icontains=search_value) |
|
||||||
|
Q(title__icontains=search_value) |
|
||||||
|
Q(queue__title__icontains=search_value) |
|
||||||
|
Q(status__icontains=search_value) |
|
||||||
|
Q(created__icontains=search_value) |
|
||||||
|
Q(due_date__icontains=search_value) |
|
||||||
|
Q(assigned_to__email__icontains=search_value))
|
||||||
|
|
||||||
|
count = queryset.count()
|
||||||
|
queryset = queryset.order_by(order_column)[start:start + length]
|
||||||
|
return {
|
||||||
|
'items': queryset,
|
||||||
|
'count': count,
|
||||||
|
'total': total,
|
||||||
|
'draw': draw
|
||||||
|
}
|
@ -12,7 +12,7 @@ datatables for ticket_list.html. Called from staff.datatables_ticket_list.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
class TicketSerializer(serializers.ModelSerializer):
|
class DatatablesTicketSerializer(serializers.ModelSerializer):
|
||||||
ticket = serializers.SerializerMethodField()
|
ticket = serializers.SerializerMethodField()
|
||||||
assigned_to = serializers.SerializerMethodField()
|
assigned_to = serializers.SerializerMethodField()
|
||||||
created = serializers.SerializerMethodField()
|
created = serializers.SerializerMethodField()
|
||||||
|
@ -151,7 +151,3 @@ HELPDESK_ENABLE_PER_QUEUE_STAFF_PERMISSION = getattr(
|
|||||||
|
|
||||||
# use https in the email links
|
# use https in the email links
|
||||||
HELPDESK_USE_HTTPS_IN_EMAIL_LINK = getattr(settings, 'HELPDESK_USE_HTTPS_IN_EMAIL_LINK', False)
|
HELPDESK_USE_HTTPS_IN_EMAIL_LINK = getattr(settings, 'HELPDESK_USE_HTTPS_IN_EMAIL_LINK', False)
|
||||||
|
|
||||||
# Asynchronous Datatables - Optional
|
|
||||||
HELPDESK_USE_SERVERSIDE_PROCESSING = getattr(
|
|
||||||
settings, 'HELPDESK_USE_SERVERSIDE_PROCESSING', True)
|
|
||||||
|
@ -4,7 +4,7 @@
|
|||||||
<label for='id_query'>{% trans "Keywords" %}</label>
|
<label for='id_query'>{% trans "Keywords" %}</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="col col-sm-3">
|
<div class="col col-sm-3">
|
||||||
<input type='text' name='q' value='{{ query }}' id='id_query' />
|
<input type='text' name='q' value='{{ query_params.search_string }}' id='id_query' />
|
||||||
</div>
|
</div>
|
||||||
<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>
|
||||||
|
@ -84,7 +84,7 @@
|
|||||||
<li class="list-group-item filterBox{% if query_params.filtering.created__gte or query_params.filtering.created__lte %} filterBoxShow{% endif %}" id='filterBoxDates'>
|
<li class="list-group-item filterBox{% if query_params.filtering.created__gte or query_params.filtering.created__lte %} filterBoxShow{% endif %}" id='filterBoxDates'>
|
||||||
{% include './filters/date.html' %}
|
{% include './filters/date.html' %}
|
||||||
</li>
|
</li>
|
||||||
<li class="list-group-item filterBox{% if query %} 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>
|
||||||
</ul>
|
</ul>
|
||||||
@ -185,9 +185,6 @@
|
|||||||
<th>{% trans "Time Spent" %}</th>
|
<th>{% trans "Time Spent" %}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
{% if not server_side %}
|
|
||||||
{% include 'helpdesk/ticket_list_table.html' %}
|
|
||||||
{% endif %}
|
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<p><label>{% trans "Select:" %} </label>
|
<p><label>{% trans "Select:" %} </label>
|
||||||
@ -229,135 +226,126 @@
|
|||||||
{% block helpdesk_js %}
|
{% block helpdesk_js %}
|
||||||
<script src='{% static "helpdesk/filter.js" %}'></script>
|
<script src='{% static "helpdesk/filter.js" %}'></script>
|
||||||
<script>
|
<script>
|
||||||
{% if not server_side %}
|
function get_url(row)
|
||||||
$('#ticketTable').DataTable({
|
{
|
||||||
"language": {
|
return "{% url 'helpdesk:view' 1234 %}".replace(/1234/, row.id.toString());
|
||||||
"emptyTable": "{% trans 'No Tickets Match Your Selection' %}"
|
}
|
||||||
},
|
$(document).ready(function()
|
||||||
"order": [],
|
{
|
||||||
responsive: true
|
//DataTables Initialization
|
||||||
});
|
let tasks_table = $('#ticketTable').DataTable({
|
||||||
{% else %}
|
"language": {
|
||||||
function get_url(row)
|
"emptyTable": "{% trans 'No Tickets Match Your Selection' %}"
|
||||||
{
|
},
|
||||||
return "{% url 'helpdesk:view' 1234 %}".replace(/1234/, row.id.toString());
|
"processing": true,
|
||||||
}
|
"serverSide": true,
|
||||||
$(document).ready(function()
|
"ajax": {
|
||||||
{
|
"url": "{% url 'helpdesk:datatables_ticket_list' urlsafe_query %}",
|
||||||
//DataTables Initialization
|
"type": "GET",
|
||||||
let tasks_table = $('#ticketTable').DataTable({
|
},
|
||||||
"language": {
|
createdRow: function( row, data, dataIndex )
|
||||||
"emptyTable": "{% trans 'No Tickets Match Your Selection' %}"
|
{
|
||||||
},
|
$( row ).addClass(data.row_class);
|
||||||
"processing": true,
|
},
|
||||||
"serverSide": true,
|
|
||||||
"ajax": {
|
|
||||||
"url": "{% url 'helpdesk:datatables_ticket_list' %}",
|
|
||||||
"type": "GET",
|
|
||||||
},
|
|
||||||
createdRow: function( row, data, dataIndex )
|
|
||||||
{
|
|
||||||
$( row ).addClass(data.row_class);
|
|
||||||
},
|
|
||||||
|
|
||||||
"columns": [
|
"columns": [
|
||||||
{"data": "id",
|
{"data": "id",
|
||||||
"orderable": false,
|
"orderable": false,
|
||||||
"render": function(data, type, row, meta)
|
"render": function(data, type, row, meta)
|
||||||
{
|
{
|
||||||
var pk = data;
|
var pk = data;
|
||||||
if(type === 'display'){
|
if(type === 'display'){
|
||||||
data = "<input type='checkbox' name='ticket_id' value='"+pk+"'"+ "class='ticket_multi_select' />"
|
data = "<input type='checkbox' name='ticket_id' value='"+pk+"'"+ "class='ticket_multi_select' />"
|
||||||
}
|
}
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{"data": "ticket",
|
{"data": "ticket",
|
||||||
"render": function (data, type, row, meta)
|
"render": function (data, type, row, meta)
|
||||||
{
|
{
|
||||||
var id = data.split(" ")[0];
|
var id = data.split(" ")[0];
|
||||||
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
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{"data": "priority",
|
{"data": "priority",
|
||||||
"render": function (data, type, row, meta) {
|
"render": function (data, type, row, meta) {
|
||||||
var priority = "success";
|
var priority = "success";
|
||||||
if (data == 4 ) {
|
if (data == 4 ) {
|
||||||
priority = "warning";
|
priority = "warning";
|
||||||
} else if (data == 5) {
|
} else if (data == 5) {
|
||||||
priority = "danger";
|
priority = "danger";
|
||||||
}
|
}
|
||||||
return '<p class="text-'+priority+'">'+data+'</p>';
|
return '<p class="text-'+priority+'">'+data+'</p>';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{"data": "queue",
|
{"data": "queue",
|
||||||
"render": function(data, type, row, meta) {
|
"render": function(data, type, row, meta) {
|
||||||
return data.title;
|
return data.title;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{"data": "status"},
|
{"data": "status"},
|
||||||
{"data": "created"},
|
{"data": "created"},
|
||||||
{"data": "due_date"},
|
{"data": "due_date"},
|
||||||
{"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": "time_spent"},
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
{% endif %}
|
|
||||||
$(document).ready(function()
|
|
||||||
{
|
|
||||||
$("#select_all_btn").click(function() {
|
|
||||||
$(".ticket_multi_select").prop('checked', true);
|
|
||||||
});
|
|
||||||
$("#select_none_btn").click(function() {
|
|
||||||
$(".ticket_multi_select").prop('checked', false);
|
|
||||||
});
|
|
||||||
$("#select_inverse_btn").click(function() {
|
|
||||||
$(".ticket_multi_select").each(function() {
|
|
||||||
$(this).prop('checked', !$(this).prop('checked'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
$(document).ready(function()
|
||||||
* Disable active filtering options in filter select menu
|
{
|
||||||
*/
|
$("#select_all_btn").click(function() {
|
||||||
$(document).ready(function() {
|
$(".ticket_multi_select").prop('checked', true);
|
||||||
{% if query_params.sorting %}
|
});
|
||||||
$("#filterBuilderSelect-Sort")[0].disabled = "disabled";
|
$("#select_none_btn").click(function() {
|
||||||
{% endif %}
|
$(".ticket_multi_select").prop('checked', false);
|
||||||
{% if query_params.filtering.assigned_to__id__in %}
|
});
|
||||||
$("#filterBuilderSelect-Owner")[0].disabled = "disabled";
|
$("#select_inverse_btn").click(function() {
|
||||||
{% endif %}
|
$(".ticket_multi_select").each(function() {
|
||||||
{% if query_params.filtering.queue__id__in %}
|
$(this).prop('checked', !$(this).prop('checked'));
|
||||||
$("#filterBuilderSelect-Queue")[0].disabled = "disabled";
|
});
|
||||||
{% endif %}
|
});
|
||||||
{% if query_params.filtering.status__in %}
|
})
|
||||||
$("#filterBuilderSelect-Status")[0].disabled = "disabled";
|
|
||||||
{% endif %}
|
/**
|
||||||
{% if query_params.filtering.created__gte or query_params.filtering.created__lte %}
|
* Disable active filtering options in filter select menu
|
||||||
$("#filterBuilderSelect-Dates")[0].disabled = "disabled";
|
*/
|
||||||
{% endif %}
|
$(document).ready(function() {
|
||||||
{% if query %}
|
{% if query_params.sorting %}
|
||||||
$("#filterBuilderSelect-Keywords")[0].disabled = "disabled";
|
$("#filterBuilderSelect-Sort")[0].disabled = "disabled";
|
||||||
{% endif %}
|
{% endif %}
|
||||||
});
|
{% if query_params.filtering.assigned_to__id__in %}
|
||||||
|
$("#filterBuilderSelect-Owner")[0].disabled = "disabled";
|
||||||
|
{% endif %}
|
||||||
|
{% if query_params.filtering.queue__id__in %}
|
||||||
|
$("#filterBuilderSelect-Queue")[0].disabled = "disabled";
|
||||||
|
{% endif %}
|
||||||
|
{% if query_params.filtering.status__in %}
|
||||||
|
$("#filterBuilderSelect-Status")[0].disabled = "disabled";
|
||||||
|
{% endif %}
|
||||||
|
{% if query_params.filtering.created__gte or query_params.filtering.created__lte %}
|
||||||
|
$("#filterBuilderSelect-Dates")[0].disabled = "disabled";
|
||||||
|
{% endif %}
|
||||||
|
{% if query_params.search_string %}
|
||||||
|
$("#filterBuilderSelect-Keywords")[0].disabled = "disabled";
|
||||||
|
{% endif %}
|
||||||
|
});
|
||||||
|
|
||||||
{% for f in query_params.filtering %}
|
{% for f in query_params.filtering %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
@ -37,6 +37,8 @@ class DirectTemplateView(TemplateView):
|
|||||||
|
|
||||||
app_name = 'helpdesk'
|
app_name = 'helpdesk'
|
||||||
|
|
||||||
|
base64_pattern = r'(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$'
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
url(r'^dashboard/$',
|
url(r'^dashboard/$',
|
||||||
staff.dashboard,
|
staff.dashboard,
|
||||||
@ -146,7 +148,7 @@ urlpatterns = [
|
|||||||
staff.email_ignore_del,
|
staff.email_ignore_del,
|
||||||
name='email_ignore_del'),
|
name='email_ignore_del'),
|
||||||
|
|
||||||
url(r'^datatables_ticket_list/$',
|
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"),
|
||||||
]
|
]
|
||||||
|
60
helpdesk/user.py
Normal file
60
helpdesk/user.py
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
from helpdesk.models import (
|
||||||
|
Ticket,
|
||||||
|
Queue
|
||||||
|
)
|
||||||
|
|
||||||
|
from helpdesk import settings as helpdesk_settings
|
||||||
|
|
||||||
|
class HelpdeskUser:
|
||||||
|
def __init__(self, user):
|
||||||
|
self.user = user
|
||||||
|
|
||||||
|
def get_queues(self):
|
||||||
|
"""Return the list of Queues the user can access.
|
||||||
|
|
||||||
|
:param user: The User (the class should have the has_perm method)
|
||||||
|
:return: A Python list of Queues
|
||||||
|
"""
|
||||||
|
user = self.user
|
||||||
|
all_queues = Queue.objects.all()
|
||||||
|
public_ids = [q.pk for q in
|
||||||
|
Queue.objects.filter(allow_public_submission=True)]
|
||||||
|
limit_queues_by_user = \
|
||||||
|
helpdesk_settings.HELPDESK_ENABLE_PER_QUEUE_STAFF_PERMISSION \
|
||||||
|
and not user.is_superuser
|
||||||
|
if limit_queues_by_user:
|
||||||
|
id_list = [q.pk for q in all_queues if user.has_perm(q.permission_name)]
|
||||||
|
id_list += public_ids
|
||||||
|
return all_queues.filter(pk__in=id_list)
|
||||||
|
else:
|
||||||
|
return all_queues
|
||||||
|
|
||||||
|
def get_tickets_in_queues(self):
|
||||||
|
return Ticket.objects.filter(queue__in=self.get_queues())
|
||||||
|
|
||||||
|
|
||||||
|
def can_access_queue(self, queue):
|
||||||
|
"""Check if a certain user can access a certain queue.
|
||||||
|
|
||||||
|
:param user: The User (the class should have the has_perm method)
|
||||||
|
: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:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
return user.has_perm(queue.permission_name)
|
||||||
|
|
||||||
|
|
||||||
|
def can_access_ticket(self, ticket):
|
||||||
|
"""Check to see if the user has permission to access
|
||||||
|
a ticket. If not then deny access."""
|
||||||
|
user = self.user
|
||||||
|
if self.can_access_queue(ticket.queue):
|
||||||
|
return True
|
||||||
|
elif user.is_superuser or user.is_staff or \
|
||||||
|
(ticket.assigned_to and user.id == ticket.assigned_to.id):
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
return False
|
@ -6,6 +6,9 @@ django-helpdesk - A Django powered ticket tracker for small enterprise.
|
|||||||
views/staff.py - The bulk of the application - provides most business logic and
|
views/staff.py - The bulk of the application - provides most business logic and
|
||||||
renders all staff-facing views.
|
renders all staff-facing views.
|
||||||
"""
|
"""
|
||||||
|
from copy import deepcopy
|
||||||
|
import json
|
||||||
|
|
||||||
from django import VERSION as DJANGO_VERSION
|
from django import VERSION as DJANGO_VERSION
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
@ -14,7 +17,7 @@ from django.contrib.contenttypes.models import ContentType
|
|||||||
from django.urls import reverse, reverse_lazy
|
from django.urls import reverse, reverse_lazy
|
||||||
from django.core.exceptions import ValidationError, PermissionDenied
|
from django.core.exceptions import ValidationError, PermissionDenied
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.http import HttpResponseRedirect, Http404, HttpResponse
|
from django.http import HttpResponseRedirect, Http404, HttpResponse, JsonResponse
|
||||||
from django.shortcuts import render, get_object_or_404
|
from django.shortcuts import render, get_object_or_404
|
||||||
from django.utils.dates import MONTHS_3
|
from django.utils.dates import MONTHS_3
|
||||||
from django.utils.translation import ugettext as _
|
from django.utils.translation import ugettext as _
|
||||||
@ -22,11 +25,19 @@ from django.utils.html import escape
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.views.generic.edit import FormView, UpdateView
|
from django.views.generic.edit import FormView, UpdateView
|
||||||
# For datatables serverside
|
|
||||||
from django.core.cache import cache
|
|
||||||
|
|
||||||
from helpdesk.lib import query_tickets_by_args
|
from helpdesk.query import (
|
||||||
from helpdesk.serializers import TicketSerializer
|
query_to_dict,
|
||||||
|
get_query,
|
||||||
|
apply_query,
|
||||||
|
query_tickets_by_args,
|
||||||
|
query_to_base64,
|
||||||
|
query_from_base64,
|
||||||
|
)
|
||||||
|
|
||||||
|
from helpdesk.user import HelpdeskUser
|
||||||
|
|
||||||
|
from helpdesk.serializers import DatatablesTicketSerializer
|
||||||
|
|
||||||
from helpdesk.decorators import (
|
from helpdesk.decorators import (
|
||||||
helpdesk_staff_member_required, helpdesk_superuser_required,
|
helpdesk_staff_member_required, helpdesk_superuser_required,
|
||||||
@ -38,8 +49,10 @@ from helpdesk.forms import (
|
|||||||
)
|
)
|
||||||
from helpdesk.decorators import staff_member_required, superuser_required
|
from helpdesk.decorators import staff_member_required, superuser_required
|
||||||
from helpdesk.lib import (
|
from helpdesk.lib import (
|
||||||
query_to_dict, apply_query, safe_template_context,
|
safe_template_context,
|
||||||
process_attachments, queue_template_context, format_time_spent
|
process_attachments,
|
||||||
|
queue_template_context,
|
||||||
|
format_time_spent,
|
||||||
)
|
)
|
||||||
from helpdesk.models import (
|
from helpdesk.models import (
|
||||||
Ticket, Queue, FollowUp, TicketChange, PreSetReply, FollowUpAttachment, SavedSearch,
|
Ticket, Queue, FollowUp, TicketChange, PreSetReply, FollowUpAttachment, SavedSearch,
|
||||||
@ -85,44 +98,10 @@ def _get_queue_choices(queues):
|
|||||||
return queue_choices
|
return queue_choices
|
||||||
|
|
||||||
|
|
||||||
def _get_user_queues(user):
|
|
||||||
"""Return the list of Queues the user can access.
|
|
||||||
|
|
||||||
:param user: The User (the class should have the has_perm method)
|
|
||||||
:return: A Python list of Queues
|
|
||||||
"""
|
|
||||||
all_queues = Queue.objects.all()
|
|
||||||
public_ids = [q.pk for q in
|
|
||||||
Queue.objects.filter(allow_public_submission=True)]
|
|
||||||
|
|
||||||
limit_queues_by_user = \
|
|
||||||
helpdesk_settings.HELPDESK_ENABLE_PER_QUEUE_STAFF_PERMISSION \
|
|
||||||
and not user.is_superuser
|
|
||||||
if limit_queues_by_user:
|
|
||||||
id_list = [q.pk for q in all_queues if user.has_perm(q.permission_name)]
|
|
||||||
id_list += public_ids
|
|
||||||
return all_queues.filter(pk__in=id_list)
|
|
||||||
else:
|
|
||||||
return all_queues
|
|
||||||
|
|
||||||
|
|
||||||
def _has_access_to_queue(user, queue):
|
|
||||||
"""Check if a certain user can access a certain queue.
|
|
||||||
|
|
||||||
:param user: The User (the class should have the has_perm method)
|
|
||||||
:param queue: The django-helpdesk Queue instance
|
|
||||||
:return: True if the user has permission (either by default or explicitly), false otherwise
|
|
||||||
"""
|
|
||||||
if user.is_superuser or not helpdesk_settings.HELPDESK_ENABLE_PER_QUEUE_STAFF_PERMISSION:
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
return user.has_perm(queue.permission_name)
|
|
||||||
|
|
||||||
|
|
||||||
def _is_my_ticket(user, ticket):
|
def _is_my_ticket(user, ticket):
|
||||||
"""Check to see if the user has permission to access
|
"""Check to see if the user has permission to access
|
||||||
a ticket. If not then deny access."""
|
a ticket. If not then deny access."""
|
||||||
if _has_access_to_queue(user, ticket.queue):
|
if (user, ticket.queue):
|
||||||
return True
|
return True
|
||||||
elif user.is_superuser or user.is_staff or \
|
elif user.is_superuser or user.is_staff or \
|
||||||
(ticket.assigned_to and user.id == ticket.assigned_to.id):
|
(ticket.assigned_to and user.id == ticket.assigned_to.id):
|
||||||
@ -152,7 +131,7 @@ 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 = _get_user_queues(request.user)
|
user_queues = HelpdeskUser(request.user).get_queues()
|
||||||
|
|
||||||
unassigned_tickets = active_tickets.filter(
|
unassigned_tickets = active_tickets.filter(
|
||||||
assigned_to__isnull=True,
|
assigned_to__isnull=True,
|
||||||
@ -178,7 +157,7 @@ def dashboard(request):
|
|||||||
# Queue 1 10 4
|
# Queue 1 10 4
|
||||||
# Queue 2 4 12
|
# Queue 2 4 12
|
||||||
|
|
||||||
queues = _get_user_queues(request.user).values_list('id', flat=True)
|
queues = HelpdeskUser(request.user).get_queues().values_list('id', flat=True)
|
||||||
|
|
||||||
from_clause = """FROM helpdesk_ticket t,
|
from_clause = """FROM helpdesk_ticket t,
|
||||||
helpdesk_queue q"""
|
helpdesk_queue q"""
|
||||||
@ -199,14 +178,17 @@ def dashboard(request):
|
|||||||
|
|
||||||
dashboard = staff_member_required(dashboard)
|
dashboard = staff_member_required(dashboard)
|
||||||
|
|
||||||
|
def ticket_perm_check(request, ticket):
|
||||||
|
huser = HelpdeskUser(request.user)
|
||||||
|
if not huser.can_access_queue(ticket.queue):
|
||||||
|
raise PermissionDenied()
|
||||||
|
if not huser.can_access_ticket(ticket):
|
||||||
|
raise PermissionDenied()
|
||||||
|
|
||||||
@helpdesk_staff_member_required
|
@helpdesk_staff_member_required
|
||||||
def delete_ticket(request, ticket_id):
|
def delete_ticket(request, ticket_id):
|
||||||
ticket = get_object_or_404(Ticket, id=ticket_id)
|
ticket = get_object_or_404(Ticket, id=ticket_id)
|
||||||
if not _has_access_to_queue(request.user, ticket.queue):
|
ticket_perm_check(request, ticket)
|
||||||
raise PermissionDenied()
|
|
||||||
if not _is_my_ticket(request.user, ticket):
|
|
||||||
raise PermissionDenied()
|
|
||||||
|
|
||||||
if request.method == 'GET':
|
if request.method == 'GET':
|
||||||
return render(request, 'helpdesk/delete_ticket.html', {
|
return render(request, 'helpdesk/delete_ticket.html', {
|
||||||
@ -225,10 +207,7 @@ def followup_edit(request, ticket_id, followup_id):
|
|||||||
"""Edit followup options with an ability to change the ticket."""
|
"""Edit followup options with an ability to change the ticket."""
|
||||||
followup = get_object_or_404(FollowUp, id=followup_id)
|
followup = get_object_or_404(FollowUp, id=followup_id)
|
||||||
ticket = get_object_or_404(Ticket, id=ticket_id)
|
ticket = get_object_or_404(Ticket, id=ticket_id)
|
||||||
if not _has_access_to_queue(request.user, ticket.queue):
|
ticket_perm_check(request, ticket)
|
||||||
raise PermissionDenied()
|
|
||||||
if not _is_my_ticket(request.user, ticket):
|
|
||||||
raise PermissionDenied()
|
|
||||||
|
|
||||||
if request.method == 'GET':
|
if request.method == 'GET':
|
||||||
form = EditFollowUpForm(initial={
|
form = EditFollowUpForm(initial={
|
||||||
@ -300,10 +279,7 @@ followup_delete = staff_member_required(followup_delete)
|
|||||||
@helpdesk_staff_member_required
|
@helpdesk_staff_member_required
|
||||||
def view_ticket(request, ticket_id):
|
def view_ticket(request, ticket_id):
|
||||||
ticket = get_object_or_404(Ticket, id=ticket_id)
|
ticket = get_object_or_404(Ticket, id=ticket_id)
|
||||||
if not _has_access_to_queue(request.user, ticket.queue):
|
ticket_perm_check(request, ticket)
|
||||||
raise PermissionDenied()
|
|
||||||
if not _is_my_ticket(request.user, ticket):
|
|
||||||
raise PermissionDenied()
|
|
||||||
|
|
||||||
if 'take' in request.GET:
|
if 'take' in request.GET:
|
||||||
# Allow the user to assign the ticket to themselves whilst viewing it.
|
# Allow the user to assign the ticket to themselves whilst viewing it.
|
||||||
@ -349,7 +325,7 @@ def view_ticket(request, ticket_id):
|
|||||||
else:
|
else:
|
||||||
users = User.objects.filter(is_active=True).order_by(User.USERNAME_FIELD)
|
users = User.objects.filter(is_active=True).order_by(User.USERNAME_FIELD)
|
||||||
|
|
||||||
queues = _get_user_queues(request.user)
|
queues = HelpdeskUser(request.user).get_queues()
|
||||||
queue_choices = _get_queue_choices(queues)
|
queue_choices = _get_queue_choices(queues)
|
||||||
# TODO: shouldn't this template get a form to begin with?
|
# TODO: shouldn't this template get a form to begin with?
|
||||||
form = TicketForm(initial={'due_date': ticket.due_date},
|
form = TicketForm(initial={'due_date': ticket.due_date},
|
||||||
@ -746,8 +722,9 @@ def mass_update(request):
|
|||||||
user = request.user
|
user = request.user
|
||||||
action = 'assign'
|
action = 'assign'
|
||||||
|
|
||||||
|
huser = HelpdeskUser(request.user)
|
||||||
for t in Ticket.objects.filter(id__in=tickets):
|
for t in Ticket.objects.filter(id__in=tickets):
|
||||||
if not _has_access_to_queue(request.user, t.queue):
|
if not huser.can_access_queue(t.queue):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if action == 'assign' and t.assigned_to != user:
|
if action == 'assign' and t.assigned_to != user:
|
||||||
@ -827,9 +804,7 @@ mass_update = staff_member_required(mass_update)
|
|||||||
def ticket_list(request):
|
def ticket_list(request):
|
||||||
context = {}
|
context = {}
|
||||||
|
|
||||||
user_queues = _get_user_queues(request.user)
|
huser = HelpdeskUser(request.user)
|
||||||
# Prefilter the allowed tickets
|
|
||||||
base_tickets = Ticket.objects.filter(queue__in=user_queues)
|
|
||||||
|
|
||||||
# Query_params will hold a dictionary of parameters relating to
|
# Query_params will hold a dictionary of parameters relating to
|
||||||
# a query, to be saved if needed:
|
# a query, to be saved if needed:
|
||||||
@ -837,11 +812,14 @@ def ticket_list(request):
|
|||||||
'filtering': {},
|
'filtering': {},
|
||||||
'sorting': None,
|
'sorting': None,
|
||||||
'sortreverse': False,
|
'sortreverse': False,
|
||||||
'keyword': None,
|
'search_string': '',
|
||||||
'search_string': None,
|
}
|
||||||
|
default_query_params = {
|
||||||
|
'filtering': {'status__in': [1, 2, 3]},
|
||||||
|
'sorting': 'created',
|
||||||
|
'search_string': '',
|
||||||
|
'sortreverse': False,
|
||||||
}
|
}
|
||||||
|
|
||||||
from_saved_query = False
|
|
||||||
|
|
||||||
# If the user is coming from the header/navigation search box, lets' first
|
# If the user is coming from the header/navigation search box, lets' first
|
||||||
# look at their query to see if they have entered a valid ticket number. If
|
# look at their query to see if they have entered a valid ticket number. If
|
||||||
@ -871,73 +849,38 @@ def ticket_list(request):
|
|||||||
|
|
||||||
if filter:
|
if filter:
|
||||||
try:
|
try:
|
||||||
ticket = base_tickets.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
|
||||||
pass
|
pass
|
||||||
|
|
||||||
saved_query = None
|
try:
|
||||||
if request.GET.get('saved_query', None):
|
saved_query, query_params = load_saved_query(request, query_params)
|
||||||
from_saved_query = True
|
except QueryLoadError:
|
||||||
try:
|
return HttpResponseRedirect(reverse('helpdesk:list'))
|
||||||
saved_query = SavedSearch.objects.get(pk=request.GET.get('saved_query'))
|
|
||||||
except SavedSearch.DoesNotExist:
|
|
||||||
return HttpResponseRedirect(reverse('helpdesk:list'))
|
|
||||||
if not (saved_query.shared or saved_query.user == request.user):
|
|
||||||
return HttpResponseRedirect(reverse('helpdesk:list'))
|
|
||||||
|
|
||||||
import json
|
|
||||||
from helpdesk.lib import query_from_base64
|
|
||||||
try:
|
|
||||||
# we get a string like: b'stuff'
|
|
||||||
# so leave of the first two chars (b') and last (')
|
|
||||||
b64query = saved_query.query[2:-1]
|
|
||||||
query_params = query_from_base64(b64query)
|
|
||||||
except ValueError:
|
|
||||||
# Query deserialization failed. (E.g. was a pickled query)
|
|
||||||
return HttpResponseRedirect(reverse('helpdesk:list'))
|
|
||||||
|
|
||||||
elif not ('queue' in request.GET or
|
|
||||||
'assigned_to' in request.GET or
|
|
||||||
'status' in request.GET or
|
|
||||||
'q' in request.GET or
|
|
||||||
'sort' in request.GET or
|
|
||||||
'sortreverse' in request.GET):
|
|
||||||
|
|
||||||
# Fall-back if no querying is being done, force the list to only
|
|
||||||
# show open/reopened/resolved (not closed) cases sorted by creation
|
|
||||||
# date.
|
|
||||||
|
|
||||||
|
if saved_query:
|
||||||
|
pass
|
||||||
|
elif not {'queue', 'assigned_to', 'status', 'q', 'sort', 'sortreverse'}.intersection(request.GET):
|
||||||
|
# Fall-back if no querying is being done
|
||||||
all_queues = Queue.objects.all()
|
all_queues = Queue.objects.all()
|
||||||
query_params = {
|
query_params = deepcopy(default_query_params)
|
||||||
'filtering': {'status__in': [1, 2, 3]},
|
|
||||||
'sorting': 'created',
|
|
||||||
}
|
|
||||||
else:
|
else:
|
||||||
queues = request.GET.getlist('queue')
|
filter_in_params = [
|
||||||
if queues:
|
('queue', 'queue__id__in'),
|
||||||
try:
|
('assigned_to', 'assigned_to__id__in'),
|
||||||
queues = [int(q) for q in queues]
|
('status', 'status__in'),
|
||||||
query_params['filtering']['queue__id__in'] = queues
|
]
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
owners = request.GET.getlist('assigned_to')
|
for param, filter_command in filter_in_params:
|
||||||
if owners:
|
patterns = request.GET.getlist(param)
|
||||||
try:
|
if patterns:
|
||||||
owners = [int(u) for u in owners]
|
try:
|
||||||
query_params['filtering']['assigned_to__id__in'] = owners
|
pattern_pks = [int(pattern) for pattern in patterns]
|
||||||
except ValueError:
|
query_params['filtering'][filter_command] = pattern_pks
|
||||||
pass
|
except ValueError:
|
||||||
|
pass
|
||||||
statuses = request.GET.getlist('status')
|
|
||||||
if statuses:
|
|
||||||
try:
|
|
||||||
statuses = [int(s) for s in statuses]
|
|
||||||
query_params['filtering']['status__in'] = statuses
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
date_from = request.GET.get('date_from')
|
date_from = request.GET.get('date_from')
|
||||||
if date_from:
|
if date_from:
|
||||||
@ -948,11 +891,9 @@ def ticket_list(request):
|
|||||||
query_params['filtering']['created__lte'] = date_to
|
query_params['filtering']['created__lte'] = date_to
|
||||||
|
|
||||||
# KEYWORD SEARCHING
|
# KEYWORD SEARCHING
|
||||||
q = request.GET.get('q', None)
|
q = request.GET.get('q', '')
|
||||||
|
context['query'] = q
|
||||||
if q:
|
query_params['search_string'] = q
|
||||||
context = dict(context, query=q)
|
|
||||||
query_params['search_string'] = q
|
|
||||||
|
|
||||||
# SORTING
|
# SORTING
|
||||||
sort = request.GET.get('sort', None)
|
sort = request.GET.get('sort', None)
|
||||||
@ -963,20 +904,14 @@ def ticket_list(request):
|
|||||||
sortreverse = request.GET.get('sortreverse', None)
|
sortreverse = request.GET.get('sortreverse', None)
|
||||||
query_params['sortreverse'] = sortreverse
|
query_params['sortreverse'] = sortreverse
|
||||||
|
|
||||||
tickets = base_tickets.select_related()
|
urlsafe_query = query_to_base64(query_params)
|
||||||
|
|
||||||
try:
|
get_query(urlsafe_query, huser)
|
||||||
ticket_qs = apply_query(tickets, query_params)
|
|
||||||
except ValidationError:
|
user_saved_queries = SavedSearch.objects.filter(Q(user=request.user) | Q(shared__exact=True))
|
||||||
# invalid parameters in query, return default query
|
|
||||||
query_params = {
|
|
||||||
'filtering': {'status__in': [1, 2, 3]},
|
|
||||||
'sorting': 'created',
|
|
||||||
}
|
|
||||||
ticket_qs = apply_query(tickets, query_params)
|
|
||||||
|
|
||||||
search_message = ''
|
search_message = ''
|
||||||
if 'query' in context and settings.DATABASES['default']['ENGINE'].endswith('sqlite'):
|
if query_params['search_string'] and settings.DATABASES['default']['ENGINE'].endswith('sqlite'):
|
||||||
search_message = _(
|
search_message = _(
|
||||||
'<p><strong>Note:</strong> Your keyword search is case sensitive '
|
'<p><strong>Note:</strong> Your keyword search is case sensitive '
|
||||||
'because of your database. This means the search will <strong>not</strong> '
|
'because of your database. This means the search will <strong>not</strong> '
|
||||||
@ -985,31 +920,17 @@ 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>.')
|
||||||
|
|
||||||
import json
|
|
||||||
from helpdesk.lib import query_to_base64
|
|
||||||
urlsafe_query = query_to_base64(query_params)
|
|
||||||
|
|
||||||
user_saved_queries = SavedSearch.objects.filter(Q(user=request.user) | Q(shared__exact=True))
|
|
||||||
|
|
||||||
# Serverside processing on datatables is optional. Set
|
|
||||||
# HELPDESK_USE_SERVERSIDE_PROCESSING to False in settings.py to disable
|
|
||||||
if helpdesk_settings.HELPDESK_USE_SERVERSIDE_PROCESSING:
|
|
||||||
cache.set('ticket_qs', ticket_qs)
|
|
||||||
context['server_side'] = True
|
|
||||||
else:
|
|
||||||
context['server_side'] = False
|
|
||||||
|
|
||||||
return render(request, 'helpdesk/ticket_list.html', dict(
|
return render(request, 'helpdesk/ticket_list.html', dict(
|
||||||
context,
|
context,
|
||||||
tickets=ticket_qs,
|
|
||||||
default_tickets_per_page=request.user.usersettings_helpdesk.tickets_per_page,
|
default_tickets_per_page=request.user.usersettings_helpdesk.tickets_per_page,
|
||||||
user_choices=User.objects.filter(is_active=True, is_staff=True),
|
user_choices=User.objects.filter(is_active=True, is_staff=True),
|
||||||
queue_choices=user_queues,
|
queue_choices=huser.get_queues(),
|
||||||
status_choices=Ticket.STATUS_CHOICES,
|
status_choices=Ticket.STATUS_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,
|
||||||
from_saved_query=from_saved_query,
|
from_saved_query=saved_query is not None,
|
||||||
saved_query=saved_query,
|
saved_query=saved_query,
|
||||||
search_message=search_message,
|
search_message=search_message,
|
||||||
))
|
))
|
||||||
@ -1018,36 +939,57 @@ def ticket_list(request):
|
|||||||
ticket_list = staff_member_required(ticket_list)
|
ticket_list = staff_member_required(ticket_list)
|
||||||
|
|
||||||
|
|
||||||
|
class QueryLoadError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def load_saved_query(request, query_params=None):
|
||||||
|
saved_query = None
|
||||||
|
|
||||||
|
if request.GET.get('saved_query', None):
|
||||||
|
try:
|
||||||
|
saved_query = SavedSearch.objects.get(pk=request.GET.get('saved_query'))
|
||||||
|
except SavedSearch.DoesNotExist:
|
||||||
|
raise QueryLoadError()
|
||||||
|
if not (saved_query.shared or saved_query.user == request.user):
|
||||||
|
raise QueryLoadError()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# we get a string like: b'stuff'
|
||||||
|
# so leave of the first two chars (b') and last (')
|
||||||
|
if saved_query.query.startswith('b\''):
|
||||||
|
b64query = saved_query.query[2:-1]
|
||||||
|
else:
|
||||||
|
b64query = saved_query.query
|
||||||
|
query_params = query_from_base64(b64query)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
raise QueryLoadError()
|
||||||
|
return (saved_query, query_params)
|
||||||
|
|
||||||
|
|
||||||
@helpdesk_staff_member_required
|
@helpdesk_staff_member_required
|
||||||
@api_view(['GET', 'POST'])
|
@api_view(['GET'])
|
||||||
def datatables_ticket_list(request):
|
def datatables_ticket_list(request, query):
|
||||||
"""
|
"""
|
||||||
Datatable on ticket_list.html uses this view from to get objects to display
|
Datatable on ticket_list.html uses this view from to get objects to display
|
||||||
on the table. query_tickets_by_args is at lib.py, TicketSerializer 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
|
||||||
"""
|
"""
|
||||||
try:
|
objects = get_query(query, HelpdeskUser(request.user))
|
||||||
objects = cache.get('ticket_qs')
|
model_object = query_tickets_by_args(objects, '-date_created', **request.query_params)
|
||||||
model_object = query_tickets_by_args(objects, '-date_created', **request.query_params)
|
serializer = DatatablesTicketSerializer(model_object['items'], many=True)
|
||||||
serializer = TicketSerializer(model_object['items'], many=True)
|
result = dict()
|
||||||
result = dict()
|
result['data'] = serializer.data
|
||||||
result['data'] = serializer.data
|
result['draw'] = model_object['draw']
|
||||||
result['draw'] = model_object['draw']
|
result['recordsTotal'] = model_object['total']
|
||||||
result['recordsTotal'] = model_object['total']
|
result['recordsFiltered'] = model_object['count']
|
||||||
result['recordsFiltered'] = model_object['count']
|
return (JsonResponse(result, status=status.HTTP_200_OK))
|
||||||
return (Response(result, status=status.HTTP_200_OK, template_name=None, content_type=None))
|
|
||||||
|
|
||||||
except TypeError as e:
|
|
||||||
return (Response(e, status=status.HTTP_404_NOT_FOUND, template_name=None, content_type=None))
|
|
||||||
|
|
||||||
|
|
||||||
@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)
|
||||||
if not _has_access_to_queue(request.user, ticket.queue):
|
ticket_perm_check(request, ticket)
|
||||||
raise PermissionDenied()
|
|
||||||
if not _is_my_ticket(request.user, ticket):
|
|
||||||
raise PermissionDenied()
|
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
form = EditTicketForm(request.POST, instance=ticket)
|
form = EditTicketForm(request.POST, instance=ticket)
|
||||||
@ -1078,7 +1020,7 @@ class CreateTicketView(MustBeStaffMixin, FormView):
|
|||||||
|
|
||||||
def get_form_kwargs(self):
|
def get_form_kwargs(self):
|
||||||
kwargs = super().get_form_kwargs()
|
kwargs = super().get_form_kwargs()
|
||||||
queues = _get_user_queues(self.request.user)
|
queues = HelpdeskUser(self.request.user).get_queues()
|
||||||
kwargs["queue_choices"] = _get_queue_choices(queues)
|
kwargs["queue_choices"] = _get_queue_choices(queues)
|
||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
@ -1088,7 +1030,7 @@ class CreateTicketView(MustBeStaffMixin, FormView):
|
|||||||
|
|
||||||
def get_success_url(self):
|
def get_success_url(self):
|
||||||
request = self.request
|
request = self.request
|
||||||
if _has_access_to_queue(request.user, self.ticket.queue):
|
if HelpdeskUser(request.user).can_access_queue(self.ticket.queue):
|
||||||
return self.ticket.get_absolute_url()
|
return self.ticket.get_absolute_url()
|
||||||
else:
|
else:
|
||||||
return reverse('helpdesk:dashboard')
|
return reverse('helpdesk:dashboard')
|
||||||
@ -1119,10 +1061,7 @@ raw_details = staff_member_required(raw_details)
|
|||||||
@helpdesk_staff_member_required
|
@helpdesk_staff_member_required
|
||||||
def hold_ticket(request, ticket_id, unhold=False):
|
def hold_ticket(request, ticket_id, unhold=False):
|
||||||
ticket = get_object_or_404(Ticket, id=ticket_id)
|
ticket = get_object_or_404(Ticket, id=ticket_id)
|
||||||
if not _has_access_to_queue(request.user, ticket.queue):
|
ticket_perm_check(request, ticket)
|
||||||
raise PermissionDenied()
|
|
||||||
if not _is_my_ticket(request.user, ticket):
|
|
||||||
raise PermissionDenied()
|
|
||||||
|
|
||||||
if unhold:
|
if unhold:
|
||||||
ticket.on_hold = False
|
ticket.on_hold = False
|
||||||
@ -1169,7 +1108,7 @@ def report_index(request):
|
|||||||
number_tickets = Ticket.objects.all().count()
|
number_tickets = Ticket.objects.all().count()
|
||||||
saved_query = request.GET.get('saved_query', None)
|
saved_query = request.GET.get('saved_query', None)
|
||||||
|
|
||||||
user_queues = _get_user_queues(request.user)
|
user_queues = HelpdeskUser(request.user).get_queues()
|
||||||
Tickets = Ticket.objects.filter(queue__in=user_queues)
|
Tickets = Ticket.objects.filter(queue__in=user_queues)
|
||||||
basic_ticket_stats = calc_basic_ticket_stats(Tickets)
|
basic_ticket_stats = calc_basic_ticket_stats(Tickets)
|
||||||
|
|
||||||
@ -1212,31 +1151,15 @@ def run_report(request, report):
|
|||||||
return HttpResponseRedirect(reverse("helpdesk:report_index"))
|
return HttpResponseRedirect(reverse("helpdesk:report_index"))
|
||||||
|
|
||||||
report_queryset = Ticket.objects.all().select_related().filter(
|
report_queryset = Ticket.objects.all().select_related().filter(
|
||||||
queue__in=_get_user_queues(request.user)
|
queue__in=HelpdeskUser(request.user).get_queues()
|
||||||
)
|
)
|
||||||
|
|
||||||
from_saved_query = False
|
try:
|
||||||
saved_query = None
|
saved_query, query_params = load_saved_query(request)
|
||||||
|
except QueryLoadError:
|
||||||
|
return HttpResponseRedirect(reverse('helpdesk:report_index'))
|
||||||
|
|
||||||
if request.GET.get('saved_query', None):
|
if request.GET.get('saved_query', None):
|
||||||
from_saved_query = True
|
|
||||||
try:
|
|
||||||
saved_query = SavedSearch.objects.get(pk=request.GET.get('saved_query'))
|
|
||||||
except SavedSearch.DoesNotExist:
|
|
||||||
return HttpResponseRedirect(reverse('helpdesk:report_index'))
|
|
||||||
if not (saved_query.shared or saved_query.user == request.user):
|
|
||||||
return HttpResponseRedirect(reverse('helpdesk:report_index'))
|
|
||||||
|
|
||||||
import json
|
|
||||||
from helpdesk.lib import query_from_base64
|
|
||||||
try:
|
|
||||||
# we get a string like: b'stuff'
|
|
||||||
# so leave of the first two chars (b') and last (')
|
|
||||||
b64query = saved_query.query[2:-1]
|
|
||||||
query_params = query_from_base64(b64query)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return HttpResponseRedirect(reverse('helpdesk:report_index'))
|
|
||||||
|
|
||||||
report_queryset = apply_query(report_queryset, query_params)
|
report_queryset = apply_query(report_queryset, query_params)
|
||||||
|
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
@ -1278,7 +1201,7 @@ def run_report(request, report):
|
|||||||
elif report == 'userqueue':
|
elif report == 'userqueue':
|
||||||
title = _('User by Queue')
|
title = _('User by Queue')
|
||||||
col1heading = _('User')
|
col1heading = _('User')
|
||||||
queue_options = _get_user_queues(request.user)
|
queue_options = HelpdeskUser(request.user).get_queues()
|
||||||
possible_options = [q.title for q in queue_options]
|
possible_options = [q.title for q in queue_options]
|
||||||
charttype = 'bar'
|
charttype = 'bar'
|
||||||
|
|
||||||
@ -1399,7 +1322,7 @@ def run_report(request, report):
|
|||||||
'headings': column_headings,
|
'headings': column_headings,
|
||||||
'series_names': series_names,
|
'series_names': series_names,
|
||||||
'morrisjs_data': morrisjs_data,
|
'morrisjs_data': morrisjs_data,
|
||||||
'from_saved_query': from_saved_query,
|
'from_saved_query': saved_query is not None,
|
||||||
'saved_query': saved_query,
|
'saved_query': saved_query,
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -1493,10 +1416,7 @@ email_ignore_del = superuser_required(email_ignore_del)
|
|||||||
@helpdesk_staff_member_required
|
@helpdesk_staff_member_required
|
||||||
def ticket_cc(request, ticket_id):
|
def ticket_cc(request, ticket_id):
|
||||||
ticket = get_object_or_404(Ticket, id=ticket_id)
|
ticket = get_object_or_404(Ticket, id=ticket_id)
|
||||||
if not _has_access_to_queue(request.user, ticket.queue):
|
ticket_perm_check(request, ticket)
|
||||||
raise PermissionDenied()
|
|
||||||
if not _is_my_ticket(request.user, ticket):
|
|
||||||
raise PermissionDenied()
|
|
||||||
|
|
||||||
copies_to = ticket.ticketcc_set.all()
|
copies_to = ticket.ticketcc_set.all()
|
||||||
return render(request, 'helpdesk/ticket_cc_list.html', {
|
return render(request, 'helpdesk/ticket_cc_list.html', {
|
||||||
@ -1511,10 +1431,7 @@ ticket_cc = staff_member_required(ticket_cc)
|
|||||||
@helpdesk_staff_member_required
|
@helpdesk_staff_member_required
|
||||||
def ticket_cc_add(request, ticket_id):
|
def ticket_cc_add(request, ticket_id):
|
||||||
ticket = get_object_or_404(Ticket, id=ticket_id)
|
ticket = get_object_or_404(Ticket, id=ticket_id)
|
||||||
if not _has_access_to_queue(request.user, ticket.queue):
|
ticket_perm_check(request, ticket)
|
||||||
raise PermissionDenied()
|
|
||||||
if not _is_my_ticket(request.user, ticket):
|
|
||||||
raise PermissionDenied()
|
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
form = TicketCCForm(request.POST)
|
form = TicketCCForm(request.POST)
|
||||||
@ -1554,10 +1471,7 @@ ticket_cc_del = staff_member_required(ticket_cc_del)
|
|||||||
@helpdesk_staff_member_required
|
@helpdesk_staff_member_required
|
||||||
def ticket_dependency_add(request, ticket_id):
|
def ticket_dependency_add(request, ticket_id):
|
||||||
ticket = get_object_or_404(Ticket, id=ticket_id)
|
ticket = get_object_or_404(Ticket, id=ticket_id)
|
||||||
if not _has_access_to_queue(request.user, ticket.queue):
|
ticket_perm_check(request, ticket)
|
||||||
raise PermissionDenied()
|
|
||||||
if not _is_my_ticket(request.user, ticket):
|
|
||||||
raise PermissionDenied()
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
form = TicketDependencyForm(request.POST)
|
form = TicketDependencyForm(request.POST)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
@ -1592,10 +1506,7 @@ ticket_dependency_del = staff_member_required(ticket_dependency_del)
|
|||||||
@helpdesk_staff_member_required
|
@helpdesk_staff_member_required
|
||||||
def attachment_del(request, ticket_id, attachment_id):
|
def attachment_del(request, ticket_id, attachment_id):
|
||||||
ticket = get_object_or_404(Ticket, id=ticket_id)
|
ticket = get_object_or_404(Ticket, id=ticket_id)
|
||||||
if not _has_access_to_queue(request.user, ticket.queue):
|
ticket_perm_check(request, ticket)
|
||||||
raise PermissionDenied()
|
|
||||||
if not _is_my_ticket(request.user, ticket):
|
|
||||||
raise PermissionDenied()
|
|
||||||
|
|
||||||
attachment = get_object_or_404(FollowUpAttachment, id=attachment_id)
|
attachment = get_object_or_404(FollowUpAttachment, id=attachment_id)
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
|
Loading…
x
Reference in New Issue
Block a user