Merge pull request #717 from OpenGeoLabs/time_tracking

Basic support for tracking time spent on tickets and follow-ups
This commit is contained in:
Garret Wassermann 2019-02-24 15:18:01 -05:00 committed by GitHub
commit 4fb6c40c4e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 202 additions and 6 deletions

View File

@ -8,13 +8,22 @@ 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,6 +37,9 @@ 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
@ -40,7 +52,8 @@ class AttachmentInline(admin.StackedInline):
@admin.register(FollowUp)
class FollowUpAdmin(admin.ModelAdmin):
inlines = [TicketChangeInline, AttachmentInline]
list_display = ('ticket_get_ticket_for_url', 'title', 'date', 'ticket', 'user', 'new_status')
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):

View File

@ -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),
),
]

View File

@ -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),
),
]

View File

@ -17,6 +17,7 @@ from django.utils import timezone
from django.utils.translation import ugettext_lazy as _, ugettext
from io import StringIO
import re
import datetime
import uuid
@ -275,6 +276,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 +307,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 +514,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.
@ -771,6 +799,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')

View File

@ -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)

View File

@ -46,6 +46,8 @@
<dt><label for="id_new_status">New Status:</label></dt>
<dd>{{ form.new_status }}</dd>
<p>If the status was changed, what was it changed to?</p>
<dt><label for="id_time_spent">Time spent:</label></dt>
<dd>{{ form.time_spent }}</dd>
</dl>
</fieldset>
<p><input class="btn btn-primary" type="submit" value="Submit"></p>{% csrf_token %}

View File

@ -45,6 +45,7 @@
<th>{% trans "Open" %}</th>
<th>{% trans "Resolved" %}</th>
<th>{% trans "Closed" %}</th>
<th>{% trans "Time spent" %}</th>
</tr>
</thead>
<tbody>
@ -54,6 +55,7 @@
<td>{% if queue.open %}<a href='{{ hdlist }}?queue={{ queue.queue }}&status=1&status=2'>{% endif %}{{ queue.open }}{% if queue.open %}</a>{% endif %}</td>
<td>{% if queue.resolved %}<a href='{{ hdlist }}?queue={{ queue.queue }}&status=3'>{% endif %}{{ queue.resolved }}{% if queue.resolved %}</a>{% endif %}</td>
<td>{% if queue.closed %}<a href='{{ hdlist }}?queue={{ queue.queue }}&status=4'>{% endif %}{{ queue.closed }}{% if queue.closed %}</a>{% endif %}</td>
<td>{{ queue.time_spent }}{% if queue.dedicated_time %} / {{ queue.dedicated_time }}{% endif %}</td>
</tr>
{% empty %}
<tr><td colspan='6'>{% trans "There are no unassigned tickets." %}</td></tr>

View File

@ -46,6 +46,9 @@
{% if followup.comment %}
<p>{{ followup.comment|force_escape|urlizetrunc:50|num_to_link|linebreaksbr }}</p>
{% endif %}
{% if followup.time_spent %}
<small>{% trans "Time spent" %}: {{ followup.time_spent }}</small></p>
{% endif %}
{% for change in followup.ticketchange_set.all %}
{% if forloop.first %}<div class='changes'><ul>{% endif %}
<li>{% 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 %}</li>
@ -152,6 +155,11 @@
<dd><input type='checkbox' name='public' value='1' checked='checked' />&nbsp; {% trans 'Yes, make this update public.' %}</dd>
<dd class='form_help_text'>{% trans "If this is public, the submitter will be e-mailed your comment or resolution." %}</dd>
{% endif %}
<dt>
<label for='id_time_spent'>{% trans "Time spent" %}</label> <span class='form_optional'>{% trans "(Optional)" %}</span>
</dt>
<dd><input name='time_spent' type="time" /></dd>
</dl>
<p id='ShowFurtherOptPara'><button class="btn btn-warning btn-sm" id='ShowFurtherEditOptions'>{% trans "Change Further Details &raquo;" %}</button></p>

View File

@ -89,6 +89,10 @@
<p><a data-toggle='tooltip' href='{% url 'helpdesk:ticket_dependency_add' ticket.id %}' title="{% trans "Click on 'Add Dependency', if you want to make this ticket dependent on another ticket. A ticket may not be closed until all tickets it depends on are closed." %}"><button type="button" class="btn btn-primary btn-sm"><i class="fas fa-child"></i>&nbsp;{% trans "Add Dependency" %}</button></a></p>
</td>
</tr>
<tr>
<th>{% trans "Total time spent" %}</th>
<td>{{ ticket.time_spent }}</td>
</tr>
</tbody>
</table>
</div>

View File

@ -222,6 +222,7 @@
<th>{% trans "Created" %}</th>
<th>{% trans "Due Date" %}</th>
<th>{% trans "Owner" %}</th>
<th>{% trans "Time Spent" %}</th>
</tr>
</thead>
{% if not server_side %}
@ -339,6 +340,7 @@
{"data": "created"},
{"data": "due_date"},
{"data": "assigned_to"},
{"data": "time_spent"},
]
});
})

View File

@ -12,6 +12,7 @@
<td data-order='{{ ticket.created|date:"U" }}'><span title='{{ ticket.created|date:"r" }}'>{{ ticket.created|naturaltime }}</span></td>
<td data-order='{{ ticket.due_date|date:"U" }}'><span title='{{ ticket.due_date|date:"r" }}'>{{ ticket.due_date|naturaltime }}</span></td>
<td>{{ ticket.get_assigned_to }}</td>
<td>{{ ticket.time_spent }}</td>
</tr>
{% endfor %}
</tbody>

View File

@ -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
)

View File

@ -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,9 +257,13 @@ 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
@ -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)