diff --git a/demo/demodesk/config/settings.py b/demo/demodesk/config/settings.py index 84e51398..b672dfc4 100644 --- a/demo/demodesk/config/settings.py +++ b/demo/demodesk/config/settings.py @@ -37,7 +37,6 @@ INSTALLED_APPS = [ 'django.contrib.staticfiles', 'django.contrib.sites', 'django.contrib.humanize', - 'markdown_deux', 'bootstrap4form', 'helpdesk' ] diff --git a/docs/install.rst b/docs/install.rst index 67e90b9f..66df1905 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -57,7 +57,6 @@ errors with trying to create User settings. 'django.contrib.sites', # Required for determining domain url for use in emails 'django.contrib.admin', # Required for helpdesk admin/maintenance 'django.contrib.humanize', # Required for elapsed time formatting - 'markdown_deux', # Required for Knowledgebase item formatting 'bootstrap4form', # Required for nicer formatting of forms with the default templates 'helpdesk', # This is us! ) @@ -114,21 +113,17 @@ errors with trying to create User settings. Ideally, accessing http://MEDIA_URL/helpdesk/attachments/ will give you a 403 access denied error. -7. If it's not already installed, install ``markdown_deux`` and ensure it's in your ``INSTALLED_APPS``:: - - pip install django-markdown-deux - -8. If you already have a view handling your logins, then great! If not, add the following to ``settings.py`` to get your Django installation to use the login view included in ``django-helpdesk``:: +7. If you already have a view handling your logins, then great! If not, add the following to ``settings.py`` to get your Django installation to use the login view included in ``django-helpdesk``:: LOGIN_URL = '/helpdesk/login/' Alter the URL to suit your installation path. -9. Load initial e-mail templates, otherwise you will not be able to send e-mail:: +8. Load initial e-mail templates, otherwise you will not be able to send e-mail:: python manage.py loaddata emailtemplate.json -10. If you intend on using local mail directories for processing email into tickets, be sure to create the mail directory before adding it to the queue in the Django administrator interface. The default mail directory is ``/var/lib/mail/helpdesk/``. Ensure that the directory has appropriate permissions so that your Django/web server instance may read and write files from this directory. +9. If you intend on using local mail directories for processing email into tickets, be sure to create the mail directory before adding it to the queue in the Django administrator interface. The default mail directory is ``/var/lib/mail/helpdesk/``. Ensure that the directory has appropriate permissions so that your Django/web server instance may read and write files from this directory. Note that by default, any mail files placed in your local directory will be permanently deleted after being successfully processed. It is strongly recommended that you take further steps to save emails if you wish to retain backups. diff --git a/helpdesk/admin.py b/helpdesk/admin.py index 58d5ef09..52318ec6 100644 --- a/helpdesk/admin.py +++ b/helpdesk/admin.py @@ -2,19 +2,28 @@ from django.contrib import admin from django.utils.translation import ugettext_lazy as _ from helpdesk.models import Queue, Ticket, FollowUp, PreSetReply, KBCategory from helpdesk.models import EscalationExclusion, EmailTemplate, KBItem -from helpdesk.models import TicketChange, Attachment, IgnoreEmail +from helpdesk.models import TicketChange, KBIAttachment, FollowUpAttachment, IgnoreEmail from helpdesk.models import CustomField @admin.register(Queue) class QueueAdmin(admin.ModelAdmin): - list_display = ('title', 'slug', 'email_address', 'locale') + list_display = ('title', 'slug', 'email_address', 'locale', 'time_spent') prepopulated_fields = {"slug": ("title",)} + def time_spent(self, q): + if q.dedicated_time: + return "{} / {}".format(q.time_spent, q.dedicated_time) + elif q.time_spent: + return q.time_spent + else: + return "-" + @admin.register(Ticket) class TicketAdmin(admin.ModelAdmin): - list_display = ('title', 'status', 'assigned_to', 'queue', 'hidden_submitter_email',) + list_display = ('title', 'status', 'assigned_to', 'queue', + 'hidden_submitter_email', 'time_spent') date_hierarchy = 'created' list_filter = ('queue', 'assigned_to', 'status') @@ -28,19 +37,30 @@ class TicketAdmin(admin.ModelAdmin): return ticket.submitter_email hidden_submitter_email.short_description = _('Submitter E-Mail') + def time_spent(self, ticket): + return ticket.time_spent + class TicketChangeInline(admin.StackedInline): model = TicketChange + extra = 0 -class AttachmentInline(admin.StackedInline): - model = Attachment +class FollowUpAttachmentInline(admin.StackedInline): + model = FollowUpAttachment + extra = 0 + + +class KBIAttachmentInline(admin.StackedInline): + model = KBIAttachment + extra = 0 @admin.register(FollowUp) class FollowUpAdmin(admin.ModelAdmin): - inlines = [TicketChangeInline, AttachmentInline] - list_display = ('ticket_get_ticket_for_url', 'title', 'date', 'ticket', 'user', 'new_status') + inlines = [TicketChangeInline, FollowUpAttachmentInline] + list_display = ('ticket_get_ticket_for_url', 'title', 'date', 'ticket', + 'user', 'new_status', 'time_spent') list_filter = ('user', 'date', 'new_status') def ticket_get_ticket_for_url(self, obj): @@ -51,6 +71,7 @@ class FollowUpAdmin(admin.ModelAdmin): @admin.register(KBItem) class KBItemAdmin(admin.ModelAdmin): list_display = ('category', 'title', 'last_updated',) + inlines = [KBIAttachmentInline] readonly_fields = ('voted_by',) list_display_links = ('title',) diff --git a/helpdesk/email.py b/helpdesk/email.py index ff80f47a..2e076585 100644 --- a/helpdesk/email.py +++ b/helpdesk/email.py @@ -481,8 +481,9 @@ def object_from_message(message, queue, logger): body.encode('utf-8') logger.debug("Discovered plain text MIME part") else: + payload = encoding.smart_bytes(part.get_payload(decode=True)) files.append( - SimpleUploadedFile(_("email_html_body.html"), encoding.smart_bytes(part.get_payload()), 'text/html') + SimpleUploadedFile(_("email_html_body.html"), payload, 'text/html') ) logger.debug("Discovered HTML MIME part") else: diff --git a/helpdesk/forms.py b/helpdesk/forms.py index 95eb7e2d..2fc4479f 100644 --- a/helpdesk/forms.py +++ b/helpdesk/forms.py @@ -17,7 +17,7 @@ from django.contrib.auth import get_user_model from django.utils import timezone from helpdesk.lib import safe_template_context, process_attachments -from helpdesk.models import (Ticket, Queue, FollowUp, Attachment, IgnoreEmail, TicketCC, +from helpdesk.models import (Ticket, Queue, FollowUp, IgnoreEmail, TicketCC, CustomField, TicketCustomFieldValue, TicketDependency, UserSettings) from helpdesk import settings as helpdesk_settings diff --git a/helpdesk/lib.py b/helpdesk/lib.py index 2c32e299..b44e4109 100644 --- a/helpdesk/lib.py +++ b/helpdesk/lib.py @@ -15,7 +15,7 @@ from django.db.models import Q from django.utils.encoding import smart_text, smart_str from django.utils.safestring import mark_safe -from helpdesk.models import Attachment, EmailTemplate +from helpdesk.models import FollowUpAttachment, EmailTemplate from model_utils import Choices @@ -218,7 +218,7 @@ def process_attachments(followup, attached_files): if attached.size: filename = smart_text(attached.name) - att = Attachment( + att = FollowUpAttachment( followup=followup, file=attached, filename=filename, diff --git a/helpdesk/migrations/0024_time_spent.py b/helpdesk/migrations/0024_time_spent.py new file mode 100644 index 00000000..bbb0f22f --- /dev/null +++ b/helpdesk/migrations/0024_time_spent.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.5 on 2019-02-06 13:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('helpdesk', '0023_add_enable_notifications_on_email_events_to_ticket'), + ] + + operations = [ + migrations.AddField( + model_name='followup', + name='time_spent', + field=models.DurationField(blank=True, help_text='Time spent on this follow up', null=True), + ), + ] diff --git a/helpdesk/migrations/0025_queue_dedicated_time.py b/helpdesk/migrations/0025_queue_dedicated_time.py new file mode 100644 index 00000000..d3dfd8d3 --- /dev/null +++ b/helpdesk/migrations/0025_queue_dedicated_time.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.5 on 2019-02-19 21:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('helpdesk', '0024_time_spent'), + ] + + operations = [ + migrations.AddField( + model_name='queue', + name='dedicated_time', + field=models.DurationField(blank=True, help_text='Time to be spent on this Queue in total', null=True), + ), + ] diff --git a/helpdesk/migrations/0026_kbitem_attachments.py b/helpdesk/migrations/0026_kbitem_attachments.py new file mode 100644 index 00000000..810672c5 --- /dev/null +++ b/helpdesk/migrations/0026_kbitem_attachments.py @@ -0,0 +1,36 @@ +# Generated by Django 2.0.5 on 2019-03-07 20:30 + +from django.db import migrations, models +import django.db.models.deletion +import helpdesk.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('helpdesk', '0025_queue_dedicated_time'), + ] + + operations = [ + migrations.CreateModel( + name='KBIAttachment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file', models.FileField(max_length=1000, upload_to=helpdesk.models.attachment_path, verbose_name='File')), + ('filename', models.CharField(max_length=1000, verbose_name='Filename')), + ('mime_type', models.CharField(max_length=255, verbose_name='MIME Type')), + ('size', models.IntegerField(help_text='Size of this file in bytes', verbose_name='Size')), + ('kbitem', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='helpdesk.KBItem', verbose_name='Knowledge base item')), + ], + options={ + 'verbose_name': 'Attachment', + 'verbose_name_plural': 'Attachments', + 'ordering': ('filename',), + 'abstract': False, + }, + ), + migrations.RenameModel( + old_name='Attachment', + new_name='FollowUpAttachment', + ), + ] diff --git a/helpdesk/models.py b/helpdesk/models.py index e05c4b7d..be69b589 100644 --- a/helpdesk/models.py +++ b/helpdesk/models.py @@ -17,12 +17,41 @@ from django.utils import timezone from django.utils.translation import ugettext_lazy as _, ugettext from io import StringIO import re +import os +import mimetypes +import datetime + +from django.utils.safestring import mark_safe +from markdown import markdown +from markdown.extensions import Extension + import uuid from .templated_email import send_templated_mail +class EscapeHtml(Extension): + def extendMarkdown(self, md, md_globals): + del md.preprocessors['html_block'] + del md.inlinePatterns['html'] + + +def get_markdown(text): + if not text: + return "" + + return mark_safe( + markdown( + text, + extensions=[ + EscapeHtml(), 'markdown.extensions.nl2br', + 'markdown.extensions.fenced_code' + ] + ) + ) + + class Queue(models.Model): """ A queue is a collection of tickets into what would generally be business @@ -275,6 +304,11 @@ class Queue(models.Model): verbose_name=_('Default owner'), ) + dedicated_time = models.DurationField( + help_text=_("Time to be spent on this Queue in total"), + blank=True, null=True + ) + def __str__(self): return "%s" % self.title @@ -301,6 +335,17 @@ class Queue(models.Model): return u'%s <%s>' % (self.title, self.email_address) from_address = property(_from_address) + @property + def time_spent(self): + """Return back total time spent on the ticket. This is calculated value + based on total sum from all FollowUps + """ + total = datetime.timedelta(0) + for val in self.ticket_set.all(): + if val.time_spent: + total = total + val.time_spent + return total + def prepare_permission_name(self): """Prepare internally the codename for the permission and store it in permission_name. :return: The codename that can be used to create a new Permission object. @@ -497,6 +542,17 @@ class Ticket(models.Model): default=mk_secret, ) + @property + def time_spent(self): + """Return back total time spent on the ticket. This is calculated value + based on total sum from all FollowUps + """ + total = datetime.timedelta(0) + for val in self.followup_set.all(): + if val.time_spent: + total = total + val.time_spent + return total + def send(self, roles, dont_send_to=None, **kwargs): """ Send notifications to everyone interested in this ticket. @@ -689,6 +745,9 @@ class Ticket(models.Model): queue = '-'.join(parts[0:-1]) return queue, parts[-1] + def get_markdown(self): + return get_markdown(self.description) + class FollowUpManager(models.Manager): @@ -740,8 +799,10 @@ class FollowUp(models.Model): _('Public'), blank=True, default=False, - help_text=_('Public tickets are viewable by the submitter and all ' - 'staff, but non-public tickets can only be seen by staff.'), + 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( @@ -771,6 +832,11 @@ class FollowUp(models.Model): objects = FollowUpManager() + time_spent = models.DurationField( + help_text=_("Time spent on this follow up"), + blank=True, null=True + ) + class Meta: ordering = ('date',) verbose_name = _('Follow-up') @@ -788,6 +854,9 @@ class FollowUp(models.Model): t.save() super(FollowUp, self).save(*args, **kwargs) + def get_markdown(self): + return get_markdown(self.comment) + class TicketChange(models.Model): """ @@ -837,18 +906,8 @@ class TicketChange(models.Model): def attachment_path(instance, filename): - """ - Provide a file path that will help prevent files being overwritten, by - putting attachments in a folder off attachments for ticket/followup_id/. - """ - import os - os.umask(0) - path = 'helpdesk/attachments/%s-%s/%s' % (instance.followup.ticket.ticket_for_url, instance.followup.ticket.secret_key, instance.followup.id) - att_path = os.path.join(settings.MEDIA_ROOT, path) - if settings.DEFAULT_FILE_STORAGE == "django.core.files.storage.FileSystemStorage": - if not os.path.exists(att_path): - os.makedirs(att_path, 0o777) - return os.path.join(path, filename) + """Just bridge""" + return instance.attachment_path(filename) class Attachment(models.Model): @@ -857,12 +916,6 @@ class Attachment(models.Model): attachment, or it could be uploaded via the web interface. """ - followup = models.ForeignKey( - FollowUp, - on_delete=models.CASCADE, - verbose_name=_('Follow-up'), - ) - file = models.FileField( _('File'), upload_to=attachment_path, @@ -871,26 +924,102 @@ class Attachment(models.Model): filename = models.CharField( _('Filename'), + blank=True, max_length=1000, ) mime_type = models.CharField( _('MIME Type'), + blank=True, max_length=255, ) size = models.IntegerField( _('Size'), + blank=True, help_text=_('Size of this file in bytes'), ) def __str__(self): return '%s' % self.filename + def save(self, *args, **kwargs): + + if not self.size: + self.size = self.get_size() + + if not self.filename: + self.filename = self.get_filename() + + if not self.mime_type: + self.mime_type = \ + mimetypes.guess_type(self.filename, strict=False)[0] or \ + 'application/octet-stream', + + return super(Attachment, self).save(*args, **kwargs) + + def get_filename(self): + return str(self.file) + + def get_size(self): + return self.file.file.size + + def attachment_path(self, filename): + """Provide a file path that will help prevent files being overwritten, by + putting attachments in a folder off attachments for ticket/followup_id/. + """ + assert NotImplementedError( + "This method is to be implemented by Attachment classes" + ) + class Meta: ordering = ('filename',) verbose_name = _('Attachment') verbose_name_plural = _('Attachments') + abstract = True + + +class FollowUpAttachment(Attachment): + + followup = models.ForeignKey( + FollowUp, + on_delete=models.CASCADE, + verbose_name=_('Follow-up'), + ) + + def attachment_path(self, filename): + + os.umask(0) + path = 'helpdesk/attachments/{ticket_for_url}-{secret_key}/{id_}'.format( + ticket_for_url=self.followup.ticket.ticket_for_url, + secret_key=self.followup.ticket.secret_key, + id_=self.followup.id) + att_path = os.path.join(settings.MEDIA_ROOT, path) + if settings.DEFAULT_FILE_STORAGE == "django.core.files.storage.FileSystemStorage": + if not os.path.exists(att_path): + os.makedirs(att_path, 0o777) + return os.path.join(path, filename) + + +class KBIAttachment(Attachment): + + kbitem = models.ForeignKey( + "KBItem", + on_delete=models.CASCADE, + verbose_name=_('Knowledge base item'), + ) + + def attachment_path(self, filename): + + os.umask(0) + path = 'helpdesk/attachments/kb/{category}/{kbi}'.format( + category=self.kbitem.category, + kbi=self.kbitem.id) + att_path = os.path.join(settings.MEDIA_ROOT, path) + if settings.DEFAULT_FILE_STORAGE == "django.core.files.storage.FileSystemStorage": + if not os.path.exists(att_path): + os.makedirs(att_path, 0o777) + return os.path.join(path, filename) class PreSetReply(models.Model): @@ -1128,6 +1257,9 @@ class KBItem(models.Model): from django.urls import reverse return reverse('helpdesk:kb_item', args=(self.id,)) + def get_markdown(self): + return get_markdown(self.answer) + class SavedSearch(models.Model): """ diff --git a/helpdesk/serializers.py b/helpdesk/serializers.py index e5c7dc7d..abd95581 100644 --- a/helpdesk/serializers.py +++ b/helpdesk/serializers.py @@ -18,11 +18,14 @@ class TicketSerializer(serializers.ModelSerializer): due_date = serializers.SerializerMethodField() status = serializers.SerializerMethodField() row_class = serializers.SerializerMethodField() + time_spent = serializers.SerializerMethodField() class Meta: model = Ticket # fields = '__all__' - fields = ('ticket', 'id', 'priority', 'title', 'queue', 'status', 'created', 'due_date', 'assigned_to', 'row_class') + fields = ('ticket', 'id', 'priority', 'title', 'queue', 'status', + 'created', 'due_date', 'assigned_to', 'row_class', + 'time_spent') def get_ticket(self, obj): return (str(obj.id) + " " + obj.ticket) @@ -45,5 +48,8 @@ class TicketSerializer(serializers.ModelSerializer): else: return ("None") + def get_time_spent(self, obj): + return str(obj.time_spent) + def get_row_class(self, obj): return (obj.get_priority_css_class) diff --git a/helpdesk/static/helpdesk/helpdesk-extend.css b/helpdesk/static/helpdesk/helpdesk-extend.css index 91af82b7..4fa6c7dd 100644 --- a/helpdesk/static/helpdesk/helpdesk-extend.css +++ b/helpdesk/static/helpdesk/helpdesk-extend.css @@ -81,3 +81,9 @@ img.brand {padding-right: 30px;} div.card-body img { max-width: 900px; } + +pre { + background: #eee; + padding: 1em; + border: 1pt solid white; +} diff --git a/helpdesk/templates/helpdesk/followup_edit.html b/helpdesk/templates/helpdesk/followup_edit.html index 0afff6e8..bbd2f0ca 100644 --- a/helpdesk/templates/helpdesk/followup_edit.html +++ b/helpdesk/templates/helpdesk/followup_edit.html @@ -46,6 +46,8 @@
{{ form.new_status }}

If the status was changed, what was it changed to?

+
+
{{ form.time_spent }}

{% csrf_token %} diff --git a/helpdesk/templates/helpdesk/kb_item.html b/helpdesk/templates/helpdesk/kb_item.html index cfefaa1d..bdd8457b 100644 --- a/helpdesk/templates/helpdesk/kb_item.html +++ b/helpdesk/templates/helpdesk/kb_item.html @@ -1,4 +1,4 @@ -{% extends "helpdesk/public_base.html" %}{% load i18n %}{% load markdown_deux_tags %} +{% extends "helpdesk/public_base.html" %}{% load i18n %} {% block helpdesk_breadcrumb %}
  • {% blocktrans with change.field as field and change.old_value as old_value and change.new_value as new_value %}Changed {{ field }} from {{ old_value }} to {{ new_value }}.{% endblocktrans %}
  • {% endfor %} {% endif %} -{% for attachment in followup.attachment_set.all %}{% if forloop.first %}

    {% if followup.comment %} -

    {{ followup.comment|force_escape|urlizetrunc:50|num_to_link|linebreaksbr }}

    +

    {{ followup.get_markdown|urlizetrunc:50|num_to_link|linebreaksbr }}

    + {% endif %} + {% if followup.time_spent %} + {% trans "Time spent" %}: {{ followup.time_spent }}

    {% endif %} {% for change in followup.ticketchange_set.all %} {% if forloop.first %}
    diff --git a/helpdesk/templates/helpdesk/ticket_list.html b/helpdesk/templates/helpdesk/ticket_list.html index ea8a5d1b..46db483e 100644 --- a/helpdesk/templates/helpdesk/ticket_list.html +++ b/helpdesk/templates/helpdesk/ticket_list.html @@ -222,6 +222,7 @@ {% trans "Created" %} {% trans "Due Date" %} {% trans "Owner" %} + {% trans "Time Spent" %} {% if not server_side %} @@ -339,6 +340,7 @@ {"data": "created"}, {"data": "due_date"}, {"data": "assigned_to"}, + {"data": "time_spent"}, ] }); }) diff --git a/helpdesk/templates/helpdesk/ticket_list_table.html b/helpdesk/templates/helpdesk/ticket_list_table.html index 6b999cb7..fa4b9d69 100644 --- a/helpdesk/templates/helpdesk/ticket_list_table.html +++ b/helpdesk/templates/helpdesk/ticket_list_table.html @@ -12,6 +12,7 @@ {{ ticket.created|naturaltime }} {{ ticket.due_date|naturaltime }} {{ ticket.get_assigned_to }} + {{ ticket.time_spent }} {% endfor %} diff --git a/helpdesk/tests/test_attachments.py b/helpdesk/tests/test_attachments.py index 5433e378..5da367b3 100644 --- a/helpdesk/tests/test_attachments.py +++ b/helpdesk/tests/test_attachments.py @@ -58,7 +58,7 @@ class AttachmentIntegrationTests(TestCase): self.assertContains(response, test_file.name) # Ensure attachment is available with correct content - att = models.Attachment.objects.get(followup__ticket=response.context['ticket']) + att = models.FollowUpAttachment.objects.get(followup__ticket=response.context['ticket']) with open(os.path.join(MEDIA_DIR, att.file.name)) as file_on_disk: disk_content = file_on_disk.read() self.assertEqual(disk_content, 'attached file content') @@ -76,7 +76,7 @@ class AttachmentIntegrationTests(TestCase): self.assertContains(response, test_file.name) # Ensure attachment is available with correct content - att = models.Attachment.objects.get(followup__ticket=response.context['ticket']) + att = models.FollowUpAttachment.objects.get(followup__ticket=response.context['ticket']) with open(os.path.join(MEDIA_DIR, att.file.name)) as file_on_disk: disk_content = smart_text(file_on_disk.read(), 'utf-8') self.assertEqual(disk_content, 'โจ') @@ -96,7 +96,7 @@ class AttachmentUnitTests(TestCase): self.test_file = SimpleUploadedFile.from_dict(self.file_attrs) self.follow_up = models.FollowUp(ticket=models.Ticket(queue=models.Queue())) - @mock.patch('helpdesk.lib.Attachment', autospec=True) + @mock.patch('helpdesk.lib.FollowUpAttachment', autospec=True) def test_unicode_attachment_filename(self, mock_att_save, mock_queue_save, mock_ticket_save, mock_follow_up_save): """ check utf-8 data is parsed correcltly """ filename, fileobj = lib.process_attachments(self.follow_up, [self.test_file])[0] @@ -109,7 +109,22 @@ class AttachmentUnitTests(TestCase): ) self.assertEqual(filename, self.file_attrs['filename']) - @mock.patch.object(lib.Attachment, 'save', autospec=True) + @mock.patch('helpdesk.lib.FollowUpAttachment', autospec=True) + def xest_autofill_(self, mock_att_save, mock_queue_save, mock_ticket_save, mock_follow_up_save): + """ check utf-8 data is parsed correcltly """ + filename, fileobj = lib.process_attachments(self.follow_up, [self.test_file])[0] + obj = mock_att_save.assert_called_with( + file=self.test_file, + filename=None, + mime_type=None, + size=None, + followup=self.follow_up + ) + self.assertEqual(obj.filename, self.file_attrs['filename']) + self.assertEqual(obj.size, len(self.file_attrs['content'])) + self.assertEqual(obj.mime_type, self.file_attrs['content-type']) + + @mock.patch.object(lib.FollowUpAttachment, 'save', autospec=True) @override_settings(MEDIA_ROOT=MEDIA_DIR) def test_unicode_filename_to_filesystem(self, mock_att_save, mock_queue_save, mock_ticket_save, mock_follow_up_save): """ don't mock saving to filesystem to test file renames caused by storage layer """ @@ -118,7 +133,7 @@ class AttachmentUnitTests(TestCase): attachment_obj = mock_att_save.call_args[0][0] mock_att_save.assert_called_once_with(attachment_obj) - self.assertIsInstance(attachment_obj, models.Attachment) + self.assertIsInstance(attachment_obj, models.FollowUpAttachment) self.assertEqual(attachment_obj.filename, self.file_attrs['filename']) diff --git a/helpdesk/tests/test_files/all-special-chars.eml b/helpdesk/tests/test_files/all-special-chars.eml new file mode 100644 index 00000000..4d922482 --- /dev/null +++ b/helpdesk/tests/test_files/all-special-chars.eml @@ -0,0 +1,15 @@ +To: helpdesk@auto-mat.cz +From: Timothy Hobbs +Subject: =?UTF-8?B?VGVzdG92w6Fjw60gZW1haWw=?= +Openpgp: preference=signencrypt +Message-ID: <0fd7067e-2842-5b6c-3548-3cf7e6a1c9ea@auto-mat.cz> +Date: Fri, 8 Mar 2019 23:40:04 +0100 +User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:60.0) Gecko/20100101 + Thunderbird/60.4.0 +MIME-Version: 1.0 +Content-Type: text/plain; charset=utf-8 +Content-Transfer-Encoding: 8bit +Content-Language: en-US + +íářčšáíéřášč + diff --git a/helpdesk/tests/test_files/quoted_printable.eml b/helpdesk/tests/test_files/quoted_printable.eml new file mode 100644 index 00000000..477079f5 --- /dev/null +++ b/helpdesk/tests/test_files/quoted_printable.eml @@ -0,0 +1,22 @@ +MIME-Version: 1.0 +Date: Tue, 5 Mar 2019 18:03:06 +0100 +Message-ID: +Subject: =?UTF-8?B?xIxlc2vDvSB0ZXN0?= +From: Tim hobbs +To: helpdesk@auto-mat.cz +Content-Type: multipart/alternative; boundary="00000000000002a7c505835bd998" + +--00000000000002a7c505835bd998 +Content-Type: text/plain; charset="UTF-8" +Content-Transfer-Encoding: quoted-printable + +Tohle je test =C4=8Desk=C3=BDch p=C3=ADsmen odeslan=C3=BDch z gmailu. + +--00000000000002a7c505835bd998 +Content-Type: text/html; charset="UTF-8" +Content-Transfer-Encoding: quoted-printable + +
    Tohle je test =C4=8Desk=C3=BDch p=C3=ADsmen odeslan=C3=BDc= +h z gmailu.
    + +--00000000000002a7c505835bd998-- \ No newline at end of file diff --git a/helpdesk/tests/test_get_email.py b/helpdesk/tests/test_get_email.py index a861cb14..76184ec1 100644 --- a/helpdesk/tests/test_get_email.py +++ b/helpdesk/tests/test_get_email.py @@ -5,7 +5,7 @@ from django.shortcuts import get_object_or_404 from django.contrib.auth.models import User from django.contrib.auth.hashers import make_password -from helpdesk.models import Queue, Ticket, TicketCC, FollowUp, Attachment +from helpdesk.models import Queue, Ticket, TicketCC, FollowUp, FollowUpAttachment import helpdesk.email import itertools @@ -53,9 +53,37 @@ class GetEmailCommonTests(TestCase): with open(os.path.join(THIS_DIR, "test_files/blank-body-with-attachment.eml")) as fd: test_email = fd.read() ticket = helpdesk.email.object_from_message(test_email, self.queue_public, self.logger) - self.assertEqual(ticket.title, "Attachment without body") + self.assertEqual(ticket.title, "FollowUpAttachment without body") self.assertEqual(ticket.description, "") + def test_email_with_quoted_printable_body(self): + """ + Tests that emails with quoted-printable bodies work. + """ + with open(os.path.join(THIS_DIR, "test_files/quoted_printable.eml")) as fd: + test_email = fd.read() + ticket = helpdesk.email.object_from_message(test_email, self.queue_public, self.logger) + self.assertEqual(ticket.title, "Český test") + self.assertEqual(ticket.description, "Tohle je test českých písmen odeslaných z gmailu.") + followups = FollowUp.objects.filter(ticket=ticket) + self.assertEqual(len(followups), 1) + followup = followups[0] + attachments = FollowUpAttachment.objects.filter(followup=followup) + self.assertEqual(len(attachments), 1) + attachment = attachments[0] + self.assertEqual(attachment.file.read().decode("utf-8"), '
    Tohle je test českých písmen odeslaných z gmailu.
    \n') + + def test_email_with_8bit_encoding_and_utf_8(self): + """ + Tests that emails with 8bit transfer encoding and utf-8 charset + https://github.com/django-helpdesk/django-helpdesk/issues/732 + """ + with open(os.path.join(THIS_DIR, "test_files/all-special-chars.eml")) as fd: + test_email = fd.read() + ticket = helpdesk.email.object_from_message(test_email, self.queue_public, self.logger) + self.assertEqual(ticket.title, "Testovácí email") + self.assertEqual(ticket.description, "íářčšáíéřášč") + class GetEmailParametricTemplate(object): """TestCase that checks basic email functionality across methods and socks configs.""" @@ -346,7 +374,7 @@ class GetEmailParametricTemplate(object): # HTML MIME part should be attached to follow up followup1 = get_object_or_404(FollowUp, pk=1) self.assertEqual(followup1.ticket.id, 1) - attach1 = get_object_or_404(Attachment, pk=1) + attach1 = get_object_or_404(FollowUpAttachment, pk=1) self.assertEqual(attach1.followup.id, 1) self.assertEqual(attach1.filename, 'email_html_body.html') cc0 = get_object_or_404(TicketCC, pk=1) @@ -365,7 +393,7 @@ class GetEmailParametricTemplate(object): # HTML MIME part should be attached to follow up followup2 = get_object_or_404(FollowUp, pk=2) self.assertEqual(followup2.ticket.id, 2) - attach2 = get_object_or_404(Attachment, pk=2) + attach2 = get_object_or_404(FollowUpAttachment, pk=2) self.assertEqual(attach2.followup.id, 2) self.assertEqual(attach2.filename, 'email_html_body.html') @@ -432,7 +460,7 @@ class GetEmailParametricTemplate(object): # MIME part should be attached to follow up followup1 = get_object_or_404(FollowUp, pk=1) self.assertEqual(followup1.ticket.id, 1) - attach1 = get_object_or_404(Attachment, pk=1) + attach1 = get_object_or_404(FollowUpAttachment, pk=1) self.assertEqual(attach1.followup.id, 1) self.assertEqual(attach1.filename, 'signature.asc') self.assertEqual(attach1.file.read(), b"""-----BEGIN PGP SIGNATURE----- diff --git a/helpdesk/tests/test_ticket_actions.py b/helpdesk/tests/test_ticket_actions.py index 5e5ac70f..8b2b4627 100644 --- a/helpdesk/tests/test_ticket_actions.py +++ b/helpdesk/tests/test_ticket_actions.py @@ -55,6 +55,18 @@ class TicketActionsTestCase(TestCase): self.user.save() self.client.login(username='User_1', password='pass') + def test_ticket_markdown(self): + + ticket_data = { + 'queue': self.queue_public, + 'title': 'Test Ticket', + 'description': '*bold*', + } + + ticket = Ticket.objects.create(**ticket_data) + self.assertEqual(ticket.get_markdown(), + "

    bold

    ") + def test_delete_ticket_staff(self): # make staff user self.loginUser() diff --git a/helpdesk/tests/test_time_spent.py b/helpdesk/tests/test_time_spent.py new file mode 100644 index 00000000..a71c3ac9 --- /dev/null +++ b/helpdesk/tests/test_time_spent.py @@ -0,0 +1,76 @@ +from django.contrib.auth import get_user_model +from django.contrib.sites.models import Site +from django.core import mail +from django.urls import reverse +from django.test import TestCase +from django.test.client import Client +from helpdesk.models import Queue, Ticket, FollowUp +from helpdesk import settings as helpdesk_settings +from django.contrib.auth.models import User +from django.contrib.auth.hashers import make_password +import uuid +import datetime + +try: # python 3 + from urllib.parse import urlparse +except ImportError: # python 2 + from urlparse import urlparse + +from helpdesk.templatetags.ticket_to_link import num_to_link +from helpdesk.views.staff import _is_my_ticket + + +class TimeSpentTestCase(TestCase): + + def setUp(self): + self.queue_public = Queue.objects.create( + title='Queue 1', + slug='q1', + allow_public_submission=True, + dedicated_time=datetime.timedelta(minutes=60) + ) + + self.ticket_data = { + 'title': 'Test Ticket', + 'description': 'Some Test Ticket', + } + + ticket_data = dict(queue=self.queue_public, **self.ticket_data) + self.ticket = Ticket.objects.create(**ticket_data) + + self.client = Client() + + user1_kwargs = { + 'username': 'staff', + 'email': 'staff@example.com', + 'password': make_password('Test1234'), + 'is_staff': True, + 'is_superuser': False, + 'is_active': True + } + self.user = User.objects.create(**user1_kwargs) + + def test_add_followup(self): + """Tests whether staff can delete tickets""" + + message_id = uuid.uuid4().hex + followup = FollowUp.objects.create( + ticket=self.ticket, + date=datetime.datetime.now(), + title="Testing followup", + comment="Testing followup time spent", + public=True, + user=self.user, + new_status=1, + message_id=message_id, + time_spent=datetime.timedelta(minutes=30) + ) + + followup.save() + + self.assertEqual(followup.time_spent.seconds, 1800) + self.assertEqual(self.ticket.time_spent.seconds, 1800) + self.assertEqual(self.queue_public.time_spent.seconds, 1800) + self.assertTrue( + self.queue_public.dedicated_time.seconds > self.queue_public.time_spent.seconds + ) diff --git a/helpdesk/views/staff.py b/helpdesk/views/staff.py index 331b5f6c..b09d8cba 100644 --- a/helpdesk/views/staff.py +++ b/helpdesk/views/staff.py @@ -42,7 +42,7 @@ from helpdesk.lib import ( process_attachments, queue_template_context, ) from helpdesk.models import ( - Ticket, Queue, FollowUp, TicketChange, PreSetReply, Attachment, SavedSearch, + Ticket, Queue, FollowUp, TicketChange, PreSetReply, FollowUpAttachment, SavedSearch, IgnoreEmail, TicketCC, TicketDependency, UserSettings, ) from helpdesk import settings as helpdesk_settings @@ -237,6 +237,7 @@ def followup_edit(request, ticket_id, followup_id): 'comment': escape(followup.comment), 'public': followup.public, 'new_status': followup.new_status, + 'time_spent': followup.time_spent, }) ticketcc_string, show_subscribe = \ @@ -256,15 +257,19 @@ def followup_edit(request, ticket_id, followup_id): comment = form.cleaned_data['comment'] public = form.cleaned_data['public'] new_status = form.cleaned_data['new_status'] + time_spent = form.cleaned_data['time_spent'] # will save previous date old_date = followup.date - new_followup = FollowUp(title=title, date=old_date, ticket=_ticket, comment=comment, public=public, new_status=new_status, ) + new_followup = FollowUp(title=title, date=old_date, ticket=_ticket, + comment=comment, public=public, + new_status=new_status, + time_spent=time_spent) # keep old user if one did exist before. if followup.user: new_followup.user = followup.user new_followup.save() # get list of old attachments & link them to new_followup - attachments = Attachment.objects.filter(followup=followup) + attachments = FolllowUpAttachment.objects.filter(followup=followup) for attachment in attachments: attachment.followup = new_followup attachment.save() @@ -469,6 +474,11 @@ def update_ticket(request, ticket_id, public=False): due_date_year = int(request.POST.get('due_date_year', 0)) due_date_month = int(request.POST.get('due_date_month', 0)) due_date_day = int(request.POST.get('due_date_day', 0)) + if request.POST.get("time_spent"): + (hours, minutes) = [int(f) for f in request.POST.get("time_spent").split(":")] + time_spent = timedelta(hours=hours, minutes=minutes) + else: + time_spent = None # NOTE: jQuery's default for dates is mm/dd/yy # very US-centric but for now that's the only format supported # until we clean up code to internationalize a little more @@ -523,7 +533,8 @@ def update_ticket(request, ticket_id, public=False): if owner is -1 and ticket.assigned_to: owner = ticket.assigned_to.id - f = FollowUp(ticket=ticket, date=timezone.now(), comment=comment) + f = FollowUp(ticket=ticket, date=timezone.now(), comment=comment, + time_spent=time_spent) if is_helpdesk_staff(request.user): f.user = request.user @@ -1161,6 +1172,8 @@ def report_index(request): 'open': queue.ticket_set.filter(status__in=[1, 2]).count(), 'resolved': queue.ticket_set.filter(status=3).count(), 'closed': queue.ticket_set.filter(status=4).count(), + 'time_spent': queue.time_spent, + 'dedicated_time': queue.dedicated_time } dash_tickets.append(dash_ticket) @@ -1568,7 +1581,7 @@ def attachment_del(request, ticket_id, attachment_id): if not _is_my_ticket(request.user, ticket): raise PermissionDenied() - attachment = get_object_or_404(Attachment, id=attachment_id) + attachment = get_object_or_404(FolllowUpAttachment, id=attachment_id) if request.method == 'POST': attachment.delete() return HttpResponseRedirect(reverse('helpdesk:view', args=[ticket_id])) diff --git a/quicktest.py b/quicktest.py index 3b7d6bbf..85eb9c41 100644 --- a/quicktest.py +++ b/quicktest.py @@ -27,7 +27,6 @@ class QuickDjangoTest(object): 'django.contrib.sessions', 'django.contrib.sites', 'django.contrib.staticfiles', - 'markdown_deux', 'bootstrap4form' ) MIDDLEWARE = [ diff --git a/requirements.txt b/requirements.txt index 72cf63f8..27a03969 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ celery django-celery-beat email-reply-parser akismet -django-markdown-deux +markdown beautifulsoup4 lxml simplejson