Example #1
0
 def _get_sprint(self, sprint_name):
     from agilo.scrum.sprint.model import Sprint, SprintModelManager
     if isinstance(sprint_name, Sprint):
         return sprint_name
     sprint_name = self.super.validate(self._sprint_name(sprint_name))
     sprint = SprintModelManager(self.env).get(name=sprint_name)
     return sprint
Example #2
0
 def _currently_running_sprint(self):
     running_sprints = []
     for sprint in SprintModelManager(self.env).select():
         if not sprint.is_currently_running:
             continue
         running_sprints.append(sprint)
     if len(running_sprints) == 0:
         return None
     return running_sprints[-1]
Example #3
0
 def testMilestoneRenamePropagatesToSprints(self):
     """Tests that the rename of a Milestone, propagates to the Sprints, this
     is an AgiloMilestone feature"""
     m = Milestone(self.env)
     m.name = 'test_me'
     m.insert()
     s = self.teh.create_sprint('my sprint', milestone=m.name)
     self.assert_equals(m.name, s.milestone)
     # AT: we need to reload the milestone as there is a problem in trac,
     # that the insert is not updating the _old_name, making the update
     # silently fail. I sent a patch for this
     m = Milestone(self.env, m.name)
     m.name = 'test_me_not'
     m.update()
     smm = SprintModelManager(self.env)
     smm.get_cache().invalidate()
     s = smm.get(name=s.name)
     self.assert_equals(m.name, s.milestone)
Example #4
0
 def sprint(self):
     """Return a sprint object from the session, if that fails, reset the
     sprint scope in the session and return None."""
     assert self.env is not None
     # AT: It is not stated that the current name of sprint set in
     # the session is still there or valid, we need to check it
     sprint = SprintModelManager(self.env).get(name=self.sprint_name())
     if sprint is None:
         self.reset_sprint_scope()
     return sprint
 def test_stores_team_capacity_in_metrics(self):
     # Sprint must start at the same time (or before) the team members
     # start working or not all of his capacity will be in the sprint
     self.sprint.start = self.sprint.start.replace(
         hour=9,
         minute=0,
         second=0,
         microsecond=0,
         tzinfo=self.team.members[0].timezone())
     self.sprint.save()
     SprintModelManager(self.env).get_cache().invalidate()
     self.assert_none(self._capacity(self.sprint, self.team))
     self._confirm_commitment()
     self.assert_almost_equals(10 * 6,
                               self._capacity(self.sprint, self.team),
                               max_delta=0.01)
 def setUp(self):
     self.super()
     self.teh.disable_sprint_date_normalization()
     self._create_sprint_with_team_and_small_backlog()
     self.teh.grant_permission(self.username(), Action.CONFIRM_COMMITMENT)
     self.smm = SprintModelManager(self.env)
class CanConfirmCommitmentWithJSONTest(JSONAgiloTestCase):
    def setUp(self):
        self.super()
        self.teh.disable_sprint_date_normalization()
        self._create_sprint_with_team_and_small_backlog()
        self.teh.grant_permission(self.username(), Action.CONFIRM_COMMITMENT)
        self.smm = SprintModelManager(self.env)
    
    def _create_sprint_with_team_and_small_backlog(self, sprint_name=None, team=None):
        if team is None:
            self.team = self.teh.create_team(name=self.team_name())
            self.teh.create_member(Usernames.team_member, self.team_name())
        else:
            self.team = team
        self.sprint = self.teh.create_sprint(sprint_name or self.sprint_name(),
                                             team=self.team,
                                             duration=14,
                                             start=datetime.now() - timedelta(hours=2))
        self._create_tasks()
        
    def _create_tasks(self):
        self.teh.create_task(sprint=self.sprint.name, remaining_time=13)
        self.teh.create_story(sprint=self.sprint.name, rd_points=10)
    
    # --------------------------------------------------------------------------
    # helper methods
    
    def username(self):
        return 'Login User'
    
    def team_name(self):
        return 'Fnord Team'
    
    def _commitment(self, sprint, team):
        cmd = TeamController.GetTeamCommitmentCommand(self.env, sprint=sprint, team=team)
        commitment = TeamController(self.env).process_command(cmd)
        return commitment
    
    def _metric(self, sprint, team, metric):
        cmd = TeamController.GetTeamMetricCommand(self.env, sprint=sprint, team=team, metric=metric)
        result = TeamController(self.env).process_command(cmd)
        return result
    
    def _capacity(self, sprint, team):
        return self._metric(sprint, team, Key.CAPACITY)
    
    def _velocity(self, sprint, team):
        return self._metric(sprint, team, Key.ESTIMATED_VELOCITY)
    
    def _confirm_commitment(self, sprint=None, req=None):
        if req is None:
            req = self.teh.mock_request(self.username())
        sprint_name = sprint and sprint.name or self.sprint.name
        args = dict(sprint=sprint_name)
        return ConfirmCommitmentJSONView(self.env).do_post(req, args)
    
    # --------------------------------------------------------------------------
    # tests
    
    def test_stores_team_commitment_in_metrics(self):
        self.assert_none(self._commitment(self.sprint, self.team))
        self._confirm_commitment()
        self.assert_equals(13, self._commitment(self.sprint, self.team))
    
    def test_stores_team_velocity_in_metrics(self):
        self.assert_none(self._velocity(self.sprint, self.team))
        self._confirm_commitment()
        self.assert_equals(10, self._velocity(self.sprint, self.team))
    
    def test_stores_team_capacity_in_metrics(self):
        # Sprint must start at the same time (or before) the team members
        # start working or not all of his capacity will be in the sprint
        self.sprint.start = self.sprint.start.replace(hour=9, minute=0, second=0, microsecond=0,
                                                      tzinfo=self.team.members[0].timezone())
        self.sprint.save()
        SprintModelManager(self.env).get_cache().invalidate()
        self.assert_none(self._capacity(self.sprint, self.team))
        self._confirm_commitment()
        self.assert_almost_equals(10*6, self._capacity(self.sprint, self.team), max_delta=0.01)
    
    def test_stores_historic_data_change(self):
        self.teh.create_task(sprint=self.sprint.name, remaining_time=8)
        aggregator = BurndownDataAggregator(self.env)
        changes = aggregator.changes_for_sprint(self.sprint)
        self.assert_length(2, changes)
        self._confirm_commitment()
        changes = aggregator.changes_for_sprint(self.sprint)
        self.assert_length(1, changes)
    
    def test_cannot_confirm_without_permission(self):
        req = self.teh.mock_request(username=Usernames.product_owner)
        
        self.assert_raises(PermissionError, lambda: self._confirm_commitment(req=req))
    
    def test_scrum_master_can_commit(self):
        self.teh.grant_permission(Usernames.scrum_master, Role.SCRUM_MASTER)
        
        req = self.teh.mock_request(username=Usernames.scrum_master)
        self._confirm_commitment(req=req)
        self.assert_equals(13, self._commitment(self.sprint, self.team))
    
    def _invalidate_backlog_cache(self):
        BacklogModelManager(self.env).get_cache().invalidate()
    
    def test_second_commit_updates_the_first(self):
        self.assert_none(self._commitment(self.sprint, self.team))
        self._confirm_commitment()
        self.assert_equals(13, self._commitment(self.sprint, self.team))
        
        self.teh.create_task(sprint=self.sprint.name, remaining_time=8)
        self._invalidate_backlog_cache()
        self._confirm_commitment()
        self.assert_equals(21, self._commitment(self.sprint, self.team))
        
        aggregator = BurndownDataAggregator(self.env)
        changes = aggregator.changes_for_sprint(self.sprint)
        self.assert_length(1, changes)
    
    def test_can_only_commit_for_one_day(self):
        self.sprint.start = now() - timedelta(days=2)
        self.smm.save(self.sprint)
        self.assert_raises(PermissionError, self._confirm_commitment)
    
    def test_does_not_fail_with_team_without_members(self):
        team = self.teh.create_team(name='test_does_not_fail_with_team_without_membersTeam')
        self._create_sprint_with_team_and_small_backlog('test_does_not_fail_with_team_without_membersSprint', team)
        self.assert_length(0, self.team.members)
        
        self._confirm_commitment()
        self.assert_equals(0, self._capacity(self.sprint, self.team))
    
    def test_does_not_explode_with_no_team(self):
        sprint_without_team = self.teh.create_sprint('Fnord')
        
        self.assert_raises(PermissionError, lambda: self._confirm_commitment(sprint=sprint_without_team))
    
    def test_does_not_fail_with_unknown_sprint(self):
        req = self.teh.mock_request(username=self.username())
        json_view = ConfirmCommitmentJSONView(self.env)
        self.assert_method_returns_error_with_empty_data(json_view.do_post, req, dict(sprint='Missing Fnord'))
Example #8
0
 def __init__(self):
     # Create an instance of Sprint Manager
     self.sm = SprintModelManager(self.env)
     self.tm = TeamModelManager(self.env)
Example #9
0
class SprintAdminPanel(AgiloAdminPanel):
    """
    Administration panel for sprints.
    """

    _type = 'sprints'
    _label = ('Sprints', _('Sprints'))

    def __init__(self):
        # Create an instance of Sprint Manager
        self.sm = SprintModelManager(self.env)
        self.tm = TeamModelManager(self.env)

    def _parse_args(self, req):
        start = req.args.get('start')
        if start:
            start = datefmt.parse_date(start, tzinfo=req.tz)
        end = req.args.get('end')
        if end:
            end = datefmt.parse_date(end, tzinfo=req.tz)
        duration = req.args.get('duration')
        if duration:
            try:
                duration = int(duration)
            except ValueError:
                duration = None

        return start, end, duration

    def detail_save_view(self, req, cat, page, name):
        sprint = self.sm.get(name=name)
        if not sprint or not sprint.exists:
            return req.redirect(req.href.admin(cat, page))

        new_name = req.args.get('name')
        # if necessary, rename sprint
        if sprint.name != new_name:
            new_sprint = self.sm.get(name=new_name)
            if new_sprint and new_sprint.exists:
                add_warning(
                    req,
                    'A sprint with this name already exists - cannot rename.')
                return self.detail_view(req, cat, page, name)
            if '/' in new_name:
                add_warning(req, 'Please don\'t use "/" in a sprint name.')
                return self.detail_view(req, cat, page, name)

        sprint.name = new_name
        sprint.description = req.args.get('description')
        sprint.milestone = req.args.get('milestone')

        team_name = req.args.get('team')
        team = None
        if team_name:
            team = self.tm.get(name=team_name)
            if not team or not team.exists:
                add_warning(req,
                            u"Invalid team name, that team doesn't exist.")
                return self.detail_view(req, cat, page, name)
        sprint.team = team

        start, end, duration = self._parse_args(req)

        if start and start != sprint.start:
            if (end and duration) or (not end and not duration):
                add_warning(req, 'Please enter an end date OR a duration.')
                return self.detail_view(req, cat, page, name)
            sprint.start = start

        if end and end != sprint.end:
            if (start and duration) or (not start and not duration):
                add_warning(req, 'Please enter a start date OR a duration.')
                return self.detail_view(req, cat, page, name)
            sprint.end = end

        if duration and duration != sprint.duration:
            if (start and end) or (not start and not end):
                add_warning(req, 'Please enter an start date OR an end date.')
                return self.detail_view(req, cat, page, name)
            sprint.duration = duration

        self.sm.save(sprint)
        req.redirect(req.href.admin(cat, page))

    def detail_view(self, req, cat, page, name):
        sprint = self.sm.get(name=name)
        if not sprint or not sprint.exists:
            return req.redirect(req.href.admin(cat, page))

        data = {
            'view': 'detail',
            'sprint': sprint,
            'teams': self.tm.select(),
            'format_datetime': datefmt.format_datetime,
            'date_hint': datefmt.get_date_format_hint(),
            'datetime_hint': datefmt.get_datetime_format_hint(),
            'milestones': [m.name for m in Milestone.select(self.env)],
        }
        data.update(req.args)
        add_script(req, 'common/js/wikitoolbar.js')
        return 'agilo_admin_sprint.html', data

    def list_view(self, req, cat, page):
        data = {
            'view': 'list',
            'sprints': self.sm.select(),
            'format_datetime': datefmt.format_datetime,
            'date_hint': datefmt.get_date_format_hint(),
            'datetime_hint': datefmt.get_datetime_format_hint(),
            'milestones': [m.name for m in Milestone.select(self.env)],
        }
        data.update(req.args)
        return 'agilo_admin_sprint.html', data

    def list_save_view(self, req, cat, page):
        name = req.args.get('name')
        start, end, duration = self._parse_args(req)

        if req.args.get('add'):
            if not name:
                add_warning(req, 'Please enter a sprint name.')
                return self.list_view(req, cat, page)
            if '/' in name:
                add_warning(req, 'Please do not use "/" in a sprint name.')
                return self.list_view(req, cat, page)

            sprint = self.sm.create(name=name, save=False)
            if not sprint:
                # sprint already exists, redirect to it
                req.redirect(req.href.admin(cat, page, name))

            if not start and not end and not duration:
                add_warning(req, 'Not enough data to set a sprint.')
                return self.list_view(req, cat, page)

            if start:
                if (end and duration) or (not end and not duration):
                    add_warning(req, 'Please enter an end date OR a duration.')
                    return self.list_view(req, cat, page)
                sprint.start = start

            if end:
                if (start and duration) or (not start and not duration):
                    add_warning(req,
                                'Please enter an start date OR a duration.')
                    return self.list_view(req, cat, page)
                sprint.end = end

            if duration:
                if (start and end) or (not start and not end):
                    add_warning(req,
                                'Please enter an start date OR an end date.')
                    return self.list_view(req, cat, page)
                sprint.duration = duration

            sprint.milestone = req.args.get('milestone')
            self.sm.save(sprint)

        # Remove components
        if req.args.get('remove'):
            sel = req.args.get('sel')
            if not sel:
                raise TracError(_('No sprint selected'))
            if not isinstance(sel, list):
                sel = [sel]
            for name in sel:
                # TODO: relocate not closed ticket to another Sprint
                sprint = self.sm.get(name=name)
                if sprint:
                    self.sm.delete(sprint)

        req.redirect(req.href.admin(cat, page))
 def setUp(self):
     self.super()
     self.teh.disable_sprint_date_normalization()
     self._create_sprint_with_team_and_small_backlog()
     self.teh.grant_permission(self.username(), Action.CONFIRM_COMMITMENT)
     self.smm = SprintModelManager(self.env)
class CanConfirmCommitmentWithJSONTest(JSONAgiloTestCase):
    def setUp(self):
        self.super()
        self.teh.disable_sprint_date_normalization()
        self._create_sprint_with_team_and_small_backlog()
        self.teh.grant_permission(self.username(), Action.CONFIRM_COMMITMENT)
        self.smm = SprintModelManager(self.env)

    def _create_sprint_with_team_and_small_backlog(self,
                                                   sprint_name=None,
                                                   team=None):
        if team is None:
            self.team = self.teh.create_team(name=self.team_name())
            self.teh.create_member(Usernames.team_member, self.team_name())
        else:
            self.team = team
        self.sprint = self.teh.create_sprint(sprint_name or self.sprint_name(),
                                             team=self.team,
                                             duration=14,
                                             start=datetime.now() -
                                             timedelta(hours=2))
        self._create_tasks()

    def _create_tasks(self):
        self.teh.create_task(sprint=self.sprint.name, remaining_time=13)
        self.teh.create_story(sprint=self.sprint.name, rd_points=10)

    # --------------------------------------------------------------------------
    # helper methods

    def username(self):
        return 'Login User'

    def team_name(self):
        return 'Fnord Team'

    def _commitment(self, sprint, team):
        cmd = TeamController.GetTeamCommitmentCommand(self.env,
                                                      sprint=sprint,
                                                      team=team)
        commitment = TeamController(self.env).process_command(cmd)
        return commitment

    def _metric(self, sprint, team, metric):
        cmd = TeamController.GetTeamMetricCommand(self.env,
                                                  sprint=sprint,
                                                  team=team,
                                                  metric=metric)
        result = TeamController(self.env).process_command(cmd)
        return result

    def _capacity(self, sprint, team):
        return self._metric(sprint, team, Key.CAPACITY)

    def _velocity(self, sprint, team):
        return self._metric(sprint, team, Key.ESTIMATED_VELOCITY)

    def _confirm_commitment(self, sprint=None, req=None):
        if req is None:
            req = self.teh.mock_request(self.username())
        sprint_name = sprint and sprint.name or self.sprint.name
        args = dict(sprint=sprint_name)
        return ConfirmCommitmentJSONView(self.env).do_post(req, args)

    # --------------------------------------------------------------------------
    # tests

    def test_stores_team_commitment_in_metrics(self):
        self.assert_none(self._commitment(self.sprint, self.team))
        self._confirm_commitment()
        self.assert_equals(13, self._commitment(self.sprint, self.team))

    def test_stores_team_velocity_in_metrics(self):
        self.assert_none(self._velocity(self.sprint, self.team))
        self._confirm_commitment()
        self.assert_equals(10, self._velocity(self.sprint, self.team))

    def test_stores_team_capacity_in_metrics(self):
        # Sprint must start at the same time (or before) the team members
        # start working or not all of his capacity will be in the sprint
        self.sprint.start = self.sprint.start.replace(
            hour=9,
            minute=0,
            second=0,
            microsecond=0,
            tzinfo=self.team.members[0].timezone())
        self.sprint.save()
        SprintModelManager(self.env).get_cache().invalidate()
        self.assert_none(self._capacity(self.sprint, self.team))
        self._confirm_commitment()
        self.assert_almost_equals(10 * 6,
                                  self._capacity(self.sprint, self.team),
                                  max_delta=0.01)

    def test_stores_historic_data_change(self):
        self.teh.create_task(sprint=self.sprint.name, remaining_time=8)
        aggregator = BurndownDataAggregator(self.env)
        changes = aggregator.changes_for_sprint(self.sprint)
        self.assert_length(2, changes)
        self._confirm_commitment()
        changes = aggregator.changes_for_sprint(self.sprint)
        self.assert_length(1, changes)

    def test_cannot_confirm_without_permission(self):
        req = self.teh.mock_request(username=Usernames.product_owner)

        self.assert_raises(PermissionError,
                           lambda: self._confirm_commitment(req=req))

    def test_scrum_master_can_commit(self):
        self.teh.grant_permission(Usernames.scrum_master, Role.SCRUM_MASTER)

        req = self.teh.mock_request(username=Usernames.scrum_master)
        self._confirm_commitment(req=req)
        self.assert_equals(13, self._commitment(self.sprint, self.team))

    def _invalidate_backlog_cache(self):
        BacklogModelManager(self.env).get_cache().invalidate()

    def test_second_commit_updates_the_first(self):
        self.assert_none(self._commitment(self.sprint, self.team))
        self._confirm_commitment()
        self.assert_equals(13, self._commitment(self.sprint, self.team))

        self.teh.create_task(sprint=self.sprint.name, remaining_time=8)
        self._invalidate_backlog_cache()
        self._confirm_commitment()
        self.assert_equals(21, self._commitment(self.sprint, self.team))

        aggregator = BurndownDataAggregator(self.env)
        changes = aggregator.changes_for_sprint(self.sprint)
        self.assert_length(1, changes)

    def test_can_only_commit_for_one_day(self):
        self.sprint.start = now() - timedelta(days=2)
        self.smm.save(self.sprint)
        self.assert_raises(PermissionError, self._confirm_commitment)

    def test_does_not_fail_with_team_without_members(self):
        team = self.teh.create_team(
            name='test_does_not_fail_with_team_without_membersTeam')
        self._create_sprint_with_team_and_small_backlog(
            'test_does_not_fail_with_team_without_membersSprint', team)
        self.assert_length(0, self.team.members)

        self._confirm_commitment()
        self.assert_equals(0, self._capacity(self.sprint, self.team))

    def test_does_not_explode_with_no_team(self):
        sprint_without_team = self.teh.create_sprint('Fnord')

        self.assert_raises(
            PermissionError,
            lambda: self._confirm_commitment(sprint=sprint_without_team))

    def test_does_not_fail_with_unknown_sprint(self):
        req = self.teh.mock_request(username=self.username())
        json_view = ConfirmCommitmentJSONView(self.env)
        self.assert_method_returns_error_with_empty_data(
            json_view.do_post, req, dict(sprint='Missing Fnord'))
Example #12
0
 def __init__(self):
     """Initialize the component, sets some references to needed
     Model Managers"""
     self.sp_manager = SprintModelManager(self.env)
     self.tm_manager = TeamModelManager(self.env)
Example #13
0
 def __init__(self):
     # Create an instance of Sprint Manager
     self.sm = SprintModelManager(self.env)
     self.tm = TeamModelManager(self.env)
Example #14
0
class SprintAdminPanel(AgiloAdminPanel):
    """
    Administration panel for sprints.
    """
    
    _type = 'sprints'
    _label = ('Sprints', _('Sprints'))

    def __init__(self):
        # Create an instance of Sprint Manager
        self.sm = SprintModelManager(self.env)
        self.tm = TeamModelManager(self.env)

    def _parse_args(self, req):
        start = req.args.get('start')
        if start:
            start = datefmt.parse_date(start, tzinfo=req.tz)
        end = req.args.get('end')
        if end:
            end = datefmt.parse_date(end, tzinfo=req.tz)
        duration = req.args.get('duration')
        if duration:
            try:
                duration = int(duration)
            except ValueError:
                duration = None
                
        return start, end, duration

    def detail_save_view(self, req, cat, page, name):
        sprint = self.sm.get(name=name)
        if not sprint or not sprint.exists:
            return req.redirect(req.href.admin(cat, page))

        new_name = req.args.get('name')
        # if necessary, rename sprint
        if sprint.name != new_name:
            new_sprint = self.sm.get(name=new_name)
            if new_sprint and new_sprint.exists:
                add_warning(req, 'A sprint with this name already exists - cannot rename.')
                return self.detail_view(req, cat, page, name)
            if '/' in new_name:
                add_warning(req, 'Please don\'t use "/" in a sprint name.')
                return self.detail_view(req, cat, page, name)
        
        sprint.name = new_name
        sprint.description = req.args.get('description')
        sprint.milestone = req.args.get('milestone')
        
        team_name = req.args.get('team')
        team = None
        if team_name:
            team = self.tm.get(name=team_name)
            if not team or not team.exists:
                add_warning(req, u"Invalid team name, that team doesn't exist.")
                return self.detail_view(req, cat, page, name)
        sprint.team = team
        
        start, end, duration = self._parse_args(req)
        
        if start and start != sprint.start:
            if (end and duration) or (not end and not duration):
                add_warning(req, 'Please enter an end date OR a duration.')
                return self.detail_view(req, cat, page, name)
            sprint.start = start
            
        if end and end != sprint.end:
            if (start and duration) or (not start and not duration):
                add_warning(req, 'Please enter a start date OR a duration.')
                return self.detail_view(req, cat, page, name)
            sprint.end = end
        
        if duration and duration != sprint.duration:
            if (start and end) or (not start and not end):
                add_warning(req, 'Please enter an start date OR an end date.')
                return self.detail_view(req, cat, page, name)
            sprint.duration = duration
            
        self.sm.save(sprint)
        req.redirect(req.href.admin(cat, page))

    def detail_view(self, req, cat, page, name):
        sprint = self.sm.get(name=name)
        if not sprint or not sprint.exists:
            return req.redirect(req.href.admin(cat, page))

        data = {
            'view': 'detail',
            'sprint': sprint,
            'teams': self.tm.select(),
            'format_datetime': datefmt.format_datetime,
            'date_hint': datefmt.get_date_format_hint(),
            'datetime_hint': datefmt.get_datetime_format_hint(),
            'milestones': [m.name for m in Milestone.select(self.env)],
        }
        data.update(req.args)
        add_script(req, 'common/js/wikitoolbar.js')
        return 'agilo_admin_sprint.html', data
    
    def list_view(self, req, cat, page):
        data = {
            'view': 'list',
            'sprints': self.sm.select(),
            'format_datetime' : datefmt.format_datetime,
            'date_hint' : datefmt.get_date_format_hint(),
            'datetime_hint' : datefmt.get_datetime_format_hint(),
            'milestones' : [m.name for m in Milestone.select(self.env)],
        }
        data.update(req.args)
        return 'agilo_admin_sprint.html', data

    def list_save_view(self, req, cat, page):
        name = req.args.get('name')
        start, end, duration = self._parse_args(req)

        if req.args.get('add'):
            if not name:
                add_warning(req, 'Please enter a sprint name.')
                return self.list_view(req, cat, page)
            if '/' in name:
                add_warning(req, 'Please do not use "/" in a sprint name.')
                return self.list_view(req, cat, page)

            sprint = self.sm.create(name=name, save=False)
            if not sprint:
                # sprint already exists, redirect to it
                req.redirect(req.href.admin(cat, page, name))

            if not start and not end and not duration:
                add_warning(req, 'Not enough data to set a sprint.')
                return self.list_view(req, cat, page)
                
            if start:
                if (end and duration) or (not end and not duration):
                    add_warning(req, 'Please enter an end date OR a duration.')
                    return self.list_view(req, cat, page)
                sprint.start = start
                
            if end:
                if (start and duration) or (not start and not duration):
                    add_warning(req, 'Please enter an start date OR a duration.')
                    return self.list_view(req, cat, page)
                sprint.end = end
            
            if duration:
                if (start and end) or (not start and not end):
                    add_warning(req, 'Please enter an start date OR an end date.')
                    return self.list_view(req, cat, page)
                sprint.duration = duration

            sprint.milestone = req.args.get('milestone')
            self.sm.save(sprint)

        # Remove components
        if req.args.get('remove'):
            sel = req.args.get('sel')
            if not sel:
                raise TracError(_('No sprint selected'))
            if not isinstance(sel, list):
                sel = [sel]
            for name in sel:
                # TODO: relocate not closed ticket to another Sprint
                sprint = self.sm.get(name=name)
                if sprint:
                    self.sm.delete(sprint)

        req.redirect(req.href.admin(cat, page))