Added story types

Added 4 new migrations:
added story_types table, column with story_type_id
to stories table, column restricted to branches table and
mey_mutate_to table.

Added rules for story types mutations.
Rules for private stories are not made.
Added tests.

Change-Id: If4c6bf0956128390573a80b5eb2e3e347621f1fd
This commit is contained in:
Aleksey Ripinen 2015-03-05 12:23:53 +03:00
parent f80bb9c830
commit a94596e191
14 changed files with 625 additions and 17 deletions

View File

@ -137,8 +137,14 @@ class StoriesController(rest.RestController):
:param story: A story within the request body.
"""
story_dict = story.as_dict()
# Reject private story types while ACL is not created.
if (story.story_type_id and
(story.story_type_id == 3 or story.story_type_id == 4)):
abort(400, _("Now you can't add story with type %s.") %
story.story_type_id)
story_dict = story.as_dict()
user_id = request.current_user_id
if story.creator_id and story.creator_id != user_id:
@ -146,11 +152,13 @@ class StoriesController(rest.RestController):
story_dict.update({"creator_id": user_id})
if not stories_api.story_can_create_story(story.story_type_id):
abort(400, _("Can't create story of this type."))
if not "tags" in story_dict or not story_dict["tags"]:
story_dict["tags"] = []
created_story = stories_api.story_create(story_dict)
events_api.story_created_event(created_story.id, user_id, story.title)
return wmodels.Story.from_db_model(created_story)
@ -165,23 +173,36 @@ class StoriesController(rest.RestController):
:param story: A story within the request body.
"""
# Reject private story types while ACL is not created.
if (story.story_type_id and
(story.story_type_id == 3 or story.story_type_id == 4)):
abort(400, _("Now you can't change story type to %s.") %
story.story_type_id)
original_story = stories_api.story_get_simple(story_id)
if not original_story:
raise exc.NotFound(_("Story %s not found") % story_id)
if story.creator_id and story.creator_id != original_story.creator_id:
abort(400, _("You can't change author of story."))
story_dict = story.as_dict(omit_unset=True)
stories_api.story_check_story_type_id(story_dict)
if not stories_api.story_can_mutate(original_story,
story.story_type_id):
abort(400, _("Can't change story type."))
updated_story = stories_api.story_update(
story_id,
story.as_dict(omit_unset=True))
story_dict)
if updated_story:
user_id = request.current_user_id
events_api.story_details_changed_event(story_id, user_id,
updated_story.title)
user_id = request.current_user_id
events_api.story_details_changed_event(story_id, user_id,
updated_story.title)
return wmodels.Story.from_db_model(updated_story)
else:
raise exc.NotFound(_("Story %s not found") % story_id)
return wmodels.Story.from_db_model(updated_story)
@decorators.db_exceptions
@secure(checks.superuser)

View File

@ -30,6 +30,8 @@ from storyboard.common import decorators
from storyboard.common import exception as exc
from storyboard.db.api import branches as branches_api
from storyboard.db.api import milestones as milestones_api
from storyboard.db.api import stories as stories_api
from storyboard.db.api import story_types as story_types_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
@ -80,6 +82,24 @@ def branch_is_valid(task):
abort(400, _("You can't associate task with expired branch %s.") %
task.branch_id)
return branch
def story_is_valid(task, branch):
"""Check that branch is restricted if story type is restricted.
"""
story = stories_api.story_get(task.story_id)
if not story:
raise exc.NotFound("Story %s not found." % task.story_id)
story_type = story_types_api.story_type_get(story.story_type_id)
if story_type.restricted:
if not branch.restricted:
abort(400, _("Branch %s must be restricted.") % branch.id)
def task_is_valid_post(task):
"""Check that task can be created.
@ -93,14 +113,24 @@ def task_is_valid_post(task):
if not task.project_id:
abort(400, _("You must select a project for task."))
# Check that story_id is in request
if not task.story_id:
abort(400, _("You must select a story for task."))
# Set branch_id to 'master' branch defaults and check that
# branch is valid for this task.
branch = None
if not task.branch_id:
task.branch_id = branches_api.branch_get_master_branch(
branch = branches_api.branch_get_master_branch(
task.project_id
).id
)
task.branch_id = branch.id
else:
branch_is_valid(task)
branch = branch_is_valid(task)
# Check that branch is restricted if story type is restricted
story_is_valid(task, branch)
# Check that task status is merged and milestone is valid for this task
# if milestone_id is in request.

View File

@ -134,6 +134,9 @@ class Story(base.APIBase):
creator_id = int
"""User ID of the Story creator"""
story_type_id = int
"""ID of story type"""
status = wtypes.text
"""The derived status of the story, one of 'active', 'merged', 'invalid'"""
@ -151,6 +154,7 @@ class Story(base.APIBase):
is_bug=False,
creator_id=1,
task_statuses=[TaskStatusCount],
story_type_id=1,
status="active",
tags=["t1", "t2"])
@ -227,6 +231,9 @@ class Branch(base.APIBase):
be auto-expired when the corresponding branch is deleted in the git repo.
"""
restricted = bool
"""This flag marks branch as restricted."""
@classmethod
def sample(cls):
return cls(
@ -234,7 +241,8 @@ class Branch(base.APIBase):
project_id=1,
expired=True,
expiration_date=datetime(2015, 1, 1, 1, 1),
autocreated=False
autocreated=False,
restricted=False
)

View File

@ -20,6 +20,7 @@ class MasterBranchHelper:
expired = False
expiration_date = None
autocreated = False
restricted = True
def __init__(self, project_id):
self.project_id = project_id
@ -30,7 +31,8 @@ class MasterBranchHelper:
"project_id": self.project_id,
"expired": self.expired,
"expiration_date": self.expiration_date,
"autocreated": self.autocreated
"autocreated": self.autocreated,
"restricted": self.restricted
}
return master_branch_dict

View File

@ -19,6 +19,7 @@ from storyboard.api.v1 import wmodels
from storyboard.common import exception as exc
from storyboard.db.api import base as api_base
from storyboard.db.api import story_tags
from storyboard.db.api import story_types
from storyboard.db import models
from storyboard.openstack.common.gettextutils import _ # noqa
@ -222,3 +223,69 @@ def story_delete(story_id):
if story:
api_base.entity_hard_delete(models.Story, story_id)
def story_check_story_type_id(story_dict):
if "story_type_id" in story_dict and not story_dict["story_type_id"]:
del story_dict["story_type_id"]
def story_can_create_story(story_type_id):
if not story_type_id:
return True
story_type = story_types.story_type_get(story_type_id)
if not story_type:
raise exc.NotFound("Story type %s not found." % story_type_id)
if not story_type.visible:
return False
return True
def story_can_mutate(story, new_story_type_id):
if not new_story_type_id:
return True
if story.story_type_id == new_story_type_id:
return True
old_story_type = story_types.story_type_get(story.story_type_id)
new_story_type = story_types.story_type_get(new_story_type_id)
if not new_story_type:
raise exc.NotFound(_("Story type %s not found.") % new_story_type_id)
if not old_story_type.private and new_story_type.private:
return False
mutation = story_types.story_type_get_mutations(story.story_type_id,
new_story_type_id)
if not mutation:
return False
if not new_story_type.restricted:
return True
query = api_base.model_query(models.Task)
query = query.filter_by(story_id=story.id)
tasks = query.all()
branch_ids = set()
for task in tasks:
if task.branch_id:
branch_ids.add(task.branch_id)
branch_ids = list(branch_ids)
query = api_base.model_query(models.Branch)
branch = query.filter(models.Branch.id.in_(branch_ids),
models.Branch.restricted.__eq__(1)).first()
if not branch:
return True
return False

View File

@ -0,0 +1,29 @@
# Copyright (c) 2014 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 story_type_get(story_type_id, session=None):
return api_base.entity_get(models.StoryType, story_type_id, session)
def story_type_get_mutations(story_type_id_from, story_type_id_to):
query = api_base.model_query(models.may_mutate_to)
query = query.filter_by(story_type_id_from=story_type_id_from,
story_type_id_to=story_type_id_to)
return query.first()

View File

@ -0,0 +1,89 @@
# 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 story types table.
Revision ID: 044
Revises: 043
Create Date: 2015-03-10 14:52:55.783625
"""
# revision identifiers, used by Alembic.
revision = '044'
down_revision = '043'
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql.expression import table
MYSQL_ENGINE = 'InnoDB'
MYSQL_CHARSET = 'utf8'
def upgrade(active_plugins=None, options=None):
op.create_table(
'story_types',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.Column('name', sa.String(50), nullable=True),
sa.Column('icon', sa.String(50), nullable=True),
sa.Column('restricted', sa.Boolean(), default=False),
sa.Column('private', sa.Boolean(), default=False),
sa.Column('visible', sa.Boolean(), default=True),
sa.PrimaryKeyConstraint('id'),
mysql_engine=MYSQL_ENGINE,
mysql_charset=MYSQL_CHARSET
)
bind = op.get_bind()
story_types_table = table(
'story_types',
sa.Column('name', sa.String(50), nullable=True),
sa.Column('icon', sa.String(50), nullable=True),
sa.Column('restricted', sa.Boolean(), default=False),
sa.Column('private', sa.Boolean(), default=False),
sa.Column('visible', sa.Boolean(), default=True),
)
bind.execute(story_types_table.insert().values(
name='bug',
icon='fa-bug'
))
bind.execute(story_types_table.insert().values(
name='feature',
icon='fa-lightbulb-o',
restricted=True
))
bind.execute(story_types_table.insert().values(
name='private_vulnerability',
icon='fa-lock',
private=True
))
bind.execute(story_types_table.insert().values(
name='public_vulnerability',
icon='fa-bomb',
visible=False
))
def downgrade(active_plugins=None, options=None):
op.drop_table('story_types')

View File

@ -0,0 +1,55 @@
# 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 story type id to stories table and sets story type id
to 1 (bugs) in all stories.
Revision ID: 045
Revises: 044
Create Date: 2015-03-10 15:23:54.723124
"""
# revision identifiers, used by Alembic.
revision = '045'
down_revision = '044'
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql.expression import table
MYSQL_ENGINE = 'InnoDB'
MYSQL_CHARSET = 'utf8'
def upgrade(active_plugins=None, options=None):
op.add_column(
'stories',
sa.Column('story_type_id', sa.Integer(), default=1)
)
bind = op.get_bind()
stories_table = table(
'stories',
sa.Column('story_type_id', sa.Integer(), default=1)
)
bind.execute(stories_table.update().values(story_type_id=1))
def downgrade(active_plugins=None, options=None):
op.drop_column('stories', 'story_type_id')

View File

@ -0,0 +1,66 @@
# 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 restricted field to branches table and sets this field
to True in branches with name 'master'.
Revision ID: 046
Revises: 045
Create Date: 2015-03-10 15:23:54.723124
"""
# revision identifiers, used by Alembic.
revision = '046'
down_revision = '045'
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql.expression import table
MYSQL_ENGINE = 'InnoDB'
MYSQL_CHARSET = 'utf8'
def upgrade(active_plugins=None, options=None):
op.add_column(
'branches',
sa.Column('restricted', sa.Boolean(), default=False)
)
bind = op.get_bind()
branches_table = table(
'branches',
sa.Column('name', sa.String(100), nullable=True),
sa.Column('restricted', sa.Boolean(), default=False)
)
bind.execute(
branches_table.update().where(
branches_table.c.name != 'master'
).values(restricted=False)
)
bind.execute(
branches_table.update().where(
branches_table.c.name == 'master'
).values(restricted=True)
)
def downgrade(active_plugins=None, options=None):
op.drop_column('branches', 'restricted')

View File

@ -0,0 +1,90 @@
# 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 may_mutate_to table.
Revision ID: 047
Revises: 046
Create Date: 2015-03-10 17:47:34.395641
"""
# revision identifiers, used by Alembic.
revision = '047'
down_revision = '046'
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql.expression import table
MYSQL_ENGINE = 'InnoDB'
MYSQL_CHARSET = 'utf8'
def upgrade(active_plugins=None, options=None):
op.create_table(
'may_mutate_to',
sa.Column('story_type_id_from', sa.Integer(), nullable=False),
sa.Column('story_type_id_to', sa.Integer(), nullable=False),
sa.UniqueConstraint('story_type_id_from',
'story_type_id_to',
name="mutate_un_constr"),
sa.PrimaryKeyConstraint(),
mysql_engine=MYSQL_ENGINE,
mysql_charset=MYSQL_CHARSET
)
bind = op.get_bind()
story_types_table = table(
'may_mutate_to',
sa.Column('story_type_id_from', sa.Integer(), nullable=False),
sa.Column('story_type_id_to', sa.Integer(), nullable=False),
)
bind.execute(story_types_table.insert().values(
story_type_id_from=1,
story_type_id_to=4
))
bind.execute(story_types_table.insert().values(
story_type_id_from=1,
story_type_id_to=2
))
bind.execute(story_types_table.insert().values(
story_type_id_from=2,
story_type_id_to=1
))
bind.execute(story_types_table.insert().values(
story_type_id_from=3,
story_type_id_to=4
))
bind.execute(story_types_table.insert().values(
story_type_id_from=3,
story_type_id_to=1
))
bind.execute(story_types_table.insert().values(
story_type_id_from=4,
story_type_id_to=1
))
def downgrade(active_plugins=None, options=None):
op.drop_table('may_mutate_to')

View File

@ -215,6 +215,29 @@ project_group_mapping = Table(
)
may_mutate_to = Table(
'may_mutate_to', Base.metadata,
Column('story_type_id_from', Integer, ForeignKey('story_types.id'),
nullable=False),
Column('story_type_id_to', Integer, ForeignKey('story_types.id'),
nullable=False),
schema.UniqueConstraint('story_type_id_from', 'story_type_id_to')
)
class StoryType(ModelBuilder, Base):
__tablename__ = 'story_types'
name = Column(String(CommonLength.top_middle_length))
icon = Column(String(CommonLength.top_middle_length))
restricted = Column(Boolean, default=False)
private = Column(Boolean, default=False)
visible = Column(Boolean, default=True)
_public_fields = ["id", "name", "icon", "restricted", "private",
"visible"]
class Permission(ModelBuilder, Base):
__table_args__ = (
schema.UniqueConstraint('name', name='uniq_permission_name'),
@ -275,6 +298,8 @@ class Story(FullText, ModelBuilder, Base):
__fulltext_columns__ = ['title', 'description']
creator_id = Column(Integer, ForeignKey('users.id'))
story_type_id = Column(Integer, ForeignKey('story_types.id'),
default=1)
creator = relationship(User, primaryjoin=creator_id == User.id)
title = Column(Unicode(CommonLength.top_large_length))
description = Column(UnicodeText())
@ -327,6 +352,7 @@ class Branch(ModelBuilder, Base):
expired = Column(Boolean, default=False)
expiration_date = Column(UTCDateTime, default=None)
autocreated = Column(Boolean, default=False)
restricted = Column(Boolean, default=False)
_public_fields = ["id", "name", "project_id", "expired",
"expiration_date", "autocreated"]

View File

@ -12,6 +12,8 @@
# License for the specific language governing permissions and limitations
# under the License.
import unittest
import six.moves.urllib.parse as urlparse
from storyboard.db.api import tasks
@ -44,6 +46,47 @@ class TestStories(base.FunctionalTest):
self.assertIn('created_at', story)
self.assertEqual(story['title'], self.story_01['title'])
self.assertEqual(story['description'], self.story_01['description'])
self.assertEqual(1, story['story_type_id'])
def test_create_feature(self):
story = {
'title': 'StoryBoard',
'description': 'Awesome Task Tracker',
'story_type_id': 2
}
response = self.post_json(self.resource, story)
created_story = response.json
self.assertEqual(story['title'], created_story['title'])
self.assertEqual(story['description'], created_story['description'])
self.assertEqual(story['story_type_id'],
created_story['story_type_id'])
@unittest.skip("vulnerabilities are not supported.")
def test_create_private_vulnerability(self):
story = {
'title': 'StoryBoard',
'description': 'Awesome Task Tracker',
'story_type_id': 3
}
response = self.post_json(self.resource, story)
created_story = response.json
self.assertEqual(story['title'], created_story['title'])
self.assertEqual(story['description'], created_story['description'])
self.assertEqual(story['story_type_id'],
created_story['story_type_id'])
@unittest.skip("vulnerabilities are not supported.")
def test_create_public_vulnerability(self):
story = {
'title': 'StoryBoard',
'description': 'Awesome Task Tracker',
'story_type_id': 4
}
response = self.post_json(self.resource, story,
expect_errors=True)
self.assertEqual(400, response.status_code)
def test_update(self):
response = self.post_json(self.resource, self.story_01)
@ -65,6 +108,57 @@ class TestStories(base.FunctionalTest):
self.assertNotEqual(updated['description'],
original['description'])
def test_update_story_types(self):
story = {
'title': 'StoryBoard',
'description': 'Awesome Task Tracker',
'story_type_id': 1
}
response = self.post_json(self.resource, story)
created_story = response.json
self.assertEqual(story['story_type_id'],
created_story['story_type_id'])
story_types = [2, 1]
for story_type_id in story_types:
response = self.put_json(self.resource +
('/%s' % created_story["id"]),
{'story_type_id': story_type_id})
self.assertEqual(story_type_id, response.json['story_type_id'])
@unittest.skip("vulnerabilities are not supported.")
def test_update_private_to_public_vulnerability(self):
story = {
'title': 'StoryBoard',
'description': 'Awesome Task Tracker',
'story_type_id': 3
}
response = self.post_json(self.resource, story)
created_story = response.json
self.assertEqual(story["story_type_id"],
created_story["story_type_id"])
response = self.put_json(self.resource +
('/%s' % created_story["id"]),
{'story_type_id': 4})
created_story = response.json
self.assertEqual(4, created_story['story_type_id'])
def test_update_restricted_branches(self):
response = self.put_json(self.resource + '/1', {'story_type_id': 2},
expect_errors=True)
self.assertEqual(400, response.status_code)
def test_update_invalid(self):
response = self.put_json(self.resource + '/1', {'story_type_id': 2},
expect_errors=True)
self.assertEqual(400, response.status_code)
def test_add_tags(self):
url = "/stories/%d/tags" % 1
response = self.put_json(url, ["tag_1", "tag_2"])

View File

@ -144,6 +144,34 @@ class TestTasksPrimary(base.FunctionalTest):
expect_errors=True)
self.assertEqual(400, response.status_code)
def test_create_invalid_restricted(self):
branch = {
'name': 'some_branch',
'project_id': 1,
'restricted': False
}
story = {
'title': 'some_story',
'description': 'some_description',
'story_type_id': 2
}
branch = self.post_json('/branches', branch)
branch = branch.json
story = self.post_json('/stories', story)
story = story.json
task = {
'title': 'some_task',
'project_id': 1,
'story_id': story["id"],
'branch_id': branch["id"]
}
response = self.post_json(self.resource, task, expect_errors=True)
self.assertEqual(400, response.status_code)
def test_create_invalid_expired(self):
response = self.put_json('/branches/1', {'expired': True})
branch = response.json

View File

@ -300,16 +300,19 @@ def load():
id=1,
project_id=1,
name='master',
restricted=True
),
Branch(
id=2,
project_id=2,
name='master'
name='master',
restricted=True
),
Branch(
id=3,
project_id=3,
name='master'
name='master',
restricted=True
)
])