diff --git a/taiga/projects/models.py b/taiga/projects/models.py index 1bc8ab4d..d7f77a10 100644 --- a/taiga/projects/models.py +++ b/taiga/projects/models.py @@ -271,31 +271,6 @@ class Project(ProjectDefaults, TaggedMixin, models.Model): rp_query = rp_query.exclude(role__id__in=roles.values_list("id", flat=True)) rp_query.delete() - def _get_user_stories_points(self, user_stories): - role_points = [us.role_points.all() for us in user_stories] - flat_role_points = itertools.chain(*role_points) - flat_role_dicts = map(lambda x: {x.role_id: x.points.value if x.points.value else 0}, - flat_role_points) - return dict_sum(*flat_role_dicts) - - def _get_points_increment(self, client_requirement, team_requirement): - last_milestones = self.milestones.order_by('-estimated_finish') - last_milestone = last_milestones[0] if last_milestones else None - if last_milestone: - user_stories = self.user_stories.filter( - created_date__gte=last_milestone.estimated_finish, - client_requirement=client_requirement, - team_requirement=team_requirement - ) - else: - user_stories = self.user_stories.filter( - client_requirement=client_requirement, - team_requirement=team_requirement - ) - user_stories = user_stories.prefetch_related('role_points', 'role_points__points') - return self._get_user_stories_points(user_stories) - - @property def project(self): return self @@ -304,45 +279,6 @@ class Project(ProjectDefaults, TaggedMixin, models.Model): def project(self): return self - @property - def future_team_increment(self): - team_increment = self._get_points_increment(False, True) - shared_increment = {key: value / 2 for key, value in self.future_shared_increment.items()} - return dict_sum(team_increment, shared_increment) - - @property - def future_client_increment(self): - client_increment = self._get_points_increment(True, False) - shared_increment = {key: value / 2 for key, value in self.future_shared_increment.items()} - return dict_sum(client_increment, shared_increment) - - @property - def future_shared_increment(self): - return self._get_points_increment(True, True) - - @property - def closed_points(self): - return self.calculated_points["closed"] - - @property - def defined_points(self): - return self.calculated_points["defined"] - - @property - def assigned_points(self): - return self.calculated_points["assigned"] - - @property - def calculated_points(self): - user_stories = self.user_stories.all().prefetch_related('role_points', 'role_points__points') - closed_user_stories = user_stories.filter(is_closed=True) - assigned_user_stories = user_stories.filter(milestone__isnull=False) - return { - "defined": self._get_user_stories_points(user_stories), - "closed": self._get_user_stories_points(closed_user_stories), - "assigned": self._get_user_stories_points(assigned_user_stories), - } - def _get_q_watchers(self): return Q(notify_policies__project_id=self.id) & ~Q(notify_policies__notify_level=NotifyLevel.none) diff --git a/taiga/projects/services/stats.py b/taiga/projects/services/stats.py index 727db6cf..7f915a31 100644 --- a/taiga/projects/services/stats.py +++ b/taiga/projects/services/stats.py @@ -19,94 +19,11 @@ from django.db.models import Q, Count from django.apps import apps import datetime import copy +import collections from taiga.projects.history.models import HistoryEntry from taiga.projects.userstories.models import RolePoints - -def _get_total_story_points(project): - return (project.total_story_points if project.total_story_points not in [None, 0] else - sum(project.calculated_points["defined"].values())) - -def _get_total_milestones(project): - return (project.total_milestones if project.total_milestones not in [None, 0] else - project.milestones.count()) - -def _get_milestones_stats_for_backlog(project): - """ - Get collection of stats for each millestone of project. - Data returned by this function are used on backlog. - """ - current_evolution = 0 - current_team_increment = 0 - current_client_increment = 0 - - optimal_points_per_sprint = 0 - - total_story_points = _get_total_story_points(project) - total_milestones = _get_total_milestones(project) - - if total_story_points and total_milestones: - optimal_points_per_sprint = total_story_points / total_milestones - - future_team_increment = sum(project.future_team_increment.values()) - future_client_increment = sum(project.future_client_increment.values()) - - milestones = project.milestones.order_by('estimated_start').\ - prefetch_related("user_stories", - "user_stories__role_points", - "user_stories__role_points__points") - - milestones = list(milestones) - milestones_count = len(milestones) - optimal_points = 0 - team_increment = 0 - client_increment = 0 - - for current_milestone in range(0, max(milestones_count, total_milestones)): - optimal_points = (total_story_points - - (optimal_points_per_sprint * current_milestone)) - - evolution = (total_story_points - current_evolution - if current_evolution is not None else None) - - if current_milestone < milestones_count: - ml = milestones[current_milestone] - - milestone_name = ml.name - team_increment = current_team_increment - client_increment = current_client_increment - - current_evolution += sum(ml.closed_points.values()) - current_team_increment += sum(ml.team_increment_points.values()) - current_client_increment += sum(ml.client_increment_points.values()) - - else: - milestone_name = _("Future sprint") - team_increment = current_team_increment + future_team_increment, - client_increment = current_client_increment + future_client_increment, - current_evolution = None - - yield { - 'name': milestone_name, - 'optimal': optimal_points, - 'evolution': evolution, - 'team-increment': team_increment, - 'client-increment': client_increment, - } - - optimal_points -= optimal_points_per_sprint - evolution = (total_story_points - current_evolution - if current_evolution is not None and total_story_points else None) - yield { - 'name': _('Project End'), - 'optimal': optimal_points, - 'evolution': evolution, - 'team-increment': team_increment, - 'client-increment': client_increment, - } - - def _count_status_object(status_obj, counting_storage): if status_obj.id in counting_storage: counting_storage[status_obj.id]['count'] += 1 @@ -218,35 +135,217 @@ def get_stats_for_project_issues(project): return project_issues_stats +def _get_milestones_stats_for_backlog(project, milestones): + """ + Calculates the stats associated to the milestones parameter. + + - project is a Project model instance + We assume this object have also the following numeric attributes: + - _defined_points + - _future_team_increment + - _future_client_increment + + - milestones is a sorted dict of Milestone model instances sorted by estimated_start. + We assume this objects have also the following numeric attributes: + - _closed_points + - _team_increment_points + - _client_increment_points + + The returned result is a list of dicts where each entry contains the following keys: + - name + - optimal + - evolution + - team + - client + """ + current_evolution = 0 + current_team_increment = 0 + current_client_increment = 0 + optimal_points_per_sprint = 0 + optimal_points = 0 + team_increment = 0 + client_increment = 0 + + total_story_points = project.total_story_points\ + if project.total_story_points not in [None, 0] else project._defined_points + + total_milestones = project.total_milestones\ + if project.total_milestones not in [None, 0] else len(milestones) + + if total_story_points and total_milestones: + optimal_points_per_sprint = total_story_points / total_milestones + + milestones_count = len(milestones) + milestones_stats = [] + for current_milestone_pos in range(0, max(milestones_count, total_milestones)): + optimal_points = (total_story_points - + (optimal_points_per_sprint * current_milestone_pos)) + + evolution = (total_story_points - current_evolution + if current_evolution is not None else None) + + if current_milestone_pos < milestones_count: + current_milestone = list(milestones.values())[current_milestone_pos] + milestone_name = current_milestone.name + team_increment = current_team_increment + client_increment = current_client_increment + current_evolution += current_milestone._closed_points + current_team_increment += current_milestone._team_increment_points + current_client_increment += current_milestone._client_increment_points + + else: + milestone_name = _("Future sprint") + team_increment = current_team_increment + project._future_team_increment, + client_increment = current_client_increment + project._future_client_increment, + current_evolution = None + + milestones_stats.append({ + 'name': milestone_name, + 'optimal': optimal_points, + 'evolution': evolution, + 'team-increment': team_increment, + 'client-increment': client_increment, + }) + + optimal_points -= optimal_points_per_sprint + evolution = (total_story_points - current_evolution + if current_evolution is not None and total_story_points else None) + + milestones_stats.append({ + 'name': _('Project End'), + 'optimal': optimal_points, + 'evolution': evolution, + 'team-increment': team_increment, + 'client-increment': client_increment, + }) + + return milestones_stats + + def get_stats_for_project(project): - project = apps.get_model("projects", "Project").objects.\ - prefetch_related("milestones", - "user_stories").\ - get(id=project.id) + # Let's fetch all the estimations related to a project with all the necesary + # related data + role_points = RolePoints.objects.filter( + user_story__project = project, + ).prefetch_related( + "user_story", + "user_story__assigned_to", + "user_story__milestone", + "user_story__status", + "role", + "points") - points = project.calculated_points - closed_points = sum(points["closed"].values()) - closed_points_from_closed_milestones = sum(RolePoints.objects.filter( - Q(user_story__project=project) & Q(user_story__milestone__closed=True) - ).exclude(points__value__isnull=True).values_list("points__value", flat=True)) + # Data inicialization + project._closed_points = 0 + project._closed_points_per_role = {} + project._closed_points_from_closed_milestones = 0 + project._defined_points = 0 + project._defined_points_per_role = {} + project._assigned_points = 0 + project._assigned_points_per_role = {} + project._future_team_increment = 0 + project._future_client_increment = 0 - closed_milestones = project.milestones.filter(closed=True).count() + # The key will be the milestone id and it will be ordered by estimated_start + milestones = collections.OrderedDict() + for milestone in project.milestones.order_by("estimated_start"): + milestone._closed_points = 0 + milestone._team_increment_points = 0 + milestone._client_increment_points = 0 + milestones[milestone.id] = milestone + + def _find_milestone_for_userstory(user_story): + for m in milestones.values(): + if m.estimated_finish > user_story.created_date.date() and\ + m.estimated_start <= user_story.created_date.date(): + + return m + + return None + + def _update_team_increment(milestone, value): + if milestone: + milestones[milestone.id]._team_increment_points += value + else: + project._future_team_increment += value + + def _update_client_increment(milestone, value): + if milestone: + milestones[milestone.id]._client_increment_points += value + else: + project._future_client_increment += value + + # Iterate over all the project estimations and update our stats + for role_point in role_points: + role_id = role_point.role.id + points_value = role_point.points.value + milestone = role_point.user_story.milestone + is_team_requirement = role_point.user_story.team_requirement + is_client_requirement = role_point.user_story.client_requirement + us_milestone = _find_milestone_for_userstory(role_point.user_story) + + # None estimations doesn't affect to project stats + if points_value is None: + continue + + # Total defined points + project._defined_points += points_value + + # Defined points per role + project._defined_points_for_role = project._defined_points_per_role.get(role_id, 0) + project._defined_points_for_role += points_value + project._defined_points_per_role[role_id] = project._defined_points_for_role + + # Closed points + if role_point.user_story.is_closed: + project._closed_points += points_value + closed_points_for_role = project._closed_points_per_role.get(role_id, 0) + closed_points_for_role += points_value + project._closed_points_per_role[role_id] = closed_points_for_role + + if milestone is not None: + milestones[milestone.id]._closed_points += points_value + + if milestone is not None and milestone.closed: + project._closed_points_from_closed_milestones += points_value + + # Assigned to milestone points + if role_point.user_story.milestone is not None: + project._assigned_points += points_value + assigned_points_for_role = project._assigned_points_per_role.get(role_id, 0) + assigned_points_for_role += points_value + project._assigned_points_per_role[role_id] = assigned_points_for_role + + # Extra requirements + if is_team_requirement and is_client_requirement: + _update_team_increment(us_milestone, points_value/2) + _update_client_increment(us_milestone, points_value/2) + + if is_team_requirement and not is_client_requirement: + _update_team_increment(us_milestone, points_value) + + if not is_team_requirement and is_client_requirement: + _update_client_increment(us_milestone, points_value) + + # Speed calculations speed = 0 - + closed_milestones = len([m for m in milestones.values() if m.closed]) if closed_milestones != 0: - speed = closed_points_from_closed_milestones / closed_milestones + speed = project._closed_points_from_closed_milestones / closed_milestones + + milestones_stats = _get_milestones_stats_for_backlog(project, milestones) project_stats = { 'name': project.name, 'total_milestones': project.total_milestones, 'total_points': project.total_story_points, - 'closed_points': closed_points, - 'closed_points_per_role': points["closed"], - 'defined_points': sum(points["defined"].values()), - 'defined_points_per_role': points["defined"], - 'assigned_points': sum(points["assigned"].values()), - 'assigned_points_per_role': points["assigned"], - 'milestones': _get_milestones_stats_for_backlog(project), + 'closed_points': project._closed_points, + 'closed_points_per_role': project._closed_points_per_role, + 'defined_points': project._defined_points, + 'defined_points_per_role': project._defined_points_per_role, + 'assigned_points': project._assigned_points, + 'assigned_points_per_role': project._assigned_points_per_role, + 'milestones': milestones_stats, 'speed': speed, } return project_stats diff --git a/tests/integration/test_stats.py b/tests/integration/test_stats.py index 11038330..df68970b 100644 --- a/tests/integration/test_stats.py +++ b/tests/integration/test_stats.py @@ -86,36 +86,46 @@ def data(): def test_project_defined_points(client, data): - assert data.project.defined_points == {data.role1.pk: 63} + project_stats = get_stats_for_project(data.project) + assert project_stats["defined_points_per_role"] == {data.role1.pk: 63} data.role_points1.role = data.role2 data.role_points1.save() - assert data.project.defined_points == {data.role1.pk: 62, data.role2.pk: 1} + project_stats = get_stats_for_project(data.project) + assert project_stats["defined_points_per_role"] == {data.role1.pk: 62, data.role2.pk: 1} def test_project_closed_points(client, data): - assert data.project.closed_points == {} + project_stats = get_stats_for_project(data.project) + assert project_stats["closed_points_per_role"] == {} data.role_points1.role = data.role2 data.role_points1.save() - assert data.project.closed_points == {} + project_stats = get_stats_for_project(data.project) + assert project_stats["closed_points_per_role"] == {} data.user_story1.is_closed = True data.user_story1.save() - assert data.project.closed_points == {data.role2.pk: 1} + project_stats = get_stats_for_project(data.project) + assert project_stats["closed_points_per_role"] == {data.role2.pk: 1} data.user_story2.is_closed = True data.user_story2.save() - assert data.project.closed_points == {data.role1.pk: 2, data.role2.pk: 1} + project_stats = get_stats_for_project(data.project) + assert project_stats["closed_points_per_role"] == {data.role1.pk: 2, data.role2.pk: 1} data.user_story3.is_closed = True data.user_story3.save() - assert data.project.closed_points == {data.role1.pk: 6, data.role2.pk: 1} + project_stats = get_stats_for_project(data.project) + assert project_stats["closed_points_per_role"] == {data.role1.pk: 6, data.role2.pk: 1} data.user_story4.is_closed = True data.user_story4.save() - assert data.project.closed_points == {data.role1.pk: 14, data.role2.pk: 1} + project_stats = get_stats_for_project(data.project) + assert project_stats["closed_points_per_role"] == {data.role1.pk: 14, data.role2.pk: 1} data.user_story5.is_closed = True data.user_story5.save() - assert data.project.closed_points == {data.role1.pk: 30, data.role2.pk: 1} + project_stats = get_stats_for_project(data.project) + assert project_stats["closed_points_per_role"] == {data.role1.pk: 30, data.role2.pk: 1} data.user_story6.is_closed = True data.user_story6.save() - assert data.project.closed_points == {data.role1.pk: 62, data.role2.pk: 1} + project_stats = get_stats_for_project(data.project) + assert project_stats["closed_points_per_role"] == {data.role1.pk: 62, data.role2.pk: 1} project_stats = get_stats_for_project(data.project) assert project_stats["closed_points"] == 63 @@ -123,19 +133,25 @@ def test_project_closed_points(client, data): def test_project_assigned_points(client, data): - assert data.project.assigned_points == {data.role1.pk: 48} + project_stats = get_stats_for_project(data.project) + assert project_stats["assigned_points_per_role"] == {data.role1.pk: 48} data.role_points1.role = data.role2 data.role_points1.save() - assert data.project.assigned_points == {data.role1.pk: 48} + project_stats = get_stats_for_project(data.project) + assert project_stats["assigned_points_per_role"] == {data.role1.pk: 48} data.user_story1.milestone = data.milestone data.user_story1.save() - assert data.project.assigned_points == {data.role1.pk: 48, data.role2.pk: 1} + project_stats = get_stats_for_project(data.project) + assert project_stats["assigned_points_per_role"] == {data.role1.pk: 48, data.role2.pk: 1} data.user_story2.milestone = data.milestone data.user_story2.save() - assert data.project.assigned_points == {data.role1.pk: 50, data.role2.pk: 1} + project_stats = get_stats_for_project(data.project) + assert project_stats["assigned_points_per_role"] == {data.role1.pk: 50, data.role2.pk: 1} data.user_story3.milestone = data.milestone data.user_story3.save() - assert data.project.assigned_points == {data.role1.pk: 54, data.role2.pk: 1} + project_stats = get_stats_for_project(data.project) + assert project_stats["assigned_points_per_role"] == {data.role1.pk: 54, data.role2.pk: 1} data.user_story4.milestone = data.milestone data.user_story4.save() - assert data.project.assigned_points == {data.role1.pk: 62, data.role2.pk: 1} + project_stats = get_stats_for_project(data.project) + assert project_stats["assigned_points_per_role"] == {data.role1.pk: 62, data.role2.pk: 1}