diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f2feedb..3317b6b 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -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 ======== diff --git a/stalker/__init__.py b/stalker/__init__.py index e68896c..1a47c26 100644 --- a/stalker/__init__.py +++ b/stalker/__init__.py @@ -7,7 +7,7 @@ import sys -__version__ = '0.2.25.1' +__version__ = '0.2.26' __title__ = "stalker" __description__ = 'A Production Asset Management (ProdAM) System' diff --git a/stalker/models/mixins.py b/stalker/models/mixins.py index bc30e15..e2f0c32 100644 --- a/stalker/models/mixins.py +++ b/stalker/models/mixins.py @@ -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 @@ -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): @@ -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): @@ -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() @@ -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): diff --git a/stalker/models/task.py b/stalker/models/task.py index 93e58b2..239ffc7 100644 --- a/stalker/models/task.py +++ b/stalker/models/task.py @@ -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. @@ -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') @@ -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: @@ -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 @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py index 252a8a0..c39dbaa 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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) diff --git a/tests/models/test_task.py b/tests/models/test_task.py index f88841f..445aa11 100644 --- a/tests/models/test_task.py +++ b/tests/models/test_task.py @@ -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