2025-03-24 09:43:19 -10:00

772 lines
26 KiB
Python

"""
django-helpdesk - A Django powered ticket tracker for small enterprise.
(c) Copyright 2008 Jutda. All Rights Reserved. See LICENSE for details.
forms.py - Definitions of newforms-based forms for creating and maintaining
tickets.
"""
from datetime import datetime
from django import forms
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from helpdesk import settings as helpdesk_settings
from helpdesk.lib import convert_value, process_attachments, safe_template_context
from helpdesk.models import (
Checklist,
ChecklistTemplate,
CustomField,
FollowUp,
IgnoreEmail,
Queue,
Ticket,
TicketCC,
TicketCustomFieldValue,
TicketDependency,
UserSettings,
)
from helpdesk.settings import (
CUSTOMFIELD_DATE_FORMAT,
CUSTOMFIELD_DATETIME_FORMAT,
CUSTOMFIELD_TIME_FORMAT,
CUSTOMFIELD_TO_FIELD_DICT,
HELPDESK_SHOW_CUSTOM_FIELDS_FOLLOW_UP_LIST,
)
from helpdesk.validators import validate_file_extension
from helpdesk.signals import new_ticket_done
import logging
if helpdesk_settings.HELPDESK_KB_ENABLED:
from helpdesk.models import KBItem
logger = logging.getLogger(__name__)
User = get_user_model()
class CustomFieldMixin(object):
"""
Mixin that provides a method to turn CustomFields into an actual field
"""
def customfield_to_field(self, field, instanceargs):
# Use TextInput widget by default
instanceargs["widget"] = forms.TextInput(attrs={"class": "form-control"})
# if-elif branches start with special cases
if field.data_type == "varchar":
fieldclass = forms.CharField
instanceargs["max_length"] = field.max_length
elif field.data_type == "text":
fieldclass = forms.CharField
instanceargs["widget"] = forms.Textarea(attrs={"class": "form-control"})
instanceargs["max_length"] = field.max_length
elif field.data_type == "integer":
fieldclass = forms.IntegerField
instanceargs["widget"] = forms.NumberInput(attrs={"class": "form-control"})
elif field.data_type == "decimal":
fieldclass = forms.DecimalField
instanceargs["decimal_places"] = field.decimal_places
instanceargs["max_digits"] = field.max_length
instanceargs["widget"] = forms.NumberInput(attrs={"class": "form-control"})
elif field.data_type == "list":
fieldclass = forms.ChoiceField
instanceargs["choices"] = field.get_choices()
instanceargs["widget"] = forms.Select(attrs={"class": "form-control"})
else:
# Try to use the immediate equivalences dictionary
try:
fieldclass = CUSTOMFIELD_TO_FIELD_DICT[field.data_type]
# Change widgets for the following classes
if fieldclass == forms.DateField:
instanceargs["widget"] = forms.DateInput(
attrs={"class": "form-control date-field"}
)
elif fieldclass == forms.DateTimeField:
instanceargs["widget"] = forms.DateTimeInput(
attrs={"class": "form-control datetime-field"}
)
elif fieldclass == forms.TimeField:
instanceargs["widget"] = forms.TimeInput(
attrs={"class": "form-control time-field"}
)
elif fieldclass == forms.BooleanField:
instanceargs["widget"] = forms.CheckboxInput(
attrs={"class": "form-control"}
)
except KeyError:
# The data_type was not found anywhere
raise NameError("Unrecognized data_type %s" % field.data_type)
self.fields["custom_%s" % field.name] = fieldclass(**instanceargs)
class EditTicketForm(CustomFieldMixin, forms.ModelForm):
class Meta:
model = Ticket
exclude = (
"created",
"modified",
"status",
"on_hold",
"resolution",
"last_escalation",
"assigned_to",
)
class Media:
js = ("helpdesk/js/init_due_date.js", "helpdesk/js/init_datetime_classes.js")
def __init__(self, *args, **kwargs):
"""
Add any custom fields that are defined to the form
"""
super(EditTicketForm, self).__init__(*args, **kwargs)
# Disable and add help_text to the merged_to field on this form
self.fields["merged_to"].disabled = True
self.fields["merged_to"].help_text = _(
"This ticket is merged into the selected ticket."
)
for field in CustomField.objects.all():
initial_value = None
try:
current_value = TicketCustomFieldValue.objects.get(
ticket=self.instance, field=field
)
initial_value = current_value.value
# Attempt to convert from fixed format string to date/time data
# type
if "datetime" == current_value.field.data_type:
initial_value = datetime.strptime(
initial_value, CUSTOMFIELD_DATETIME_FORMAT
)
elif "date" == current_value.field.data_type:
initial_value = datetime.strptime(
initial_value, CUSTOMFIELD_DATE_FORMAT
)
elif "time" == current_value.field.data_type:
initial_value = datetime.strptime(
initial_value, CUSTOMFIELD_TIME_FORMAT
)
# If it is boolean field, transform the value to a real boolean
# instead of a string
elif "boolean" == current_value.field.data_type:
initial_value = "True" == initial_value
except (TicketCustomFieldValue.DoesNotExist, ValueError, TypeError):
# ValueError error if parsing fails, using initial_value = current_value.value
# TypeError if parsing None type
pass
instanceargs = {
"label": field.label,
"help_text": field.help_text,
"required": field.required,
"initial": initial_value,
}
self.customfield_to_field(field, instanceargs)
def save(self, *args, **kwargs):
for field, value in self.cleaned_data.items():
if field.startswith("custom_"):
field_name = field.replace("custom_", "", 1)
customfield = CustomField.objects.get(name=field_name)
try:
cfv = TicketCustomFieldValue.objects.get(
ticket=self.instance, field=customfield
)
except ObjectDoesNotExist:
cfv = TicketCustomFieldValue(
ticket=self.instance, field=customfield
)
cfv.value = convert_value(value)
cfv.save()
return super(EditTicketForm, self).save(*args, **kwargs)
class EditTicketCustomFieldForm(EditTicketForm):
"""
Uses the EditTicketForm logic to provide a form for Ticket custom fields.
"""
def __init__(self, *args, **kwargs):
"""
Add any custom fields that are defined to the form
"""
super(EditTicketCustomFieldForm, self).__init__(*args, **kwargs)
if HELPDESK_SHOW_CUSTOM_FIELDS_FOLLOW_UP_LIST:
fields = list(self.fields)
for field in fields:
if (
field != "id"
and field.replace("custom_", "", 1)
not in HELPDESK_SHOW_CUSTOM_FIELDS_FOLLOW_UP_LIST
):
self.fields.pop(field, None)
def save(self, *args, **kwargs):
# if form is saved in a ticket update, it is passed
# a followup instance to trace custom fields changes
if "followup" in kwargs:
followup = kwargs.pop("followup", None)
for field, value in self.cleaned_data.items():
if field.startswith("custom_"):
if value != self.fields[field].initial:
followup.ticketchange_set.create(
field=field.replace("custom_", "", 1),
old_value=self.fields[field].initial,
new_value=value,
)
super(EditTicketCustomFieldForm, self).save(*args, **kwargs)
class Meta:
model = Ticket
fields = (
"id",
"merged_to",
)
class EditFollowUpForm(forms.ModelForm):
class Meta:
model = FollowUp
exclude = (
"date",
"user",
)
def __init__(self, *args, **kwargs):
"""Filter not opened tickets here."""
super(EditFollowUpForm, self).__init__(*args, **kwargs)
self.fields["ticket"].queryset = Ticket.objects.filter(
status__in=Ticket.OPEN_STATUSES
)
class AbstractTicketForm(CustomFieldMixin, forms.Form):
"""
Contain all the common code and fields between "TicketForm" and
"PublicTicketForm". This Form is not intended to be used directly.
"""
queue = forms.ChoiceField(
widget=forms.Select(attrs={"class": "form-control"}),
label=_("Queue"),
required=True,
choices=(),
)
title = forms.CharField(
max_length=100,
required=True,
widget=forms.TextInput(attrs={"class": "form-control"}),
label=_("Summary of the problem"),
)
body = forms.CharField(
widget=forms.Textarea(attrs={"class": "form-control"}),
label=_("Description of your issue"),
required=True,
help_text=_("Please be as descriptive as possible and include all details"),
)
priority = forms.ChoiceField(
widget=forms.Select(attrs={"class": "form-control"}),
choices=Ticket.PRIORITY_CHOICES,
required=True,
initial=getattr(settings, "HELPDESK_PUBLIC_TICKET_PRIORITY", "3"),
label=_("Priority"),
help_text=_("Please select a priority carefully. If unsure, leave it as '3'."),
)
due_date = forms.DateTimeField(
widget=forms.TextInput(attrs={"class": "form-control", "autocomplete": "off"}),
required=False,
input_formats=[
CUSTOMFIELD_DATE_FORMAT,
CUSTOMFIELD_DATETIME_FORMAT,
"%d/%m/%Y",
"%m/%d/%Y",
"%d.%m.%Y",
],
label=_("Due on"),
)
if helpdesk_settings.HELPDESK_ENABLE_ATTACHMENTS:
attachment = forms.FileField(
widget=forms.FileInput(attrs={"class": "form-control-file"}),
required=False,
label=_("Attach File"),
help_text=_(
"You can attach a file to this ticket. "
"Only file types such as plain text (.txt), "
"a document (.pdf, .docx, or .odt), "
"or screenshot (.png or .jpg) may be uploaded."
),
validators=[validate_file_extension],
)
class Media:
js = ("helpdesk/js/init_due_date.js", "helpdesk/js/init_datetime_classes.js")
def __init__(self, kbcategory=None, *args, **kwargs):
super().__init__(*args, **kwargs)
if helpdesk_settings.HELPDESK_KB_ENABLED:
if kbcategory:
self.fields["kbitem"] = forms.ChoiceField(
widget=forms.Select(attrs={"class": "form-control"}),
required=False,
label=_("Knowledge Base Item"),
choices=[
(kbi.pk, kbi.title)
for kbi in KBItem.objects.filter(
category=kbcategory.pk, enabled=True
)
],
)
def _add_form_custom_fields(self, staff_only_filter=None):
if staff_only_filter is None:
queryset = CustomField.objects.all()
else:
queryset = CustomField.objects.filter(staff_only=staff_only_filter)
for field in queryset:
instanceargs = {
"label": field.label,
"help_text": field.help_text,
"required": field.required,
}
self.customfield_to_field(field, instanceargs)
def _get_queue(self):
# this procedure is re-defined for public submission form
return Queue.objects.get(id=int(self.cleaned_data["queue"]))
def _create_ticket(self):
queue = self._get_queue()
kbitem = None
if "kbitem" in self.cleaned_data:
kbitem = KBItem.objects.get(id=int(self.cleaned_data["kbitem"]))
ticket = Ticket(
title=self.cleaned_data["title"],
submitter_email=self.cleaned_data["submitter_email"],
created=timezone.now(),
status=Ticket.OPEN_STATUS,
queue=queue,
description=self.cleaned_data["body"],
priority=self.cleaned_data.get(
"priority", getattr(settings, "HELPDESK_PUBLIC_TICKET_PRIORITY", "3")
),
due_date=self.cleaned_data.get(
"due_date", getattr(settings, "HELPDESK_PUBLIC_TICKET_DUE_DATE", None)
)
or None,
kbitem=kbitem,
)
return ticket, queue
def _create_custom_fields(self, ticket):
ticket.save_custom_field_values(self.cleaned_data)
def _create_follow_up(self, ticket, title, user=None):
followup = FollowUp(
ticket=ticket,
title=title,
date=timezone.now(),
public=True,
comment=self.cleaned_data["body"],
)
if user:
followup.user = user
return followup
def _attach_files_to_follow_up(self, followup):
files = self.cleaned_data.get("attachment")
if files:
files = process_attachments(followup, [files])
return files
@staticmethod
def _send_messages(ticket, queue, followup, files, user=None):
context = safe_template_context(ticket)
context["comment"] = followup.comment
roles = {
"submitter": ("newticket_submitter", context),
"new_ticket_cc": ("newticket_cc", context),
"ticket_cc": ("newticket_cc", context),
}
if (
ticket.assigned_to
and ticket.assigned_to.usersettings_helpdesk.email_on_ticket_assign
):
roles["assigned_to"] = ("assigned_owner", context)
ticket.send(
roles,
fail_silently=True,
files=files,
)
class TicketForm(AbstractTicketForm):
"""
Ticket Form creation for registered users.
"""
submitter_email = forms.EmailField(
required=False,
label=_("Submitter E-Mail Address"),
widget=forms.TextInput(attrs={"class": "form-control", "type": "email"}),
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"})
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.
"""
queue_choices = kwargs.pop("queue_choices")
super().__init__(*args, **kwargs)
self.fields["queue"].choices = queue_choices
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):
"""
Writes and returns a Ticket() object
"""
ticket, queue = self._create_ticket()
if self.cleaned_data["assigned_to"]:
try:
u = User.objects.get(id=self.cleaned_data["assigned_to"])
ticket.assigned_to = u
except User.DoesNotExist:
ticket.assigned_to = None
ticket.save()
self._create_custom_fields(ticket)
if self.cleaned_data["assigned_to"]:
title = _("Ticket Opened & Assigned to %(name)s") % {
"name": ticket.get_assigned_to or _("<invalid user>")
}
else:
title = _("Ticket Opened")
followup = self._create_follow_up(ticket, title=title, user=user)
followup.save()
if helpdesk_settings.HELPDESK_ENABLE_ATTACHMENTS:
files = self._attach_files_to_follow_up(followup)
else:
files = None
# emit signal when the TicketForm.save is done
new_ticket_done.send(sender="TicketForm", ticket=ticket)
self._send_messages(
ticket=ticket, queue=queue, followup=followup, files=files, user=user
)
return ticket
class PublicTicketForm(AbstractTicketForm):
"""
Ticket Form creation for all users (public-facing).
"""
submitter_email = forms.EmailField(
widget=forms.TextInput(attrs={"class": "form-control", "type": "email"}),
required=True,
label=_("Your E-Mail Address"),
help_text=_("We will e-mail you when your ticket is updated."),
)
def __init__(self, hidden_fields=(), readonly_fields=(), *args, **kwargs):
"""
Add any (non-staff) custom fields that are defined to the form
"""
super(PublicTicketForm, self).__init__(*args, **kwargs)
self._add_form_custom_fields(False)
for field in self.fields.keys():
if field in hidden_fields:
self.fields[field].widget = forms.HiddenInput()
if field in readonly_fields:
self.fields[field].disabled = True
field_deletion_table = {
"queue": "HELPDESK_PUBLIC_TICKET_QUEUE",
"priority": "HELPDESK_PUBLIC_TICKET_PRIORITY",
"due_date": "HELPDESK_PUBLIC_TICKET_DUE_DATE",
}
for field_name, field_setting_key in field_deletion_table.items():
has_settings_default_value = getattr(settings, field_setting_key, None)
if has_settings_default_value is not None:
del self.fields[field_name]
public_queues = Queue.objects.filter(allow_public_submission=True)
if len(public_queues) == 0:
logger.warning(
"There are no public queues defined - public ticket creation is impossible"
)
if "queue" in self.fields:
self.fields["queue"].choices = [("", "--------")] + [
(q.id, q.title) for q in public_queues
]
def _get_queue(self):
if getattr(settings, "HELPDESK_PUBLIC_TICKET_QUEUE", None) is not None:
# force queue to be the pre-defined one
# (only for public submissions)
public_queue = Queue.objects.filter(
slug=settings.HELPDESK_PUBLIC_TICKET_QUEUE
).first()
if not public_queue:
logger.fatal(
"Public queue '%s' is configured as default but can't be found",
settings.HELPDESK_PUBLIC_TICKET_QUEUE,
)
return public_queue
else:
# get the queue user entered
return Queue.objects.get(id=int(self.cleaned_data["queue"]))
def save(self, user):
"""
Writes and returns a Ticket() object
"""
ticket, queue = self._create_ticket()
if queue.default_owner and not ticket.assigned_to:
ticket.assigned_to = queue.default_owner
ticket.save()
self._create_custom_fields(ticket)
followup = self._create_follow_up(
ticket, title=_("Ticket Opened Via Web"), user=user
)
followup.save()
files = self._attach_files_to_follow_up(followup)
# emit signal when the PublicTicketForm.save is done
new_ticket_done.send(sender="PublicTicketForm", ticket=ticket)
self._send_messages(ticket=ticket, queue=queue, followup=followup, files=files)
return ticket
class UserSettingsForm(forms.ModelForm):
class Meta:
model = UserSettings
exclude = ["user", "settings_pickled"]
class EmailIgnoreForm(forms.ModelForm):
class Meta:
model = IgnoreEmail
exclude = []
class TicketCCForm(forms.ModelForm):
"""Adds either an email address or helpdesk user as a CC on a Ticket. Used for processing POST requests."""
class Meta:
model = TicketCC
exclude = ("ticket",)
def __init__(self, *args, **kwargs):
super(TicketCCForm, self).__init__(*args, **kwargs)
if helpdesk_settings.HELPDESK_STAFF_ONLY_TICKET_CC:
users = User.objects.filter(is_active=True, is_staff=True).order_by(
User.USERNAME_FIELD
)
else:
users = User.objects.filter(is_active=True).order_by(User.USERNAME_FIELD)
self.fields["user"].queryset = users
class TicketCCUserForm(forms.ModelForm):
"""Adds a helpdesk user as a CC on a Ticket"""
def __init__(self, *args, **kwargs):
super(TicketCCUserForm, self).__init__(*args, **kwargs)
if helpdesk_settings.HELPDESK_STAFF_ONLY_TICKET_CC:
users = User.objects.filter(is_active=True, is_staff=True).order_by(
User.USERNAME_FIELD
)
else:
users = User.objects.filter(is_active=True).order_by(User.USERNAME_FIELD)
self.fields["user"].queryset = users
class Meta:
model = TicketCC
exclude = (
"ticket",
"email",
)
class TicketCCEmailForm(forms.ModelForm):
"""Adds an email address as a CC on a Ticket"""
def __init__(self, *args, **kwargs):
super(TicketCCEmailForm, self).__init__(*args, **kwargs)
class Meta:
model = TicketCC
exclude = (
"ticket",
"user",
)
class TicketDependencyForm(forms.ModelForm):
"""Adds a different ticket as a dependency for this Ticket"""
class Meta:
model = TicketDependency
fields = ("depends_on",)
def __init__(self, ticket, *args, **kwargs):
super(TicketDependencyForm, self).__init__(*args, **kwargs)
# Only open tickets except myself, existing dependencies and parents
self.fields["depends_on"].queryset = (
Ticket.objects.filter(status__in=Ticket.OPEN_STATUSES)
.exclude(id=ticket.id)
.exclude(depends_on__ticket=ticket)
.exclude(ticketdependency__depends_on=ticket)
)
class TicketResolvesForm(forms.ModelForm):
"""Adds this ticket as a dependency for a different ticket"""
class Meta:
model = TicketDependency
fields = ("ticket",)
def __init__(self, ticket, *args, **kwargs):
super(TicketResolvesForm, self).__init__(*args, **kwargs)
# Only open tickets except myself, existing dependencies and parents
self.fields["ticket"].queryset = (
Ticket.objects.exclude(status__in=Ticket.OPEN_STATUSES)
.exclude(id=ticket.id)
.exclude(depends_on__ticket=ticket)
.exclude(ticketdependency__depends_on=ticket)
)
class MultipleTicketSelectForm(forms.Form):
tickets = forms.ModelMultipleChoiceField(
label=_("Tickets to merge"),
queryset=Ticket.objects.filter(merged_to=None),
widget=forms.SelectMultiple(attrs={"class": "form-control"}),
)
def clean_tickets(self):
tickets = self.cleaned_data.get("tickets")
if len(tickets) < 2:
raise ValidationError(_("Please choose at least 2 tickets."))
if len(tickets) > 4:
raise ValidationError(_("Impossible to merge more than 4 tickets..."))
queues = tickets.order_by("queue").distinct().values_list("queue", flat=True)
if len(queues) != 1:
raise ValidationError(
_(
"All selected tickets must share the same queue in order to be merged."
)
)
return tickets
class ChecklistTemplateForm(forms.ModelForm):
name = forms.CharField(
widget=forms.TextInput(attrs={"class": "form-control"}),
required=True,
)
task_list = forms.JSONField(widget=forms.HiddenInput())
class Meta:
model = ChecklistTemplate
fields = ("name", "task_list")
def clean_task_list(self):
task_list = self.cleaned_data["task_list"]
return list(map(lambda task: task.strip(), task_list))
class ChecklistForm(forms.ModelForm):
name = forms.CharField(
widget=forms.TextInput(attrs={"class": "form-control"}),
required=True,
)
class Meta:
model = Checklist
fields = ("name",)
class CreateChecklistForm(ChecklistForm):
checklist_template = forms.ModelChoiceField(
label=_("Template"),
queryset=ChecklistTemplate.objects.all(),
widget=forms.Select(attrs={"class": "form-control"}),
required=False,
)
class Meta(ChecklistForm.Meta):
fields = ("checklist_template", "name")
class FormControlDeleteFormSet(forms.BaseInlineFormSet):
deletion_widget = forms.CheckboxInput(attrs={"class": "form-control"})