diff --git a/forms.py b/forms.py index 73f40a4f..7b1b5867 100644 --- a/forms.py +++ b/forms.py @@ -1,50 +1,73 @@ -""" .. +""" Jutda 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 +forms.py - Definitions of newforms-based forms for creating and maintaining tickets. """ -from django import forms -from helpdesk.models import Ticket, Queue, FollowUp -from django.contrib.auth.models import User from datetime import datetime + +from django import forms +from django.contrib.auth.models import User from django.utils.translation import ugettext as _ +from helpdesk.lib import send_templated_mail +from helpdesk.models import Ticket, Queue, FollowUp + class TicketForm(forms.Form): - queue = forms.ChoiceField(label=_('Queue'), required=True, choices=()) + queue = forms.ChoiceField( + label=_('Queue'), + required=True, + choices=() + ) - title = forms.CharField(max_length=100, required=True, - widget=forms.TextInput(), - label=_('Summary of the problem')) - - submitter_email = forms.EmailField(required=False, - label=_('Submitter E-Mail Address'), - help_text=_('This e-mail address will receive copies of all public updates to this ticket.')) - - body = forms.CharField(widget=forms.Textarea(), - label=_('Description of Issue'), required=True) - - assigned_to = forms.ChoiceField(choices=(), 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.')) + title = forms.CharField( + max_length=100, + required=True, + widget=forms.TextInput(), + label=_('Summary of the problem'), + ) + + submitter_email = forms.EmailField( + required=False, + label=_('Submitter E-Mail Address'), + help_text=_('This e-mail address will receive copies of all public ' + 'updates to this ticket.'), + ) + + body = forms.CharField( + widget=forms.Textarea(), + label=_('Description of Issue'), + required=True, + ) + + assigned_to = forms.ChoiceField( + choices=(), + 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.'), + ) + + priority = forms.ChoiceField( + choices=Ticket.PRIORITY_CHOICES, + required=False, + initial='3', + label=_('Priority'), + help_text=_('Please select a priority carefully. If unsure, leave it ' + 'as \'3\'.'), + ) - priority = forms.ChoiceField(choices=Ticket.PRIORITY_CHOICES, - required=False, - initial='3', - label=_('Priority'), - help_text=_('Please select a priority carefully. If unsure, leave it as \'3\'.')) - def save(self, user): """ Writes and returns a Ticket() object - """ + q = Queue.objects.get(id=int(self.cleaned_data['queue'])) - - t = Ticket( title = self.cleaned_data['title'], + + t = Ticket( title = self.cleaned_data['title'], submitter_email = self.cleaned_data['submitter_email'], created = datetime.now(), status = Ticket.OPEN_STATUS, @@ -52,7 +75,7 @@ class TicketForm(forms.Form): description = self.cleaned_data['body'], priority = self.cleaned_data['priority'], ) - + if self.cleaned_data['assigned_to']: try: u = User.objects.get(id=self.cleaned_data['assigned_to']) @@ -69,91 +92,151 @@ class TicketForm(forms.Form): user = user, ) if self.cleaned_data['assigned_to']: - f.title = _('Ticket Opened & Assigned to %(name)s') % {'name': t.get_assigned_to} + f.title = _('Ticket Opened & Assigned to %(name)s') % { + 'name': t.get_assigned_to + } f.save() - + context = { 'ticket': t, 'queue': q, } - from helpdesk.lib import send_templated_mail - if t.submitter_email: - send_templated_mail('newticket_submitter', context, recipients=t.submitter_email, sender=q.from_address, fail_silently=True) + send_templated_mail( + 'newticket_submitter', + context, + recipients=t.submitter_email, + sender=q.from_address, + fail_silently=True, + ) if t.assigned_to and t.assigned_to != user: - send_templated_mail('assigned_owner', context, recipients=t.assigned_to.email, sender=q.from_address, fail_silently=True) + send_templated_mail( + 'assigned_owner', + context, + recipients=t.assigned_to.email, + sender=q.from_address, + fail_silently=True, + ) if q.new_ticket_cc: - send_templated_mail('newticket_cc', context, recipients=q.new_ticket_cc, sender=q.from_address, fail_silently=True) - + send_templated_mail( + 'newticket_cc', + context, + recipients=q.new_ticket_cc, + sender=q.from_address, + fail_silently=True, + ) + if q.updated_ticket_cc and q.updated_ticket_cc != q.new_ticket_cc: - send_templated_mail('newticket_cc', context, recipients=q.updated_ticket_cc, sender=q.from_address, fail_silently=True) + send_templated_mail( + 'newticket_cc', + context, + recipients=q.updated_ticket_cc, + sender=q.from_address, + fail_silently=True, + ) return t -class PublicTicketForm(forms.Form): - queue = forms.ChoiceField(label=_('Queue'), required=True, choices=()) - title = forms.CharField(max_length=100, required=True, - widget=forms.TextInput(), - label=_('Summary of your query')) - - submitter_email = forms.EmailField(required=True, - label=_('Your E-Mail Address'), - help_text=_('We will e-mail you when your ticket is updated.')) - - body = forms.CharField(widget=forms.Textarea(), - label=_('Description of your issue'), required=True, - help_text=_('Please be as descriptive as possible, including any details we may need to address your query.')) - - priority = forms.ChoiceField(choices=Ticket.PRIORITY_CHOICES, - required=True, - initial='3', - label=_('Urgency'), - help_text=_('Please select a priority carefully.')) - +class PublicTicketForm(forms.Form): + queue = forms.ChoiceField( + label=_('Queue'), + required=True, + choices=() + ) + + title = forms.CharField( + max_length=100, + required=True, + widget=forms.TextInput(), + label=_('Summary of your query'), + ) + + submitter_email = forms.EmailField( + required=True, + label=_('Your E-Mail Address'), + help_text=_('We will e-mail you when your ticket is updated.'), + ) + + body = forms.CharField( + widget=forms.Textarea(), + label=_('Description of your issue'), + required=True, + help_text=_('Please be as descriptive as possible, including any ' + 'details we may need to address your query.'), + ) + + priority = forms.ChoiceField( + choices=Ticket.PRIORITY_CHOICES, + required=True, + initial='3', + label=_('Urgency'), + help_text=_('Please select a priority carefully.'), + ) + def save(self): """ Writes and returns a Ticket() object - """ + q = Queue.objects.get(id=int(self.cleaned_data['queue'])) - - t = Ticket( title = self.cleaned_data['title'], - submitter_email = self.cleaned_data['submitter_email'], - created = datetime.now(), - status = Ticket.OPEN_STATUS, - queue = q, - description = self.cleaned_data['body'], - priority = self.cleaned_data['priority'], - ) - + + t = Ticket( + title = self.cleaned_data['title'], + submitter_email = self.cleaned_data['submitter_email'], + created = datetime.now(), + status = Ticket.OPEN_STATUS, + queue = q, + description = self.cleaned_data['body'], + priority = self.cleaned_data['priority'], + ) + t.save() - f = FollowUp( ticket = t, - title = _('Ticket Opened Via Web'), - date = datetime.now(), - public = True, - comment = self.cleaned_data['body'], - ) + f = FollowUp( + ticket = t, + title = _('Ticket Opened Via Web'), + date = datetime.now(), + public = True, + comment = self.cleaned_data['body'], + ) f.save() - + context = { 'ticket': t, 'queue': q, } - from helpdesk.lib import send_templated_mail - send_templated_mail('newticket_submitter', context, recipients=t.submitter_email, sender=q.from_address, fail_silently=True) + send_templated_mail( + 'newticket_submitter', + context, + recipients=t.submitter_email, + sender=q.from_address, + fail_silently=True, + ) if q.new_ticket_cc: - send_templated_mail('newticket_cc', context, recipients=q.new_ticket_cc, sender=q.from_address, fail_silently=True) - + send_templated_mail( + 'newticket_cc', + context, + recipients=q.new_ticket_cc, + sender=q.from_address, + fail_silently=True, + ) + if q.updated_ticket_cc and q.updated_ticket_cc != q.new_ticket_cc: - send_templated_mail('newticket_cc', context, recipients=q.updated_ticket_cc, sender=q.from_address, fail_silently=True) + send_templated_mail( + 'newticket_cc', + context, + recipients=q.updated_ticket_cc, + sender=q.from_address, + fail_silently=True, + ) return t + diff --git a/lib.py b/lib.py index 33f7d3ae..57b865bf 100644 --- a/lib.py +++ b/lib.py @@ -1,15 +1,46 @@ -""" .. +""" Jutda Helpdesk - A Django powered ticket tracker for small enterprise. (c) Copyright 2008 Jutda. All Rights Reserved. See LICENSE for details. lib.py - Common functions (eg multipart e-mail) """ + +chart_colours = ('80C65A', '990066', 'FF9900', '3399CC', 'BBCCED', '3399CC', 'FFCC33') + + def send_templated_mail(template_name, email_context, recipients, sender=None, bcc=None, fail_silently=False, files=None): - from helpdesk.models import EmailTemplate + """ + send_templated_mail() is a warpper around Django's e-mail routines that + allows us to easily send multipart (text/plain & text/html) e-mails using + templates that are stored in the database. This lets the admin provide + both a text and a HTML template for each message. + + template_name is the slug of the template to use for this message (see + models.EmailTemplate) + + email_context is a dictionary to be used when rendering the template + + recipients can be either a string, eg 'a@b.com', or a list of strings. + + sender should contain a string, eg 'My Site '. If you leave it + blank, it'll use settings.DEFAULT_FROM_EMAIL as a fallback. + + bcc is an optional list of addresses that will receive this message as a + blind carbon copy. + + fail_silently is passed to Django's mail routine. Set to 'True' to ignore + any errors at send time. + + files can be a list of file paths to be attached, or it can be left blank. + eg ('/tmp/file1.txt', '/tmp/image.png') + + """ + from django.conf import settings from django.core.mail import EmailMultiAlternatives from django.template import loader, Context - from django.conf import settings + + from helpdesk.models import EmailTemplate t = EmailTemplate.objects.get(template_name__iexact=template_name) @@ -17,15 +48,27 @@ def send_templated_mail(template_name, email_context, recipients, sender=None, b sender = settings.DEFAULT_FROM_EMAIL context = Context(email_context) - - text_part = loader.get_template_from_string("%s{%% include 'helpdesk/email_text_footer.txt' %%}" % t.plain_text).render(context) - html_part = loader.get_template_from_string("{%% extends 'helpdesk/email_html_base.html' %%}{%% block title %%}%s{%% endblock %%}{%% block content %%}%s{%% endblock %%}" % (t.heading, t.html)).render(context) - subject_part = loader.get_template_from_string("{{ ticket.ticket }} {{ ticket.title }} %s" % t.subject).render(context) + + text_part = loader.get_template_from_string( + "%s{%% include 'helpdesk/email_text_footer.txt' %%}" % t.plain_text + ).render(context) + + html_part = loader.get_template_from_string( + "{%% extends 'helpdesk/email_html_base.html' %%}{%% block title %%}%s{%% endblock %%}{%% block content %%}%s{%% endblock %%}" % (t.heading, t.html) + ).render(context) + + subject_part = loader.get_template_from_string( + "{{ ticket.ticket }} {{ ticket.title }} %s" % t.subject + ).render(context) if type(recipients) != list: recipients = [recipients,] - msg = EmailMultiAlternatives(subject_part, text_part, sender, recipients, bcc=bcc) + msg = EmailMultiAlternatives( subject_part, + text_part, + sender, + recipients, + bcc=bcc) msg.attach_alternative(html_part, "text/html") if files: @@ -34,18 +77,18 @@ def send_templated_mail(template_name, email_context, recipients, sender=None, b for file in files: msg.attach_file(file) - + return msg.send(fail_silently) - def send_multipart_mail(template_name, email_context, subject, recipients, sender=None, bcc=None, fail_silently=False, files=None): """ - This function will send a multi-part e-mail with both HTML and - Text parts. + This function will send a multi-part e-mail with both HTML and + Text parts. Note we don't use this any more; wsee send_templated_mail + instead. - template_name must NOT contain an extension. Both HTML (.html) and TEXT - (.txt) versions must exist, eg 'emails/public_submit' will use both + template_name must NOT contain an extension. Both HTML (.html) and TEXT + (.txt) versions must exist, eg 'emails/public_submit' will use both public_submit.html and public_submit.txt. email_context should be a plain python dictionary. It is applied against @@ -57,7 +100,7 @@ def send_multipart_mail(template_name, email_context, subject, recipients, sende recipients can be either a string, eg 'a@b.com' or a list, eg: ['a@b.com', 'c@d.com']. Type conversion is done if needed. - sender can be an e-mail, 'Name ' or None. If unspecified, the + sender can be an e-mail, 'Name ' or None. If unspecified, the DEFAULT_FROM_EMAIL will be used. Originally posted on my blog at http://www.rossp.org/ @@ -70,7 +113,7 @@ def send_multipart_mail(template_name, email_context, subject, recipients, sende sender = settings.DEFAULT_FROM_EMAIL context = Context(email_context) - + text_part = loader.get_template('%s.txt' % template_name).render(context) html_part = loader.get_template('%s.html' % template_name).render(context) subject_part = loader.get_template_from_string(subject).render(context) @@ -87,13 +130,16 @@ def send_multipart_mail(template_name, email_context, subject, recipients, sende for file in files: msg.attach_file(file) - + return msg.send(fail_silently) def normalise_data(data, to=100): """ - Used for normalising data prior to graphing with Google charting API + Used for normalising data prior to graphing with Google charting API. EG: + + [1, 4, 10] becomes [10, 40, 100] + [36, 54, 240] becomes [15, 23, 100] """ max_value = max(data) if max_value > to: @@ -103,7 +149,6 @@ def normalise_data(data, to=100): data = new_data return data -chart_colours = ('80C65A', '990066', 'FF9900', '3399CC', 'BBCCED', '3399CC', 'FFCC33') def line_chart(data): """ @@ -138,7 +183,7 @@ def line_chart(data): first = True for row in range(rows): # Set maximum data ranges to '0:x' where 'x' is the maximum number in use. - if not first: + if not first: chart_url += ',' chart_url += '0,%s' % max first = False @@ -149,6 +194,7 @@ def line_chart(data): return chart_url + def bar_chart(data): """ 'data' is a list of lists making a table. @@ -185,12 +231,16 @@ def bar_chart(data): return chart_url + def query_to_dict(results, descriptions): - """ Replacement method for cursor.dictfetchall() as that method no longer + """ + 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. """ + + 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 = {} diff --git a/locale/en/LC_MESSAGES/django.mo b/locale/en/LC_MESSAGES/django.mo index 145c3fa0..88746d04 100644 Binary files a/locale/en/LC_MESSAGES/django.mo and b/locale/en/LC_MESSAGES/django.mo differ diff --git a/locale/en/LC_MESSAGES/django.po b/locale/en/LC_MESSAGES/django.po index 78a0e32e..a48d0b2f 100644 --- a/locale/en/LC_MESSAGES/django.po +++ b/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2008-08-13 23:32+0000\n" +"POT-Creation-Date: 2008-08-19 07:01+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -16,7 +16,7 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: forms.py:17 forms.py:98 templates/helpdesk/dashboard.html:10 +#: forms.py:21 forms.py:147 templates/helpdesk/dashboard.html:10 #: templates/helpdesk/dashboard.html:26 templates/helpdesk/dashboard.html:41 #: templates/helpdesk/rss_list.html:23 templates/helpdesk/ticket_list.html:14 #: templates/helpdesk/ticket_list.html:25 @@ -24,520 +24,564 @@ msgstr "" msgid "Queue" msgstr "" -#: forms.py:21 +#: forms.py:30 msgid "Summary of the problem" msgstr "" -#: forms.py:24 +#: forms.py:35 msgid "Submitter E-Mail Address" msgstr "" -#: forms.py:25 +#: forms.py:36 msgid "" "This e-mail address will receive copies of all public updates to this ticket." msgstr "" -#: forms.py:28 +#: forms.py:42 msgid "Description of Issue" msgstr "" -#: forms.py:31 +#: forms.py:49 msgid "Case owner" msgstr "" -#: forms.py:32 +#: forms.py:50 msgid "" "If you select an owner other than yourself, they'll be e-mailed details of " "this ticket immediately." msgstr "" -#: forms.py:37 models.py:116 scripts/escalate_tickets.py:76 +#: forms.py:58 models.py:289 management/commands/escalate_tickets.py:152 #: templates/helpdesk/public_view_ticket.html:29 #: templates/helpdesk/ticket.html:67 templates/helpdesk/ticket.html.py:159 -#: templates/helpdesk/ticket_list.html:27 views/staff.py:146 +#: templates/helpdesk/ticket_list.html:27 views/staff.py:182 msgid "Priority" msgstr "" -#: forms.py:38 +#: forms.py:59 msgid "Please select a priority carefully. If unsure, leave it as '3'." msgstr "" -#: forms.py:65 +#: forms.py:88 msgid "Ticket Opened" msgstr "" -#: forms.py:72 +#: forms.py:95 #, python-format msgid "Ticket Opened & Assigned to %(name)s" msgstr "" -#: forms.py:102 +#: forms.py:156 msgid "Summary of your query" msgstr "" -#: forms.py:105 +#: forms.py:161 msgid "Your E-Mail Address" msgstr "" -#: forms.py:106 +#: forms.py:162 msgid "We will e-mail you when your ticket is updated." msgstr "" -#: forms.py:109 +#: forms.py:167 msgid "Description of your issue" msgstr "" -#: forms.py:110 +#: forms.py:169 msgid "" "Please be as descriptive as possible, including any details we may need to " "address your query." msgstr "" -#: forms.py:115 +#: forms.py:177 msgid "Urgency" msgstr "" -#: forms.py:116 +#: forms.py:178 msgid "Please select a priority carefully." msgstr "" -#: forms.py:137 +#: forms.py:202 msgid "Ticket Opened Via Web" msgstr "" -#: models.py:28 models.py:103 models.py:216 models.py:360 models.py:380 +#: models.py:29 models.py:227 models.py:434 models.py:728 models.py:759 #: templates/helpdesk/dashboard.html:26 templates/helpdesk/dashboard.html:41 #: templates/helpdesk/ticket.html:153 templates/helpdesk/ticket_list.html:24 -#: templates/helpdesk/ticket_list.html:59 views/staff.py:141 +#: templates/helpdesk/ticket_list.html:59 views/staff.py:172 msgid "Title" msgstr "" -#: models.py:29 models.py:361 +#: models.py:34 models.py:733 msgid "Slug" msgstr "" -#: models.py:29 +#: models.py:35 msgid "" "This slug is used when building ticket ID's. Once set, try not to change it " "or e-mailing may get messy." msgstr "" -#: models.py:30 +#: models.py:40 msgid "E-Mail Address" msgstr "" -#: models.py:30 +#: models.py:43 msgid "" "All outgoing e-mails for this queue will use this e-mail address. If you use " "IMAP or POP3, this should be the e-mail address for that mailbox." msgstr "" -#: models.py:31 +#: models.py:49 msgid "Allow Public Submission?" msgstr "" -#: models.py:31 +#: models.py:52 msgid "Should this queue be listed on the public submission form?" msgstr "" -#: models.py:32 +#: models.py:57 msgid "Allow E-Mail Submission?" msgstr "" -#: models.py:32 +#: models.py:60 msgid "Do you want to poll the e-mail box below for new tickets?" msgstr "" -#: models.py:33 +#: models.py:65 msgid "Escalation Days" msgstr "" -#: models.py:33 +#: models.py:68 msgid "" "For tickets which are not held, how often do you wish to increase their " "priority? Set to 0 for no escalation." msgstr "" -#: models.py:42 +#: models.py:73 msgid "New Ticket CC Address" msgstr "" -#: models.py:42 +#: models.py:76 msgid "" "If an e-mail address is entered here, then it will receive notification of " "all new tickets created for this queue" msgstr "" -#: models.py:43 +#: models.py:81 msgid "Updated Ticket CC Address" msgstr "" -#: models.py:43 +#: models.py:84 msgid "" "If an e-mail address is entered here, then it will receive notification of " "all activity (new tickets, closed tickets, updates, reassignments, etc) for " "this queue" msgstr "" -#: models.py:45 +#: models.py:90 msgid "E-Mail Box Type" msgstr "" -#: models.py:45 +#: models.py:92 msgid "POP 3" msgstr "" -#: models.py:45 +#: models.py:92 msgid "IMAP" msgstr "" -#: models.py:45 +#: models.py:95 msgid "" "E-Mail server type for creating tickets automatically from a mailbox - both " "POP3 and IMAP are supported." msgstr "" -#: models.py:46 +#: models.py:100 msgid "E-Mail Hostname" msgstr "" -#: models.py:46 +#: models.py:104 msgid "" "Your e-mail server address - either the domain name or IP address. May be " "\"localhost\"." msgstr "" -#: models.py:47 +#: models.py:109 msgid "E-Mail Port" msgstr "" -#: models.py:47 +#: models.py:112 msgid "" "Port number to use for accessing e-mail. Default for POP3 is \"110\", and " "for IMAP is \"143\". This may differ on some servers. Leave it blank to use " "the defaults." msgstr "" -#: models.py:48 +#: models.py:118 msgid "E-Mail Username" msgstr "" -#: models.py:48 +#: models.py:122 msgid "Username for accessing this mailbox." msgstr "" -#: models.py:49 +#: models.py:126 msgid "E-Mail Password" msgstr "" -#: models.py:49 +#: models.py:130 msgid "Password for the above username" msgstr "" -#: models.py:50 +#: models.py:134 msgid "IMAP Folder" msgstr "" -#: models.py:50 +#: models.py:138 msgid "" "If using IMAP, what folder do you wish to fetch messages from? This allows " "you to use one IMAP account for multiple queues, by filtering messages on " "your IMAP server into separate folders. Default: INBOX." msgstr "" -#: models.py:51 +#: models.py:145 msgid "E-Mail Check Interval" msgstr "" -#: models.py:51 +#: models.py:146 msgid "How often do you wish to check this mailbox? (in Minutes)" msgstr "" -#: models.py:89 templates/helpdesk/dashboard.html:10 +#: models.py:212 templates/helpdesk/dashboard.html:10 #: templates/helpdesk/ticket.html:123 msgid "Open" msgstr "" -#: models.py:90 templates/helpdesk/ticket.html:128 +#: models.py:213 templates/helpdesk/ticket.html:128 #: templates/helpdesk/ticket.html.py:133 templates/helpdesk/ticket.html:138 msgid "Reopened" msgstr "" -#: models.py:91 templates/helpdesk/dashboard.html:10 +#: models.py:214 templates/helpdesk/dashboard.html:10 #: templates/helpdesk/ticket.html:124 templates/helpdesk/ticket.html.py:129 #: templates/helpdesk/ticket.html:134 msgid "Resolved" msgstr "" -#: models.py:92 templates/helpdesk/ticket.html:125 +#: models.py:215 templates/helpdesk/ticket.html:125 #: templates/helpdesk/ticket.html.py:130 templates/helpdesk/ticket.html:135 #: templates/helpdesk/ticket.html.py:139 msgid "Closed" msgstr "" -#: models.py:96 +#: models.py:219 msgid "1. Critical" msgstr "" -#: models.py:97 +#: models.py:220 msgid "2. High" msgstr "" -#: models.py:98 +#: models.py:221 msgid "3. Normal" msgstr "" -#: models.py:99 +#: models.py:222 msgid "4. Low" msgstr "" -#: models.py:100 +#: models.py:223 msgid "5. Very Low" msgstr "" -#: models.py:105 templates/helpdesk/dashboard.html:41 +#: models.py:234 templates/helpdesk/dashboard.html:41 #: templates/helpdesk/ticket_list.html:23 #: templates/helpdesk/ticket_list.html:59 msgid "Created" msgstr "" -#: models.py:106 +#: models.py:236 +msgid "Date this ticket was first created" +msgstr "" + +#: models.py:240 msgid "Modified" msgstr "" -#: models.py:107 templates/helpdesk/public_view_ticket.html:24 +#: models.py:242 +msgid "Date this ticket was most recently changed." +msgstr "" + +#: models.py:246 templates/helpdesk/public_view_ticket.html:24 #: templates/helpdesk/ticket.html:62 msgid "Submitter E-Mail" msgstr "" -#: models.py:107 +#: models.py:249 msgid "" "The submitter will receive an email for all public follow-ups left for this " "task." msgstr "" -#: models.py:109 templates/helpdesk/dashboard.html:26 +#: models.py:261 templates/helpdesk/dashboard.html:26 #: templates/helpdesk/ticket_list.html:15 #: templates/helpdesk/ticket_list.html:26 #: templates/helpdesk/ticket_list.html:59 msgid "Status" msgstr "" -#: models.py:111 +#: models.py:267 msgid "On Hold" msgstr "" -#: models.py:113 models.py:362 templates/helpdesk/public_view_ticket.html:34 +#: models.py:270 +msgid "If a ticket is on hold, it will not automatically be escalated." +msgstr "" + +#: models.py:275 models.py:737 templates/helpdesk/public_view_ticket.html:34 #: templates/helpdesk/ticket.html:72 msgid "Description" msgstr "" -#: models.py:114 templates/helpdesk/public_view_ticket.html:41 +#: models.py:278 +msgid "The content of the customers query." +msgstr "" + +#: models.py:282 templates/helpdesk/public_view_ticket.html:41 #: templates/helpdesk/ticket.html:79 msgid "Resolution" msgstr "" -#: models.py:125 templates/helpdesk/ticket.html:58 views/feeds.py:55 -#: views/feeds.py:77 views/feeds.py:120 views/staff.py:120 +#: models.py:285 +msgid "The resolution provided to the customer by our staff." +msgstr "" + +#: models.py:293 +msgid "1 = Highest Priority, 5 = Low Priority" +msgstr "" + +#: models.py:300 +msgid "" +"The date this ticket was last escalated - updated automatically by " +"management/commands/escalate_tickets.py." +msgstr "" + +#: models.py:309 templates/helpdesk/ticket.html:58 views/feeds.py:91 +#: views/feeds.py:117 views/feeds.py:171 views/staff.py:149 msgid "Unassigned" msgstr "" -#: models.py:156 +#: models.py:348 msgid " - On Hold" msgstr "" -#: models.py:215 models.py:330 +#: models.py:430 models.py:662 msgid "Date" msgstr "" -#: models.py:217 views/staff.py:134 +#: models.py:441 views/staff.py:163 msgid "Comment" msgstr "" -#: models.py:218 +#: models.py:447 msgid "Public" msgstr "" -#: models.py:221 templates/helpdesk/ticket.html:121 +#: models.py:450 +msgid "" +"Public tickets are viewable by the submitter and all staff, but non-public " +"tickets can only be seen by staff." +msgstr "" + +#: models.py:461 templates/helpdesk/ticket.html:121 msgid "New Status" msgstr "" -#: models.py:246 +#: models.py:465 +msgid "If the status was changed, what was it changed to?" +msgstr "" + +#: models.py:496 msgid "Field" msgstr "" -#: models.py:247 +#: models.py:501 msgid "Old Value" msgstr "" -#: models.py:248 +#: models.py:507 msgid "New Value" msgstr "" -#: models.py:253 +#: models.py:515 msgid "removed" msgstr "" -#: models.py:255 +#: models.py:517 #, python-format msgid "set to %s" msgstr "" -#: models.py:257 +#: models.py:519 #, python-format msgid "changed from \"%(old_value)s\" to \"%(new_value)s\"" msgstr "" -#: models.py:289 +#: models.py:562 msgid "File" msgstr "" -#: models.py:290 +#: models.py:567 msgid "Filename" msgstr "" -#: models.py:291 +#: models.py:572 msgid "MIME Type" msgstr "" -#: models.py:292 +#: models.py:577 msgid "Size" msgstr "" -#: models.py:292 +#: models.py:578 msgid "Size of this file in bytes" msgstr "" -#: models.py:315 +#: models.py:611 msgid "" "Leave blank to allow this reply to be used for all queues, or select those " "queues you wish to limit this reply to." msgstr "" -#: models.py:316 models.py:328 +#: models.py:616 models.py:657 msgid "Name" msgstr "" -#: models.py:316 +#: models.py:618 msgid "" "Only used to assist users with selecting a reply - not shown to the user." msgstr "" -#: models.py:317 +#: models.py:623 msgid "Body" msgstr "" -#: models.py:317 +#: models.py:624 msgid "" "Context available: {{ ticket }} - ticket object (eg {{ ticket.title }}); " "{{ queue }} - The queue; and {{ user }} - the current user." msgstr "" -#: models.py:326 +#: models.py:651 msgid "" "Leave blank for this exclusion to be applied to all queues, or select those " "queues you wish to exclude with this entry." msgstr "" -#: models.py:330 +#: models.py:663 msgid "Date on which escalation should not happen" msgstr "" -#: models.py:341 +#: models.py:680 msgid "Template Name" msgstr "" -#: models.py:343 +#: models.py:686 msgid "Subject" msgstr "" -#: models.py:343 +#: models.py:688 msgid "" "This will be prefixed with \"[ticket.ticket] ticket.title\". We recommend " "something simple such as \"(Updated\") or \"(Closed)\" - the same context is " "available as in plain_text, below." msgstr "" -#: models.py:344 +#: models.py:694 msgid "Heading" msgstr "" -#: models.py:344 +#: models.py:696 msgid "" "In HTML e-mails, this will be the heading at the top of the email - the same " "context is available as in plain_text, below." msgstr "" -#: models.py:345 +#: models.py:702 msgid "Plain Text" msgstr "" -#: models.py:345 +#: models.py:703 msgid "" "The context available to you includes {{ ticket }}, {{ queue }}, and " "depending on the time of the call: {{ resolution }} or {{ comment }}." msgstr "" -#: models.py:346 +#: models.py:709 msgid "HTML" msgstr "" -#: models.py:346 +#: models.py:710 msgid "The same context is available here as in plain_text, above." msgstr "" -#: models.py:381 +#: models.py:764 msgid "Question" msgstr "" -#: models.py:382 +#: models.py:768 msgid "Answer" msgstr "" -#: models.py:384 +#: models.py:772 msgid "Votes" msgstr "" -#: models.py:384 +#: models.py:773 msgid "Total number of votes cast for this item" msgstr "" -#: models.py:385 +#: models.py:777 msgid "Positive Votes" msgstr "" -#: models.py:385 +#: models.py:778 msgid "Number of votes for this item which were POSITIVE." msgstr "" -#: models.py:387 +#: models.py:782 msgid "Last Updated" msgstr "" -#: models.py:397 +#: models.py:783 +msgid "The date on which this question was most recently changed." +msgstr "" + +#: models.py:795 msgid "Unrated" msgstr "" -#: scripts/escalate_tickets.py:70 +#: management/commands/escalate_tickets.py:146 #, python-format msgid "Ticket escalated after %s days" msgstr "" -#: scripts/get_email.py:74 +#: management/commands/get_email.py:97 msgid "Created from e-mail" msgstr "" -#: scripts/get_email.py:77 +#: management/commands/get_email.py:100 msgid "Unknown Sender" msgstr "" -#: scripts/get_email.py:156 +#: management/commands/get_email.py:206 msgid " (Updated)" msgstr "" -#: scripts/get_email.py:166 +#: management/commands/get_email.py:228 #, python-format -msgid "E-Mail Received from %s" +msgid "E-Mail Received from %(sender_email)s" msgstr "" #: templates/helpdesk/base.html:4 @@ -806,7 +850,7 @@ msgstr "" #: templates/helpdesk/public_view_ticket.html:16 #, python-format -msgid "Queue: %(ticket.queue)s" +msgid "Queue: %(queue_name)s" msgstr "" #: templates/helpdesk/public_view_ticket.html:19 @@ -1087,75 +1131,75 @@ msgstr "" msgid "Password" msgstr "" -#: views/feeds.py:26 +#: views/feeds.py:35 #, python-format msgid "Helpdesk: Open Tickets in queue %(queue)s for %(username)s" msgstr "" -#: views/feeds.py:28 +#: views/feeds.py:40 #, python-format msgid "Helpdesk: Open Tickets for %(username)s" msgstr "" -#: views/feeds.py:32 +#: views/feeds.py:46 #, python-format msgid "Open and Reopened Tickets in queue %(queue)s for %(username)s" msgstr "" -#: views/feeds.py:34 +#: views/feeds.py:51 #, python-format msgid "Open and Reopened Tickets for %(username)s" msgstr "" -#: views/feeds.py:62 +#: views/feeds.py:98 msgid "Helpdesk: Unassigned Tickets" msgstr "" -#: views/feeds.py:63 +#: views/feeds.py:99 msgid "Unassigned Open and Reopened tickets" msgstr "" -#: views/feeds.py:84 +#: views/feeds.py:124 msgid "Helpdesk: Recent Followups" msgstr "" -#: views/feeds.py:85 +#: views/feeds.py:125 msgid "" "Recent FollowUps, such as e-mail replies, comments, attachments and " "resolutions" msgstr "" -#: views/feeds.py:102 +#: views/feeds.py:142 #, python-format msgid "Helpdesk: Open Tickets in queue %(queue)s" msgstr "" -#: views/feeds.py:105 +#: views/feeds.py:147 #, python-format msgid "Open and Reopened Tickets in queue %(queue)s" msgstr "" -#: views/public.py:53 +#: views/public.py:58 msgid "Invalid ticket ID or e-mail address. Please try again." msgstr "" -#: views/staff.py:82 +#: views/staff.py:107 msgid "Accepted resolution and closed ticket" msgstr "" -#: views/staff.py:116 +#: views/staff.py:143 #, python-format msgid "Assigned to %(username)s" msgstr "" -#: views/staff.py:136 +#: views/staff.py:165 msgid "Updated" msgstr "" -#: views/staff.py:298 +#: views/staff.py:369 msgid "Ticket taken off hold" msgstr "" -#: views/staff.py:301 +#: views/staff.py:372 msgid "Ticket placed on hold" msgstr "" diff --git a/management/commands/create_escalation_exclusions.py b/management/commands/create_escalation_exclusions.py index e497e15a..3e83c7d1 100644 --- a/management/commands/create_escalation_exclusions.py +++ b/management/commands/create_escalation_exclusions.py @@ -8,13 +8,17 @@ scripts/create_escalation_exclusion.py - Easy way to routinely add particular days to the list of days on which no escalation should take place. """ + from datetime import datetime, timedelta, date -from django.db.models import Q -from helpdesk.models import EscalationExclusion, Queue -import sys, getopt +import getopt +from optparse import make_option +import sys from django.core.management.base import BaseCommand, CommandError -from optparse import make_option +from django.db.models import Q + +from helpdesk.models import EscalationExclusion, Queue + class Command(BaseCommand): def __init__(self): @@ -22,15 +26,15 @@ class Command(BaseCommand): self.option_list += ( make_option( - '--days', '-d', + '--days', '-d', help='Days of week (monday, tuesday, etc)'), make_option( - '--occurrences', '-o', + '--occurrences', '-o', type='int', default=1, help='Occurrences: How many weeks ahead to exclude this day'), make_option( - '--queues', '-q', + '--queues', '-q', help='Queues to include (default: all). Use queue slugs'), make_option( '--verbose', '-v', @@ -53,7 +57,7 @@ class Command(BaseCommand): if not occurrences: occurrences = 1 if not (days and occurrences): raise CommandError('One or more occurrences must be specified.') - + if queue_slugs is not None: queue_set = queue_slugs.split(',') for queue in queue_set: @@ -65,6 +69,7 @@ class Command(BaseCommand): create_exclusions(days=days, occurrences=occurrences, verbose=verbose, queues=queues) + day_names = { 'monday': 0, 'tuesday': 1, @@ -75,6 +80,7 @@ day_names = { 'sunday': 6, } + def create_exclusions(days, occurrences, verbose, queues): days = days.split(',') for day in days: @@ -87,10 +93,10 @@ def create_exclusions(days, occurrences, verbose, queues): if EscalationExclusion.objects.filter(date=workdate).count() == 0: esc = EscalationExclusion(name='Auto Exclusion for %s' % day_name, date=workdate) esc.save() - + if verbose: print "Created exclusion for %s %s" % (day_name, workdate) - + for q in queues: esc.queues.add(q) if verbose: @@ -107,13 +113,15 @@ def usage(): print " --queues, -q: Queues to include (default: all). Use queue slugs" print " --verbose, -v: Display a list of dates excluded" + if __name__ == '__main__': + # This script can be run from the command-line or via Django's manage.py. try: opts, args = getopt.getopt(sys.argv[1:], 'd:o:q:v', ['days=', 'occurrences=', 'verbose', 'queues=']) except getopt.GetoptError: usage() sys.exit(2) - + days = None occurrences = None verbose = False @@ -134,7 +142,7 @@ if __name__ == '__main__': if not (days and occurrences): usage() sys.exit(2) - + if queue_slugs is not None: queue_set = queue_slugs.split(',') for queue in queue_set: diff --git a/management/commands/escalate_tickets.py b/management/commands/escalate_tickets.py index 885926c6..b4ef8221 100644 --- a/management/commands/escalate_tickets.py +++ b/management/commands/escalate_tickets.py @@ -4,20 +4,22 @@ Jutda Helpdesk - A Django powered ticket tracker for small enterprise. (c) Copyright 2008 Jutda. All Rights Reserved. See LICENSE for details. -scripts/escalate_tickets.py - Easy way to escalate tickets based on their age, +scripts/escalate_tickets.py - Easy way to escalate tickets based on their age, designed to be run from Cron or similar. """ -from datetime import datetime, timedelta, date -import sys, getopt +from datetime import datetime, timedelta, date +import getopt +from optparse import make_option +import sys + +from django.core.management.base import BaseCommand from django.db.models import Q from django.utils.translation import ugettext as _ from helpdesk.models import Queue, Ticket, FollowUp, EscalationExclusion, TicketChange from helpdesk.lib import send_templated_mail - -from django.core.management.base import BaseCommand -from optparse import make_option + class Command(BaseCommand): def __init__(self): @@ -25,7 +27,7 @@ class Command(BaseCommand): self.option_list += ( make_option( - '--queues', '-q', + '--queues', '-q', help='Queues to include (default: all). Use queue slugs'), make_option( '--verbose', '-v', @@ -33,17 +35,17 @@ class Command(BaseCommand): default=False, help='Display a list of dates excluded'), ) - + def handle(self, *args, **options): verbose = False queue_slugs = None queues = [] - + if options['verbose']: verbose = True if options['queues']: queue_slugs = options['queues'] - + if queue_slugs is not None: queue_set = queue_slugs.split(',') for queue in queue_set: @@ -55,12 +57,13 @@ class Command(BaseCommand): escalate_tickets(queues=queues, verbose=verbose) + def escalate_tickets(queues, verbose): """ Only include queues with escalation configured """ queryset = Queue.objects.filter(escalate_days__isnull=False).exclude(escalate_days=0) if queues: queryset = queryset.filter(slug__in=queues) - + for q in queryset: last = date.today() - timedelta(days=q.escalate_days) today = date.today() @@ -78,28 +81,62 @@ def escalate_tickets(queues, verbose): if verbose: print "Processing: %s" % q - - for t in q.ticket_set.filter(Q(status=Ticket.OPEN_STATUS) | Q(status=Ticket.REOPENED_STATUS)).exclude(priority=1).filter(Q(on_hold__isnull=True) | Q(on_hold=False)).filter(Q(last_escalation__lte=req_last_escl_date) | Q(last_escalation__isnull=True)): + + for t in q.ticket_set.filter( + Q(status=Ticket.OPEN_STATUS) + | Q(status=Ticket.REOPENED_STATUS) + ).exclude( + priority=1 + ).filter( + Q(on_hold__isnull=True) + | Q(on_hold=False) + ).filter( + Q(last_escalation__lte=req_last_escl_date) + | Q(last_escalation__isnull=True) + ): + t.last_escalation = datetime.now() t.priority -= 1 t.save() - + context = { 'ticket': t, 'queue': q, } if t.submitter_email: - send_templated_mail('escalated_submitter', context, recipients=t.submitter_email, sender=t.queue.from_address, fail_silently=True) - + send_templated_mail( + 'escalated_submitter', + context, + recipients=t.submitter_email, + sender=t.queue.from_address, + fail_silently=True, + ) + if t.queue.updated_ticket_cc: - send_templated_mail('escalated_cc', context, recipients=t.queue.updated_ticket_cc, sender=t.queue.from_address, fail_silently=True) - + send_templated_mail( + 'escalated_cc', + context, + recipients=t.queue.updated_ticket_cc, + sender=t.queue.from_address, + fail_silently=True, + ) + if t.assigned_to: - send_templated_mail('escalated_owner', context, recipients=t.assigned_to.email, sender=t.queue.from_address, fail_silently=True) + send_templated_mail( + 'escalated_owner', + context, + recipients=t.assigned_to.email, + sender=t.queue.from_address, + fail_silently=True, + ) if verbose: - print " - Esclating %s from %s>%s" % (t.ticket, t.priority+1, t.priority) + print " - Esclating %s from %s>%s" % ( + t.ticket, + t.priority+1, + t.priority + ) f = FollowUp( ticket = t, @@ -118,11 +155,13 @@ def escalate_tickets(queues, verbose): ) tc.save() + def usage(): print "Options:" print " --queues, -q: Queues to include (default: all). Use queue slugs" print " --verbose, -v: Display a list of dates excluded" + if __name__ == '__main__': try: opts, args = getopt.getopt(sys.argv[1:], 'q:v', ['queues=', 'verbose']) @@ -133,13 +172,13 @@ if __name__ == '__main__': verbose = False queue_slugs = None queues = [] - + for o, a in opts: if o in ('-v', '--verbose'): verbose = True if o in ('-q', '--queues'): queue_slugs = a - + if queue_slugs is not None: queue_set = queue_slugs.split(',') for queue in queue_set: diff --git a/management/commands/get_email.py b/management/commands/get_email.py index 5e5957b2..671f44ce 100644 --- a/management/commands/get_email.py +++ b/management/commands/get_email.py @@ -5,8 +5,8 @@ Jutda Helpdesk - A Django powered ticket tracker for small enterprise. (c) Copyright 2008 Jutda. All Rights Reserved. See LICENSE for details. scripts/get_email.py - Designed to be run from cron, this script checks the - POP and IMAP boxes defined for the queues within a - helpdesk, creating tickets from the new messages (or + POP and IMAP boxes defined for the queues within a + helpdesk, creating tickets from the new messages (or adding to existing tickets if needed) """ @@ -25,16 +25,27 @@ from django.utils.translation import ugettext as _ from helpdesk.lib import send_templated_mail from helpdesk.models import Queue, Ticket, FollowUp, Attachment + class Command(BaseCommand): def handle(self, *args, **options): process_email() -def process_email(): - for q in Queue.objects.filter(email_box_type__isnull=False, allow_email_submission=True): - if not q.email_box_last_check: q.email_box_last_check = datetime.now()-timedelta(minutes=30) - if not q.email_box_interval: q.email_box_interval = 0 - if (q.email_box_last_check + timedelta(minutes=q.email_box_interval)) > datetime.now(): +def process_email(): + for q in Queue.objects.filter( + email_box_type__isnull=False, + allow_email_submission=True): + + if not q.email_box_last_check: + q.email_box_last_check = datetime.now()-timedelta(minutes=30) + + if not q.email_box_interval: + q.email_box_interval = 0 + + + queue_time_delta = timedelta(minutes=q.email_box_interval) + + if (q.email_box_last_check + queue_time_delta) > datetime.now(): continue process_queue(q) @@ -42,6 +53,7 @@ def process_email(): q.email_box_last_check = datetime.now() q.save() + def process_queue(q): print "Processing: %s" % q if q.email_box_type == 'pop3': @@ -55,16 +67,16 @@ def process_queue(q): for msg in messagesInfo: msgNum = msg.split(" ")[0] msgSize = msg.split(" ")[1] - + full_message = "\n".join(server.retr(msgNum)[1]) ticket_from_message(message=full_message, queue=q) - + server.dele(msgNum) server.quit() elif q.email_box_type == 'imap': if not q.email_box_port: q.email_box_port = 143 - + server = imaplib.IMAP4(q.email_box_host, q.email_box_port) server.login(q.email_box_user, q.email_box_pass) server.select(q.email_box_imap_folder) @@ -77,15 +89,16 @@ def process_queue(q): server.close() server.logout() + def ticket_from_message(message, queue): # 'message' must be an RFC822 formatted message. msg = message message = email.message_from_string(msg) subject = message.get('subject', _('Created from e-mail')) subject = subject.replace("Re: ", "").replace("Fw: ", "").strip() - + sender = message.get('from', _('Unknown Sender')) - + sender_email = parseaddr(sender)[1] if sender_email.startswith('postmaster'): sender_email = '' @@ -96,24 +109,31 @@ def ticket_from_message(message, queue): ticket = re.match(r"^\[(?P[A-Za-z0-9]+)-(?P\d+)\]", subject).group('id') else: ticket = None + counter = 0 files = [] + for part in message.walk(): if part.get_content_maintype() == 'multipart': continue - + name = part.get_param("name") - + if part.get_content_maintype() == 'text' and name == None: body = part.get_payload() else: if not name: ext = mimetypes.guess_extension(part.get_content_type()) name = "part-%i%s" % (counter, ext) - files.append({'filename': name, 'content': part.get_payload(decode=True), 'type': part.get_content_type()}) + + files.append({ + 'filename': name, + 'content': part.get_payload(decode=True), + 'type': part.get_content_type()}, + ) counter += 1 - + now = datetime.now() if ticket: @@ -130,7 +150,8 @@ def ticket_from_message(message, queue): high_priority_types = ('high', 'important', '1', 'urgent') - if smtp_priority in high_priority_types or smtp_importance in high_priority_types: + if smtp_priority in high_priority_types + or smtp_importance in high_priority_types: priority = 2 if ticket == None: @@ -145,7 +166,7 @@ def ticket_from_message(message, queue): t.save() new = True update = '' - + context = { 'ticket': t, 'queue': queue, @@ -154,26 +175,57 @@ def ticket_from_message(message, queue): if new: if sender_email: - send_templated_mail('newticket_submitter', context, recipients=sender_email, sender=queue.from_address, fail_silently=True) + send_templated_mail( + 'newticket_submitter', + context, + recipients=sender_email, + sender=queue.from_address, + fail_silently=True, + ) if queue.new_ticket_cc: - send_templated_mail('newticket_cc', context, recipients=queue.new_ticket_cc, sender=queue.from_address, fail_silently=True) - - if queue.updated_ticket_cc and queue.updated_ticket_cc != queue.new_ticket_cc: - send_templated_mail('newticket_cc', context, recipients=queue.updated_ticket_cc, sender=queue.from_address, fail_silently=True) + send_templated_mail( + 'newticket_cc', + context, + recipients=queue.new_ticket_cc, + sender=queue.from_address, + fail_silently=True, + ) + + if queue.updated_ticket_cc + and queue.updated_ticket_cc != queue.new_ticket_cc: + send_templated_mail( + 'newticket_cc', + context, + recipients=queue.updated_ticket_cc, + sender=queue.from_address, + fail_silently=True, + ) else: update = _(' (Updated)') if t.assigned_to: - send_templated_mail('updated_owner', context, recipients=t.assigned_to.email, sender=queue.from_address, fail_silently=True) + send_templated_mail( + 'updated_owner', + context, + recipients=t.assigned_to.email, + sender=queue.from_address, + fail_silently=True, + ) if queue.updated_ticket_cc: - send_templated_mail('updated_cc', context, recipients=queue.updated_ticket_cc, sender=queue.from_address, fail_silently=True) + send_templated_mail( + 'updated_cc', + context, + recipients=queue.updated_ticket_cc, + sender=queue.from_address, + fail_silently=True, + ) f = FollowUp( ticket = t, - title = _('E-Mail Received from %s' % sender_email), + title = _('E-Mail Received from %(sender_email)s' % {'sender_email': sender_email}), date = datetime.now(), public = True, comment = body, @@ -184,11 +236,17 @@ def ticket_from_message(message, queue): for file in files: filename = file['filename'].replace(' ', '_') - a = Attachment(followup=f, filename=filename, mime_type=file['type'], size=len(file['content'])) - #a.save_file_file(file['filename'], file['content']) + a = Attachment( + followup=f, + filename=filename, + mime_type=file['type'], + size=len(file['content']), + ) a.file.save(file['filename'], ContentFile(file['content'])) a.save() print " - %s" % file['filename'] - + + if __name__ == '__main__': process_email() + diff --git a/models.py b/models.py index a96d85d7..9fa3f93b 100644 --- a/models.py +++ b/models.py @@ -14,69 +14,192 @@ from django.db import models from django.conf import settings from django.utils.translation import ugettext_lazy as _ + class Queue(models.Model): """ - A queue is a collection of tickets into what would generally be business + A queue is a collection of tickets into what would generally be business areas or departments. - For example, a company may have a queue for each Product they provide, or + For example, a company may have a queue for each Product they provide, or a queue for each of Accounts, Pre-Sales, and Support. - TODO: Add e-mail inboxes (either using piped e-mail or IMAP/POP3) so we - can automatically get tickets via e-mail. """ - title = models.CharField(_('Title'), max_length=100) - slug = models.SlugField(_('Slug'), help_text=_('This slug is used when building ticket ID\'s. Once set, try not to change it or e-mailing may get messy.')) - email_address = models.EmailField(_('E-Mail Address'), blank=True, null=True, help_text=_('All outgoing e-mails for this queue will use this e-mail address. If you use IMAP or POP3, this should be the e-mail address for that mailbox.')) - allow_public_submission = models.BooleanField(_('Allow Public Submission?'), blank=True, null=True, help_text=_('Should this queue be listed on the public submission form?')) - allow_email_submission = models.BooleanField(_('Allow E-Mail Submission?'), blank=True, null=True, help_text=_('Do you want to poll the e-mail box below for new tickets?')) - escalate_days = models.IntegerField(_('Escalation Days'), blank=True, null=True, help_text=_('For tickets which are not held, how often do you wish to increase their priority? Set to 0 for no escalation.')) - def _from_address(self): - if not self.email_address: - return u'NO QUEUE EMAIL ADDRESS DEFINED <%s>' % settings.DEFAULT_FROM_EMAIL - else: - return u'%s <%s>' % (self.title, self.email_address) - from_address = property(_from_address) + title = models.CharField( + _('Title'), + max_length=100, + ) - new_ticket_cc = models.EmailField(_('New Ticket CC Address'), blank=True, null=True, help_text=_('If an e-mail address is entered here, then it will receive notification of all new tickets created for this queue')) - updated_ticket_cc = models.EmailField(_('Updated Ticket CC Address'), blank=True, null=True, help_text=_('If an e-mail address is entered here, then it will receive notification of all activity (new tickets, closed tickets, updates, reassignments, etc) for this queue')) + slug = models.SlugField( + _('Slug'), + help_text=_('This slug is used when building ticket ID\'s. Once set, ' + 'try not to change it or e-mailing may get messy.'), + ) - email_box_type = models.CharField(_('E-Mail Box Type'), max_length=5, choices=(('pop3', _('POP 3')),('imap', _('IMAP'))), blank=True, null=True, help_text=_('E-Mail server type for creating tickets automatically from a mailbox - both POP3 and IMAP are supported.')) - email_box_host = models.CharField(_('E-Mail Hostname'), max_length=200, blank=True, null=True, help_text=_('Your e-mail server address - either the domain name or IP address. May be "localhost".')) - email_box_port = models.IntegerField(_('E-Mail Port'), blank=True, null=True, help_text=_('Port number to use for accessing e-mail. Default for POP3 is "110", and for IMAP is "143". This may differ on some servers. Leave it blank to use the defaults.')) - email_box_user = models.CharField(_('E-Mail Username'), max_length=200, blank=True, null=True, help_text=_('Username for accessing this mailbox.')) - email_box_pass = models.CharField(_('E-Mail Password'), max_length=200, blank=True, null=True, help_text=_('Password for the above username')) - email_box_imap_folder = models.CharField(_('IMAP Folder'), max_length=100, blank=True, null=True, help_text=_('If using IMAP, what folder do you wish to fetch messages from? This allows you to use one IMAP account for multiple queues, by filtering messages on your IMAP server into separate folders. Default: INBOX.')) - email_box_interval = models.IntegerField(_('E-Mail Check Interval'), help_text=_('How often do you wish to check this mailbox? (in Minutes)'), blank=True, null=True, default='5') - email_box_last_check = models.DateTimeField(blank=True, null=True, editable=False) # Updated by the auto-pop3-and-imap-checker + email_address = models.EmailField( + _('E-Mail Address'), + blank=True, + null=True, + help_text=_('All outgoing e-mails for this queue will use this e-mail ' + 'address. If you use IMAP or POP3, this should be the e-mail ' + 'address for that mailbox.'), + ) + + allow_public_submission = models.BooleanField( + _('Allow Public Submission?'), + blank=True, + null=True, + help_text=_('Should this queue be listed on the public submission ' + 'form?'), + ) + + allow_email_submission = models.BooleanField( + _('Allow E-Mail Submission?'), + blank=True, + null=True, + help_text=_('Do you want to poll the e-mail box below for new ' + 'tickets?'), + ) + + escalate_days = models.IntegerField( + _('Escalation Days'), + blank=True, + null=True, + help_text=_('For tickets which are not held, how often do you wish to ' + 'increase their priority? Set to 0 for no escalation.'), + ) + + new_ticket_cc = models.EmailField( + _('New Ticket CC Address'), + blank=True, + null=True, + help_text=_('If an e-mail address is entered here, then it will ' + 'receive notification of all new tickets created for this queue'), + ) + + updated_ticket_cc = models.EmailField( + _('Updated Ticket CC Address'), + blank=True, + null=True, + help_text=_('If an e-mail address is entered here, then it will ' + 'receive notification of all activity (new tickets, closed ' + 'tickets, updates, reassignments, etc) for this queue'), + ) + + email_box_type = models.CharField( + _('E-Mail Box Type'), + max_length=5, + choices=(('pop3', _('POP 3')), ('imap', _('IMAP'))), + blank=True, + null=True, + help_text=_('E-Mail server type for creating tickets automatically ' + 'from a mailbox - both POP3 and IMAP are supported.'), + ) + + email_box_host = models.CharField( + _('E-Mail Hostname'), + max_length=200, + blank=True, + null=True, + help_text=_('Your e-mail server address - either the domain name or ' + 'IP address. May be "localhost".'), + ) + + email_box_port = models.IntegerField( + _('E-Mail Port'), + blank=True, + null=True, + help_text=_('Port number to use for accessing e-mail. Default for ' + 'POP3 is "110", and for IMAP is "143". This may differ on some ' + 'servers. Leave it blank to use the defaults.'), + ) + + email_box_user = models.CharField( + _('E-Mail Username'), + max_length=200, + blank=True, + null=True, + help_text=_('Username for accessing this mailbox.'), + ) + + email_box_pass = models.CharField( + _('E-Mail Password'), + max_length=200, + blank=True, + null=True, + help_text=_('Password for the above username'), + ) + + email_box_imap_folder = models.CharField( + _('IMAP Folder'), + max_length=100, + blank=True, + null=True, + help_text=_('If using IMAP, what folder do you wish to fetch messages ' + 'from? This allows you to use one IMAP account for multiple ' + 'queues, by filtering messages on your IMAP server into separate ' + 'folders. Default: INBOX.'), + ) + + email_box_interval = models.IntegerField( + _('E-Mail Check Interval'), + help_text=_('How often do you wish to check this mailbox? (in Minutes)'), + blank=True, + null=True, + default='5', + ) + + email_box_last_check = models.DateTimeField( + blank=True, + null=True, + editable=False, + # This is updated by management/commands/get_mail.py. + ) def __unicode__(self): return u"%s" % self.title class Meta: ordering = ('title',) - + + def _from_address(self): + """ + Short property to provide a sender address in SMTP format, + eg 'Name '. We do this so we can put a simple error message + in the sender name field, so hopefully the admin can see and fix it. + """ + if not self.email_address: + return u'NO QUEUE EMAIL ADDRESS DEFINED <%s>' % settings.DEFAULT_FROM_EMAIL + else: + return u'%s <%s>' % (self.title, self.email_address) + from_address = property(_from_address) + def save(self): if self.email_box_type == 'imap' and not self.email_box_imap_folder: self.email_box_imap_folder = 'INBOX' + + if not self.email_box_port: + if self.email_box_type == 'imap': + self.email_box_port = 143 + else: + self.email_box_port = 110 super(Queue, self).save() class Ticket(models.Model): """ - To allow a ticket to be entered as quickly as possible, only the - bare minimum fields are required. These basically allow us to - sort and manage the ticket. The user can always go back and + To allow a ticket to be entered as quickly as possible, only the + bare minimum fields are required. These basically allow us to + sort and manage the ticket. The user can always go back and enter more information later. - A good example of this is when a customer is on the phone, and + A good example of this is when a customer is on the phone, and you want to give them a ticket ID as quickly as possible. You can - enter some basic info, save the ticket, give the customer the ID + enter some basic info, save the ticket, give the customer the ID and get off the phone, then add in further detail at a later time (once the customer is not on the line). - Note that assigned_to is optional - unassigned tickets are displayed on + Note that assigned_to is optional - unassigned tickets are displayed on the dashboard to prompt users to take ownership of them. """ @@ -84,7 +207,7 @@ class Ticket(models.Model): REOPENED_STATUS = 2 RESOLVED_STATUS = 3 CLOSED_STATUS = 4 - + STATUS_CHOICES = ( (OPEN_STATUS, _('Open')), (REOPENED_STATUS, _('Reopened')), @@ -100,26 +223,87 @@ class Ticket(models.Model): (5, _('5. Very Low')), ) - title = models.CharField(_('Title'), max_length=200) + title = models.CharField( + _('Title'), + max_length=200, + ) + queue = models.ForeignKey(Queue) - created = models.DateTimeField(_('Created'), blank=True) - modified = models.DateTimeField(_('Modified'), blank=True) - submitter_email = models.EmailField(_('Submitter E-Mail'), blank=True, null=True, help_text=_('The submitter will receive an email for all public follow-ups left for this task.')) - assigned_to = models.ForeignKey(User, related_name='assigned_to', blank=True, null=True) - status = models.IntegerField(_('Status'), choices=STATUS_CHOICES, default=OPEN_STATUS) - on_hold = models.BooleanField(_('On Hold'), blank=True, null=True) + created = models.DateTimeField( + _('Created'), + blank=True, + help_text=_('Date this ticket was first created'), + ) - description = models.TextField(_('Description'), blank=True, null=True) - resolution = models.TextField(_('Resolution'), blank=True, null=True) + modified = models.DateTimeField( + _('Modified'), + blank=True, + help_text=_('Date this ticket was most recently changed.'), + ) - priority = models.IntegerField(_('Priority'), choices=PRIORITY_CHOICES, default=3, blank=3) - - last_escalation = models.DateTimeField(blank=True, null=True, editable=False) + submitter_email = models.EmailField( + _('Submitter E-Mail'), + blank=True, + null=True, + help_text=_('The submitter will receive an email for all public ' + 'follow-ups left for this task.'), + ) + + assigned_to = models.ForeignKey( + User, + related_name='assigned_to', + blank=True, + null=True, + ) + + status = models.IntegerField( + _('Status'), + choices=STATUS_CHOICES, + default=OPEN_STATUS, + ) + + on_hold = models.BooleanField( + _('On Hold'), + blank=True, + null=True, + help_text=_('If a ticket is on hold, it will not automatically be ' + 'escalated.'), + ) + + description = models.TextField( + _('Description'), + blank=True, + null=True, + help_text=_('The content of the customers query.'), + ) + + resolution = models.TextField( + _('Resolution'), + blank=True, + null=True, + help_text=_('The resolution provided to the customer by our staff.'), + ) + + priority = models.IntegerField( + _('Priority'), + choices=PRIORITY_CHOICES, + default=3, + blank=3, + help_text=_('1 = Highest Priority, 5 = Low Priority'), + ) + + last_escalation = models.DateTimeField( + blank=True, + null=True, + editable=False, + help_text=_('The date this ticket was last escalated - updated ' + 'automatically by management/commands/escalate_tickets.py.'), + ) 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 + """ 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 has a full name configured, we use that, otherwise their username. """ if not self.assigned_to: return _('Unassigned') @@ -131,44 +315,69 @@ class Ticket(models.Model): get_assigned_to = property(_get_assigned_to) def _get_ticket(self): - """ A user-friendly ticket ID, which is a combination of ticket ID - and queue slug. This is generally used in e-mails. """ + """ A user-friendly ticket ID, which is a combination of ticket ID + and queue slug. This is generally used in e-mail subjects. """ return u"[%s]" % (self.ticket_for_url) ticket = property(_get_ticket) def _get_ticket_for_url(self): + """ A URL-friendly ticket ID, used in links. """ return u"%s-%s" % (self.queue.slug, self.id) ticket_for_url = property(_get_ticket_for_url) def _get_priority_img(self): + """ Image-based representation of the priority """ from django.conf import settings return u"%s/helpdesk/priorities/priority%s.png" % (settings.MEDIA_URL, self.priority) get_priority_img = property(_get_priority_img) def _get_priority_span(self): + """ + A HTML providing a CSS_styled representation of the priority. + """ from django.utils.safestring import mark_safe return mark_safe(u"%s" % (self.priority, self.priority)) get_priority_span = property(_get_priority_span) def _get_status(self): + """ + Displays the ticket status, with an "On Hold" message if needed. + """ held_msg = '' if self.on_hold: held_msg = _(' - On Hold') return u'%s%s' % (self.get_status_display(), held_msg) get_status = property(_get_status) def _get_ticket_url(self): + """ + Returns a publicly-viewable URL for this ticket, used when giving + a URL to the submitter of a ticket. + """ from django.contrib.sites.models import Site from django.core.urlresolvers import reverse site = Site.objects.get_current() - return u"http://%s%s?ticket=%s&email=%s" % (site.domain, reverse('helpdesk_public_view'), self.ticket_for_url, self.submitter_email) + return u"http://%s%s?ticket=%s&email=%s" % ( + site.domain, + reverse('helpdesk_public_view'), + self.ticket_for_url, + self.submitter_email + ) ticket_url = property(_get_ticket_url) def _get_staff_url(self): + """ + Returns a staff-only URL for this ticket, used when giving a URL to + a staff member (in emails etc) + """ from django.contrib.sites.models import Site from django.core.urlresolvers import reverse site = Site.objects.get_current() - return u"http://%s%s" % (site.domain, reverse('helpdesk_view', args=[self.id])) + return u"http://%s%s" % ( + site.domain, + reverse('helpdesk_view', + args=[self.id]) + ) staff_url = property(_get_staff_url) class Meta: @@ -185,67 +394,120 @@ class Ticket(models.Model): if not self.id: # This is a new ticket as no ID yet exists. self.created = datetime.now() - + if not self.priority: self.priority = 3 - + self.modified = datetime.now() super(Ticket, self).save() + class FollowUpManager(models.Manager): def private_followups(self): return self.filter(public=False) - + def public_followups(self): return self.filter(public=True) + class FollowUp(models.Model): - """ A FollowUp is a comment and/or change to a ticket. We keep a simple - title, the comment entered by the user, and the new status of a ticket + """ + A FollowUp is a comment and/or change to a ticket. We keep a simple + title, the comment entered by the user, and the new status of a ticket to enable easy flagging of details on the view-ticket page. The title is automatically generated at save-time, based on what action the user took. - Tickets that aren't public are never shown to or e-mailed to the submitter, + Tickets that aren't public are never shown to or e-mailed to the submitter, although all staff can see them. """ + ticket = models.ForeignKey(Ticket) - date = models.DateTimeField(_('Date'), auto_now_add=True) - title = models.CharField(_('Title'), max_length=200, blank=True, null=True) - comment = models.TextField(_('Comment'), blank=True, null=True) - public = models.BooleanField(_('Public'), blank=True, null=True) - user = models.ForeignKey(User, blank=True, null=True) - - new_status = models.IntegerField(_('New Status'), choices=Ticket.STATUS_CHOICES, blank=True, null=True) + + date = models.DateTimeField( + _('Date'), + ) + + title = models.CharField( + _('Title'), + max_length=200, + blank=True, + null=True, + ) + + comment = models.TextField( + _('Comment'), + blank=True, + null=True, + ) + + public = models.BooleanField( + _('Public'), + blank=True, + null=True, + help_text=_('Public tickets are viewable by the submitter and all ' + 'staff, but non-public tickets can only be seen by staff.'), + ) + + user = models.ForeignKey( + User, + blank=True, + null=True, + ) + + new_status = models.IntegerField( + _('New Status'), + choices=Ticket.STATUS_CHOICES, + blank=True, + null=True, + help_text=_('If the status was changed, what was it changed to?'), + ) objects = FollowUpManager() class Meta: ordering = ['date'] - + def __unicode__(self): return u'%s' % self.title def get_absolute_url(self): return u"%s#followup%s" % (self.ticket.get_absolute_url(), self.id) - def save(self): t = self.ticket t.modified = datetime.now() + self.date = datetime.now() t.save() super(FollowUp, self).save() + class TicketChange(models.Model): - """ For each FollowUp, any changes to the parent ticket (eg Title, Priority, + """ + For each FollowUp, any changes to the parent ticket (eg Title, Priority, etc) are tracked here for display purposes. """ + followup = models.ForeignKey(FollowUp) - field = models.CharField(_('Field'), max_length=100) - old_value = models.TextField(_('Old Value'), blank=True, null=True) - new_value = models.TextField(_('New Value'), blank=True, null=True) + + field = models.CharField( + _('Field'), + max_length=100, + ) + + old_value = models.TextField( + _('Old Value'), + blank=True, + null=True, + ) + + new_value = models.TextField( + _('New Value'), + blank=True, + null=True, + ) def __unicode__(self): str = u'%s ' % field @@ -254,7 +516,10 @@ class TicketChange(models.Model): elif not old_value: str += _('set to %s' % new_value) else: - str += _('changed from "%(old_value)s" to "%(new_value)s"' % {'old_value': old_value, 'new_value': new_value}) + str += _('changed from "%(old_value)s" to "%(new_value)s"' % { + 'old_value': old_value, + 'new_value': new_value + }) return str @@ -263,7 +528,7 @@ class DynamicFileField(models.FileField): Allows model instance to specify upload_to dynamically. Model class should have a method like: - + def get_upload_to(self, attname): return 'path/to/%d' % self.parent.id @@ -282,18 +547,43 @@ class DynamicFileField(models.FileField): def db_type(self): """Required by Django for ORM.""" - return 'varchar(100)' + return 'varchar(100)' + class Attachment(models.Model): + """ + Represents a file attached to a follow-up. This could come from an e-mail + attachment, or it could be uploaded via the web interface. + """ + followup = models.ForeignKey(FollowUp) - file = DynamicFileField(_('File'), upload_to='helpdesk/attachments') - filename = models.CharField(_('Filename'), max_length=100) - mime_type = models.CharField(_('MIME Type'), max_length=30) - size = models.IntegerField(_('Size'), help_text=_('Size of this file in bytes')) + + file = DynamicFileField( + _('File'), + upload_to='helpdesk/attachments', + ) + + filename = models.CharField( + _('Filename'), + max_length=100, + ) + + mime_type = models.CharField( + _('MIME Type'), + max_length=30, + ) + + size = models.IntegerField( + _('Size'), + help_text=_('Size of this file in bytes'), + ) def get_upload_to(self, field_attname): """ Get upload_to path specific to this item """ - return u'helpdesk/attachments/%s/%s' % (self.followup.ticket.ticket_for_url, self.followup.id) + return u'helpdesk/attachments/%s/%s' % ( + self.followup.ticket.ticket_for_url, + self.followup.id + ) def __unicode__(self): return u'%s' % self.filename @@ -303,18 +593,38 @@ class Attachment(models.Model): class PreSetReply(models.Model): - """ We can allow the admin to define a number of pre-set replies, used to + """ + We can allow the admin to define a number of pre-set replies, used to simplify the sending of updates and resolutions. These are basically Django - templates with a limited context - however if yo uwanted to get crafy it would - be easy to write a reply that displays ALL updates in hierarchical order etc + templates with a limited context - however if you wanted to get crafy it would + be easy to write a reply that displays ALL updates in hierarchical order etc with use of for loops over {{ ticket.followup_set.all }} and friends. - + When replying to a ticket, the user can select any reply set for the current - queue, and the body text is fetched via AJAX.""" - - queues = models.ManyToManyField(Queue, blank=True, null=True, help_text=_('Leave blank to allow this reply to be used for all queues, or select those queues you wish to limit this reply to.')) - name = models.CharField(_('Name'), max_length=100, help_text=_('Only used to assist users with selecting a reply - not shown to the user.')) - body = models.TextField(_('Body'), help_text=_('Context available: {{ ticket }} - ticket object (eg {{ ticket.title }}); {{ queue }} - The queue; and {{ user }} - the current user.')) + queue, and the body text is fetched via AJAX. + """ + + queues = models.ManyToManyField( + Queue, + blank=True, + null=True, + help_text=_('Leave blank to allow this reply to be used for all ' + 'queues, or select those queues you wish to limit this reply to.'), + ) + + name = models.CharField( + _('Name'), + max_length=100, + help_text=_('Only used to assist users with selecting a reply - not ' + 'shown to the user.'), + ) + + body = models.TextField( + _('Body'), + help_text=_('Context available: {{ ticket }} - ticket object (eg ' + '{{ ticket.title }}); {{ queue }} - The queue; and {{ user }} ' + '- the current user.'), + ) class Meta: ordering = ['name',] @@ -322,28 +632,84 @@ class PreSetReply(models.Model): def __unicode__(self): return u'%s' % self.name -class EscalationExclusion(models.Model): - queues = models.ManyToManyField(Queue, blank=True, null=True, help_text=_('Leave blank for this exclusion to be applied to all queues, or select those queues you wish to exclude with this entry.')) - name = models.CharField(_('Name'), max_length=100) - - date = models.DateField(_('Date'), help_text=_('Date on which escalation should not happen')) +class EscalationExclusion(models.Model): + """ + An 'EscalationExclusion' lets us define a date on which escalation should + not happen, for example a weekend or public holiday. + + You may also have a queue that is only used on one day per week. + + To create these on a regular basis, check out the README file for an + example cronjob that runs 'create_escalation_exclusions.py'. + """ + + queues = models.ManyToManyField( + Queue, + blank=True, + null=True, + help_text=_('Leave blank for this exclusion to be applied to all ' + 'queues, or select those queues you wish to exclude with this ' + 'entry.'), + ) + + name = models.CharField( + _('Name'), + max_length=100, + ) + + date = models.DateField( + _('Date'), + help_text=_('Date on which escalation should not happen'), + ) def __unicode__(self): return u'%s' % self.name + class EmailTemplate(models.Model): """ Since these are more likely to be changed than other templates, we store - them in the database. + them in the database. + + This means that an admin can change email templates without having to have + access to the filesystem. """ - template_name = models.CharField(_('Template Name'), max_length=100, unique=True) - - subject = models.CharField(_('Subject'), max_length=100, help_text=_('This will be prefixed with "[ticket.ticket] ticket.title". We recommend something simple such as "(Updated") or "(Closed)" - the same context is available as in plain_text, below.')) - heading = models.CharField(_('Heading'), max_length=100, help_text=_('In HTML e-mails, this will be the heading at the top of the email - the same context is available as in plain_text, below.')) - plain_text = models.TextField(_('Plain Text'), help_text=_('The context available to you includes {{ ticket }}, {{ queue }}, and depending on the time of the call: {{ resolution }} or {{ comment }}.')) - html = models.TextField(_('HTML'), help_text=_('The same context is available here as in plain_text, above.')) + template_name = models.CharField( + _('Template Name'), + max_length=100, + unique=True, + ) + + subject = models.CharField( + _('Subject'), + max_length=100, + help_text=_('This will be prefixed with "[ticket.ticket] ticket.title"' + '. We recommend something simple such as "(Updated") or "(Closed)"' + ' - the same context is available as in plain_text, below.'), + ) + + heading = models.CharField( + _('Heading'), + max_length=100, + help_text=_('In HTML e-mails, this will be the heading at the top of ' + 'the email - the same context is available as in plain_text, ' + 'below.'), + ) + + plain_text = models.TextField( + _('Plain Text'), + help_text=_('The context available to you includes {{ ticket }}, ' + '{{ queue }}, and depending on the time of the call: ' + '{{ resolution }} or {{ comment }}.'), + ) + + html = models.TextField( + _('HTML'), + help_text=_('The same context is available here as in plain_text, ' + 'above.'), + ) def __unicode__(self): return u'%s' % self.template_name @@ -351,40 +717,72 @@ class EmailTemplate(models.Model): class Meta: ordering = ['template_name',] + class KBCategory(models.Model): """ - Lets help users help themselves: the Knowledge Base is a categorised + Lets help users help themselves: the Knowledge Base is a categorised listing of questions & answers. """ - - title = models.CharField(_('Title'), max_length=100) - slug = models.SlugField(_('Slug')) - description = models.TextField(_('Description')) + + title = models.CharField( + _('Title'), + max_length=100, + ) + + slug = models.SlugField( + _('Slug'), + ) + + description = models.TextField( + _('Description'), + ) def __unicode__(self): return u'%s' % self.title class Meta: ordering = ['title',] - + def get_absolute_url(self): return ('helpdesk_kb_category', [str(self.slug)]) get_absolute_url = models.permalink(get_absolute_url) + class KBItem(models.Model): """ - An item within the knowledgebase. Very straightforward question/answer + An item within the knowledgebase. Very straightforward question/answer style system. """ category = models.ForeignKey(KBCategory) - title = models.CharField(_('Title'), max_length=100) - question = models.TextField(_('Question')) - answer = models.TextField(_('Answer')) - - votes = models.IntegerField(_('Votes'), help_text=_('Total number of votes cast for this item')) - recommendations = models.IntegerField(_('Positive Votes'), help_text=_('Number of votes for this item which were POSITIVE.')) - last_updated = models.DateTimeField(_('Last Updated')) + title = models.CharField( + _('Title'), + max_length=100, + ) + + question = models.TextField( + _('Question'), + ) + + answer = models.TextField( + _('Answer'), + ) + + votes = models.IntegerField( + _('Votes'), + help_text=_('Total number of votes cast for this item'), + ) + + recommendations = models.IntegerField( + _('Positive Votes'), + help_text=_('Number of votes for this item which were POSITIVE.'), + ) + + last_updated = models.DateTimeField( + _('Last Updated'), + help_text=_('The date on which this question was most recently ' + 'changed.'), + ) def save(self): self.last_updated = datetime.now() @@ -396,7 +794,7 @@ class KBItem(models.Model): else: return _('Unrated') score = property(_score) - + def __unicode__(self): return u'%s' % self.title @@ -406,3 +804,4 @@ class KBItem(models.Model): def get_absolute_url(self): return ('helpdesk_kb_item', [str(self.id)]) get_absolute_url = models.permalink(get_absolute_url) + diff --git a/templatetags/in_list.py b/templatetags/in_list.py index 2dc0e0aa..f84149ec 100644 --- a/templatetags/in_list.py +++ b/templatetags/in_list.py @@ -3,7 +3,7 @@ Jutda Helpdesk - A Django powered ticket tracker for small enterprise. (c) Copyright 2008 Jutda. All Rights Reserved. See LICENSE for details. -templatetags/in_list.py - Very simple template tag to allow us to use the +templatetags/in_list.py - Very simple template tag to allow us to use the equivilent of 'if x in y' in templates. eg: Assuming 'food' = 'pizza' and 'best_foods' = ['pizza', 'pie', 'cake]: @@ -14,8 +14,11 @@ Assuming 'food' = 'pizza' and 'best_foods' = ['pizza', 'pie', 'cake]: Your food isn't one of our favourites. {% endif %} """ + from django import template + def in_list(value, arg): return value in arg + register = template.Library() register.filter(in_list) diff --git a/templatetags/ticket_to_link.py b/templatetags/ticket_to_link.py index 403118a2..f942d878 100644 --- a/templatetags/ticket_to_link.py +++ b/templatetags/ticket_to_link.py @@ -5,18 +5,24 @@ Jutda Helpdesk - A Django powered ticket tracker for small enterprise. templatetags/ticket_to_link.py - Used in ticket comments to allow wiki-style linking to other tickets. Including text such - as '#3180' in a comment automatically links + as '#3180' in a comment automatically links that text to ticket number 3180, with styling to show the status of that ticket (eg a closed ticket would have a strikethrough). """ + +import re + from django import template +from django.core.urlresolvers import reverse + from helpdesk.models import Ticket + class ReverseProxy: def __init__(self, sequence): self.sequence = sequence - + def __iter__(self): length = len(self.sequence) i = length @@ -24,16 +30,15 @@ class ReverseProxy: i = i - 1 yield self.sequence[i] + def num_to_link(text): if text == '': return text - import re - from django.core.urlresolvers import reverse - + matches = [] for match in re.finditer("#(\d+)", text): matches.append(match) - + for match in ReverseProxy(matches): start = match.start() end = match.end() diff --git a/urls.py b/urls.py index 8ba2d1e3..7412ee91 100644 --- a/urls.py +++ b/urls.py @@ -3,43 +3,41 @@ Jutda Helpdesk - A Django powered ticket tracker for small enterprise. (c) Copyright 2008 Jutda. All Rights Reserved. See LICENSE for details. -urls.py - Mapping of URL's to our various views. Note we always used NAMED +urls.py - Mapping of URL's to our various views. Note we always used NAMED views for simplicity in linking later on. """ from django.conf.urls.defaults import * - from django.contrib.auth.decorators import login_required +from django.contrib.syndication.views import feed as django_feed from helpdesk.views.feeds import feed_setup -from django.contrib.syndication.views import feed as django_feed - urlpatterns = patterns('helpdesk.views.staff', - url(r'^dashboard/$', + url(r'^dashboard/$', 'dashboard', name='helpdesk_dashboard'), - - url(r'^tickets/$', + + url(r'^tickets/$', 'ticket_list', name='helpdesk_list'), - - url(r'^tickets/submit/$', + + url(r'^tickets/submit/$', 'create_ticket', name='helpdesk_submit'), url(r'^tickets/(?P[0-9]+)/$', 'view_ticket', name='helpdesk_view'), - + url(r'^tickets/(?P[0-9]+)/update/$', 'update_ticket', name='helpdesk_update'), - + url(r'^tickets/(?P[0-9]+)/delete/$', 'delete_ticket', name='helpdesk_delete'), - + url(r'^tickets/(?P[0-9]+)/hold/$', 'hold_ticket', name='helpdesk_hold'), @@ -51,7 +49,7 @@ urlpatterns = patterns('helpdesk.views.staff', url(r'^raw/(?P\w+)/$', 'raw_details', name='helpdesk_raw'), - + url(r'^rss/$', 'rss_list', name='helpdesk_rss_index'), @@ -59,14 +57,14 @@ urlpatterns = patterns('helpdesk.views.staff', url(r'^reports/$', 'report_index', name='helpdesk_report_index'), - + url(r'^reports/(?P\w+)/$', 'run_report', name='helpdesk_run_report'), ) urlpatterns += patterns('helpdesk.views.public', - url(r'^$', + url(r'^$', 'homepage', name='helpdesk_home'), @@ -80,7 +78,7 @@ urlpatterns += patterns('', login_required(django_feed), {'feed_dict': feed_setup}, name='helpdesk_rss'), - + url(r'^api/(?P[a-z_-]+)/$', 'helpdesk.views.api.api', name='helpdesk_api'), @@ -89,7 +87,7 @@ urlpatterns += patterns('', 'django.views.generic.simple.direct_to_template', {'template': 'helpdesk/api_help.html',}, name='helpdesk_api_help'), - + url(r'^login/$', 'django.contrib.auth.views.login', name='login'), @@ -102,13 +100,13 @@ urlpatterns += patterns('', urlpatterns += patterns('helpdesk.views.kb', url(r'^kb/$', 'index', name='helpdesk_kb_index'), - + url(r'^kb/(?P[A-Za-z_-]+)/$', 'category', name='helpdesk_kb_category'), - + url(r'^kb/(?P[0-9]+)/$', 'item', name='helpdesk_kb_item'), - + url(r'^kb/(?P[0-9]+)/vote/$', 'vote', name='helpdesk_kb_vote'), ) diff --git a/views/api.py b/views/api.py index 34e7d602..45b54743 100644 --- a/views/api.py +++ b/views/api.py @@ -1,4 +1,4 @@ -""" .. +""" .. Jutda Helpdesk - A Django powered ticket tracker for small enterprise. (c) Copyright 2008 Jutda. All Rights Reserved. See LICENSE for details. @@ -6,23 +6,24 @@ Jutda Helpdesk - A Django powered ticket tracker for small enterprise. api.py - Wrapper around API calls, and core functions to provide complete API to third party applications. -The API documentation can be accessed by visiting http://helpdesk/api/help/ +The API documentation can be accessed by visiting http://helpdesk/api/help/ (obviously, substitute helpdesk for your Jutda Helpdesk URI), or by reading through templates/helpdesk/api_help.html. """ -from datetime import datetime -from django.utils import simplejson -from django.contrib.auth.models import User +from datetime import datetime + +from django import forms from django.contrib.auth import authenticate +from django.contrib.auth.models import User from django.http import HttpResponse from django.shortcuts import render_to_response from django.template import loader, Context -from django import forms +from django.utils import simplejson +from helpdesk.forms import TicketForm from helpdesk.lib import send_templated_mail from helpdesk.models import Ticket, Queue, FollowUp -from helpdesk.forms import TicketForm STATUS_OK = 200 @@ -31,27 +32,34 @@ STATUS_ERROR_NOT_FOUND = 404 STATUS_ERROR_PERMISSIONS = 403 STATUS_ERROR_BADMETHOD = 405 -def api(request, method): - if method == 'help': - """ Regardless of any other paramaters, we provide a help screen - to the user if they requested one. """ - return render_to_response('helpdesk/api_help.html') +def api(request, method): """ + Regardless of any other paramaters, we provide a help screen + to the user if they requested one. + If the user isn't looking for help, then we enforce a few conditions: * The request must be sent via HTTP POST * The request must contain a 'user' and 'password' which must be valid users * The method must match one of the public methods of the API class. + """ - + + if method == 'help': + return render_to_response('helpdesk/api_help.html') + if request.method != 'POST': return api_return(STATUS_ERROR_BADMETHOD) - - request.user = authenticate(username=request.POST.get('user', False), password=request.POST.get('password')) + + # TODO: Move away from having the username & password in every request. + request.user = authenticate( + username=request.POST.get('user', False), + password=request.POST.get('password')) + if request.user is None: return api_return(STATUS_ERROR_PERMISSIONS) - + api = API(request) if hasattr(api, 'api_public_%s' % method): return getattr(api, 'api_public_%s' % method)() @@ -63,7 +71,7 @@ def api_return(status, text='', json=False): content_type = 'text/plain' if status == STATUS_OK and json: content_type = 'text/json' - + if text is None: if status == STATUS_ERROR: text = 'Error' @@ -75,10 +83,12 @@ def api_return(status, text='', json=False): text = 'Invalid request method' elif status == STATUS_OK: text = 'OK' + r = HttpResponse(status=status, content=text, content_type=content_type) - + if status == STATUS_ERROR_BADMETHOD: r.Allow = 'POST' + return r @@ -86,27 +96,30 @@ class API: def __init__(self, request): self.request = request - def api_public_create_ticket(self): + def api_public_create_ticket(self): form = TicketForm(self.request.POST) form.fields['queue'].choices = [[q.id, q.title] for q in Queue.objects.all()] form.fields['assigned_to'].choices = [[u.id, u.username] for u in User.objects.filter(is_active=True)] + if form.is_valid(): ticket = form.save(user=self.request.user) return api_return(STATUS_OK, "%s" % ticket.id) else: return api_return(STATUS_ERROR, text=form.errors.as_text()) - + def api_public_list_queues(self): return api_return(STATUS_OK, simplejson.dumps([{"id": "%s" % q.id, "title": "%s" % q.title} for q in Queue.objects.all()]), json=True) - - + + def api_public_find_user(self): username = self.request.POST.get('username', False) + try: u = User.objects.get(username=username) return api_return(STATUS_OK, "%s" % u.id) + except User.DoesNotExist: return api_return(STATUS_ERROR, "Invalid username provided") @@ -121,9 +134,10 @@ class API: return api_return(STATUS_ERROR, "Invalid ticket ID") ticket.delete() + return api_return(STATUS_OK) - - + + def api_public_hold_ticket(self): try: ticket = Ticket.objects.get(id=self.request.POST.get('ticket', False)) @@ -135,7 +149,7 @@ class API: return api_return(STATUS_OK) - + def api_public_unhold_ticket(self): try: ticket = Ticket.objects.get(id=self.request.POST.get('ticket', False)) @@ -162,12 +176,20 @@ class API: if not message: return api_return(STATUS_ERROR, "Blank message") - - f = FollowUp(ticket=ticket, date=datetime.now(), comment=message, user=self.request.user, title='Comment Added') + + f = FollowUp( + ticket=ticket, + date=datetime.now(), + comment=message, + user=self.request.user, + title='Comment Added', + ) + if public: f.public = True + f.save() - + context = { 'ticket': ticket, 'queue': ticket.queue, @@ -175,13 +197,31 @@ class API: } if public and ticket.submitter_email: - send_templated_mail('updated_submitter', context, recipients=ticket.submitter_email, sender=ticket.queue.from_address, fail_silently=True) - + send_templated_mail( + 'updated_submitter', + context, + recipients=ticket.submitter_email, + sender=ticket.queue.from_address, + fail_silently=True, + ) + if ticket.queue.updated_ticket_cc: - send_templated_mail('updated_cc', context, recipients=ticket.queue.updated_ticket_cc, sender=ticket.queue.from_address, fail_silently=True) - + send_templated_mail( + 'updated_cc', + context, + recipients=ticket.queue.updated_ticket_cc, + sender=ticket.queue.from_address, + fail_silently=True, + ) + if ticket.assigned_to and self.request.user != ticket.assigned_to: - send_templated_mail('updated_owner', context, recipients=ticket.assigned_to.email, sender=ticket.queue.from_address, fail_silently=True) + send_templated_mail( + 'updated_owner', + context, + recipients=ticket.assigned_to.email, + sender=ticket.queue.from_address, + fail_silently=True, + ) ticket.save() @@ -198,26 +238,51 @@ class API: if not resolution: return api_return(STATUS_ERROR, "Blank resolution") - - f = FollowUp(ticket=ticket, date=datetime.now(), comment=resolution, user=self.request.user, title='Resolved', public=True) + + f = FollowUp( + ticket=ticket, + date=datetime.now(), + comment=resolution, + user=self.request.user, + title='Resolved', + public=True, + ) f.save() - + context = { 'ticket': ticket, 'queue': ticket.queue, 'resolution': f.comment, } - + subject = '%s %s (Resolved)' % (ticket.ticket, ticket.title) if ticket.submitter_email: - send_templated_mail('resolved_submitter', context, recipients=ticket.submitter_email, sender=ticket.queue.from_address, fail_silently=True) - + send_templated_mail( + 'resolved_submitter', + context, + recipients=ticket.submitter_email, + sender=ticket.queue.from_address, + fail_silently=True, + ) + if ticket.queue.updated_ticket_cc: - send_templated_mail('resolved_cc', context, recipients=ticket.queue.updated_ticket_cc, sender=ticket.queue.from_address, fail_silently=True) - + send_templated_mail( + 'resolved_cc', + context, + recipients=ticket.queue.updated_ticket_cc, + sender=ticket.queue.from_address, + fail_silently=True, + ) + if ticket.assigned_to and self.request.user != ticket.assigned_to: - send_templated_mail('resolved_resolved', context, recipients=ticket.assigned_to.email, sender=ticket.queue.from_address, fail_silently=True) + send_templated_mail( + 'resolved_resolved', + context, + recipients=ticket.assigned_to.email, + sender=ticket.queue.from_address, + fail_silently=True, + ) ticket.resoltuion = f.comment ticket.status = Ticket.RESOLVED_STATUS @@ -225,3 +290,4 @@ class API: ticket.save() return api_return(STATUS_OK) + diff --git a/views/feeds.py b/views/feeds.py index 7f40a8c1..1cf989ff 100644 --- a/views/feeds.py +++ b/views/feeds.py @@ -1,3 +1,12 @@ +""" +Jutda Helpdesk - A Django powered ticket tracker for small enterprise. + +(c) Copyright 2008 Jutda. All Rights Reserved. See LICENSE for details. + +views/feeds.py - A handful of staff-only RSS feeds to provide ticket details + to feed readers or similar software. +""" + from django.contrib.auth.models import User from django.contrib.syndication.feeds import Feed from django.core.urlresolvers import reverse @@ -23,28 +32,55 @@ class OpenTicketsByUser(Feed): def title(self, obj): if obj['queue']: - return _("Helpdesk: Open Tickets in queue %(queue)s for %(username)s") % {'queue': obj['queue'].title, 'username': obj['user'].username} + return _("Helpdesk: Open Tickets in queue %(queue)s for %(username)s") % { + 'queue': obj['queue'].title, + 'username': obj['user'].username, + } else: - return _("Helpdesk: Open Tickets for %(username)s") % {'username': obj['user'].username} + return _("Helpdesk: Open Tickets for %(username)s") % { + 'username': obj['user'].username, + } def description(self, obj): if obj['queue']: - return _("Open and Reopened Tickets in queue %(queue)s for %(username)s") % {'queue': obj['queue'].title, 'username': obj['user'].username} + return _("Open and Reopened Tickets in queue %(queue)s for %(username)s") % { + 'queue': obj['queue'].title, + 'username': obj['user'].username, + } else: - return _("Open and Reopened Tickets for %(username)s") % {'username': obj['user'].username} + return _("Open and Reopened Tickets for %(username)s") % { + 'username': obj['user'].username, + } def link(self, obj): if obj['queue']: - return u'%s?assigned_to=%s&queue=%s' % (reverse('helpdesk_list'), obj['user'].id, obj['queue'].id) + return u'%s?assigned_to=%s&queue=%s' % ( + reverse('helpdesk_list'), + obj['user'].id, + obj['queue'].id, + ) else: - return u'%s?assigned_to=%s' % (reverse('helpdesk_list'), obj['user'].id) + return u'%s?assigned_to=%s' % ( + reverse('helpdesk_list'), + obj['user'].id, + ) def items(self, obj): if obj['queue']: - return Ticket.objects.filter(assigned_to=obj['user']).filter(queue=obj['queue']).filter(Q(status=Ticket.OPEN_STATUS) | Q(status=Ticket.REOPENED_STATUS)) + return Ticket.objects.filter( + assigned_to=obj['user'] + ).filter( + queue=obj['queue'] + ).filter( + Q(status=Ticket.OPEN_STATUS) | Q(status=Ticket.REOPENED_STATUS) + ) else: - return Ticket.objects.filter(assigned_to=obj['user']).filter(Q(status=Ticket.OPEN_STATUS) | Q(status=Ticket.REOPENED_STATUS)) - + return Ticket.objects.filter( + assigned_to=obj['user'] + ).filter( + Q(status=Ticket.OPEN_STATUS) | Q(status=Ticket.REOPENED_STATUS) + ) + def item_pubdate(self, item): return item.created @@ -58,18 +94,22 @@ class OpenTicketsByUser(Feed): class UnassignedTickets(Feed): title_template = 'helpdesk/rss/ticket_title.html' description_template = 'helpdesk/rss/ticket_description.html' - + title = _('Helpdesk: Unassigned Tickets') description = _('Unassigned Open and Reopened tickets') link = ''#%s?assigned_to=' % reverse('helpdesk_list') def items(self, obj): - return Ticket.objects.filter(assigned_to__isnull=True).filter(Q(status=Ticket.OPEN_STATUS) | Q(status=Ticket.REOPENED_STATUS)) - + return Ticket.objects.filter( + assigned_to__isnull=True + ).filter( + Q(status=Ticket.OPEN_STATUS) | Q(status=Ticket.REOPENED_STATUS) + ) + def item_pubdate(self, item): return item.created - - + + def item_author_name(self, item): if item.assigned_to: return item.assigned_to.username @@ -99,20 +139,31 @@ class OpenTicketsByQueue(Feed): return Queue.objects.get(slug__exact=bits[0]) def title(self, obj): - return _('Helpdesk: Open Tickets in queue %(queue)s') % {'queue': obj.title} + return _('Helpdesk: Open Tickets in queue %(queue)s') % { + 'queue': obj.title, + } def description(self, obj): - return _('Open and Reopened Tickets in queue %(queue)s') % {'queue': obj.title} + return _('Open and Reopened Tickets in queue %(queue)s') % { + 'queue': obj.title, + } def link(self, obj): - return '%s?queue=%s' % (reverse('helpdesk_list'), obj.id) + return '%s?queue=%s' % ( + reverse('helpdesk_list'), + obj.id, + ) def items(self, obj): - return Ticket.objects.filter(queue=obj).filter(Q(status=Ticket.OPEN_STATUS) | Q(status=Ticket.REOPENED_STATUS)) + return Ticket.objects.filter( + queue=obj + ).filter( + Q(status=Ticket.OPEN_STATUS) | Q(status=Ticket.REOPENED_STATUS) + ) def item_pubdate(self, item): return item.created - + def item_author_name(self, item): if item.assigned_to: return item.assigned_to.username diff --git a/views/kb.py b/views/kb.py index 4183d5c7..5cbd880f 100644 --- a/views/kb.py +++ b/views/kb.py @@ -3,10 +3,11 @@ Jutda Helpdesk - A Django powered ticket tracker for small enterprise. (c) Copyright 2008 Jutda. All Rights Reserved. See LICENSE for details. -views/kb.py - Public-facing knowledgebase views. The knowledgebase is a +views/kb.py - Public-facing knowledgebase views. The knowledgebase is a simple categorised question/answer system to show common resolutions to common problems. """ + from datetime import datetime from django.http import HttpResponseRedirect @@ -16,23 +17,26 @@ from django.utils.translation import ugettext as _ from helpdesk.models import KBCategory, KBItem + def index(request): category_list = KBCategory.objects.all() - # Add most popular items here. - return render_to_response('helpdesk/kb_index.html', + # TODO: It'd be great to have a list of most popular items here. + return render_to_response('helpdesk/kb_index.html', RequestContext(request, { 'categories': category_list, })) + def category(request, slug): category = get_object_or_404(KBCategory, slug__iexact=slug) items = category.kbitem_set.all() - return render_to_response('helpdesk/kb_category.html', + return render_to_response('helpdesk/kb_category.html', RequestContext(request, { 'category': category, 'items': items, })) + def item(request, item): item = get_object_or_404(KBItem, pk=item) return render_to_response('helpdesk/kb_item.html', @@ -40,6 +44,7 @@ def item(request, item): 'item': item, })) + def vote(request, item): item = get_object_or_404(KBItem, pk=item) vote = request.GET.get('vote', None) @@ -50,3 +55,4 @@ def vote(request, item): item.save() return HttpResponseRedirect(item.get_absolute_url()) + diff --git a/views/public.py b/views/public.py index 6f6462ba..21ae23b2 100644 --- a/views/public.py +++ b/views/public.py @@ -6,6 +6,7 @@ Jutda Helpdesk - A Django powered ticket tracker for small enterprise. views/public.py - All public facing views, eg non-staff (no authentication required) views. """ + from datetime import datetime from django.core.urlresolvers import reverse @@ -18,16 +19,21 @@ from helpdesk.forms import PublicTicketForm from helpdesk.lib import send_templated_mail from helpdesk.models import Ticket, Queue + def homepage(request): if request.user.is_authenticated(): return HttpResponseRedirect(reverse('helpdesk_dashboard')) - + if request.method == 'POST': form = PublicTicketForm(request.POST) form.fields['queue'].choices = [('', '--------')] + [[q.id, q.title] for q in Queue.objects.filter(allow_public_submission=True)] if form.is_valid(): ticket = form.save() - return HttpResponseRedirect('%s?ticket=%s&email=%s'% (reverse('helpdesk_public_view'), ticket.ticket_for_url, ticket.submitter_email)) + return HttpResponseRedirect('%s?ticket=%s&email=%s'% ( + reverse('helpdesk_public_view'), + ticket.ticket_for_url, + ticket.submitter_email) + ) else: form = PublicTicketForm() form.fields['queue'].choices = [('', '--------')] + [[q.id, q.title] for q in Queue.objects.filter(allow_public_submission=True)] @@ -37,24 +43,30 @@ def homepage(request): 'form': form, })) + def view_ticket(request): ticket = request.GET.get('ticket', '') email = request.GET.get('email', '') error_message = '' if ticket and email: + queue, ticket_id = ticket.split('-') try: - queue, ticket_id = ticket.split('-') t = Ticket.objects.get(id=ticket_id, queue__slug__iexact=queue, submitter_email__iexact=email) - return render_to_response('helpdesk/public_view_ticket.html', - RequestContext(request, {'ticket': t,})) except Ticket.DoesNotExist: t = False; error_message = _('Invalid ticket ID or e-mail address. Please try again.') - return render_to_response('helpdesk/public_view_form.html', + if t: + return render_to_response('helpdesk/public_view_ticket.html', + RequestContext(request, { + 'ticket': t, + })) + + return render_to_response('helpdesk/public_view_form.html', RequestContext(request, { 'ticket': ticket, 'email': email, 'error_message': error_message, })) + diff --git a/views/staff.py b/views/staff.py index dc91ac24..154e1c70 100644 --- a/views/staff.py +++ b/views/staff.py @@ -3,15 +3,17 @@ Jutda Helpdesk - A Django powered ticket tracker for small enterprise. (c) Copyright 2008 Jutda. All Rights Reserved. See LICENSE for details. -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. """ + from datetime import datetime from django.contrib.auth.models import User from django.contrib.auth.decorators import login_required from django.core.files.base import ContentFile from django.core.urlresolvers import reverse +from django.db import connection from django.db.models import Q from django.http import HttpResponseRedirect, Http404, HttpResponse from django.shortcuts import render_to_response, get_object_or_404 @@ -22,17 +24,28 @@ from helpdesk.forms import TicketForm from helpdesk.lib import send_templated_mail, line_chart, bar_chart, query_to_dict from helpdesk.models import Ticket, Queue, FollowUp, TicketChange, PreSetReply, Attachment + def dashboard(request): """ A quick summary overview for users: A list of their own tickets, a table - showing ticket counts by queue/status, and a list of unassigned tickets + showing ticket counts by queue/status, and a list of unassigned tickets with options for them to 'Take' ownership of said tickets. """ - tickets = Ticket.objects.filter(assigned_to=request.user).exclude(status=Ticket.CLOSED_STATUS) - unassigned_tickets = Ticket.objects.filter(assigned_to__isnull=True).exclude(status=Ticket.CLOSED_STATUS) + tickets = Ticket.objects + .filter(assigned_to=request.user) + .exclude(status=Ticket.CLOSED_STATUS) + + unassigned_tickets = Ticket.objects + .filter(assigned_to__isnull=True) + .exclude(status=Ticket.CLOSED_STATUS) + + # The following query builds a grid of queues & ticket statuses, + # to be displayed to the user. EG: + # Open Resolved + # Queue 1 10 4 + # Queue 2 4 12 - from django.db import connection cursor = connection.cursor() cursor.execute(""" SELECT q.id as queue, @@ -55,9 +68,10 @@ def dashboard(request): })) dashboard = login_required(dashboard) + def delete_ticket(request, ticket_id): ticket = get_object_or_404(Ticket, id=ticket_id) - + if request.method == 'GET': return render_to_response('helpdesk/delete_ticket.html', RequestContext(request, { @@ -68,18 +82,31 @@ def delete_ticket(request, ticket_id): return HttpResponseRedirect(reverse('helpdesk_home')) delete_ticket = login_required(delete_ticket) + def view_ticket(request, ticket_id): ticket = get_object_or_404(Ticket, id=ticket_id) + if request.GET.has_key('take'): + # Allow the user to assign the ticket to themselves whilst viewing it. ticket.assigned_to = request.user ticket.save() if request.GET.has_key('close') and ticket.status == Ticket.RESOLVED_STATUS: - if not ticket.assigned_to: + if not ticket.assigned_to: owner = 0 else: owner = ticket.assigned_to.id - request.POST = {'new_status': Ticket.CLOSED_STATUS, 'public': 1, 'owner': owner, 'title': ticket.title, 'comment': _('Accepted resolution and closed 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, + 'owner': owner, + 'title': ticket.title, + 'comment': _('Accepted resolution and closed ticket'), + } + return update_ticket(request, ticket_id) return render_to_response('helpdesk/ticket.html', @@ -91,6 +118,7 @@ def view_ticket(request, ticket_id): })) view_ticket = login_required(view_ticket) + def update_ticket(request, ticket_id): ticket = get_object_or_404(Ticket, id=ticket_id) @@ -100,26 +128,27 @@ def update_ticket(request, ticket_id): public = request.POST.get('public', False) owner = int(request.POST.get('owner', None)) priority = int(request.POST.get('priority', ticket.priority)) - + if not owner and ticket.assigned_to: owner = ticket.assigned_to.id f = FollowUp(ticket=ticket, date=datetime.now(), comment=comment, user=request.user) - if public: - f.public = True + f.public = public reassigned = False if owner: if owner != 0 and (ticket.assigned_to and owner != ticket.assigned_to.id) or not ticket.assigned_to: new_user = User.objects.get(id=owner) - f.title = _('Assigned to %(username)s') % {'username': new_user.username} + f.title = _('Assigned to %(username)s') % { + 'username': new_user.username, + } ticket.assigned_to = new_user reassigned = True else: f.title = _('Unassigned') ticket.assigned_to = None - + if new_status != ticket.status: ticket.status = new_status ticket.save() @@ -136,20 +165,30 @@ def update_ticket(request, ticket_id): f.title = _('Updated') f.save() - + if title != ticket.title: - c = TicketChange(followup=f, field=_('Title'), old_value=ticket.title, new_value=title) + c = TicketChange( + followup=f, + field=_('Title'), + old_value=ticket.title, + new_value=title, + ) c.save() ticket.title = title if priority != ticket.priority: - c = TicketChange(followup=f, field=_('Priority'), old_value=ticket.priority, new_value=priority) + c = TicketChange( + followup=f, + field=_('Priority'), + old_value=ticket.priority, + new_value=priority, + ) c.save() ticket.priority = priority if f.new_status == Ticket.RESOLVED_STATUS: ticket.resolution = comment - + if ticket.submitter_email and ((f.comment != '' and public) or (f.new_status in (Ticket.RESOLVED_STATUS, Ticket.CLOSED_STATUS))): context = { 'ticket': ticket, @@ -157,16 +196,24 @@ def update_ticket(request, ticket_id): 'resolution': ticket.resolution, 'comment': f.comment, } + if f.new_status == Ticket.RESOLVED_STATUS: template = 'resolved_submitter' elif f.new_status == Ticket.CLOSED_STATUS: template = 'closed_submitter' else: template = 'updated_submitter' - send_templated_mail(template, context, recipients=ticket.submitter_email, sender=ticket.queue.from_address, fail_silently=True) + + send_templated_mail( + template, + context, + recipients=ticket.submitter_email, + sender=ticket.queue.from_address, + fail_silently=True, + ) if ticket.assigned_to and request.user != ticket.assigned_to and ticket.assigned_to.email: - # We only send e-mails to staff members if the ticket is updated by + # We only send e-mails to staff members if the ticket is updated by # another user. if reassigned: template_staff = 'assigned_owner' @@ -176,9 +223,15 @@ def update_ticket(request, ticket_id): template_staff = 'closed_owner' else: template_staff = 'updated_owner' - - send_templated_mail(template_staff, context, recipients=ticket.assigned_to.email, sender=ticket.queue.from_address, fail_silently=True) - + + send_templated_mail( + template_staff, + context, + recipients=ticket.assigned_to.email, + sender=ticket.queue.from_address, + fail_silently=True, + ) + if ticket.queue.updated_ticket_cc: if reassigned: template_cc = 'assigned_cc' @@ -188,22 +241,33 @@ def update_ticket(request, ticket_id): template_cc = 'closed_cc' else: template_cc = 'updated_cc' - - send_templated_mail(template_cc, context, recipients=ticket.queue.updated_ticket_cc, sender=ticket.queue.from_address, fail_silently=True) + + send_templated_mail( + template_cc, + context, + recipients=ticket.queue.updated_ticket_cc, + sender=ticket.queue.from_address, + fail_silently=True, + ) if request.FILES: for file in request.FILES.getlist('attachment'): filename = file['filename'].replace(' ', '_') - a = Attachment(followup=f, filename=filename, mime_type=file['content-type'], size=len(file['content'])) - #a.save_file_file(file['filename'], file['content']) + a = Attachment( + followup=f, + filename=filename, + mime_type=file['content-type'], + size=len(file['content']), + ) a.file.save(file['filename'], ContentFile(file['content'])) a.save() ticket.save() - + return HttpResponseRedirect(ticket.get_absolute_url()) update_ticket = login_required(update_ticket) + def ticket_list(request): tickets = Ticket.objects.select_related() context = {} @@ -229,7 +293,7 @@ def ticket_list(request): ### KEYWORD SEARCHING q = request.GET.get('q', None) - + if q: qset = ( Q(title__icontains=q) | @@ -239,7 +303,7 @@ def ticket_list(request): ) tickets = tickets.filter(qset) context = dict(context, query=q) - + ### SORTING sort = request.GET.get('sort', None) if sort not in ('status', 'assigned_to', 'created', 'title', 'queue', 'priority'): @@ -257,6 +321,7 @@ def ticket_list(request): ))) ticket_list = login_required(ticket_list) + def create_ticket(request): if request.method == 'POST': form = TicketForm(request.POST) @@ -270,26 +335,32 @@ def create_ticket(request): form.fields['queue'].choices = [('', '--------')] + [[q.id, q.title] for q in Queue.objects.all()] form.fields['assigned_to'].choices = [('', '--------')] + [[u.id, u.username] for u in User.objects.filter(is_active=True)] - return render_to_response('helpdesk/create_ticket.html', + return render_to_response('helpdesk/create_ticket.html', RequestContext(request, { 'form': form, })) create_ticket = login_required(create_ticket) + def raw_details(request, type): + # TODO: This currently only supports spewing out 'PreSetReply' objects, + # in the future it needs to be expanded to include other items. All it + # does is return a plain-text representation of an object. + if not type in ('preset',): raise Http404 - + if type == 'preset' and request.GET.get('id', False): try: preset = PreSetReply.objects.get(id=request.GET.get('id')) return HttpResponse(preset.body) except PreSetReply.DoesNotExist: raise Http404 - + raise Http404 raw_details = login_required(raw_details) + def hold_ticket(request, ticket_id, unhold=False): ticket = get_object_or_404(Ticket, id=ticket_id) @@ -299,7 +370,7 @@ def hold_ticket(request, ticket_id, unhold=False): else: ticket.on_hold = True title = _('Ticket placed on hold') - + f = FollowUp( ticket = ticket, user = request.user, @@ -308,28 +379,32 @@ def hold_ticket(request, ticket_id, unhold=False): public = True, ) f.save() - + ticket.save() - + return HttpResponseRedirect(ticket.get_absolute_url()) hold_ticket = login_required(hold_ticket) + def unhold_ticket(request, ticket_id): return hold_ticket(request, ticket_id, unhold=True) unhold_ticket = login_required(unhold_ticket) + def rss_list(request): - return render_to_response('helpdesk/rss_list.html', + return render_to_response('helpdesk/rss_list.html', RequestContext(request, { 'queues': Queue.objects.all(), })) rss_list = login_required(rss_list) + def report_index(request): return render_to_response('helpdesk/report_index.html', RequestContext(request, {})) report_index = login_required(report_index) + def run_report(request, report): priority_sql = [] priority_columns = [] @@ -337,14 +412,14 @@ def run_report(request, report): priority_sql.append("COUNT(CASE t.priority WHEN '%s' THEN t.id END) AS \"%s\"" % (p[0], p[1]._proxy____unicode_cast())) priority_columns.append("%s" % p[1]._proxy____unicode_cast()) priority_sql = ", ".join(priority_sql) - + status_sql = [] status_columns = [] for s in Ticket.STATUS_CHOICES: status_sql.append("COUNT(CASE t.status WHEN '%s' THEN t.id END) AS \"%s\"" % (s[0], s[1]._proxy____unicode_cast())) status_columns.append("%s" % s[1]._proxy____unicode_cast()) status_sql = ", ".join(status_sql) - + queue_sql = [] queue_columns = [] for q in Queue.objects.all(): @@ -368,11 +443,11 @@ def run_report(request, report): 'Dec', ) month_columns = [] - + first_ticket = Ticket.objects.all().order_by('created')[0] first_month = first_ticket.created.month first_year = first_ticket.created.year - + last_ticket = Ticket.objects.all().order_by('-created')[0] last_month = last_ticket.created.month last_year = last_ticket.created.year @@ -420,33 +495,32 @@ def run_report(request, report): if report == 'userpriority': sql = user_base_sql % priority_sql columns = ['username'] + priority_columns - + elif report == 'userqueue': sql = user_base_sql % queue_sql columns = ['username'] + queue_columns - + elif report == 'userstatus': sql = user_base_sql % status_sql columns = ['username'] + status_columns - + elif report == 'usermonth': sql = user_base_sql % month_sql columns = ['username'] + month_columns - + elif report == 'queuepriority': sql = queue_base_sql % priority_sql columns = ['queue'] + priority_columns - + elif report == 'queuestatus': sql = queue_base_sql % status_sql columns = ['queue'] + status_columns - + elif report == 'queuemonth': sql = queue_base_sql % month_sql columns = ['queue'] + month_columns - from django.db import connection cursor = connection.cursor() cursor.execute(sql) report_output = query_to_dict(cursor.fetchall(), cursor.description) @@ -465,7 +539,7 @@ def run_report(request, report): chart_url = bar_chart([columns] + data) else: chart_url = '' - + return render_to_response('helpdesk/report_output.html', RequestContext(request, { 'headings': columns, @@ -474,3 +548,4 @@ def run_report(request, report): 'chart': chart_url, })) run_report = login_required(run_report) +