diff --git a/.gitignore b/.gitignore index d5885822..9e6dab0f 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ docs/doctrees/* .project .pydevproject .directory +*.swp # ignore demo attachments that user might have added helpdesk/attachments/ diff --git a/helpdesk/forms.py b/helpdesk/forms.py index e9d4ef64..f5d1473c 100644 --- a/helpdesk/forms.py +++ b/helpdesk/forms.py @@ -301,21 +301,27 @@ class TicketForm(AbstractTicketForm): help_text=_('This e-mail address will receive copies of all public ' 'updates to this ticket.'), ) - assigned_to = forms.ChoiceField( - widget=forms.Select(attrs={'class': 'form-control'}), - choices=(), + widget=forms.Select(attrs={'class': 'form-control'}) if not helpdesk_settings.HELPDESK_CREATE_TICKET_HIDE_ASSIGNED_TO else forms.HiddenInput(), required=False, label=_('Case owner'), help_text=_('If you select an owner other than yourself, they\'ll be ' 'e-mailed details of this ticket immediately.'), + + choices=() ) def __init__(self, *args, **kwargs): """ Add any custom fields that are defined to the form. """ - super(TicketForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) + self.fields['queue'].choices = [('', '--------')] + [(q.id, q.title) for q in Queue.objects.all()] + if helpdesk_settings.HELPDESK_STAFF_ONLY_TICKET_OWNERS: + assignable_users = User.objects.filter(is_active=True, is_staff=True).order_by(User.USERNAME_FIELD) + else: + assignable_users = User.objects.filter(is_active=True).order_by(User.USERNAME_FIELD) + self.fields['assigned_to'].choices = [('', '--------')] + [(u.id, u.get_username()) for u in assignable_users] self._add_form_custom_fields() def save(self, user=None): @@ -375,8 +381,8 @@ class PublicTicketForm(AbstractTicketForm): self.fields['priority'].widget = forms.HiddenInput() if hasattr(settings, 'HELPDESK_PUBLIC_TICKET_DUE_DATE'): self.fields['due_date'].widget = forms.HiddenInput() - - self._add_form_custom_fields(False) + self.fields['queue'].choices = [('', '--------')] + [ + (q.id, q.title) for q in Queue.objects.filter(allow_public_submission=True)] def save(self): """ diff --git a/helpdesk/migrations/0018_ticket_secret_key.py b/helpdesk/migrations/0018_ticket_secret_key.py new file mode 100644 index 00000000..f3b2f4ee --- /dev/null +++ b/helpdesk/migrations/0018_ticket_secret_key.py @@ -0,0 +1,28 @@ +# Generated by Django 2.0.1 on 2018-09-07 21:22 + +from django.db import migrations, models +import helpdesk.models + + +def clear_secret_keys(apps, schema_editor): + Ticket = apps.get_model("helpdesk", "Ticket") + db_alias = schema_editor.connection.alias + + for ticket in Ticket.objects.using(db_alias).all(): + ticket.secret_key='' + ticket.save() + +class Migration(migrations.Migration): + + dependencies = [ + ('helpdesk', '0017_default_owner_on_delete_null'), + ] + + operations = [ + migrations.AddField( + model_name='ticket', + name='secret_key', + field=models.CharField(default=helpdesk.models.mk_secret, max_length=36, null=True, verbose_name='Secret key needed for viewing/editing ticket by non-logged in users'), + ), + migrations.RunPython(clear_secret_keys), + ] diff --git a/helpdesk/migrations/0019_ticket_secret_key.py b/helpdesk/migrations/0019_ticket_secret_key.py new file mode 100644 index 00000000..be1dd385 --- /dev/null +++ b/helpdesk/migrations/0019_ticket_secret_key.py @@ -0,0 +1,19 @@ +# Generated by Django 2.0.1 on 2018-09-07 21:22 + +from django.db import migrations, models +import helpdesk.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('helpdesk', '0018_ticket_secret_key'), + ] + + operations = [ + migrations.AlterField( + model_name='ticket', + name='secret_key', + field=models.CharField(default=helpdesk.models.mk_secret, max_length=36, verbose_name='Secret key needed for viewing/editing ticket by non-logged in users'), + ), + ] diff --git a/helpdesk/models.py b/helpdesk/models.py index 9b0b822a..cb80219d 100644 --- a/helpdesk/models.py +++ b/helpdesk/models.py @@ -21,6 +21,7 @@ from django.utils.encoding import python_2_unicode_compatible import re import six +import uuid @python_2_unicode_compatible @@ -351,6 +352,10 @@ class Queue(models.Model): pass +def mk_secret(): + return str(uuid.uuid4()) + + @python_2_unicode_compatible class Ticket(models.Model): """ @@ -480,6 +485,12 @@ class Ticket(models.Model): 'automatically by management/commands/escalate_tickets.py.'), ) + secret_key = models.CharField( + _("Secret key needed for viewing/editing ticket by non-logged in users"), + max_length=36, + default=mk_secret, + ) + def _get_assigned_to(self): """ Custom property to allow us to easily print 'Unassigned' if a ticket has no owner, or the users name if it's assigned. If the user @@ -544,11 +555,12 @@ class Ticket(models.Model): site = Site.objects.get_current() except ImproperlyConfigured: site = Site(domain='configure-django-sites.com') - return u"http://%s%s?ticket=%s&email=%s" % ( + return u"http://%s%s?ticket=%s&email=%s&key=%s" % ( site.domain, reverse('helpdesk:public_view'), self.ticket_for_url, - self.submitter_email + self.submitter_email, + self.secret_key ) ticket_url = property(_get_ticket_url) diff --git a/helpdesk/templates/helpdesk/public_create_ticket.html b/helpdesk/templates/helpdesk/public_create_ticket.html new file mode 100644 index 00000000..139657b6 --- /dev/null +++ b/helpdesk/templates/helpdesk/public_create_ticket.html @@ -0,0 +1,47 @@ +{% extends "helpdesk/public_base.html" %} +{% load i18n bootstrap %} + +{% block helpdesk_body %} + +{% if helpdesk_settings.HELPDESK_SUBMIT_A_TICKET_PUBLIC %} +
+
+ +
+

{% trans "Submit a Ticket" %}

+

{% trans "Please provide as descriptive a title and description as possible." %}

+ +
+
+ {{ form|bootstrap }} + {% comment %} + {% for field in form %} + + {% if field.is_hidden %} + {{ field }} + {% else %} + + +
+ {% if not field.field.required %} {% trans "(Optional)" %}{% endif %} +
{{ field }}
+ {% if field.errors %}
{{ field.errors }}
{% endif %} + {% if field.help_text %}{{ field.help_text }}{% endif %} +
+ + {% endif %} + + {% endfor %} + {% endcomment %} + +
+ +
+
+ +{% csrf_token %}
+
+
+
+{% endif %} +{% endblock %} diff --git a/helpdesk/templates/helpdesk/public_homepage.html b/helpdesk/templates/helpdesk/public_homepage.html index aee16c83..df95c891 100644 --- a/helpdesk/templates/helpdesk/public_homepage.html +++ b/helpdesk/templates/helpdesk/public_homepage.html @@ -26,7 +26,7 @@

{% trans "Submit a Ticket" %}

-

{% trans "All fields are required." %} {% trans "Please provide as descriptive a title and description as possible." %}

+

{% trans "Please provide as descriptive a title and description as possible." %}

diff --git a/helpdesk/tests/test_public_actions.py b/helpdesk/tests/test_public_actions.py index a88a1c8e..ffae6d61 100644 --- a/helpdesk/tests/test_public_actions.py +++ b/helpdesk/tests/test_public_actions.py @@ -29,12 +29,21 @@ class PublicActionsTestCase(TestCase): self.client = Client() def test_public_view_ticket(self): + # Without key, we get 403 response = self.client.get('%s?ticket=%s&email=%s' % ( reverse('helpdesk:public_view'), self.ticket.ticket_for_url, 'test.submitter@example.com')) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 403) self.assertTemplateNotUsed(response, 'helpdesk/public_view_form.html') + # With a key it works + response = self.client.get('%s?ticket=%s&email=%s&key=%s' % ( + reverse('helpdesk:public_view'), + self.ticket.ticket_for_url, + 'test.submitter@example.com', + self.ticket.secret_key)) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, 'helpdesk/public_view_ticket.html') def test_public_close(self): old_status = self.ticket.status @@ -49,10 +58,11 @@ class PublicActionsTestCase(TestCase): current_followups = ticket.followup_set.all().count() - response = self.client.get('%s?ticket=%s&email=%s&close' % ( + response = self.client.get('%s?ticket=%s&email=%s&close&key=%s' % ( reverse('helpdesk:public_view'), ticket.ticket_for_url, - 'test.submitter@example.com')) + 'test.submitter@example.com', + ticket.secret_key)) ticket = Ticket.objects.get(id=self.ticket.id) diff --git a/helpdesk/tests/test_ticket_lookup.py b/helpdesk/tests/test_ticket_lookup.py index fa2b516a..54faf1d1 100644 --- a/helpdesk/tests/test_ticket_lookup.py +++ b/helpdesk/tests/test_ticket_lookup.py @@ -2,9 +2,13 @@ from django.urls import reverse from django.test import TestCase from helpdesk.models import Ticket, Queue +from django.test.utils import override_settings -class TestKBDisabled(TestCase): +@override_settings( + HELPDESK_VIEW_A_TICKET_PUBLIC=True +) +class TestTicketLookupPublicEnabled(TestCase): def setUp(self): q = Queue(title='Q1', slug='q1') q.save() diff --git a/helpdesk/urls.py b/helpdesk/urls.py index a206ff0d..969d8dd1 100644 --- a/helpdesk/urls.py +++ b/helpdesk/urls.py @@ -12,7 +12,7 @@ from django.contrib.auth.decorators import login_required from django.contrib.auth import views as auth_views from django.views.generic import TemplateView -from helpdesk.decorators import helpdesk_staff_member_required +from helpdesk.decorators import helpdesk_staff_member_required, protect_view from helpdesk import settings as helpdesk_settings from helpdesk.views import feeds, staff, public, kb @@ -46,10 +46,6 @@ urlpatterns = [ staff.mass_update, name='mass_update'), - url(r'^tickets/submit/$', - staff.create_ticket, - name='submit'), - url(r'^tickets/(?P[0-9]+)/$', staff.view_ticket, name='view'), @@ -149,9 +145,13 @@ urlpatterns = [ urlpatterns += [ url(r'^$', - public.homepage, + protect_view(public.Homepage.as_view()), name='home'), + url(r'^tickets/submit/$', + public.create_ticket, + name='submit'), + url(r'^view/$', public.view_ticket, name='public_view'), diff --git a/helpdesk/views/permissions.py b/helpdesk/views/permissions.py new file mode 100644 index 00000000..955e71c9 --- /dev/null +++ b/helpdesk/views/permissions.py @@ -0,0 +1,8 @@ +from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin + +from helpdesk.decorators import is_helpdesk_staff + + +class MustBeStaffMixin(LoginRequiredMixin, UserPassesTestMixin): + def test_func(self): + return is_helpdesk_staff(self.request.user) diff --git a/helpdesk/views/public.py b/helpdesk/views/public.py index 78fb113d..e198eec0 100644 --- a/helpdesk/views/public.py +++ b/helpdesk/views/public.py @@ -6,7 +6,7 @@ django-helpdesk - A Django powered ticket tracker for small enterprise. views/public.py - All public facing views, eg non-staff (no authentication required) views. """ -from django.core.exceptions import ObjectDoesNotExist +from django.core.exceptions import ObjectDoesNotExist, PermissionDenied try: # Django 2.0+ from django.urls import reverse @@ -18,55 +18,59 @@ from django.shortcuts import render from django.utils.http import urlquote from django.utils.translation import ugettext as _ from django.conf import settings +from django.views.generic.base import TemplateView +from django.views.generic.edit import FormView from helpdesk import settings as helpdesk_settings from helpdesk.decorators import protect_view, is_helpdesk_staff +import helpdesk.views.staff as staff from helpdesk.forms import PublicTicketForm from helpdesk.lib import text_is_spam from helpdesk.models import Ticket, Queue, UserSettings, KBCategory -@protect_view -def homepage(request): - if not request.user.is_authenticated and helpdesk_settings.HELPDESK_REDIRECT_TO_LOGIN_BY_DEFAULT: - return HttpResponseRedirect(reverse('login')) - - if is_helpdesk_staff(request.user) or \ - (request.user.is_authenticated and - helpdesk_settings.HELPDESK_ALLOW_NON_STAFF_TICKET_UPDATE): - try: - if request.user.usersettings_helpdesk.settings.get('login_view_ticketlist', False): - return HttpResponseRedirect(reverse('helpdesk:list')) - else: - return HttpResponseRedirect(reverse('helpdesk:dashboard')) - except UserSettings.DoesNotExist: - return HttpResponseRedirect(reverse('helpdesk:dashboard')) - - if request.method == 'POST': - form = PublicTicketForm(request.POST, request.FILES) - form.fields['queue'].choices = [('', '--------')] + [ - (q.id, q.title) for q in Queue.objects.filter(allow_public_submission=True)] - if form.is_valid(): - if text_is_spam(form.cleaned_data['body'], request): - # This submission is spam. Let's not save it. - return render(request, template_name='helpdesk/public_spam.html') - else: - ticket = form.save() - try: - return HttpResponseRedirect('%s?ticket=%s&email=%s' % ( - reverse('helpdesk:public_view'), - ticket.ticket_for_url, - urlquote(ticket.submitter_email)) - ) - except ValueError: - # if someone enters a non-int string for the ticket - return HttpResponseRedirect(reverse('helpdesk:home')) +def create_ticket(request, *args, **kwargs): + if is_helpdesk_staff(request.user): + return staff.CreateTicketView.as_view()(request, *args, **kwargs) else: + return CreateTicketView.as_view()(request, *args, **kwargs) + + +class CreateTicketView(FormView): + template_name = 'helpdesk/public_create_ticket.html' + form_class = PublicTicketForm + + def dispatch(self, *args, **kwargs): + request = self.request + if not request.user.is_authenticated and helpdesk_settings.HELPDESK_REDIRECT_TO_LOGIN_BY_DEFAULT: + return HttpResponseRedirect(reverse('login')) + + if is_helpdesk_staff(request.user) or \ + (request.user.is_authenticated and + helpdesk_settings.HELPDESK_ALLOW_NON_STAFF_TICKET_UPDATE): + try: + if request.user.usersettings_helpdesk.settings.get('login_view_ticketlist', False): + return HttpResponseRedirect(reverse('helpdesk:list')) + else: + return HttpResponseRedirect(reverse('helpdesk:dashboard')) + except UserSettings.DoesNotExist: + return HttpResponseRedirect(reverse('helpdesk:dashboard')) + return super().dispatch(*args, **kwargs) + + def get_context(self): + knowledgebase_categories = KBCategory.objects.all() + return { + 'helpdesk_settings': helpdesk_settings, + 'kb_categories': knowledgebase_categories + } + + def get_initial(self): + request = self.request + initial_data = {} try: queue = Queue.objects.get(slug=request.GET.get('queue', None)) except Queue.DoesNotExist: queue = None - initial_data = {} # add pre-defined data for public ticket if hasattr(settings, 'HELPDESK_PUBLIC_TICKET_QUEUE'): @@ -85,76 +89,99 @@ def homepage(request): if request.user.is_authenticated and request.user.email: initial_data['submitter_email'] = request.user.email + return initial_data - form = PublicTicketForm(initial=initial_data) - form.fields['queue'].choices = [('', '--------')] + [ - (q.id, q.title) for q in Queue.objects.filter(allow_public_submission=True)] + def form_valid(self, form): + request = self.request + if text_is_spam(form.cleaned_data['body'], request): + # This submission is spam. Let's not save it. + return render(request, template_name='helpdesk/public_spam.html') + else: + ticket = form.save() + try: + return HttpResponseRedirect('%s?ticket=%s&email=%s&key=%s' % ( + reverse('helpdesk:public_view'), + ticket.ticket_for_url, + urlquote(ticket.submitter_email), + ticket.secret_key) + ) + except ValueError: + # if someone enters a non-int string for the ticket + return HttpResponseRedirect(reverse('helpdesk:home')) - knowledgebase_categories = KBCategory.objects.all() + def get_success_url(self): + request = self.request - return render(request, 'helpdesk/public_homepage.html', { - 'form': form, - 'helpdesk_settings': helpdesk_settings, - 'kb_categories': knowledgebase_categories - }) + +class Homepage(CreateTicketView): + template_name = 'helpdesk/public_homepage.html' + + +def search_for_ticket(request, error_message=None): + if hasattr(settings, 'HELPDESK_VIEW_A_TICKET_PUBLIC') and settings.HELPDESK_VIEW_A_TICKET_PUBLIC: + email = request.GET.get('email', None) + return render(request, 'helpdesk/public_view_form.html', { + 'ticket': False, + 'email': email, + 'error_message': error_message, + 'helpdesk_settings': helpdesk_settings, + }) + else: + raise PermissionDenied("Public viewing of tickets without a secret key is forbidden.") @protect_view def view_ticket(request): ticket_req = request.GET.get('ticket', None) email = request.GET.get('email', None) + key = request.GET.get('key', '') - if ticket_req and email: - queue, ticket_id = Ticket.queue_and_id_from_query(ticket_req) - try: - ticket = Ticket.objects.get(id=ticket_id, submitter_email__iexact=email) - except ObjectDoesNotExist: - error_message = _('Invalid ticket ID or e-mail address. Please try again.') - except ValueError: - error_message = _('Invalid ticket ID or e-mail address. Please try again.') + if not (ticket_req and email): + if ticket_req is None and email is None: + return search_for_ticket(request) else: - if is_helpdesk_staff(request.user): - redirect_url = reverse('helpdesk:view', args=[ticket_id]) - if 'close' in request.GET: - redirect_url += '?close' - return HttpResponseRedirect(redirect_url) + return search_for_ticket(request, _('Missing ticket ID or e-mail address. Please try again.')) - if 'close' in request.GET and ticket.status == Ticket.RESOLVED_STATUS: - from helpdesk.views.staff import update_ticket - # Trick the update_ticket() view into thinking it's being called with - # a valid POST. - request.POST = { - 'new_status': Ticket.CLOSED_STATUS, - 'public': 1, - 'title': ticket.title, - 'comment': _('Submitter accepted resolution and closed ticket'), - } - if ticket.assigned_to: - request.POST['owner'] = ticket.assigned_to.id - request.GET = {} + queue, ticket_id = Ticket.queue_and_id_from_query(ticket_req) + try: + if hasattr(settings, 'HELPDESK_VIEW_A_TICKET_PUBLIC') and settings.HELPDESK_VIEW_A_TICKET_PUBLIC: + ticket = Ticket.objects.get(id=ticket_id, submitter_email__iexact=email) + else: + ticket = Ticket.objects.get(id=ticket_id, submitter_email__iexact=email, secret_key__iexact=key) + except (ObjectDoesNotExist, ValueError): + return search_for_ticket(request, _('Invalid ticket ID or e-mail address. Please try again.')) - return update_ticket(request, ticket_id, public=True) + if is_helpdesk_staff(request.user): + redirect_url = reverse('helpdesk:view', args=[ticket_id]) + if 'close' in request.GET: + redirect_url += '?close' + return HttpResponseRedirect(redirect_url) - # redirect user back to this ticket if possible. - redirect_url = '' - if helpdesk_settings.HELPDESK_NAVIGATION_ENABLED: - redirect_url = reverse('helpdesk:view', args=[ticket_id]) + if 'close' in request.GET and ticket.status == Ticket.RESOLVED_STATUS: + from helpdesk.views.staff import update_ticket + # Trick the update_ticket() view into thinking it's being called with + # a valid POST. + request.POST = { + 'new_status': Ticket.CLOSED_STATUS, + 'public': 1, + 'title': ticket.title, + 'comment': _('Submitter accepted resolution and closed ticket'), + } + if ticket.assigned_to: + request.POST['owner'] = ticket.assigned_to.id + request.GET = {} - return render(request, 'helpdesk/public_view_ticket.html', { - 'ticket': ticket, - 'helpdesk_settings': helpdesk_settings, - 'next': redirect_url, - }) - elif ticket_req is None and email is None: - error_message = None - else: - error_message = _('Missing ticket ID or e-mail address. Please try again.') + return update_ticket(request, ticket_id, public=True) - return render(request, 'helpdesk/public_view_form.html', { - 'ticket': False, - 'email': email, - 'error_message': error_message, + # redirect user back to this ticket if possible. + redirect_url = '' + if helpdesk_settings.HELPDESK_NAVIGATION_ENABLED: + redirect_url = reverse('helpdesk:view', args=[ticket_id]) + + return render(request, 'helpdesk/public_view_ticket.html', { + 'ticket': ticket, 'helpdesk_settings': helpdesk_settings, + 'next': redirect_url, }) diff --git a/helpdesk/views/staff.py b/helpdesk/views/staff.py index e6496694..54961f07 100644 --- a/helpdesk/views/staff.py +++ b/helpdesk/views/staff.py @@ -26,6 +26,7 @@ from django.utils.translation import ugettext as _ from django.utils.html import escape from django import forms from django.utils import timezone +from django.views.generic.edit import FormView from django.utils import six @@ -46,6 +47,7 @@ from helpdesk.models import ( IgnoreEmail, TicketCC, TicketDependency, ) from helpdesk import settings as helpdesk_settings +from helpdesk.views.permissions import MustBeStaffMixin User = get_user_model() @@ -1019,44 +1021,29 @@ def edit_ticket(request, ticket_id): edit_ticket = staff_member_required(edit_ticket) -@helpdesk_staff_member_required -def create_ticket(request): - if helpdesk_settings.HELPDESK_STAFF_ONLY_TICKET_OWNERS: - assignable_users = User.objects.filter(is_active=True, is_staff=True).order_by(User.USERNAME_FIELD) - else: - assignable_users = User.objects.filter(is_active=True).order_by(User.USERNAME_FIELD) +class CreateTicketView(MustBeStaffMixin, FormView): + template_name = 'helpdesk/create_ticket.html' + form_class = TicketForm - if request.method == 'POST': - form = TicketForm(request.POST, request.FILES) - form.fields['queue'].choices = [('', '--------')] + [ - (q.id, q.title) for q in Queue.objects.all()] - form.fields['assigned_to'].choices = [('', '--------')] + [ - (u.id, u.get_username()) for u in assignable_users] - if form.is_valid(): - ticket = form.save(user=request.user) - if _has_access_to_queue(request.user, ticket.queue): - return HttpResponseRedirect(ticket.get_absolute_url()) - else: - return HttpResponseRedirect(reverse('helpdesk:dashboard')) - else: + def get_initial(self): initial_data = {} + request = self.request if request.user.usersettings_helpdesk.settings.get('use_email_as_submitter', False) and request.user.email: initial_data['submitter_email'] = request.user.email if 'queue' in request.GET: initial_data['queue'] = request.GET['queue'] + return initial_data - form = TicketForm(initial=initial_data) - form.fields['queue'].choices = [('', '--------')] + [ - (q.id, q.title) for q in Queue.objects.all()] - form.fields['assigned_to'].choices = [('', '--------')] + [ - (u.id, u.get_username()) for u in assignable_users] - if helpdesk_settings.HELPDESK_CREATE_TICKET_HIDE_ASSIGNED_TO: - form.fields['assigned_to'].widget = forms.HiddenInput() + def form_valid(self, form): + self.ticket = form.save() + return super().form_valid(form) - return render(request, 'helpdesk/create_ticket.html', {'form': form}) - - -create_ticket = staff_member_required(create_ticket) + def get_success_url(self): + request = self.request + if _has_access_to_queue(request.user, self.ticket.queue): + return self.ticket.get_absolute_url() + else: + return reverse('helpdesk:dashboard') @helpdesk_staff_member_required