def create_sprint(self, name, start=None, end=None, duration=20, milestone=None, team=None): """Creates a Sprint for the given milestone, if doesn't exists, first it creates a Milestone""" # If the start day is set to today, the sprint will # normalize it to 9:00am of the start day and all the tests # will fail, till 9:00am in the morning... if start is None: # we set hours to 0 so will be normalized to 9am at any # time of the day, when running tests. start = (now(tz=utc) - timedelta(days=3)).replace(hour=0) if milestone is None: milestone = self.create_milestone('Milestone for %s' % name) # It should automatically load the existing Sprint if already there if isinstance(milestone, Milestone): milestone = milestone.name sprint_controller = SprintController(self.env) if start is not None: start = shift_to_utc(start) if end is not None: end = shift_to_utc(end) create_sprint_command = SprintController.CreateSprintCommand(self.env, name=name, start=start, end=end, duration=duration, milestone=milestone) create_sprint_command.native = True sprint = sprint_controller.process_command(create_sprint_command) assert sprint is not None if team is not None: if isinstance(team, basestring): team = self.create_team(name=team) sprint.team = team sprint.save() return sprint
def running_to_start_closed_sprints(self, req): MAX_ITEMS = 5 def mark_last_viewed_sprint(sprint_names): return [(name, name==last_viewed_sprint_name) for \ name in sprint_names] last_viewed_sprint_name = SessionScope(req).sprint_name() # get sprint data get_options = SprintController.GetSprintOptionListCommand(self.env) closed, running, to_start = \ SprintController(self.env).process_command(get_options) running_sprints = mark_last_viewed_sprint(running) ready_to_start_sprints = mark_last_viewed_sprint(to_start[:MAX_ITEMS]) closed_sprints = mark_last_viewed_sprint(closed[-MAX_ITEMS:]) # Show last closed sprints at the top closed_sprints.reverse() sprint_list = [ { Key.LABEL: _('Running (by Start Date)'), Key.OPTIONS: running_sprints }, { Key.LABEL: _('To Start (by Start Date)'), Key.OPTIONS: ready_to_start_sprints }, { Key.LABEL: _('Closed (by End Date)'), Key.OPTIONS: closed_sprints }, ] return sprint_list
def populate_with_sprint_data(self, sprint_name): self.data['sprint_name'] = sprint_name tickets = self._get_prefetched_backlog() cmd_resources = SprintController.GetResourceStatsCommand( self.env, sprint=sprint_name, tickets=tickets) #resource_stats = sprint.get_resources_stats(tickets=tickets) self.data['resource_stats'] = SprintController( self.env).process_command(cmd_resources)
def __init__(self, env, **kwargs): template_filename = kwargs.get('template_filename') or 'scrum/backlog/templates/agilo_burndown_chart.html' self._define_chart_resources(env, template_filename, kwargs) kwargs['scripts'] = ('agilo/js/burndown.js',) self.super(env, template_filename, **kwargs) self.t_controller = TeamController(self.env) self.sp_controller = SprintController(self.env) self.b_controller = BacklogController(self.env) self.burndown_data = dict()
def _get_sprint(self, req, sprint_name): """Retrieve the Sprint for the given name""" get_sprint = SprintController.GetSprintCommand(self.env, sprint=sprint_name) sprint = SprintController(self.env).process_command(get_sprint) # we need to convert sprint dates into local timezone sprint.start = format_datetime(sprint.start, tzinfo=req.tz) sprint.end = format_datetime(sprint.end, tzinfo=req.tz) if sprint.team is None: msg = _("No Team has been assigned to this Sprint...") if not msg in req.chrome['warnings']: add_warning(req, msg) return sprint
def create_sprint(self, name, start=None, end=None, duration=20, milestone=None, team=None): """Creates a Sprint for the given milestone, if doesn't exists, first it creates a Milestone""" # If the start day is set to today, the sprint will # normalize it to 9:00am of the start day and all the tests # will fail, till 9:00am in the morning... if start is None: # we set hours to 0 so will be normalized to 9am at any # time of the day, when running tests. start = (now(tz=utc) - timedelta(days=3)).replace(hour=0) if milestone is None: milestone = self.create_milestone('Milestone for %s' % name) # It should automatically load the existing Sprint if already there if isinstance(milestone, Milestone): milestone = milestone.name sprint_controller = SprintController(self.env) if start is not None: start = shift_to_utc(start) if end is not None: end = shift_to_utc(end) create_sprint_command = SprintController.CreateSprintCommand( self.env, name=name, start=start, end=end, duration=duration, milestone=milestone) create_sprint_command.native = True sprint = sprint_controller.process_command(create_sprint_command) assert sprint is not None if team is not None: if isinstance(team, basestring): team = self.create_team(name=team) sprint.team = team sprint.save() return sprint
def populate_with_sprint_data(self, sprint_name): """Populate the chart with sprint statistics""" cmd_stats = SprintController.GetTicketsStatisticsCommand( self.env, sprint=sprint_name) tickets_stats = SprintController(self.env).process_command(cmd_stats) planned, closed, total, labels = [], [], [], [] aliases = AgiloConfig(self.env).ALIASES for i, t_type in enumerate(tickets_stats): nr_planned, nr_in_progress, nr_closed = tickets_stats[t_type] planned.append((i, nr_planned)) closed.append((i, nr_closed)) nr_total = nr_planned + nr_in_progress + nr_closed total.append((i, nr_total)) alias = aliases.get(t_type, t_type) labels.append((i, alias)) self.data.update( dict(sprint_name=sprint_name, labels=labels, planned=planned, closed=closed, total=total))
def _get_remaining_time_series(self, sprint_name): cmd_rem_times = SprintController.GetActualBurndownCommand(self.env, sprint=sprint_name, filter_by_component=self.data.get('filter_by'), remaining_field=self.data.get('remaining_field')) return self.sp_controller.process_command(cmd_rem_times)
class BurndownWidget(ScrumFlotChartWidget): """Burndown chart widget which generates HTML and JS code so that Flot can generate a burndown chart for the sprint including actual data, ideal burndown and moving average.""" default_width = 750 default_height = 350 GOOD_COLOR = '#94d31a' WARNING_COLOR = '#e0e63d' BAD_COLOR = '#f35e5e' def __init__(self, env, **kwargs): template_filename = kwargs.get('template_filename') or 'scrum/backlog/templates/agilo_burndown_chart.html' self._define_chart_resources(env, template_filename, kwargs) kwargs['scripts'] = ('agilo/js/burndown.js',) self.super(env, template_filename, **kwargs) self.t_controller = TeamController(self.env) self.sp_controller = SprintController(self.env) self.b_controller = BacklogController(self.env) self.burndown_data = dict() def json_data(self): return self.burndown_data def populate_with_sprint_data(self, sprint_name): # REFACT: migrate to member variable? sprint = self._load_sprint(sprint_name) if sprint is None: return self.data.update(dict( # REFACT: consider to remove these, we don't really need them cached sprint_start=sprint.start, sprint_end=sprint.end, )) # REFACT: We should remove the utc_ variants from the data dict so that # there is no confusion which item to use. However we need to check that his # does not breaks caching... (or remove the caching altogether) def prepare_rendering(self, req): self.super() self._populate_with_sprint_and_viewer_timezone(self.data['sprint_name'], req.tz) self._convert_utc_times_to_local_timezone(req.tz) self._add_jsonized_plot_data() def _populate_with_sprint_and_viewer_timezone(self, sprint_name, viewer_timezone): sprint = self._load_sprint(sprint_name, native=True) if sprint is None: return days_to_remove = self._days_to_remove_from_burndown(sprint, viewer_timezone) container = ValueObject( remaining_times = self._get_remaining_time_series(sprint_name), capacity_data = self._get_capacity(sprint, viewer_timezone, self._is_filtered_backlog(), self._is_point_burndown()), ticks = self._calculate_ticks(sprint, viewer_timezone, days_to_remove), weekend_data = self._get_weekend_starts(sprint.start, sprint.end, viewer_timezone), today_data = self._get_today_data(sprint.start, sprint.end, viewer_timezone), ) self._compact_values_by_removing_days(container, days_to_remove) days_without_capacity_to_hide = ValuesPerTimeCompactor.final_shift(days_to_remove) container.trend_data = self._trend_line(container.remaining_times, sprint.end - days_without_capacity_to_hide) # REFACT: put container directly into data and then unsmart the values in the utc shifting method utc_remaining_times = self._smart_to_tuple_series(container.remaining_times) utc_capacity_data = self._smart_to_tuple_series(container.capacity_data) # need that in non utc form too to compact first_remaining_time = self._first_remaining_time(container.remaining_times) utc_ideal_data = self._calculate_ideal_burndown(utc_capacity_data, first_remaining_time, sprint) utc_trend_data = self._smart_to_tuple_series(container.trend_data) utc_ticks = self._smart_to_tuple_series(container.ticks) utc_weekend_data = self._smart_to_tuple_series(container.weekend_data) utc_today_data = self._smart_to_tuple_series(container.today_data) self.data.update(dict( utc_remaining_times=utc_remaining_times, utc_ideal_data=utc_ideal_data, utc_capacity_data=utc_capacity_data, utc_trend_data=utc_trend_data, utc_ticks=utc_ticks, utc_weekend_data=utc_weekend_data, utc_today_data=utc_today_data, )) self.burndown_data.update(dict( today_color=self._today_color(sprint, container.remaining_times, utc_ideal_data), )) def _days_to_remove_from_burndown(self, sprint, viewer_timezone): if not AgiloConfig(self.env).burndown_should_show_working_days_only: return [] if sprint.team is None: return [] days_to_remove = sprint.team.capacity(viewer_timezone).days_without_capacity_in_interval(sprint.start, sprint.end) return days_to_remove def _add_jsonized_plot_data(self): from agilo.utils.compat import json self.data['jsonized_burndown_values'] = json.dumps(self.data_as_json()) def _datetime_to_js_milliseconds(self, datetime_date, tz): """Convert a datetime into milliseconds which are directly usable by flot""" # Flot always uses UTC. In order to display the data with the correct # days, we have to move the data to UTC # FIXME: we no longer use flott in date mode, so this is not neccessary anymore # Perhaps better do the serialization in the json interface # Probably we still need this, since js cannot read times with timezones # "Normally you want the timestamps to be displayed according to a # certain time zone, usually the time zone in which the data has been # produced. However, Flot always displays timestamps according to UTC. # It has to as the only alternative with core Javascript is to interpret # the timestamps according to the time zone that the visitor is in, # which means that the ticks will shift unpredictably with the time zone # and daylight savings of each visitor. fake_utc_datetime = datetime_date.astimezone(tz).replace(tzinfo=utc) seconds_since_epoch = to_timestamp(fake_utc_datetime) milliseconds_since_epoch = seconds_since_epoch * 1000 return milliseconds_since_epoch def _convert_to_utc_timeseries(self, start, end, input_data, now=None): """Takes a list of input_data and converts it into a list of tuples (datetime in UTC, data). It returns a tuple every 24h from the sprint start datetime.""" utc_data = [] stop = False current_date = start for data in input_data: if now is not None and current_date > now: current_date = now # FIXME: (AT) This is an hack to make sure the last value is # always the last one, we need to send the request timezone # through to get the sprint days shifted to the current timezone. data = input_data[-1] # AT: We need to break after the append, we are already at now # the last data, if available would be the same exact value as # now. This happens in tests because the sprint is created in # the same microsecond as the now will be calculated adding two # values at last stop = True elif current_date > end: current_date = end # AT: the dates should be already UTC, but you never know day_data = (current_date.astimezone(utc), data) utc_data.append(day_data) if stop: break current_date += timedelta(days=1) return utc_data def _today_color(self, sprint, actual_data, ideal_data): today_color = self.GOOD_COLOR if not sprint.is_currently_running: return today_color # ideal_data can be empty if no actual data exists (e.g. sprint not start yet) if len(ideal_data) == 0 or len(actual_data) == 0: return today_color current_actual_burndown = actual_data[-1] current_remaining_time = current_actual_burndown.remaining_time ideal_remaining_time = self._calculate_ideal_burndown_at_datetime(ideal_data, current_actual_burndown.when) if (ideal_remaining_time == 0) and (current_remaining_time == 0): return today_color elif (ideal_remaining_time == 0) \ or (current_remaining_time / ideal_remaining_time > 1.3): return self.BAD_COLOR elif current_remaining_time / ideal_remaining_time > 1.1: return self.WARNING_COLOR return today_color def _calculate_ticks(self, sprint, viewer_timezone, days_to_remove): generator = TickGenerator.for_sprint(sprint, viewer_timezone, days_to_remove) return generator.generate_tick_labels() def _get_capacity_data(self, sprint, viewer_timezone): """ Returns the capacity data for this sprint in the form of a list. Capacity per day is calculated as the whole team capacity that day, removed a proportional amount for the contingent set by the team in this sprint. """ if sprint.team is None: return [] return sprint.team.capacity(viewer_timezone).summed_hourly_capacities_in_sprint(sprint) def _get_capacity(self, sprint, viewer_timezone, is_filtered_burndown, is_point_burndown=False): if is_filtered_burndown or is_point_burndown: return [] return self._get_capacity_data(sprint, viewer_timezone) def _calculate_ideal_burndown(self, utc_capacity_data, first_remaining_time, sprint): has_remaining_time = (first_remaining_time is not None) has_capacity_data = (len(utc_capacity_data) > 0 and utc_capacity_data[0][1] != 0) if has_remaining_time and has_capacity_data: return calculate_ideal_burndown(utc_capacity_data, first_remaining_time, sprint) elif has_remaining_time: # Just let flot draw a straight line... return [(first_remaining_time.when, first_remaining_time.remaining_time), (sprint.end, 0)] else: return [] def _calculate_ideal_burndown_at_datetime(self, ideal_data, a_datetime): before, after = _entries_from_timeseries_nearest_to(ideal_data, a_datetime) if before is None or after is None: return 0 return Line.from_two_tupples(before, after).y_from_x(a_datetime) def _get_remaining_time_series(self, sprint_name): cmd_rem_times = SprintController.GetActualBurndownCommand(self.env, sprint=sprint_name, filter_by_component=self.data.get('filter_by'), remaining_field=self.data.get('remaining_field')) return self.sp_controller.process_command(cmd_rem_times) def _first_remaining_time(self, actual_data): if len(actual_data) > 0: return actual_data[0] return None def _trend_line(self, actual_data, end): return BurndownTrendLineGenerator().calculate(actual_data, end) def _smart_to_tuple_series(self, a_series): return [(item.when, item.value) for item in a_series] def _is_filtered_backlog(self): return self.data.get('filter_by') is not None def _is_point_burndown(self): return self.data.get('remaining_field') == BurndownDataConstants.REMAINING_POINTS def _convert_to_flot_timeseries(self, data, tz): flot_data = [] for point_in_time, value in data: flot_milliseconds = self._datetime_to_js_milliseconds(point_in_time, tz) if isinstance(value, datetime): value = self._datetime_to_js_milliseconds(value, tz) flot_data.append((flot_milliseconds, value)) return flot_data def _get_weekend_starts(self, start, end, tz): weekend_data = [] # The start is stored in UTC but the weekend should be drawn # localized day = start.astimezone(tz) while day < end: if day.isoweekday() in (6, 7): weekend_start = midnight_with_utc_shift(day) weekend_data.append(DayMarker(weekend_start)) day += timedelta(days=1) return weekend_data def _get_today_data(self, start, end, tz): if not (start <= now(tz) <= end): return [] # Now is already calculated in the given timezone so we have to get # the midnight in that timezone, shifted to UTC time # TODO: this is being shifted later, does midnight suffice? today_midnight = midnight_with_utc_shift(now(tz)) return [DayMarker(today_midnight)] def _convert_utc_times_to_local_timezone(self, tz): for key in self.data.keys(): if key.startswith('utc_'): new_key = key[len('utc_'):] flot_data = self._convert_to_flot_timeseries(self.data[key], tz) self.burndown_data[new_key] = flot_data def _compact_values_by_removing_days(self, values, days_to_remove): for key, value in values.items(): compactor = ValuesPerTimeCompactor(value, days_to_remove) values[key] = compactor.compact_values()