Skip to content

Commit

Permalink
* Task.percent_complete value is now properly calculated for a pa…
Browse files Browse the repository at this point in the history
…rent Task that contains a mixed type of "effort", "duration" and "length" based tasks.
  • Loading branch information
eoyilmaz committed Aug 4, 2021
1 parent cb60a74 commit b4e863f
Show file tree
Hide file tree
Showing 6 changed files with 222 additions and 61 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.rst
Expand Up @@ -2,6 +2,12 @@
Stalker Changes
===============

0.2.26
======

* ``Task.percent_complete`` value is now properly calculated for a parent Task
that contains a mixed type of "effort", "duration" and "length" based tasks.

0.2.25.1
========

Expand Down
2 changes: 1 addition & 1 deletion stalker/__init__.py
Expand Up @@ -7,7 +7,7 @@

import sys

__version__ = '0.2.25.1'
__version__ = '0.2.26'

__title__ = "stalker"
__description__ = 'A Production Asset Management (ProdAM) System'
Expand Down
19 changes: 8 additions & 11 deletions stalker/models/mixins.py
Expand Up @@ -402,14 +402,14 @@ class DateRangeMixin(object):
end values.
The start and end attributes have a ``computed`` companion. Which are the
return values from TaskJuggler. so for start there is the
``computed_start`` and for end there is the ``computed_end`` attributes.
These values are going to be used in Gantt Charts.
return values from TaskJuggler. so for ``start`` there is the
``computed_start`` and for ``end`` there is the ``computed_end``
attributes.
The date attributes can be managed with timezones. Follow the Python idioms
shown in the `documentation of datetime`_
.. _documentation of datetime: http://docs.python.org/library/datetime.html
.. _documentation of datetime: https://docs.python.org/library/datetime.html
:param start: the start date of the entity, should be a datetime.datetime
instance, the start is the pin point for the date calculation. In
Expand Down Expand Up @@ -441,8 +441,7 @@ def __init__(
duration=None,
**kwargs
):
self._start, self._end, self._duration = \
self._validate_dates(start, end, duration)
self._start, self._end, self._duration = self._validate_dates(start, end, duration)

@declared_attr
def _end(cls):
Expand Down Expand Up @@ -630,8 +629,7 @@ def computed_duration(self):
and computed_end if there are computed_start and computed_end otherwise
returns None
"""
return self.computed_end - self.computed_start \
if self.computed_end and self.computed_start else None
return self.computed_end - self.computed_start if self.computed_end and self.computed_start else None

@classmethod
def round_time(cls, dt):
Expand All @@ -646,7 +644,7 @@ def round_time(cls, dt):
Based on Thierry Husson's answer in `Stackoverflow`_
_`Stackoverflow` : http://stackoverflow.com/a/10854034/1431079
_`Stackoverflow` : https://stackoverflow.com/a/10854034/1431079
"""
# to be compatible with python 2.6 use the following instead of
# total_seconds()
Expand Down Expand Up @@ -675,8 +673,7 @@ def total_seconds(self):
def computed_total_seconds(self):
"""returns the duration as seconds
"""
return self.computed_duration.days * 86400 + \
self.computed_duration.seconds
return self.computed_duration.days * 86400 + self.computed_duration.seconds


class ProjectMixin(object):
Expand Down
111 changes: 62 additions & 49 deletions stalker/models/task.py
Expand Up @@ -520,6 +520,13 @@ class Task(Entity, StatusMixin, DateRangeMixin, ReferenceMixin, ScheduleMixin, D
Complete on a duration task is calculated directly from the
:attr:`.start` and :attr:`.end` and ``datetime.datetime.now(pytz.utc)``.
.. versionadded:: 0.2.26
For parent tasks that have both effort based and duration based children
tasks the percent complete is calculated as if the
:attr:`.total_logged_seconds` is properly filled for duration based
tasks proportinal to the elapsed time from the :attr:`.start` attr
value.
Even tough, the percent_complete attribute of a task is
100% the task may not be considered as completed, because it may not be
reviewed and approved by the responsible yet.
Expand Down Expand Up @@ -1399,7 +1406,6 @@ def _validate_parent(self, key, parent):
(self.__class__.__name__, parent.__class__.__name__)
)

#with DBSession.no_autoflush:
# check for cycle
from stalker.models import check_circular_dependency
check_circular_dependency(self, parent, 'children')
Expand Down Expand Up @@ -1866,24 +1872,53 @@ def _total_logged_seconds_getter(self):
from stalker.db.session import DBSession
with DBSession.no_autoflush:
if self.is_leaf:
try:
from sqlalchemy import text
sql = """
select
extract(epoch from sum("TimeLogs".end - "TimeLogs".start))
from "TimeLogs"
where "TimeLogs".task_id = :task_id
"""
engine = DBSession.connection().engine
result = engine.execute(text(sql), task_id=self.id).fetchone()
return result[0] if result[0] else 0
except (UnboundExecutionError, OperationalError, ProgrammingError) as e:
# no database connection
# fallback to Python
logger.debug('No session found! Falling back to Python')
seconds = 0
for time_log in self.time_logs:
seconds += time_log.total_seconds
if self.schedule_model in 'effort':
logger.debug("effort based task detected!")
try:
from sqlalchemy import text
sql = """
select
extract(epoch from sum("TimeLogs".end - "TimeLogs".start))
from "TimeLogs"
where "TimeLogs".task_id = :task_id
"""
engine = DBSession.connection().engine
result = engine.execute(text(sql), task_id=self.id).fetchone()
return result[0] if result[0] else 0
except (UnboundExecutionError, OperationalError, ProgrammingError) as e:
# no database connection
# fallback to Python
logger.debug('No session found! Falling back to Python')
seconds = 0
for time_log in self.time_logs:
seconds += time_log.total_seconds
return seconds
else:
import pytz
now = datetime.datetime.now(pytz.utc)

if self.schedule_model == 'duration':
# directly return the difference between min(now - start, end - start)
logger.debug('duration based task detected!, '
'calculating schedule_info from duration of the task')
daily_working_hours = 86400.0
elif self.schedule_model == 'length':
# directly return the difference between min(now - start, end - start)
# but use working days
logger.debug('length based task detected!, '
'calculating schedule_info from duration of the task')
from stalker import defaults
daily_working_hours = defaults.daily_working_hours

if self.end <= now:
seconds = self.duration.days * daily_working_hours + self.duration.seconds
elif self.start >= now:
seconds = 0
else:
past = now - self.start
past_as_seconds = past.days * daily_working_hours + past.seconds
logger.debug('past_as_seconds : %s' % past_as_seconds)
seconds = past_as_seconds
return seconds
else:
if self._total_logged_seconds is None:
Expand Down Expand Up @@ -1971,10 +2006,14 @@ def update_schedule_info(self):
# update children if they are a container task
if child.is_container:
child.update_schedule_info()
if child.schedule_seconds:
schedule_seconds += child.schedule_seconds
if child.total_logged_seconds:
total_logged_seconds += child.total_logged_seconds
if child.schedule_seconds:
schedule_seconds += child.schedule_seconds
if child.total_logged_seconds:
total_logged_seconds += child.total_logged_seconds
else:
# DRY please!!!!
schedule_seconds += child.schedule_seconds if child.schedule_seconds else 0
total_logged_seconds += child.total_logged_seconds if child.total_logged_seconds else 0

self._schedule_seconds = schedule_seconds
self._total_logged_seconds = total_logged_seconds
Expand All @@ -1992,32 +2031,6 @@ def percent_complete(self):
if self.total_logged_seconds is None or \
self.schedule_seconds is None:
self.update_schedule_info()
else:
if self.schedule_model == 'duration':
logger.debug('calculating percent_complete from duration')
import pytz
now = datetime.datetime.now(pytz.utc)
if self.end <= now:
return 100.0
elif self.start >= now:
return 0.0
else:
past = now - self.start
logger.debug('now : %s' % now)
logger.debug('self.start : %s' % self.start)
logger.debug('now - self.start: %s' % past)
logger.debug('past.days: %s' % past.days)
logger.debug('past.seconds: %s' % past.seconds)
past_as_seconds = \
past.days * 86400.0 + past.seconds
logger.debug('past_as_seconds : %s' % past_as_seconds)

total = self.end - self.start
total_as_seconds = \
total.days * 86400.0 + total.seconds
logger.debug('total_as_seconds: %s' % total_as_seconds)

return past_as_seconds / float(total_as_seconds) * 100.0

return self.total_logged_seconds / float(self.schedule_seconds) * 100.0

Expand Down
5 changes: 5 additions & 0 deletions tests/conftest.py
Expand Up @@ -21,3 +21,8 @@ def setup_sqlite3():

import datetime
stalker.defaults.timing_resolution = datetime.timedelta(hours=1)

# Enable Debug logging
import logging
from stalker import logger
logger.setLevel(logging.DEBUG)
140 changes: 140 additions & 0 deletions tests/models/test_task.py
Expand Up @@ -4553,6 +4553,146 @@ def test_percent_complete_attribute_is_working_properly_for_a_container_task(sel
assert new_task.percent_complete == pytest.approx(77.7777778)
assert parent_task.percent_complete == pytest.approx(77.7777778)

def test_percent_complete_attribute_is_working_properly_for_a_container_task_with_both_effort_and_duration_based_child_tasks(self):
"""testing if the percent complete attribute is working properly for a container task that has both effort and
duration based tasks
"""
kwargs = copy.copy(self.kwargs)
kwargs['depends'] = [] # remove dependencies just to make it
# easy to create time logs after stalker
# v0.2.6.1
dt = datetime.datetime
td = datetime.timedelta

from stalker import defaults
defaults.timing_resolution = td(hours=1)
defaults.daily_working_hours = 9

from stalker.models.mixins import DateRangeMixin
now = DateRangeMixin.round_time(dt.now(pytz.utc))

new_task1 = Task(**kwargs)
new_task1.status = self.status_rts

parent_task = Task(**kwargs)

new_task1.time_logs = []
from stalker import TimeLog
tlog1 = TimeLog(
task=new_task1,
resource=new_task1.resources[0],
start=now - td(hours=4),
end=now - td(hours=2)
)

assert tlog1 in new_task1.time_logs

tlog2 = TimeLog(
task=new_task1,
resource=new_task1.resources[1],
start=now - td(hours=6),
end=now - td(hours=1)
)
from stalker.db.session import DBSession
DBSession.commit()

# create a duration based task
new_task2 = Task(**kwargs)
new_task2.status = self.status_rts
new_task2.schedule_model = 'duration'
new_task2.start = now - td(days=1, hours=1)
new_task2.end = now - td(hours=1)

from stalker.db.session import DBSession
DBSession.commit()

new_task1.parent = parent_task
DBSession.commit()

new_task2.parent = parent_task
DBSession.commit()

assert tlog2 in new_task1.time_logs
assert new_task1.total_logged_seconds == 7 * 3600
assert new_task1.schedule_seconds == 9 * 3600
assert new_task1.percent_complete == pytest.approx(77.7777778)
assert new_task2.total_logged_seconds == 24 * 3600 # 1 day for a duration task is 24 hours
assert new_task2.schedule_seconds == 24 * 3600 # 1 day for a duration task is 24 hours
assert new_task2.percent_complete == 100

# as if there are 9 * 3600 seconds of time logs entered to new_task2
assert parent_task.percent_complete == pytest.approx(93.939393939)

def test_percent_complete_attribute_is_working_properly_for_a_container_task_with_both_effort_and_length_based_child_tasks(self):
"""testing if the percent complete attribute is working properly for a container task that has both effort and
length based tasks
"""
kwargs = copy.copy(self.kwargs)
kwargs['depends'] = [] # remove dependencies just to make it
# easy to create time logs after stalker
# v0.2.6.1
dt = datetime.datetime
td = datetime.timedelta

from stalker import defaults
defaults.timing_resolution = td(hours=1)
defaults.daily_working_hours = 9

from stalker.models.mixins import DateRangeMixin
now = DateRangeMixin.round_time(dt.now(pytz.utc))

new_task1 = Task(**kwargs)
new_task1.status = self.status_rts

parent_task = Task(**kwargs)

new_task1.time_logs = []
from stalker import TimeLog
tlog1 = TimeLog(
task=new_task1,
resource=new_task1.resources[0],
start=now - td(hours=4),
end=now - td(hours=2)
)

assert tlog1 in new_task1.time_logs

tlog2 = TimeLog(
task=new_task1,
resource=new_task1.resources[1],
start=now - td(hours=6),
end=now - td(hours=1)
)
from stalker.db.session import DBSession
DBSession.commit()

# create a duration based task
new_task2 = Task(**kwargs)
new_task2.status = self.status_rts
new_task2.schedule_model = 'length'
new_task2.start = now - td(hours=10)
new_task2.end = now - td(hours=1)

from stalker.db.session import DBSession
DBSession.commit()

new_task1.parent = parent_task
DBSession.commit()

new_task2.parent = parent_task
DBSession.commit()

assert tlog2 in new_task1.time_logs
assert new_task1.total_logged_seconds == 7 * 3600
assert new_task1.schedule_seconds == 9 * 3600
assert new_task1.percent_complete == pytest.approx(77.7777778)
assert new_task2.total_logged_seconds == 9 * 3600 # 1 day for a length task is 9 hours
assert new_task2.schedule_seconds == 9 * 3600 # 1 day for a length task is 9 hours
assert new_task2.percent_complete == 100

# as if there are 9 * 3600 seconds of time logs entered to new_task2
assert parent_task.percent_complete == pytest.approx(88.8888889)

def test_percent_complete_attribute_is_working_properly_for_a_leaf_task(self):
"""testing if the percent_complete attribute is working properly for a
leaf task
Expand Down

0 comments on commit b4e863f

Please sign in to comment.