Added milestones

Added table for milestones,
field milestone_id in tasks table.
Added controller for milestones.
Added models for milestones.
To db api added milestones.
Added tests for patch.
Tasks controller was modified.

Change-Id: I857d36a9d579d04f6938327e48243ddd5b4d0569
This commit is contained in:
Aleksey Ripinen
2015-01-28 15:48:47 +03:00
committed by Thierry Carrez
parent ee7a86c03f
commit 24314240c3
13 changed files with 614 additions and 6 deletions

View File

@@ -0,0 +1,153 @@
# Copyright (c) 2015 Mirantis Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
# implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from datetime import datetime
import pytz
from oslo.config import cfg
from pecan import abort
from pecan import response
from pecan import rest
from pecan.secure import secure
import six
from wsme import types as wtypes
import wsmeext.pecan as wsme_pecan
from storyboard.api.auth import authorization_checks as checks
from storyboard.api.v1.search import search_engine
from storyboard.api.v1 import validations
from storyboard.api.v1 import wmodels
from storyboard.common import decorators
from storyboard.common import exception as exc
from storyboard.db.api import milestones as milestones_api
from storyboard.openstack.common.gettextutils import _ # noqa
CONF = cfg.CONF
SEARCH_ENGINE = search_engine.get_engine()
class MilestonesController(rest.RestController):
"""REST controller for milestones.
"""
_custom_actions = {"search": ["GET"]}
validation_post_schema = validations.MILESTONES_POST_SCHEMA
validation_put_schema = validations.MILESTONES_PUT_SCHEMA
@decorators.db_exceptions
@secure(checks.guest)
@wsme_pecan.wsexpose(wmodels.Milestone, int)
def get_one(self, milestone_id):
"""Retrieve information about the given milestone.
:param milestone_id: milestone ID.
"""
milestones = milestones_api.milestone_get(milestone_id)
if milestones:
return wmodels.Milestone.from_db_model(milestones)
else:
raise exc.NotFound(_("Milestone %s not found") % milestone_id)
@decorators.db_exceptions
@secure(checks.guest)
@wsme_pecan.wsexpose([wmodels.Milestone], int, int, wtypes.text, int,
wtypes.text, wtypes.text)
def get_all(self, marker=None, limit=None, name=None, branch_id=None,
sort_field='id', sort_dir='asc'):
"""Retrieve a list of milestones.
:param marker: The resource id where the page should begin.
:param limit: The number of milestones to retrieve.
:param name: Filter milestones based on name.
:param branch_id: Filter milestones based on branch_id.
:param sort_field: The name of the field to sort on.
:param sort_dir: sort direction for results (asc, desc).
"""
# Boundary check on limit.
if limit is None:
limit = CONF.page_size_default
limit = min(CONF.page_size_maximum, max(1, limit))
# Resolve the marker record.
marker_milestone = milestones_api.milestone_get(marker)
milestones = \
milestones_api.milestone_get_all(marker=marker_milestone,
limit=limit,
name=name,
branch_id=branch_id,
sort_field=sort_field,
sort_dir=sort_dir)
milestones_count = \
milestones_api.milestone_get_count(
name=name,
branch_id=branch_id)
# Apply the query response headers.
response.headers['X-Limit'] = str(limit)
response.headers['X-Total'] = str(milestones_count)
if marker_milestone:
response.headers['X-Marker'] = str(marker_milestone.id)
return [wmodels.Milestone.from_db_model(m) for m in milestones]
@decorators.db_exceptions
@secure(checks.superuser)
@wsme_pecan.wsexpose(wmodels.Milestone, body=wmodels.Milestone)
def post(self, milestone):
"""Create a new milestone.
:param milestone: a milestone within the request body.
"""
# we can't create expired milestones
if milestone.expiration_date or milestone.expired:
abort(400, _("Can't create expired milestone."))
result = milestones_api.milestone_create(milestone.as_dict())
return wmodels.Milestone.from_db_model(result)
@decorators.db_exceptions
@secure(checks.superuser)
@wsme_pecan.wsexpose(wmodels.Milestone, int, body=wmodels.Milestone)
def put(self, milestone_id, milestone):
"""Modify this milestone.
:param milestone_id: An ID of the milestone.
:param milestone: a milestone within the request body.
"""
milestone_dict = milestone.as_dict(omit_unset=True)
if milestone.expiration_date:
abort(400, _("Can't change expiration date."))
if "expired" in six.iterkeys(milestone_dict):
if milestone_dict["expired"]:
milestone_dict["expiration_date"] = datetime.now(tz=pytz.utc)
else:
milestone_dict["expiration_date"] = None
result = milestones_api.milestone_update(milestone_id, milestone_dict)
if result:
return wmodels.Milestone.from_db_model(result)
else:
raise exc.NotFound(_("Milestone %s not found") % milestone_id)

View File

@@ -4,7 +4,7 @@
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
@@ -28,6 +28,7 @@ from storyboard.api.v1 import validations
from storyboard.api.v1 import wmodels
from storyboard.common import decorators
from storyboard.common import exception as exc
from storyboard.db.api import milestones as milestones_api
from storyboard.db.api import tasks as tasks_api
from storyboard.db.api import timeline_events as events_api
from storyboard.openstack.common.gettextutils import _ # noqa
@@ -63,12 +64,12 @@ class TasksController(rest.RestController):
@decorators.db_exceptions
@secure(checks.guest)
@wsme_pecan.wsexpose([wmodels.Task], wtypes.text, int, int, int, int, int,
[wtypes.text], [wtypes.text], int, int, wtypes.text,
wtypes.text)
int, [wtypes.text], [wtypes.text], int, int,
wtypes.text, wtypes.text)
def get_all(self, title=None, story_id=None, assignee_id=None,
project_id=None, project_group_id=None, branch_id=None,
status=None, priority=None, marker=None, limit=None,
sort_field='id', sort_dir='asc'):
milestone_id=None, status=None, priority=None, marker=None,
limit=None, sort_field='id', sort_dir='asc'):
"""Retrieve definitions of all of the tasks.
:param title: search by task title.
@@ -77,6 +78,7 @@ class TasksController(rest.RestController):
:param project_id: filter the tasks based on project.
:param project_group_id: filter tasks based on project group.
:param branch_id: filter tasks based on branch_id.
:param milestone_id: filter tasks based on milestone.
:param status: filter tasks by status.
:param priority: filter tasks by priority.
:param marker: The resource id where the page should begin.
@@ -100,6 +102,7 @@ class TasksController(rest.RestController):
project_id=project_id,
project_group_id=project_group_id,
branch_id=branch_id,
milestone_id=milestone_id,
status=status,
priority=priority,
sort_field=sort_field,
@@ -113,6 +116,7 @@ class TasksController(rest.RestController):
project_id=project_id,
project_group_id=project_group_id,
branch_id=branch_id,
milestone_id=milestone_id,
status=status,
priority=priority)
@@ -124,6 +128,16 @@ class TasksController(rest.RestController):
return [wmodels.Task.from_db_model(s) for s in tasks]
def _milestone_is_valid(self, milestone_id):
milestone = milestones_api.milestone_get(milestone_id)
if not milestone:
raise exc.NotFound(_("Milestone %d not found.") %
milestone_id)
if milestone['expired']:
abort(400, _("Can't associate task to expired milestone."))
@decorators.db_exceptions
@secure(checks.authenticated)
@wsme_pecan.wsexpose(wmodels.Task, body=wmodels.Task)
@@ -136,6 +150,13 @@ class TasksController(rest.RestController):
if task.creator_id and task.creator_id != request.current_user_id:
abort(400, _("You can't select author of task."))
if task.milestone_id:
if task.status != 'merged':
abort(400,
_("Milestones can only be associated with merged tasks"))
self._milestone_is_valid(task.milestone_id)
creator_id = request.current_user_id
task.creator_id = creator_id
@@ -166,6 +187,25 @@ class TasksController(rest.RestController):
updated_task = tasks_api.task_update(task_id,
task.as_dict(omit_unset=True))
if task.milestone_id:
if original_task['status'] != 'merged' and task.status != 'merged':
abort(400,
_("Milestones can only be associated with merged tasks"))
if (original_task['status'] == 'merged' and
task.status and task.status != 'merged'):
abort(400,
_("Milestones can only be associated with merged tasks"))
self._milestone_is_valid(task.milestone_id)
task_dict = task.as_dict(omit_unset=True)
if task.status and task.status != 'merged':
task_dict['milestone_id'] = None
updated_task = tasks_api.task_update(task_id, task_dict)
if updated_task:
self._post_timeline_events(original_task, updated_task)
return wmodels.Task.from_db_model(updated_task)

View File

@@ -15,6 +15,7 @@
from storyboard.api.v1.auth import AuthController
from storyboard.api.v1.branches import BranchesController
from storyboard.api.v1.milestones import MilestonesController
from storyboard.api.v1.project_groups import ProjectGroupsController
from storyboard.api.v1.projects import ProjectsController
from storyboard.api.v1.stories import StoriesController
@@ -35,6 +36,7 @@ class V1Controller(object):
users = UsersController()
teams = TeamsController()
branches = BranchesController()
milestones = MilestonesController()
stories = StoriesController()
tags = TagsController()
tasks = TasksController()

View File

@@ -181,6 +181,21 @@ BRANCHES_PUT_SCHEMA = {
BRANCHES_POST_SCHEMA = copy.deepcopy(BRANCHES_PUT_SCHEMA)
BRANCHES_POST_SCHEMA["required"] = ["name"]
MILESTONES_PUT_SCHEMA = {
"name": "milestone_schema",
"type": "object",
"properties": {
"name": {
"type": "string",
"minLength": CommonLength.lower_middle_length,
"maxLength": CommonLength.top_middle_length
}
}
}
MILESTONES_POST_SCHEMA = copy.deepcopy(MILESTONES_PUT_SCHEMA)
MILESTONES_POST_SCHEMA["required"] = ["name"]
STORY_TAGS_PUT_SCHEMA = {
"name": "storyTag_schema",
"type": "object",

View File

@@ -203,6 +203,9 @@ class Task(base.APIBase):
branch_id = int
"""The ID of corresponding Branch"""
milestone_id = int
"""The ID of corresponding Milestone"""
class Branch(base.APIBase):
"""Represents a branch."""
@@ -238,6 +241,34 @@ class Branch(base.APIBase):
)
class Milestone(base.APIBase):
"""Represents a milestone."""
name = wtypes.text
"""The milestone unique name. This name will be displayed in the URL.
At least 3 alphanumeric symbols.
"""
branch_id = int
"""The ID of the corresponding Branch."""
expired = bool
"""a binary flag that marks milestones that should no longer be
selectable in completed tasks."""
expiration_date = datetime
"""Last date the expired flag was switched to True."""
@classmethod
def sample(cls):
return cls(
name="Storyboard-milestone",
branch_id=1,
expired=True,
expiration_date=datetime(2015, 1, 1, 1, 1)
)
class Team(base.APIBase):
"""The Team is a group od Users with a fixed set of permissions.
"""

View File

@@ -0,0 +1,43 @@
# Copyright (c) 2015 Mirantis Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
# implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from storyboard.db.api import base as api_base
from storyboard.db import models
def milestone_get(milestone_id):
return api_base.entity_get(models.Milestone, milestone_id)
def milestone_get_all(marker=None, limit=None, sort_field=None, sort_dir=None,
**kwargs):
return api_base.entity_get_all(models.Milestone,
marker=marker,
limit=limit,
sort_field=sort_field,
sort_dir=sort_dir,
**kwargs)
def milestone_get_count(**kwargs):
return api_base.entity_get_count(models.Milestone, **kwargs)
def milestone_create(values):
return api_base.entity_create(models.Milestone, values)
def milestone_update(milestone_id, values):
return api_base.entity_update(models.Milestone, milestone_id, values)

View File

@@ -0,0 +1,54 @@
# Copyright (c) 2015 Mirantis Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
#
"""This migration adds milestones table.
Revision ID: 038
Revises: 037
Create Date: 2015-01-28 15:26:34.622503
"""
# revision identifiers, used by Alembic.
revision = '038'
down_revision = '037'
from alembic import op
import sqlalchemy as sa
MYSQL_ENGINE = 'InnoDB'
MYSQL_CHARSET = 'utf8'
def upgrade(active_plugins=None, options=None):
op.create_table(
'milestones',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(100), nullable=True),
sa.Column('branch_id', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.Column('expired', sa.Boolean(), default=False, nullable=True),
sa.Column('expiration_date', sa.DateTime(), default=None),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name', 'branch_id', name="milestone_un_constr"),
mysql_engine=MYSQL_ENGINE,
mysql_charset=MYSQL_CHARSET
)
def downgrade(active_plugins=None, options=None):
op.drop_table('milestones')

View File

@@ -0,0 +1,41 @@
# Copyright (c) 2015 Mirantis Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
#
"""This migration adds new column for milestone id.
Revision ID: 039
Revises: 038
Create Date: 2015-01-27 13:17:34.622503
"""
# revision identifiers, used by Alembic.
revision = '039'
down_revision = '038'
from alembic import op
import sqlalchemy as sa
def upgrade(active_plugins=None, options=None):
op.add_column(
'tasks',
sa.Column('milestone_id', sa.Integer(), nullable=True)
)
def downgrade(active_plugins=None, options=None):
op.drop_column('tasks', 'milestone_id')

View File

@@ -304,10 +304,12 @@ class Task(FullText, ModelBuilder, Base):
project_id = Column(Integer, ForeignKey('projects.id'))
assignee_id = Column(Integer, ForeignKey('users.id'), nullable=True)
branch_id = Column(Integer, ForeignKey('branches.id'), nullable=True)
milestone_id = Column(Integer, ForeignKey('milestones.id'), nullable=True)
priority = Column(Enum(*_TASK_PRIORITIES), default='medium')
_public_fields = ["id", "creator_id", "title", "status", "story_id",
"project_id", "assignee_id", "priority", "branch_id"]
"project_id", "assignee_id", "priority", "branch_id",
"milestone_id"]
class Branch(FullText, ModelBuilder, Base):
@@ -329,6 +331,24 @@ class Branch(FullText, ModelBuilder, Base):
"expiration_date", "autocreated"]
class Milestone(FullText, ModelBuilder, Base):
__tablename__ = 'milestones'
__table_args__ = (
schema.UniqueConstraint('name', 'branch_id',
name='milestone_un_constr'),
)
__fulltext_columns__ = ['name']
name = Column(String(CommonLength.top_middle_length))
branch_id = Column(Integer, ForeignKey('branches.id'))
expired = Column(Boolean, default=False)
expiration_date = Column(UTCDateTime, default=None)
_public_fields = ["id", "name", "branch_id", "expired", "expiration_date"]
class StoryTag(ModelBuilder, Base):
__tablename__ = 'storytags'
__table_args__ = (

View File

@@ -33,6 +33,7 @@ class_mappings = {'task': [models.Task, wmodels.Task],
'team': [models.Team, wmodels.Team],
'story': [models.Story, wmodels.Story],
'branch': [models.Branch, wmodels.Branch],
'milestone': [models.Milestone, wmodels.Milestone],
'tag': [models.StoryTag, wmodels.Tag]}
@@ -142,6 +143,7 @@ class NotificationHook(hooks.PecanHook):
'project_groups': 'project_group',
'tasks': 'task',
'branches': 'branch',
'milestones': 'milestone',
'timeline_events': 'timeline_event',
'users': 'user',
'teams': 'team',

View File

@@ -0,0 +1,133 @@
# Copyright (c) 2015 Mirantis Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from storyboard.tests import base
class TestMilestones(base.FunctionalTest):
def setUp(self):
super(TestMilestones, self).setUp()
self.resource = '/milestones'
self.milestone_01 = {
'name': 'test_milestone_01',
'branch_id': 1
}
self.milestone_02 = {
'name': 'test_milestone_02',
'branch_id': 100
}
self.milestone_03 = {
'name': 'test_milestone_03',
'branch_id': 1,
'expiration_date': '2014-01-01T00:00:00+00:00'
}
self.put_milestone_01 = {
'branch_id': 2
}
self.put_milestone_02 = {
'expired': True
}
self.put_milestone_03 = {
'expired': False
}
self.put_milestone_04 = {
'expired': False,
'expiration_date': '2014-01-01T00:00:00+00:00'
}
self.default_headers['Authorization'] = 'Bearer valid_superuser_token'
def test_create(self):
response = self.post_json(self.resource, self.milestone_01)
milestone = response.json
self.assertIn("id", milestone)
self.assertEqual(milestone['name'], self.milestone_01['name'])
self.assertEqual(milestone['branch_id'],
self.milestone_01['branch_id'])
self.assertEqual(milestone['expired'], False)
self.assertIsNone(milestone['expiration_date'])
def test_create_invalid(self):
response = self.post_json(self.resource, self.milestone_03,
expect_errors=True)
self.assertEqual(response.status_code, 400)
def test_update(self):
response = self.post_json(self.resource, self.milestone_01)
milestone = response.json
self.assertEqual(milestone['name'], self.milestone_01['name'])
self.assertEqual(milestone['branch_id'],
self.milestone_01['branch_id'])
self.assertIn("id", milestone)
resource = "".join([self.resource, ("/%d" % milestone['id'])])
response = self.put_json(resource, self.put_milestone_01)
milestone = response.json
self.assertEqual(milestone['name'], self.milestone_01['name'])
self.assertEqual(milestone['branch_id'],
self.put_milestone_01['branch_id'])
response = self.put_json(resource, self.put_milestone_02)
milestone = response.json
self.assertEqual(milestone['expired'], True)
self.assertIsNotNone(milestone['expiration_date'])
response = self.put_json(resource, self.put_milestone_03)
milestone = response.json
self.assertEqual(milestone['expired'], False)
self.assertIsNone(milestone['expiration_date'])
def test_update_expiration_date(self):
response = self.post_json(self.resource, self.milestone_01)
milestone = response.json
self.assertEqual(milestone['name'], self.milestone_01['name'])
self.assertEqual(milestone['branch_id'],
self.milestone_01['branch_id'])
self.assertIn("id", milestone)
resource = "".join([self.resource, ("/%d" % milestone['id'])])
response = self.put_json(resource, self.put_milestone_02)
milestone = response.json
self.assertEqual(milestone['expired'], True)
self.assertIsNotNone(milestone['expiration_date'])
response = self.put_json(resource, self.put_milestone_04,
expect_errors=True)
self.assertEqual(response.status_code, 400)
def test_get_one(self):
response = self.post_json(self.resource, self.milestone_01)
milestone = response.json
resource = "".join([self.resource, ("/%d" % milestone['id'])])
milestone = self.get_json(path=resource)
self.assertEqual(milestone['name'], self.milestone_01['name'])
self.assertEqual(milestone['branch_id'],
self.milestone_01['branch_id'])
self.assertEqual(milestone['expired'], False)
self.assertIsNone(milestone['expiration_date'])
def test_get_invalid(self):
resource = "".join([self.resource, "/1000"])
response = self.get_json(path=resource, expect_errors=True)
self.assertEqual(404, response.status_code)

View File

@@ -0,0 +1,54 @@
# Copyright (c) 2015 Mirantis Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
# implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from storyboard.db.api import branches
from storyboard.db.api import milestones
from storyboard.db.api import projects
from storyboard.tests.db import base
class MilestonesTest(base.BaseDbTestCase):
def setUp(self):
super(MilestonesTest, self).setUp()
self.milestone_01 = {
'name': u'test_milestone',
'branch_id': 1
}
self.branch_01 = {
'name': u'test_branch',
'project_id': 1
}
self.project_01 = {
'name': u'TestProject',
'description': u'TestDescription'
}
projects.project_create(self.project_01)
branches.branch_create(self.branch_01)
def test_create_branch(self):
self._test_create(self.milestone_01, milestones.milestone_create)
def test_update_branch(self):
delta = {
'expired': True
}
self._test_update(self.milestone_01, delta,
milestones.milestone_create,
milestones.milestone_update)

View File

@@ -18,6 +18,7 @@ import pytz
import storyboard.common.event_types as event
from storyboard.db.api import base as db
from storyboard.db.models import AccessToken
from storyboard.db.models import Branch
from storyboard.db.models import Comment
from storyboard.db.models import Project
from storyboard.db.models import ProjectGroup
@@ -239,6 +240,25 @@ def load():
),
])
# Load some branches
load_data([
Branch(
id=1,
project_id=1,
name='master',
),
Branch(
id=2,
project_id=2,
name='master'
),
Branch(
id=3,
project_id=3,
name='master'
)
])
def load_data(data):
"""Pre load test data into the database.