diff --git a/doc/source/webapi/v1.rst b/doc/source/webapi/v1.rst index 4eb793b3..df7a4fc0 100644 --- a/doc/source/webapi/v1.rst +++ b/doc/source/webapi/v1.rst @@ -53,7 +53,7 @@ Permission Project ======= -.. autotype:: storyboard.api.v1.wsme_models.Project +.. autotype:: storyboard.api.v1.projects.Project :members: ProjectGroup @@ -63,7 +63,7 @@ ProjectGroup Story ===== -.. autotype:: storyboard.api.v1.wsme_models.Story +.. autotype:: storyboard.api.v1.stories.Story :members: StoryTag diff --git a/storyboard/api/v1/base.py b/storyboard/api/v1/base.py new file mode 100644 index 00000000..62cf6ffc --- /dev/null +++ b/storyboard/api/v1/base.py @@ -0,0 +1,55 @@ +# Copyright (c) 2013 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 + +from wsme import types as wtypes + + +class APIBase(wtypes.Base): + + # TODO(ruhe): add docstrings + id = int + created_at = datetime + updated_at = datetime + + @classmethod + def from_db_model(cls, db_model, skip_fields=None): + """Returns the database representation of the given transfer object.""" + skip_fields = skip_fields or [] + data = dict((k, v) for k, v in db_model.as_dict().items() + if k not in skip_fields) + return cls(**data) + + def as_dict(self, omit_unset=False): + """Converts this object into dictionary.""" + attribute_names = [a.name for a in self._wsme_attributes] + + if omit_unset: + attribute_names = [n for n in attribute_names + if getattr(self, n) != wtypes.Unset] + + values = dict((name, self._lookup(name)) for name in attribute_names) + return values + + def _lookup(self, key): + """Looks up a key, translating WSME's Unset into Python's None. + + :return: value of the given attribute; None if it is not set + """ + value = getattr(self, key) + if value == wtypes.Unset: + value = None + return value diff --git a/storyboard/api/v1/projects.py b/storyboard/api/v1/projects.py index 2d647a82..990249a5 100644 --- a/storyboard/api/v1/projects.py +++ b/storyboard/api/v1/projects.py @@ -14,10 +14,36 @@ # limitations under the License. from pecan import rest -from wsme.exc import ClientSideError +from wsme import types as wtypes import wsmeext.pecan as wsme_pecan -import storyboard.api.v1.wsme_models as wsme_models +from storyboard.api.v1 import base +from storyboard.db import api as dbapi + + +class Project(base.APIBase): + """The Storyboard Registry describes the open source world as ProjectGroups + and Products. Each ProjectGroup may be responsible for several Projects. + For example, the OpenStack Infrastructure Project has Zuul, Nodepool, + Storyboard as Projects, among others. + """ + + name = wtypes.text + """At least one lowercase letter or number, followed by letters, numbers, + dots, hyphens or pluses. Keep this name short; it is used in URLs. + """ + + description = wtypes.text + """Details about the project's work, highlights, goals, and how to + contribute. Use plain text, paragraphs are preserved and URLs are + linked in pages. + """ + + @classmethod + def sample(cls): + return cls( + name="Storyboard", + description="Awesome project") class ProjectsController(rest.RestController): @@ -26,21 +52,39 @@ class ProjectsController(rest.RestController): At this moment it provides read-only operations. """ - @wsme_pecan.wsexpose(wsme_models.Project, unicode) - def get_one(self, name): + @wsme_pecan.wsexpose(Project, unicode) + def get_one(self, project_id): """Retrieve information about the given project. - :param name: project name. + :param project_id: project ID. """ - project = wsme_models.Project.get(name=name) - if not project: - raise ClientSideError("Project %s not found" % name, - status_code=404) - return project - @wsme_pecan.wsexpose([wsme_models.Project]) - def get(self): + project = dbapi.project_get(project_id) + return Project.from_db_model(project) + + @wsme_pecan.wsexpose([Project]) + def get_all(self): """Retrieve a list of projects. """ - projects = wsme_models.Project.get_all() - return projects + projects = dbapi.project_get_all() + return [Project.from_db_model(p) for p in projects] + + @wsme_pecan.wsexpose(Project, body=Project) + def post(self, project): + """Create a new project + + :param project: a project within the request body + """ + result = dbapi.project_create(project.as_dict()) + return Project.from_db_model(result) + + @wsme_pecan.wsexpose(Project, int, body=Project) + def put(self, project_id, project): + """Modify this project. + + :param project_id: An ID of the project. + :param project: a project within the request body. + """ + result = dbapi.project_update(project_id, + project.as_dict(omit_unset=True)) + return Project.from_db_model(result) diff --git a/storyboard/api/v1/stories.py b/storyboard/api/v1/stories.py index 68bca4da..6ae28949 100644 --- a/storyboard/api/v1/stories.py +++ b/storyboard/api/v1/stories.py @@ -14,82 +14,74 @@ # limitations under the License. from pecan import rest -from wsme.exc import ClientSideError +from wsme import types as wtypes import wsmeext.pecan as wsme_pecan -import storyboard.api.v1.wsme_models as wsme_models +from storyboard.api.v1 import base +from storyboard.db import api as dbapi + + +class Story(base.APIBase): + """Represents a user-story.""" + + title = wtypes.text + """A descriptive label for this tracker to show in listings.""" + + description = wtypes.text + """A brief introduction or overview of this bug tracker instance.""" + + is_bug = bool + """Is this a bug or a feature :)""" + + #todo(nkonovalov): replace with a enum + priority = wtypes.text + """Priority. + Allowed values: ['Undefined', 'Low', 'Medium', 'High', 'Critical']. + """ + + @classmethod + def sample(cls): + return cls( + title="Use Storyboard to manage Storyboard", + description="We should use Storyboard to manage Storyboard", + is_bug=False, + priority='Critical') class StoriesController(rest.RestController): """Manages operations on stories.""" - _custom_actions = { - "add_task": ["POST"], - "add_comment": ["POST"] - } - - @wsme_pecan.wsexpose(wsme_models.Story, unicode) - def get_one(self, id): + @wsme_pecan.wsexpose(Story, unicode) + def get_one(self, story_id): """Retrieve details about one story. - :param id: An ID of the story. + :param story_id: An ID of the story. """ - story = wsme_models.Story.get(id=id) - if not story: - raise ClientSideError("Story %s not found" % id, - status_code=404) - return story + story = dbapi.story_get(story_id) + return Story.from_db_model(story) - @wsme_pecan.wsexpose([wsme_models.Story]) + @wsme_pecan.wsexpose([Story]) def get(self): """Retrieve definitions of all of the stories.""" - stories = wsme_models.Story.get_all() - return stories + stories = dbapi.story_get_all() + return [Story.from_db_model(s) for s in stories] - @wsme_pecan.wsexpose(wsme_models.Story, wsme_models.Story) + @wsme_pecan.wsexpose(Story, body=Story) def post(self, story): """Create a new story. :param story: a story within the request body. """ - created_story = wsme_models.Story.create(wsme_entry=story) - if not created_story: - raise ClientSideError("Could not create a story") - return created_story + created_story = dbapi.story_create(story.as_dict()) + return Story.from_db_model(created_story) - @wsme_pecan.wsexpose(wsme_models.Story, unicode, wsme_models.Story) + @wsme_pecan.wsexpose(Story, int, body=Story) def put(self, story_id, story): """Modify this story. :param story_id: An ID of the story. :param story: a story within the request body. """ - updated_story = wsme_models.Story.update("id", story_id, story) - if not updated_story: - raise ClientSideError("Could not update story %s" % story_id) - return updated_story - - @wsme_pecan.wsexpose(wsme_models.Story, unicode, wsme_models.Task) - def add_task(self, story_id, task): - """Associate a task with a story. - - :param story_id: An ID of the story. - :param task: a task within the request body. - """ - updated_story = wsme_models.Story.add_task(story_id, task) - if not updated_story: - raise ClientSideError("Could not add task to story %s" % story_id) - return updated_story - - @wsme_pecan.wsexpose(wsme_models.Story, unicode, wsme_models.Comment) - def add_comment(self, story_id, comment): - """Add a comment with a story. - - :param story_id: An ID of the story. - :param comment: a comment within the request body. - """ - updated_story = wsme_models.Story.add_comment(story_id, comment) - if not updated_story: - raise ClientSideError("Could not add comment to story %s" - % story_id) - return updated_story + updated_story = dbapi.story_update(story_id, + story.as_dict(omit_unset=True)) + return Story.from_db_model(updated_story) diff --git a/storyboard/api/v1/wsme_models.py b/storyboard/api/v1/wsme_models.py index ce855671..90f75c17 100644 --- a/storyboard/api/v1/wsme_models.py +++ b/storyboard/api/v1/wsme_models.py @@ -190,31 +190,6 @@ def update_db_model(cls, db_entry, wsme_entry): return db_entry -class Project(_Base): - """The Storyboard Registry describes the open source world as ProjectGroups - and Products. Each ProjectGroup may be responsible for several Projects. - For example, the OpenStack Infrastructure Project has Zuul, Nodepool, - Storyboard as Projects, among others. - """ - - name = wtypes.text - """At least one lowercase letter or number, followed by letters, numbers, - dots, hyphens or pluses. Keep this name short; it is used in URLs. - """ - - description = wtypes.text - """Details about the project's work, highlights, goals, and how to - contribute. Use plain text, paragraphs are preserved and URLs are - linked in pages. - """ - - @classmethod - def sample(cls): - return cls( - name="Storyboard", - description="Awesome project") - - class ProjectGroup(_Base): """Represents a group of projects.""" @@ -280,54 +255,6 @@ class Comment(_Base): author_id=67) -class Story(_Base): - """Represents a user-story.""" - - title = wtypes.text - """A descriptive label for this tracker to show in listings.""" - - description = wtypes.text - """A brief introduction or overview of this bug tracker instance.""" - - is_bug = bool - """Is this a bug or a feature :)""" - - #todo(nkonovalov): replace with a enum - priority = wtypes.text - """Priority. - Allowed values: ['Undefined', 'Low', 'Medium', 'High', 'Critical']. - """ - - tasks = wtypes.ArrayType(Task) - """List of linked tasks.""" - - comments = wtypes.ArrayType(Comment) - """List of linked comments.""" - - tags = wtypes.ArrayType(StoryTag) - """List of linked tags.""" - - @classmethod - def add_task(cls, story_id, task): - return cls.create_and_add_item("id", story_id, Task, task, "tasks") - - @classmethod - def add_comment(cls, story_id, comment): - return cls.create_and_add_item("id", story_id, Comment, comment, - "comments") - - @classmethod - def sample(cls): - return cls( - title="Use Storyboard to manage Storyboard", - description="We should use Storyboard to manage Storyboard", - is_bug=False, - priority='Critical', - tasks=[], - comments=[], - tags=[]) - - class User(_Base): """Represents a user.""" @@ -398,9 +325,7 @@ SQLALCHEMY_TO_WSME = { sqlalchemy_models.Team: Team, sqlalchemy_models.User: User, sqlalchemy_models.ProjectGroup: ProjectGroup, - sqlalchemy_models.Project: Project, sqlalchemy_models.Permission: Permission, - sqlalchemy_models.Story: Story, sqlalchemy_models.Task: Task, sqlalchemy_models.Comment: Comment, sqlalchemy_models.StoryTag: StoryTag diff --git a/storyboard/tests/api/test_projects.py b/storyboard/tests/api/test_projects.py new file mode 100644 index 00000000..2db9deb3 --- /dev/null +++ b/storyboard/tests/api/test_projects.py @@ -0,0 +1,63 @@ +# Copyright (c) 2013 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. + +import json + +from storyboard.tests import base + + +class TestProjects(base.FunctionalTest): + + def setUp(self): + super(TestProjects, self).setUp() + + self.resource = '/projects' + + self.project_01 = { + 'name': 'test_project', + 'description': 'some description' + } + + def test_projects_endpoint(self): + response = self.get_json(path=self.resource) + self.assertEqual([], response) + + def test_create(self): + response = self.post_json(self.resource, self.project_01) + project = json.loads(response.body) + + self.assertEqual(self.project_01['name'], project['name']) + self.assertEqual(self.project_01['description'], + project['description']) + + def test_update(self): + response = self.post_json(self.resource, self.project_01) + original = json.loads(response.body) + + delta = { + 'id': original['id'], + 'name': 'new name', + 'description': 'new description' + } + + url = "/projects/%d" % original['id'] + response = self.put_json(url, delta) + updated = json.loads(response.body) + + self.assertEqual(original['id'], updated['id']) + self.assertEqual(original['created_at'], updated['created_at']) + + self.assertNotEqual(original['name'], updated['name']) + self.assertNotEqual(original['description'], + updated['description']) diff --git a/storyboard/tests/api/test_stories.py b/storyboard/tests/api/test_stories.py index 8ffaf3c8..a62c406c 100644 --- a/storyboard/tests/api/test_stories.py +++ b/storyboard/tests/api/test_stories.py @@ -1,5 +1,5 @@ -# -*- coding: utf-8 -*- - +# Copyright (c) 2013 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 @@ -12,57 +12,56 @@ # License for the specific language governing permissions and limitations # under the License. -""" -test_storyboard ----------------------------------- - -Tests for `storyboard` module. -""" -import copy import json from storyboard.tests import base -SAMPLE_STORY = { - "title": "test_story", - "description": "some description" -} - -SAMPLE_STORY_REQUEST = { - "story": SAMPLE_STORY -} - class TestStories(base.FunctionalTest): + def setUp(self): + super(TestStories, self).setUp() + self.resource = '/stories' + + self.story_01 = { + 'title': 'StoryBoard', + 'description': 'Awesome Task Tracker', + 'priority': 'High' + } + def test_stories_endpoint(self): - response = self.get_json(path="/stories") + response = self.get_json(self.resource) self.assertEqual([], response) def test_create(self): - response = self.post_json("/stories", SAMPLE_STORY_REQUEST) + response = self.post_json(self.resource, self.story_01) story = json.loads(response.body) - self.assertIn("id", story) - self.assertIn("created_at", story) - self.assertEqual(story["title"], SAMPLE_STORY["title"]) - self.assertEqual(story["description"], SAMPLE_STORY["description"]) + url = "%s/%d" % (self.resource, story['id']) + story = self.get_json(url) + + self.assertIn('id', story) + self.assertIn('created_at', story) + self.assertEqual(story['title'], self.story_01['title']) + self.assertEqual(story['description'], self.story_01['description']) def test_update(self): - response = self.post_json("/stories", SAMPLE_STORY_REQUEST) - old_story = json.loads(response.body) + response = self.post_json(self.resource, self.story_01) + original = json.loads(response.body) - update_request = copy.deepcopy(SAMPLE_STORY_REQUEST) - update_request["story_id"] = old_story["id"] - update_request["story"]["title"] = "updated_title" - update_request["story"]["description"] = "updated_description" + delta = { + 'id': original['id'], + 'title': 'new title', + 'description': 'new description' + } - response = self.put_json("/stories", update_request) - updated_story = json.loads(response.body) + url = "/stories/%d" % original['id'] + response = self.put_json(url, delta) + updated = json.loads(response.body) - self.assertEqual(updated_story["id"], old_story["id"]) - self.assertEqual(updated_story["created_at"], old_story["created_at"]) + self.assertEqual(updated['id'], original['id']) + self.assertEqual(updated['created_at'], original['created_at']) - self.assertNotEqual(updated_story["title"], old_story["title"]) - self.assertNotEqual(updated_story["description"], - old_story["description"]) + self.assertNotEqual(updated['title'], original['title']) + self.assertNotEqual(updated['description'], + original['description']) diff --git a/storyboard/tests/base.py b/storyboard/tests/base.py index f3e90410..5980a14a 100644 --- a/storyboard/tests/base.py +++ b/storyboard/tests/base.py @@ -98,9 +98,6 @@ class TestCase(testtools.TestCase): CONF.set_override(k, v, group) -PATH_PREFIX = '/v1' - - class DbTestCase(TestCase): def setUp(self): @@ -117,6 +114,9 @@ class DbTestCase(TestCase): self.useFixture(_DB_CACHE) +PATH_PREFIX = '/v1' + + class FunctionalTest(DbTestCase): """Used for functional tests of Pecan controllers where you need to test your literal application and its integration with the