diff --git a/docs/settings.rst b/docs/settings.rst index 56199046..70ba5f6d 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -291,6 +291,56 @@ Options that change ticket properties (7, _('7. Hot')), ) +Time Tracking Options +--------------------- + +- **HELPDESK_FOLLOWUP_TIME_SPENT_AUTO** If ``True``, calculate follow-up 'time_spent' with previous follow-up or ticket creation time. + + **Default:** ``HELPDESK_FOLLOWUP_TIME_SPENT_AUTO = False`` + +- **HELPDESK_FOLLOWUP_TIME_SPENT_OPENING_HOURS** If defined, calculates follow-up 'time_spent' according to open hours. + + **Default:** ``HELPDESK_FOLLOWUP_TIME_SPENT_OPENING_HOURS = {}`` + + If HELPDESK_FOLLOWUP_TIME_SPENT_AUTO is ``True``, you may set open hours to remove off hours from 'time_spent':: + + HELPDESK_FOLLOWUP_TIME_SPENT_OPENING_HOURS = { + "monday": (8.5, 19), + "tuesday": (8.5, 19), + "wednesday": (8.5, 19), + "thursday": (8.5, 19), + "friday": (8.5, 19), + "saturday": (0, 0), + "sunday": (0, 0), + } + + Valid hour values must be set between 0 and 23.9999. + In this example 8.5 is interpreted as 8:30AM, saturdays and sundays don't count. + +- **HELPDESK_FOLLOWUP_TIME_SPENT_EXCLUDE_HOLIDAYS** List of days in format "%Y-%m-%d" to exclude from automatic follow-up 'time_spent' calculation. + + **Default:** ``HELPDESK_FOLLOWUP_TIME_SPENT_EXCLUDE_HOLIDAYS = ()`` + + This example removes Christmas and New Year's Eve in 2024:: + + HELPDESK_FOLLOWUP_TIME_SPENT_EXCLUDE_HOLIDAYS = ("2024-12-25", "2024-12-31",) + +- **HELPDESK_FOLLOWUP_TIME_SPENT_EXCLUDE_STATUSES** List of ticket statuses to exclude from automatic follow-up 'time_spent' calculation. + + **Default:** ``HELPDESK_FOLLOWUP_TIME_SPENT_EXCLUDE_STATUSES = ()`` + + This example will have follow-ups to resolved ticket status not to be counted in:: + + HELPDESK_FOLLOWUP_TIME_SPENT_EXCLUDE_STATUSES = (HELPDESK_TICKET_RESOLVED_STATUS,) + +- **HELPDESK_FOLLOWUP_TIME_SPENT_EXCLUDE_QUEUES** List of ticket queues slugs to exclude from automatic follow-up 'time_spent' calculation. + + **Default:** ``HELPDESK_FOLLOWUP_TIME_SPENT_EXCLUDE_QUEUES = ()`` + + This example will have follow-ups to ticket queue 'time-not-counting-queue' not to be counted in:: + + HELPDESK_FOLLOWUP_TIME_SPENT_EXCLUDE_QUEUES = ('time-not-counting-queue',) + Staff Ticket Creation Settings ------------------------------ diff --git a/helpdesk/lib.py b/helpdesk/lib.py index ac0d46bc..481d4cbb 100644 --- a/helpdesk/lib.py +++ b/helpdesk/lib.py @@ -9,7 +9,7 @@ lib.py - Common functions (eg multipart e-mail) from datetime import date, datetime, time from django.conf import settings -from django.core.exceptions import ValidationError +from django.core.exceptions import ValidationError, ImproperlyConfigured from django.utils.encoding import smart_str from helpdesk.settings import CUSTOMFIELD_DATE_FORMAT, CUSTOMFIELD_DATETIME_FORMAT, CUSTOMFIELD_TIME_FORMAT import logging @@ -173,11 +173,10 @@ def format_time_spent(time_spent): """Format time_spent attribute to "[H]HHh:MMm" text string to be allign in all graphical outputs """ - if time_spent: time_spent = "{0:02d}h:{1:02d}m".format( - time_spent.seconds // 3600, - time_spent.seconds // 60 + int(time_spent.total_seconds()) // 3600, + int(time_spent.total_seconds()) % 3600 // 60 ) else: time_spent = "" @@ -194,3 +193,45 @@ def convert_value(value): return value.strftime(CUSTOMFIELD_TIME_FORMAT) else: return value + + +def daily_time_spent_calculation(earliest, latest, open_hours): + """Returns the number of seconds for a single day time interval according to open hours.""" + + # avoid rendering day in different locale + weekday = ('monday', 'tuesday', 'wednesday', 'thursday', + 'friday', 'saturday', 'sunday')[earliest.weekday()] + + # enforce correct settings + MIDNIGHT = 23.9999 + start, end = open_hours.get(weekday, (0, MIDNIGHT)) + if not 0 <= start <= end <= MIDNIGHT: + raise ImproperlyConfigured("HELPDESK_FOLLOWUP_TIME_SPENT_OPENING_HOURS" + f" setting for {weekday} out of (0, 23.9999) boundary") + + # transform decimals to minutes and seconds + start_hour, start_minute, start_second = int(start), int(start % 1 * 60), int(start * 60 % 1 * 60) + end_hour, end_minute, end_second = int(end), int(end % 1 * 60), int(end * 60 % 1 * 60) + + # translate time for delta calculation + earliest_f = earliest.hour + earliest.minute / 60 + earliest.second / 3600 + latest_f = latest.hour + latest.minute / 60 + latest.second / 3600 + + if earliest_f < start: + earliest = earliest.replace(hour=start_hour, minute=start_minute, second=start_second) + elif earliest_f >= end: + earliest = earliest.replace(hour=end_hour, minute=end_minute, second=end_second) + + if latest_f < start: + latest = latest.replace(hour=start_hour, minute=start_minute, second=start_second) + elif latest_f >= end: + latest = latest.replace(hour=end_hour, minute=end_minute, second=end_second) + + day_delta = latest - earliest + + # returns up to 86399 seconds, add one second if full day + time_spent_seconds = day_delta.seconds + if time_spent_seconds == 86399: + time_spent_seconds += 1 + + return time_spent_seconds \ No newline at end of file diff --git a/helpdesk/models.py b/helpdesk/models.py index b585f9ea..4409d901 100644 --- a/helpdesk/models.py +++ b/helpdesk/models.py @@ -8,7 +8,7 @@ models.py - Model (and hence database) definitions. This is the core of the """ -from .lib import convert_value +from .lib import format_time_spent, convert_value, daily_time_spent_calculation from .templated_email import send_templated_mail from .validators import validate_file_extension from .webhooks import send_new_ticket_webhook @@ -33,17 +33,6 @@ from rest_framework import serializers import uuid -def format_time_spent(time_spent): - if time_spent: - time_spent = "{0:02d}h:{1:02d}m".format( - time_spent.seconds // 3600, - time_spent.seconds % 3600 // 60 - ) - else: - time_spent = "" - return time_spent - - class EscapeHtml(Extension): def extendMarkdown(self, md): md.preprocessors.deregister('html_block') @@ -1000,9 +989,12 @@ class FollowUp(models.Model): return u"%s#followup%s" % (self.ticket.get_absolute_url(), self.id) def save(self, *args, **kwargs): - t = self.ticket - t.modified = timezone.now() - t.save() + self.ticket.modified = timezone.now() + self.ticket.save() + + if helpdesk_settings.FOLLOWUP_TIME_SPENT_AUTO and not self.time_spent: + self.time_spent = self.time_spent_calculation() + super(FollowUp, self).save(*args, **kwargs) def get_markdown(self): @@ -1012,6 +1004,62 @@ class FollowUp(models.Model): def time_spent_formated(self): return format_time_spent(self.time_spent) + def time_spent_calculation(self): + "Returns timedelta according to rules settings." + + # extract earliest from previous follow-up or ticket + try: + prev_fup_qs = self.ticket.followup_set.all() + if self.id: + prev_fup_qs = prev_fup_qs.filter(id__lt=self.id) + prev_fup = prev_fup_qs.latest("date") + earliest = prev_fup.date + except ObjectDoesNotExist: + earliest = self.ticket.created + + # extract previous status from follow-up or ticket + try: + prev_fup_qs = self.ticket.followup_set.exclude(new_status__isnull=True) + if self.id: + prev_fup_qs = prev_fup_qs.filter(id__lt=self.id) + prev_fup = prev_fup_qs.latest("date") + prev_status = prev_fup.new_status + except ObjectDoesNotExist: + prev_status = self.ticket.status + + # latest time is current follow-up date + latest = self.date + + time_spent_seconds = 0 + open_hours = helpdesk_settings.FOLLOWUP_TIME_SPENT_OPENING_HOURS + holidays = helpdesk_settings.FOLLOWUP_TIME_SPENT_EXCLUDE_HOLIDAYS + exclude_statuses = helpdesk_settings.FOLLOWUP_TIME_SPENT_EXCLUDE_STATUSES + exclude_queues = helpdesk_settings.FOLLOWUP_TIME_SPENT_EXCLUDE_QUEUES + + # split time interval by days + days = latest.toordinal() - earliest.toordinal() + for day in range(days + 1): + if day == 0: + start_day_time = earliest + if days == 0: + # close single day case + end_day_time = latest + else: + end_day_time = earliest.replace(hour=23, minute=59, second=59) + elif day == days: + start_day_time = latest.replace(hour=0, minute=0, second=0) + end_day_time = latest + else: + middle_day_time = earliest + datetime.timedelta(days=day) + start_day_time = middle_day_time.replace(hour=0, minute=0, second=0) + end_day_time = middle_day_time.replace(hour=23, minute=59, second=59) + + if (start_day_time.strftime("%Y-%m-%d") not in holidays and + prev_status not in exclude_statuses and + self.ticket.queue.slug not in exclude_queues): + time_spent_seconds += daily_time_spent_calculation(start_day_time, end_day_time, open_hours) + + return datetime.timedelta(seconds=time_spent_seconds) class TicketChange(models.Model): """ diff --git a/helpdesk/settings.py b/helpdesk/settings.py index cf5ca644..995081f6 100644 --- a/helpdesk/settings.py +++ b/helpdesk/settings.py @@ -151,6 +151,36 @@ TICKET_PRIORITY_CHOICES = getattr(settings, 'HELPDESK_TICKET_PRIORITY_CHOICES', DEFAULT_TICKET_PRIORITY_CHOICES) + +######################### +# time tracking options # +######################### + +# Follow-ups automatic time_spent calculation +FOLLOWUP_TIME_SPENT_AUTO = getattr(settings, + 'HELPDESK_FOLLOWUP_TIME_SPENT_AUTO', + False) + +# Calculate time_spent according to open hours +FOLLOWUP_TIME_SPENT_OPENING_HOURS = getattr(settings, + 'HELPDESK_FOLLOWUP_TIME_SPENT_OPENING_HOURS', + {}) + +# Holidays don't count for time_spent calculation +FOLLOWUP_TIME_SPENT_EXCLUDE_HOLIDAYS = getattr(settings, + 'HELPDESK_FOLLOWUP_TIME_SPENT_EXCLUDE_HOLIDAYS', + ()) + +# Time doesn't count for listed ticket statuses +FOLLOWUP_TIME_SPENT_EXCLUDE_STATUSES = getattr(settings, + 'HELPDESK_FOLLOWUP_TIME_SPENT_EXCLUDE_STATUSES', + ()) + +# Time doesn't count for listed queues slugs +FOLLOWUP_TIME_SPENT_EXCLUDE_QUEUES = getattr(settings, + 'HELPDESK_FOLLOWUP_TIME_SPENT_EXCLUDE_QUEUES', + ()) + ############################ # options for public pages # ############################