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 @@
If the status was changed, what was it changed to?
+ +{{ item.answer|markdown }}
+{{ item.get_markdown }}