diff --git a/MANIFEST.in b/MANIFEST.in index 863a4b31..9a50f1cc 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,6 @@ include AUTHORS include ChangeLog +include storyboard/api/app.wsgi include storyboard/db/migration/README include storyboard/db/migration/alembic.ini include storyboard/db/migration/alembic_migrations/script.py.mako diff --git a/etc/storyboard.conf b/etc/storyboard.conf index 0ceb0588..6d7386fb 100644 --- a/etc/storyboard.conf +++ b/etc/storyboard.conf @@ -39,7 +39,7 @@ lock_path = $state_path/lock # connection = mysql://root:pass@127.0.0.1:3306/storyboard # Replace 127.0.0.1 above with the IP address of the database used by the # main storyboard server. (Leave it as is if the database runs on this host.) -# connection = sqlite:// +# connection=sqlite:// # The SQLAlchemy connection string used to connect to the slave database # slave_connection = @@ -72,3 +72,9 @@ lock_path = $state_path/lock # If set, use this value for pool_timeout with sqlalchemy # pool_timeout = 10 + +[api] +#host="0.0.0.0" +# +#port=8080 + diff --git a/openstack-common.conf b/openstack-common.conf index d2b76d56..de92db4a 100644 --- a/openstack-common.conf +++ b/openstack-common.conf @@ -4,6 +4,7 @@ module=db module=db.sqlalchemy module=processutils +module=log # The base module to hold the copy of openstack.common base=storyboard diff --git a/requirements.txt b/requirements.txt index 5b3a1afd..e71a4821 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,12 +1,14 @@ pbr>=0.5.21,<1.0 +alembic>=0.4.1 +Babel>=0.9.6 Django>=1.4,<1.6 django-openid-auth +iso8601>=0.1.8 markdown +oslo.config>=1.2.0 +pecan>=0.2.0 python-openid six>=1.4.1 -Babel>=0.9.6 SQLAlchemy>=0.8,<=0.8.99 -alembic>=0.4.1 -oslo.config>=1.2.0 -iso8601>=0.1.8 +WSME>=0.5b6 diff --git a/setup.cfg b/setup.cfg index 6db357aa..fd7c9b16 100644 --- a/setup.cfg +++ b/setup.cfg @@ -31,6 +31,7 @@ data_files = [entry_points] console_scripts = + storyboard-api = storyboard.api.app:start storyboard-db-manage = storyboard.db.migration.cli:main [build_sphinx] diff --git a/storyboard/api/__init__.py b/storyboard/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/storyboard/api/app.py b/storyboard/api/app.py new file mode 100644 index 00000000..05d42646 --- /dev/null +++ b/storyboard/api/app.py @@ -0,0 +1,86 @@ +# 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 os + +from oslo.config import cfg +import pecan +from storyboard.openstack.common.gettextutils import _ # noqa +from storyboard.openstack.common import log +from wsgiref import simple_server + +from storyboard.api import config as api_config + +CONF = cfg.CONF +LOG = log.getLogger(__name__) + +API_OPTS = [ + cfg.StrOpt('host', + default='0.0.0.0', + help='API host'), + cfg.IntOpt('port', + default=8080, + help='API port') +] +CONF.register_opts(API_OPTS, 'api') + + +def get_pecan_config(): + # Set up the pecan configuration + filename = api_config.__file__.replace('.pyc', '.py') + return pecan.configuration.conf_from_file(filename) + + +def setup_app(pecan_config=None): + if not pecan_config: + pecan_config = get_pecan_config() + + app = pecan.make_app( + pecan_config.app.root, + debug=CONF.debug, + force_canonical=getattr(pecan_config.app, 'force_canonical', True), + guess_content_type_from_ext=False + ) + + cfg.set_defaults(log.log_opts, + default_log_levels=[ + 'storyboard=INFO', + 'sqlalchemy=WARN' + ]) + log.setup('storyboard') + + return app + + +def start(): + root = setup_app() + CONF(project='storyboard') + + # Create the WSGI server and start it + host = cfg.CONF.api.host + port = cfg.CONF.api.port + srv = simple_server.make_server(host, port, root) + + LOG.info(_('Starting server in PID %s') % os.getpid()) + LOG.info(_("Configuration:")) + if host == '0.0.0.0': + LOG.info(_( + 'serving on 0.0.0.0:%(port)s, view at http://127.0.0.1:%(port)s') + % ({'port': port})) + else: + LOG.info(_("serving on http://%(host)s:%(port)s") % ( + {'host': host, 'port': port})) + + srv.serve_forever() diff --git a/storyboard/api/app.wsgi b/storyboard/api/app.wsgi new file mode 100644 index 00000000..f6e03317 --- /dev/null +++ b/storyboard/api/app.wsgi @@ -0,0 +1,23 @@ +# 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 storyboard.api import app +from oslo.config import cfg + +CONF = cfg.CONF + +CONF(project='storyboard') + +application = app.setup_app() \ No newline at end of file diff --git a/storyboard/api/config.py b/storyboard/api/config.py new file mode 100644 index 00000000..fa45a8c1 --- /dev/null +++ b/storyboard/api/config.py @@ -0,0 +1,20 @@ +# 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. + +app = { + 'root': 'storyboard.api.root_controller.RootController', + 'modules': ['storyboard.api'], + 'debug': False +} diff --git a/storyboard/api/root_controller.py b/storyboard/api/root_controller.py new file mode 100644 index 00000000..fe214e41 --- /dev/null +++ b/storyboard/api/root_controller.py @@ -0,0 +1,20 @@ +# 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 storyboard.api.v1.v1_controller import V1Controller + + +class RootController(object): + v1 = V1Controller() diff --git a/storyboard/api/v1/__init__.py b/storyboard/api/v1/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/storyboard/api/v1/project_groups.py b/storyboard/api/v1/project_groups.py new file mode 100644 index 00000000..bf964242 --- /dev/null +++ b/storyboard/api/v1/project_groups.py @@ -0,0 +1,36 @@ +# 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 pecan import rest +from wsme.exc import ClientSideError +import wsmeext.pecan as wsme_pecan + +import storyboard.api.v1.wsme_models as wsme_models + + +class ProjectGroupsController(rest.RestController): + + @wsme_pecan.wsexpose(wsme_models.ProjectGroup, unicode) + def get_one(self, name): + group = wsme_models.ProjectGroup.get(name=name) + if not group: + raise ClientSideError("Project Group %s not found" % name, + status_code=404) + return group + + @wsme_pecan.wsexpose([wsme_models.ProjectGroup]) + def get(self): + groups = wsme_models.ProjectGroup.get_all() + return groups diff --git a/storyboard/api/v1/projects.py b/storyboard/api/v1/projects.py new file mode 100644 index 00000000..ccea2654 --- /dev/null +++ b/storyboard/api/v1/projects.py @@ -0,0 +1,36 @@ +# 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 pecan import rest +from wsme.exc import ClientSideError +import wsmeext.pecan as wsme_pecan + +import storyboard.api.v1.wsme_models as wsme_models + + +class ProjectsController(rest.RestController): + + @wsme_pecan.wsexpose(wsme_models.Project, unicode) + def get_one(self, name): + 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): + projects = wsme_models.Project.get_all() + return projects diff --git a/storyboard/api/v1/stories.py b/storyboard/api/v1/stories.py new file mode 100644 index 00000000..67d2e4b4 --- /dev/null +++ b/storyboard/api/v1/stories.py @@ -0,0 +1,70 @@ +# 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 pecan import rest +from wsme.exc import ClientSideError +import wsmeext.pecan as wsme_pecan + +import storyboard.api.v1.wsme_models as wsme_models + + +class StoriesController(rest.RestController): + + _custom_actions = { + "add_task": ["POST"], + "add_comment": ["POST"] + } + + @wsme_pecan.wsexpose(wsme_models.Story, unicode) + def get_one(self, id): + story = wsme_models.Story.get(id=id) + if not story: + raise ClientSideError("Story %s not found" % id, + status_code=404) + return story + + @wsme_pecan.wsexpose([wsme_models.Story]) + def get(self): + stories = wsme_models.Story.get_all() + return stories + + @wsme_pecan.wsexpose(wsme_models.Story, wsme_models.Story) + def post(self, story): + created_story = wsme_models.Story.create(wsme_entry=story) + if not created_story: + raise ClientSideError("Could not create a story") + return created_story + + @wsme_pecan.wsexpose(wsme_models.Story, unicode, wsme_models.Story) + def put(self, story_id, story): + 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): + 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): + 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 diff --git a/storyboard/api/v1/tasks.py b/storyboard/api/v1/tasks.py new file mode 100644 index 00000000..326f3952 --- /dev/null +++ b/storyboard/api/v1/tasks.py @@ -0,0 +1,43 @@ +# 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 pecan import rest +from wsme.exc import ClientSideError +import wsmeext.pecan as wsme_pecan + +import storyboard.api.v1.wsme_models as wsme_models + + +class TasksController(rest.RestController): + + @wsme_pecan.wsexpose(wsme_models.Task, unicode) + def get_one(self, id): + task = wsme_models.Task.get(id=id) + if not task: + raise ClientSideError("Task %s not found" % id, + status_code=404) + return task + + @wsme_pecan.wsexpose([wsme_models.Task]) + def get(self): + tasks = wsme_models.Task.get_all() + return tasks + + @wsme_pecan.wsexpose(wsme_models.Task, unicode, wsme_models.Task) + def put(self, task_id, task): + updated_task = wsme_models.Task.update("id", task_id, task) + if not updated_task: + raise ClientSideError("Could not update story %s" % task_id) + return updated_task diff --git a/storyboard/api/v1/teams.py b/storyboard/api/v1/teams.py new file mode 100644 index 00000000..62294755 --- /dev/null +++ b/storyboard/api/v1/teams.py @@ -0,0 +1,56 @@ +# 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 pecan import rest +from wsme.exc import ClientSideError +import wsmeext.pecan as wsme_pecan + +import storyboard.api.v1.wsme_models as wsme_models + + +class TeamsController(rest.RestController): + + _custom_actions = { + "add_user": ["POST"] + } + + @wsme_pecan.wsexpose(wsme_models.Team, unicode) + def get_one(self, name): + team = wsme_models.Team.get(name=name) + if not team: + raise ClientSideError("Team %s not found" % name, + status_code=404) + return team + + @wsme_pecan.wsexpose([wsme_models.Team]) + def get(self): + teams = wsme_models.Team.get_all() + return teams + + @wsme_pecan.wsexpose(wsme_models.Team, wsme_models.Team) + def post(self, team): + created_team = wsme_models.Team.create(wsme_entry=team) + if not created_team: + raise ClientSideError("Could not create a team") + return created_team + + @wsme_pecan.wsexpose(wsme_models.Team, unicode, unicode) + def add_user(self, team_name, username): + updated_team = wsme_models.Team.add_user(team_name, username) + if not updated_team: + raise ClientSideError("Could not add user %s to team %s" + % (username, team_name)) + return updated_team diff --git a/storyboard/api/v1/users.py b/storyboard/api/v1/users.py new file mode 100644 index 00000000..eeda4be8 --- /dev/null +++ b/storyboard/api/v1/users.py @@ -0,0 +1,50 @@ +# 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 pecan import rest +from wsme.exc import ClientSideError +import wsmeext.pecan as wsme_pecan + +import storyboard.api.v1.wsme_models as wsme_models + + +class UsersController(rest.RestController): + + @wsme_pecan.wsexpose([wsme_models.User]) + def get(self): + users = wsme_models.User.get_all() + return users + + @wsme_pecan.wsexpose(wsme_models.User, unicode) + def get_one(self, username): + user = wsme_models.User.get(username=username) + if not user: + raise ClientSideError("User %s not found" % username, + status_code=404) + return user + + @wsme_pecan.wsexpose(wsme_models.User, wsme_models.User) + def post(self, user): + created_user = wsme_models.User.create(wsme_entry=user) + if not created_user: + raise ClientSideError("Could not create User") + return created_user + + @wsme_pecan.wsexpose(wsme_models.User, unicode, wsme_models.User) + def put(self, username, user): + updated_user = wsme_models.User.update("username", username, user) + if not updated_user: + raise ClientSideError("Could not update user %s" % username) + return updated_user diff --git a/storyboard/api/v1/v1_controller.py b/storyboard/api/v1/v1_controller.py new file mode 100644 index 00000000..29fc4199 --- /dev/null +++ b/storyboard/api/v1/v1_controller.py @@ -0,0 +1,31 @@ +# 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 storyboard.api.v1.project_groups import ProjectGroupsController +from storyboard.api.v1.projects import ProjectsController +from storyboard.api.v1.stories import StoriesController +from storyboard.api.v1.tasks import TasksController +from storyboard.api.v1.teams import TeamsController +from storyboard.api.v1.users import UsersController + + +class V1Controller(object): + + project_groups = ProjectGroupsController() + projects = ProjectsController() + teams = TeamsController() + users = UsersController() + stories = StoriesController() + tasks = TasksController() diff --git a/storyboard/api/v1/wsme_models.py b/storyboard/api/v1/wsme_models.py new file mode 100644 index 00000000..4093ab5f --- /dev/null +++ b/storyboard/api/v1/wsme_models.py @@ -0,0 +1,287 @@ +# 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 +import six +import warnings +from wsme import types as wtypes + +from oslo.config import cfg +from sqlalchemy.exc import SADeprecationWarning +import storyboard.db.models as sqlalchemy_models +from storyboard.openstack.common.db.sqlalchemy import session as db_session + + +CONF = cfg.CONF + + +class _Base(wtypes.Base): + + id = int + created_at = datetime + updated_at = datetime + + def __init__(self, **kwargs): + for key, val in six.iteritems(kwargs): + setattr(self, key, val) + super(_Base, self).__init__(**kwargs) + + @classmethod + def get(cls, **kwargs): + query = cls.from_db(**kwargs) + entry = query.first() + + return convert_to_wsme(cls, entry) + + @classmethod + def get_all(cls, **kwargs): + query = cls.from_db(**kwargs) + entries = query.all() + + return [convert_to_wsme(cls, entry) for entry in entries] + + @classmethod + def create(cls, session=None, wsme_entry=None): + if not session: + session = db_session.get_session(sqlite_fk=True) + with session.begin(): + db_entry = convert_to_db_model(cls, wsme_entry, session) + session.add(db_entry) + + return cls.get(id=db_entry.id) + + @classmethod + def update(cls, key_property_name="id", key_property_value=None, + wsme_entry=None): + db_entry = cls.from_db(**{key_property_name: key_property_value})\ + .first() + if not db_entry: + return None + + session = db_session.get_session(sqlite_fk=True) + with session.begin(): + updated_db_model = update_db_model(cls, db_entry, wsme_entry) + session.add(updated_db_model) + + return cls.get(id=db_entry.id) + + @classmethod + def add_item(cls, cont_key_name, cont_key_value, item_cls, item_key_name, + item_key_value, container_name): + session = db_session.get_session(sqlite_fk=True) + with session.begin(): + db_container_enty = cls.from_db(session=session, + **{cont_key_name: cont_key_value})\ + .first() + if not db_container_enty: + return None + + db_add_item = item_cls.from_db(session=session, + **{item_key_name: item_key_value}).\ + first() + if not db_add_item: + return None + + getattr(db_container_enty, container_name).append(db_add_item) + session.add(db_container_enty) + + return cls.get(**{cont_key_name: cont_key_value}) + + @classmethod + def create_and_add_item(cls, cont_key_name, cont_key_value, item_cls, + item_value, container_name): + + wsme_item = item_cls.create(wsme_entry=item_value) + if not wsme_item: + return None + + return cls.add_item(cont_key_name, cont_key_value, item_cls, "id", + wsme_item.id, container_name) + + @classmethod + def from_db(cls, session=None, **kwargs): + model_cls = WSME_TO_SQLALCHEMY[cls] + if not session: + session = db_session.get_session(sqlite_fk=True) + query = session.query(model_cls) + + return query.filter_by(**kwargs) + + +warnings.simplefilter("ignore", SADeprecationWarning) + + +def convert_to_wsme(cls, entry): + if not entry: + return None + + wsme_object = cls() + for attr in cls._wsme_attributes: + attr_name = attr.name + value = getattr(entry, attr_name) + + if value is None: + continue + + if isinstance(attr._get_datatype(), _Base): + value = convert_to_wsme(SQLALCHEMY_TO_WSME[type(attr)], value) + + if isinstance(attr._get_datatype(), wtypes.ArrayType): + value = [convert_to_wsme(SQLALCHEMY_TO_WSME[type(item)], item) + for item in value] + setattr(wsme_object, attr_name, value) + + return wsme_object + + +def convert_to_db_model(cls, entry, session): + if not entry: + return None + + model_cls = WSME_TO_SQLALCHEMY[cls] + + model_object = model_cls() + for attr in cls._wsme_attributes: + attr_name = attr.name + value = getattr(entry, attr_name) + + if value is None or isinstance(value, wtypes.UnsetType): + continue + + if isinstance(attr._get_datatype(), _Base): + value = convert_to_db_model(type(attr), value, session) + session.add(value) + + if isinstance(attr._get_datatype(), wtypes.ArrayType): + value = [convert_to_db_model(attr._get_datatype().item_type, + item, + session) + for item in value] + setattr(model_object, attr_name, value) + + return model_object + + +def update_db_model(cls, db_entry, wsme_entry): + if not db_entry or not wsme_entry: + return None + + for attr in cls._wsme_attributes: + attr_name = attr.name + value = getattr(wsme_entry, attr_name) + + if isinstance(value, wtypes.UnsetType): + continue + + setattr(db_entry, attr_name, value) + + return db_entry + + +class Project(_Base): + name = wtypes.text + description = wtypes.text + + +class ProjectGroup(_Base): + name = wtypes.text + title = wtypes.text + projects = wtypes.ArrayType(Project) + + +class Permission(_Base): + pass + + +class Task(_Base): + pass + + +class StoryTag(_Base): + pass + + +class Comment(_Base): + #todo(nkonovalov): replace with a enum + action = wtypes.text + comment_type = wtypes.text + content = wtypes.text + + story_id = int + author_id = int + + +class Story(_Base): + title = wtypes.text + description = wtypes.text + is_bug = bool + #todo(nkonovalov): replace with a enum + priority = wtypes.text + tasks = wtypes.ArrayType(Task) + comments = wtypes.ArrayType(Comment) + tags = wtypes.ArrayType(StoryTag) + + @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") + + +class User(_Base): + username = wtypes.text + first_name = wtypes.text + last_name = wtypes.text + email = wtypes.text + is_staff = bool + is_active = bool + is_superuser = bool + last_login = datetime + #teams = wtypes.ArrayType(Team) + permissions = wtypes.ArrayType(Permission) + #tasks = wtypes.ArrayType(Task) + + +class Team(_Base): + name = wtypes.text + users = wtypes.ArrayType(User) + permissions = wtypes.ArrayType(Permission) + + @classmethod + def add_user(cls, team_name, username): + return cls.add_item("name", team_name, + User, "username", username, + "users") + + +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 +} + +# database mappings +WSME_TO_SQLALCHEMY = dict( + (v, k) for k, v in six.iteritems(SQLALCHEMY_TO_WSME) +)