diff --git a/storyboard/api/v1/stories.py b/storyboard/api/v1/stories.py index 69d54931..22918428 100644 --- a/storyboard/api/v1/stories.py +++ b/storyboard/api/v1/stories.py @@ -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) diff --git a/storyboard/api/v1/tasks.py b/storyboard/api/v1/tasks.py index 75851156..531c86a5 100644 --- a/storyboard/api/v1/tasks.py +++ b/storyboard/api/v1/tasks.py @@ -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. diff --git a/storyboard/api/v1/wmodels.py b/storyboard/api/v1/wmodels.py index 397a576e..7b895d5f 100644 --- a/storyboard/api/v1/wmodels.py +++ b/storyboard/api/v1/wmodels.py @@ -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 ) diff --git a/storyboard/common/master_branch_helper.py b/storyboard/common/master_branch_helper.py index 517db710..b04dec6d 100644 --- a/storyboard/common/master_branch_helper.py +++ b/storyboard/common/master_branch_helper.py @@ -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 diff --git a/storyboard/db/api/stories.py b/storyboard/db/api/stories.py index ce6f3bb4..6bfe34a4 100644 --- a/storyboard/db/api/stories.py +++ b/storyboard/db/api/stories.py @@ -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 diff --git a/storyboard/db/api/story_types.py b/storyboard/db/api/story_types.py new file mode 100644 index 00000000..ab63466f --- /dev/null +++ b/storyboard/db/api/story_types.py @@ -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() diff --git a/storyboard/db/migration/alembic_migrations/versions/044_story_types.py b/storyboard/db/migration/alembic_migrations/versions/044_story_types.py new file mode 100644 index 00000000..aaa91aae --- /dev/null +++ b/storyboard/db/migration/alembic_migrations/versions/044_story_types.py @@ -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') diff --git a/storyboard/db/migration/alembic_migrations/versions/045_story_type_id.py b/storyboard/db/migration/alembic_migrations/versions/045_story_type_id.py new file mode 100644 index 00000000..be26f520 --- /dev/null +++ b/storyboard/db/migration/alembic_migrations/versions/045_story_type_id.py @@ -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') diff --git a/storyboard/db/migration/alembic_migrations/versions/046_branches_restricted.py b/storyboard/db/migration/alembic_migrations/versions/046_branches_restricted.py new file mode 100644 index 00000000..cf653028 --- /dev/null +++ b/storyboard/db/migration/alembic_migrations/versions/046_branches_restricted.py @@ -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') diff --git a/storyboard/db/migration/alembic_migrations/versions/047_may_mutate_to.py b/storyboard/db/migration/alembic_migrations/versions/047_may_mutate_to.py new file mode 100644 index 00000000..636bae44 --- /dev/null +++ b/storyboard/db/migration/alembic_migrations/versions/047_may_mutate_to.py @@ -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') diff --git a/storyboard/db/models.py b/storyboard/db/models.py index a71b51b2..51077141 100644 --- a/storyboard/db/models.py +++ b/storyboard/db/models.py @@ -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"] diff --git a/storyboard/tests/api/test_stories.py b/storyboard/tests/api/test_stories.py index 706c0d91..a65f23f5 100644 --- a/storyboard/tests/api/test_stories.py +++ b/storyboard/tests/api/test_stories.py @@ -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"]) diff --git a/storyboard/tests/api/test_tasks.py b/storyboard/tests/api/test_tasks.py index 57e0fd77..3f26d84d 100644 --- a/storyboard/tests/api/test_tasks.py +++ b/storyboard/tests/api/test_tasks.py @@ -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 diff --git a/storyboard/tests/mock_data.py b/storyboard/tests/mock_data.py index b633ee94..446dbbca 100644 --- a/storyboard/tests/mock_data.py +++ b/storyboard/tests/mock_data.py @@ -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 ) ])